Skip to content

Commit

Permalink
feat(konnect): allow configuring license polling periods (#4178)
Browse files Browse the repository at this point in the history
Co-authored-by: Travis Raines <571832+rainest@users.noreply.github.com>
  • Loading branch information
2 people authored and pmalek committed Jun 23, 2023
1 parent aca19f0 commit 0ebf486
Show file tree
Hide file tree
Showing 10 changed files with 586 additions and 103 deletions.
13 changes: 8 additions & 5 deletions internal/adminapi/konnect.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ type KonnectConfig struct {
// TODO https://github.com/Kong/kubernetes-ingress-controller/issues/3922
// ConfigSynchronizationEnabled is the only toggle we had prior to the addition of the license agent.
// We likely want to combine these into a single Konnect toggle or piggyback off other Konnect functionality.
ConfigSynchronizationEnabled bool
ConfigSynchronizationEnabled bool
RuntimeGroupID string
Address string
RefreshNodePeriod time.Duration
TLSClient TLSClientConfig

LicenseSynchronizationEnabled bool
RuntimeGroupID string
Address string
RefreshNodePeriod time.Duration
TLSClient TLSClientConfig
InitialLicensePollingPeriod time.Duration
LicensePollingPeriod time.Duration
}

func NewKongClientForKonnectRuntimeGroup(c KonnectConfig) (*KonnectClient, error) {
Expand Down
9 changes: 7 additions & 2 deletions internal/dataplane/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/blang/semver/v4"
"github.com/kong/go-kong/kong"
"github.com/samber/mo"
"github.com/sirupsen/logrus"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
Expand Down Expand Up @@ -124,7 +125,8 @@ func shouldEnableParserExpressionRoutes(

// LicenseGetter is an interface for getting the Kong Enterprise license.
type LicenseGetter interface {
GetLicense() kong.License
// GetLicense returns an optional license.
GetLicense() mo.Option[kong.License]
}

// Parser parses Kubernetes objects and configurations into their
Expand Down Expand Up @@ -239,7 +241,10 @@ func (p *Parser) BuildKongConfig() KongConfigBuildingResult {
result.CACertificates = p.getCACerts()

if p.licenseGetter != nil {
result.Licenses = append(result.Licenses, p.licenseGetter.GetLicense())
optionalLicense := p.licenseGetter.GetLicense()
if l, ok := optionalLicense.Get(); ok {
result.Licenses = append(result.Licenses, l)
}
}

if p.featureFlags.FillIDs {
Expand Down
40 changes: 40 additions & 0 deletions internal/dataplane/parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/blang/semver/v4"
"github.com/kong/go-kong/kong"
"github.com/samber/lo"
"github.com/samber/mo"
"github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -5314,6 +5315,45 @@ func TestNewFeatureFlags(t *testing.T) {
}
}

type mockLicenseGetter struct {
license mo.Option[kong.License]
}

func (m *mockLicenseGetter) GetLicense() mo.Option[kong.License] {
return m.license
}

func TestParser_License(t *testing.T) {
s, _ := store.NewFakeStore(store.FakeObjects{})
p := mustNewParser(t, s)

t.Run("no license is populated by default", func(t *testing.T) {
result := p.BuildKongConfig()
require.Empty(t, result.KongState.Licenses)
})

t.Run("no license is populated when license getter returns no license", func(t *testing.T) {
p.InjectLicenseGetter(&mockLicenseGetter{})
result := p.BuildKongConfig()
require.Empty(t, result.KongState.Licenses)
})

t.Run("license is populated when license getter returns a license", func(t *testing.T) {
licenseGetterWithLicense := &mockLicenseGetter{
license: mo.Some(kong.License{
ID: lo.ToPtr("license-id"),
Payload: lo.ToPtr("license-payload"),
}),
}
p.InjectLicenseGetter(licenseGetterWithLicense)
result := p.BuildKongConfig()
require.Len(t, result.KongState.Licenses, 1)
license := result.KongState.Licenses[0]
require.Equal(t, "license-id", *license.ID)
require.Equal(t, "license-payload", *license.Payload)
})
}

func mustNewParser(t *testing.T, storer store.Storer) *Parser {
const testKongVersion = "3.2.0"

Expand Down
74 changes: 60 additions & 14 deletions internal/konnect/license/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
neturl "net/url"
"strconv"
"time"

"github.com/samber/mo"

"github.com/kong/kubernetes-ingress-controller/v2/internal/adminapi"
"github.com/kong/kubernetes-ingress-controller/v2/internal/license"
tlsutil "github.com/kong/kubernetes-ingress-controller/v2/internal/util/tls"
)

Expand Down Expand Up @@ -53,32 +57,49 @@ func (c *Client) kicLicenseAPIEndpoint() string {
return fmt.Sprintf(KICLicenseAPIPathPattern, c.address, c.runtimeGroupID)
}

func (c *Client) List(ctx context.Context, pageNumber int) (*ListLicenseResponse, error) {
// TODO this is another case where we have a pseudo-unary object. The page is always 0 in practice, but if we have
// separate functions per entity, we end up with effectively dead code for some
url, _ := neturl.Parse(c.kicLicenseAPIEndpoint())
if pageNumber != 0 {
q := url.Query()
q.Set("page.number", strconv.Itoa(pageNumber))
url.RawQuery = q.Encode()
func (c *Client) Get(ctx context.Context) (mo.Option[license.KonnectLicense], error) {
// Make a request to the Konnect license API to list all licenses.
response, err := c.listLicenses(ctx)
if err != nil {
return mo.None[license.KonnectLicense](), fmt.Errorf("failed to list licenses: %w", err)
}

// Convert the response to a KonnectLicense - we're expecting only one license.
l, err := listLicensesResponseToKonnectLicense(response)
if err != nil {
return mo.None[license.KonnectLicense](), fmt.Errorf("failed to convert list licenses response: %w", err)
}

return l, nil
}

// isOKStatusCode returns true if the input HTTP status code is 2xx, in [200,300).
func isOKStatusCode(code int) bool {
return code >= 200 && code < 300
}

// listLicenses calls the Konnect license API to list all licenses.
func (c *Client) listLicenses(ctx context.Context) (*ListLicenseResponse, error) {
url, _ := neturl.Parse(c.kicLicenseAPIEndpoint())
req, err := http.NewRequestWithContext(ctx, "GET", url.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

httpResp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get response: %w", err)
}

defer httpResp.Body.Close()

respBuf, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}

if httpResp.StatusCode == http.StatusNotFound {
// 404 means no license is found which is a valid response.
return nil, nil
}
if !isOKStatusCode(httpResp.StatusCode) {
return nil, fmt.Errorf("non-success response from Koko: %d, resp body %s", httpResp.StatusCode, string(respBuf))
}
Expand All @@ -91,7 +112,32 @@ func (c *Client) List(ctx context.Context, pageNumber int) (*ListLicenseResponse
return resp, nil
}

// isOKStatusCode returns true if the input HTTP status code is 2xx, in [200,300).
func isOKStatusCode(code int) bool {
return code >= 200 && code < 300
// listLicensesResponseToKonnectLicense converts a ListLicenseResponse to a KonnectLicense.
// It validates the response and returns an error if the response is invalid.
func listLicensesResponseToKonnectLicense(response *ListLicenseResponse) (mo.Option[license.KonnectLicense], error) {
if response == nil {
// If the response is nil, it means no license was found.
return mo.None[license.KonnectLicense](), nil
}
if len(response.Items) == 0 {
return mo.None[license.KonnectLicense](), errors.New("no license item found in response")
}

// We're expecting only one license.
item := response.Items[0]
if item.License == "" {
return mo.None[license.KonnectLicense](), errors.New("license item has empty license")
}
if item.UpdatedAt == 0 {
return mo.None[license.KonnectLicense](), errors.New("license item has empty updated_at")
}
if item.ID == "" {
return mo.None[license.KonnectLicense](), errors.New("license item has empty id")
}

return mo.Some(license.KonnectLicense{
ID: item.ID,
UpdatedAt: time.Unix(int64(item.UpdatedAt), 0),
Payload: item.License,
}), nil
}
162 changes: 162 additions & 0 deletions internal/konnect/license/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package license_test

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"

"github.com/kong/kubernetes-ingress-controller/v2/internal/adminapi"
"github.com/kong/kubernetes-ingress-controller/v2/internal/konnect/license"
)

type mockKonnectLicenseServer struct {
response []byte
statusCode int
}

func newMockKonnectLicenseServer(response []byte, statusCode int) *mockKonnectLicenseServer {
return &mockKonnectLicenseServer{
response: response,
statusCode: statusCode,
}
}

func (m *mockKonnectLicenseServer) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(m.statusCode)
_, _ = w.Write(m.response)
}

func TestLicenseClient(t *testing.T) {
testCases := []struct {
name string
response []byte
status int
assertions func(t *testing.T, c *license.Client)
}{
{
name: "200 valid response",
response: []byte(`{
"items": [
{
"payload": "some-license-content",
"updated_at": 1234567890,
"id": "some-license-id"
}
]
}`),
status: http.StatusOK,
assertions: func(t *testing.T, c *license.Client) {
licenseOpt, err := c.Get(context.Background())
require.NoError(t, err)

l, ok := licenseOpt.Get()
require.True(t, ok)
require.Equal(t, "some-license-content", l.Payload)
require.Equal(t, int64(1234567890), l.UpdatedAt.Unix())
},
},
{
name: "200 but empty response",
response: []byte(`{}`),
status: http.StatusOK,
assertions: func(t *testing.T, c *license.Client) {
_, err := c.Get(context.Background())
require.ErrorContains(t, err, "no license item found in response")
},
},
{
name: "200 but invalid response",
response: []byte(`{invalid-json`),
status: http.StatusOK,
assertions: func(t *testing.T, c *license.Client) {
_, err := c.Get(context.Background())
require.ErrorContains(t, err, "failed to parse response body")
},
},
{
name: "200 but empty license id",
response: []byte(`{
"items": [
{
"payload": "some-license-content",
"updated_at": 1234567890,
"id": ""
}
]
}`),
status: http.StatusOK,
assertions: func(t *testing.T, c *license.Client) {
_, err := c.Get(context.Background())
require.ErrorContains(t, err, "empty id")
},
},
{
name: "200 but empty updated_at",
response: []byte(`{
"items": [
{
"payload": "some-license-content",
"updated_at": 0,
"id": "some-license-id"
}
]
}`),
status: http.StatusOK,
assertions: func(t *testing.T, c *license.Client) {
_, err := c.Get(context.Background())
require.ErrorContains(t, err, "empty updated_at")
},
},
{
name: "200 but empty payload",
response: []byte(`{
"items": [
{
"payload": "",
"updated_at": 1234567890,
"id": "some-license-id"
}
]
}`),
status: http.StatusOK,
assertions: func(t *testing.T, c *license.Client) {
_, err := c.Get(context.Background())
require.ErrorContains(t, err, "empty license")
},
},
{
name: "404 returns empty license with no error",
response: nil,
status: http.StatusNotFound,
assertions: func(t *testing.T, c *license.Client) {
l, err := c.Get(context.Background())
require.NoError(t, err)
require.False(t, l.IsPresent())
},
},
{
name: "400 returns error",
response: nil,
status: http.StatusBadRequest,
assertions: func(t *testing.T, c *license.Client) {
_, err := c.Get(context.Background())
require.Error(t, err)
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
server := newMockKonnectLicenseServer(tc.response, tc.status)
ts := httptest.NewServer(server)
defer ts.Close()

c, err := license.NewClient(adminapi.KonnectConfig{Address: ts.URL})
require.NoError(t, err)
tc.assertions(t, c)
})
}
}
1 change: 0 additions & 1 deletion internal/konnect/license/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,5 @@ type ListLicenseResponse struct {
type Item struct {
License string `json:"payload,omitempty"`
UpdatedAt uint64 `json:"updated_at,omitempty"`
CreatedAt uint64 `json:"created_at,omitempty"`
ID string `json:"id,omitempty"`
}

0 comments on commit 0ebf486

Please sign in to comment.