constellation-lib: run license check in Terraform provider and refactor code (#2740)

* Clean up license checker code

Signed-off-by: Daniel Weiße <dw@edgeless.systems>

* Create license check depending on init/upgrade actions

Signed-off-by: Daniel Weiße <dw@edgeless.systems>

* Run license check in Terraform provider

Signed-off-by: Daniel Weiße <dw@edgeless.systems>

* fix license integration test action

Signed-off-by: Daniel Weiße <dw@edgeless.systems>

* Run tests with enterprise tag

Signed-off-by: Daniel Weiße <dw@edgeless.systems>

* Allow b64 encoding for license ID

Signed-off-by: Daniel Weiße <dw@edgeless.systems>

* Update checker_enterprise.go

---------

Signed-off-by: Daniel Weiße <dw@edgeless.systems>
Co-authored-by: Thomas Tendyck <51411342+thomasten@users.noreply.github.com>
This commit is contained in:
Daniel Weiße 2023-12-22 10:16:36 +01:00 committed by GitHub
parent ac1f322044
commit 519efe637d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 206 additions and 162 deletions

View File

@ -32,7 +32,7 @@ test --test_tag_filters=-integration
# enable all tests (including integration)
test:integration --test_tag_filters= --@io_bazel_rules_go//go/config:tags=integration
# enable only integration tests
test:integration-only --test_tag_filters=+integration --@io_bazel_rules_go//go/config:tags=integration
test:integration-only --test_tag_filters=+integration --@io_bazel_rules_go//go/config:tags=integration,enterprise
# bazel configs to explicitly target a platform
common:host --platforms @local_config_platform//:host

View File

@ -244,7 +244,7 @@ func runApply(cmd *cobra.Command, _ []string) error {
)
}
applier := constellation.NewApplier(log, spinner, newDialer)
applier := constellation.NewApplier(log, spinner, constellation.ApplyContextCLI, newDialer)
apply := &applyCmd{
fileHandler: fileHandler,
@ -824,7 +824,7 @@ type warnLog interface {
// applier is used to run the different phases of the apply command.
type applier interface {
SetKubeConfig(kubeConfig []byte) error
CheckLicense(ctx context.Context, csp cloudprovider.Provider, licenseID string) (int, error)
CheckLicense(ctx context.Context, csp cloudprovider.Provider, initRequest bool, licenseID string) (int, error)
// methods required by "init"

View File

@ -536,7 +536,7 @@ type stubConstellApplier struct {
func (s *stubConstellApplier) SetKubeConfig([]byte) error { return nil }
func (s *stubConstellApplier) CheckLicense(context.Context, cloudprovider.Provider, string) (int, error) {
func (s *stubConstellApplier) CheckLicense(context.Context, cloudprovider.Provider, bool, string) (int, error) {
return 0, s.checkLicenseErr
}

View File

@ -369,7 +369,7 @@ func TestWriteOutput(t *testing.T) {
spinner: &nopSpinner{},
merger: &stubMerger{},
log: logger.NewTest(t),
applier: constellation.NewApplier(logger.NewTest(t), &nopSpinner{}, nil),
applier: constellation.NewApplier(logger.NewTest(t), &nopSpinner{}, constellation.ApplyContextCLI, nil),
}
err = i.writeInitOutput(stateFile, initOutput, false, &out, measurementSalt)
require.NoError(err)
@ -461,7 +461,7 @@ func TestGenerateMasterSecret(t *testing.T) {
i := &applyCmd{
fileHandler: fileHandler,
log: logger.NewTest(t),
applier: constellation.NewApplier(logger.NewTest(t), &nopSpinner{}, nil),
applier: constellation.NewApplier(logger.NewTest(t), &nopSpinner{}, constellation.ApplyContextCLI, nil),
}
secret, err := i.generateAndPersistMasterSecret(&out)

View File

@ -42,7 +42,7 @@ func (a *applyCmd) checkLicenseFile(cmd *cobra.Command, csp cloudprovider.Provid
}
}
quota, err := a.applier.CheckLicense(cmd.Context(), csp, licenseID)
quota, err := a.applier.CheckLicense(cmd.Context(), csp, !a.flags.skipPhases.contains(skipInitPhase), licenseID)
if err != nil {
cmd.Printf("Unable to contact license server.\n")
cmd.Printf("Please keep your vCPU quota in mind.\n")

View File

@ -20,6 +20,16 @@ import (
"github.com/edgelesssys/constellation/v2/internal/license"
)
// ApplyContext denotes the context in which the apply command is run.
type ApplyContext string
const (
// ApplyContextCLI is used when the Applier is used by the CLI.
ApplyContextCLI ApplyContext = "cli"
// ApplyContextTerraform is used when the Applier is used by Terraform.
ApplyContextTerraform ApplyContext = "terraform"
)
// An Applier handles applying a specific configuration to a Constellation cluster
// with existing Infrastructure.
// In Particular, this involves Initialization and Upgrading of the cluster.
@ -28,6 +38,8 @@ type Applier struct {
licenseChecker licenseChecker
spinner spinnerInterf
applyContext ApplyContext
// newDialer creates a new aTLS gRPC dialer.
newDialer func(validator atls.Validator) *dialer.Dialer
kubecmdClient kubecmdClient
@ -35,7 +47,7 @@ type Applier struct {
}
type licenseChecker interface {
CheckLicense(context.Context, cloudprovider.Provider, string) (license.QuotaCheckResponse, error)
CheckLicense(ctx context.Context, csp cloudprovider.Provider, action license.Action, licenseID string) (int, error)
}
type debugLog interface {
@ -44,14 +56,15 @@ type debugLog interface {
// NewApplier creates a new Applier.
func NewApplier(
log debugLog,
spinner spinnerInterf,
log debugLog, spinner spinnerInterf,
applyContext ApplyContext,
newDialer func(validator atls.Validator) *dialer.Dialer,
) *Applier {
return &Applier{
log: log,
spinner: spinner,
licenseChecker: license.NewChecker(license.NewClient()),
licenseChecker: license.NewChecker(),
applyContext: applyContext,
newDialer: newDialer,
}
}
@ -73,15 +86,26 @@ func (a *Applier) SetKubeConfig(kubeConfig []byte) error {
// CheckLicense checks the given Constellation license with the license server
// and returns the allowed quota for the license.
func (a *Applier) CheckLicense(ctx context.Context, csp cloudprovider.Provider, licenseID string) (int, error) {
func (a *Applier) CheckLicense(ctx context.Context, csp cloudprovider.Provider, initRequest bool, licenseID string) (int, error) {
a.log.Debugf("Contacting license server for license '%s'", licenseID)
quotaResp, err := a.licenseChecker.CheckLicense(ctx, csp, licenseID)
var action license.Action
if initRequest {
action = license.Init
} else {
action = license.Apply
}
if a.applyContext == ApplyContextTerraform {
action += "-terraform"
}
quota, err := a.licenseChecker.CheckLicense(ctx, csp, action, licenseID)
if err != nil {
return 0, fmt.Errorf("checking license: %w", err)
}
a.log.Debugf("Got response from license server for license '%s'", licenseID)
return quotaResp.Quota, nil
return quota, nil
}
// GenerateMasterSecret generates a new master secret.

View File

@ -38,7 +38,7 @@ func TestCheckLicense(t *testing.T) {
require := require.New(t)
a := &Applier{licenseChecker: tc.licenseChecker, log: logger.NewTest(t)}
_, err := a.CheckLicense(context.Background(), cloudprovider.Unknown, license.CommunityLicense)
_, err := a.CheckLicense(context.Background(), cloudprovider.Unknown, true, license.CommunityLicense)
if tc.wantErr {
require.Error(err)
} else {
@ -52,8 +52,8 @@ type stubLicenseChecker struct {
checkLicenseErr error
}
func (c *stubLicenseChecker) CheckLicense(context.Context, cloudprovider.Provider, string) (license.QuotaCheckResponse, error) {
return license.QuotaCheckResponse{}, c.checkLicenseErr
func (c *stubLicenseChecker) CheckLicense(context.Context, cloudprovider.Provider, license.Action, string) (int, error) {
return 0, c.checkLicenseErr
}
func TestGenerateMasterSecret(t *testing.T) {

View File

@ -22,11 +22,11 @@ go_library(
go_test(
name = "license_test",
srcs = [
"file_test.go",
"license_test.go",
],
srcs = ["file_test.go"],
embed = [":license"],
tags = [
"enterprise",
],
deps = [
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",

View File

@ -9,26 +9,90 @@ SPDX-License-Identifier: AGPL-3.0-only
package license
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
)
const (
apiHost = "license.confidential.cloud"
licensePath = "api/v1/license"
)
// Checker checks the Constellation license.
type Checker struct {
quotaChecker QuotaChecker
httpClient *http.Client
}
func NewChecker(quotaChecker QuotaChecker) *Checker {
// NewChecker creates a new Checker.
func NewChecker() *Checker {
return &Checker{
quotaChecker: quotaChecker,
httpClient: http.DefaultClient,
}
}
// CheckLicense contacts the license server to fetch quota information for the given license.
func (c *Checker) CheckLicense(ctx context.Context, provider cloudprovider.Provider, licenseID string) (QuotaCheckResponse, error) {
return c.quotaChecker.QuotaCheck(ctx, QuotaCheckRequest{
// CheckLicense checks the Constellation license. If the license is valid, it returns the vCPU quota.
func (c *Checker) CheckLicense(ctx context.Context, csp cloudprovider.Provider, action Action, licenseID string) (int, error) {
checkRequest := quotaCheckRequest{
Provider: csp.String(),
License: licenseID,
Action: Init,
Provider: provider.String(),
})
Action: action,
}
reqBody, err := json.Marshal(checkRequest)
if err != nil {
return 0, fmt.Errorf("unable to marshal input: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, licenseURL().String(), bytes.NewBuffer(reqBody))
if err != nil {
return 0, fmt.Errorf("unable to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("unable to do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("http error %d", resp.StatusCode)
}
responseContentType := resp.Header.Get("Content-Type")
if responseContentType != "application/json" {
return 0, fmt.Errorf("expected server JSON response but got '%s'", responseContentType)
}
var parsedResponse quotaCheckResponse
err = json.NewDecoder(resp.Body).Decode(&parsedResponse)
if err != nil {
return 0, fmt.Errorf("unable to parse response: %w", err)
}
return parsedResponse.Quota, nil
}
// quotaCheckRequest is JSON request to license server to check quota for a given license and action.
type quotaCheckRequest struct {
Action Action `json:"action"`
Provider string `json:"provider"`
License string `json:"license"`
}
// quotaCheckResponse is JSON response by license server.
type quotaCheckResponse struct {
Quota int `json:"quota"`
}
func licenseURL() *url.URL {
return &url.URL{
Scheme: "https",
Host: apiHost,
Path: licensePath,
}
}

View File

@ -1,3 +1,5 @@
//go:build enterprise
/*
Copyright (c) Edgeless Systems GmbH
@ -13,6 +15,7 @@ import (
"net/http"
"testing"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/stretchr/testify/assert"
)
@ -25,11 +28,9 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
}
// newTestClient returns *http.Client with Transport replaced to avoid making real calls.
func newTestClient(fn roundTripFunc) *Client {
return &Client{
httpClient: &http.Client{
Transport: fn,
},
func newTestClient(fn roundTripFunc) *http.Client {
return &http.Client{
Transport: fn,
}
}
@ -70,31 +71,26 @@ func TestQuotaCheck(t *testing.T) {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
client := newTestClient(func(req *http.Request) *http.Response {
r := &http.Response{
StatusCode: tc.serverResponseCode,
Body: io.NopCloser(bytes.NewBufferString(tc.serverResponse)),
Header: make(http.Header),
}
r.Header.Set("Content-Type", tc.serverResponseContent)
return r
})
client := &Checker{
httpClient: newTestClient(func(req *http.Request) *http.Response {
r := &http.Response{
StatusCode: tc.serverResponseCode,
Body: io.NopCloser(bytes.NewBufferString(tc.serverResponse)),
Header: make(http.Header),
}
r.Header.Set("Content-Type", tc.serverResponseContent)
return r
}),
}
resp, err := client.QuotaCheck(context.Background(), QuotaCheckRequest{
Action: test,
License: tc.license,
})
quota, err := client.CheckLicense(context.Background(), cloudprovider.Unknown, Init, tc.license)
if tc.wantError {
assert.Error(err)
return
}
assert.NoError(err)
assert.Equal(tc.wantQuota, resp.Quota)
assert.Equal(tc.wantQuota, quota)
})
}
}
func Test_licenseURL(t *testing.T) {
assert.Equal(t, "https://license.confidential.cloud/api/v1/license", licenseURL().String())
}

View File

@ -18,11 +18,11 @@ import (
type Checker struct{}
// NewChecker creates a new Checker.
func NewChecker(QuotaChecker) *Checker {
func NewChecker() *Checker {
return &Checker{}
}
// CheckLicense is a no-op for open source version of Constellation.
func (c *Checker) CheckLicense(context.Context, cloudprovider.Provider, string) (QuotaCheckResponse, error) {
return QuotaCheckResponse{}, nil
func (c *Checker) CheckLicense(context.Context, cloudprovider.Provider, Action, string) (int, error) {
return 0, nil
}

View File

@ -4,10 +4,12 @@ go_test(
name = "integration_test",
srcs = ["license_integration_test.go"],
tags = [
"enterprise",
"integration",
"requires-network",
],
deps = [
"//internal/cloud/cloudprovider",
"//internal/license",
"@com_github_stretchr_testify//assert",
],

View File

@ -12,6 +12,7 @@ import (
"context"
"testing"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/license"
"github.com/stretchr/testify/assert"
)
@ -36,20 +37,16 @@ func TestQuotaCheckIntegration(t *testing.T) {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
client := license.NewClient()
client := license.NewChecker()
req := license.QuotaCheckRequest{
Action: license.Action("test"),
License: tc.license,
}
resp, err := client.QuotaCheck(context.Background(), req)
quota, err := client.CheckLicense(context.Background(), cloudprovider.Unknown, "test", tc.license)
if tc.wantError {
assert.Error(err)
return
}
assert.NoError(err)
assert.Equal(tc.wantQuota, resp.Quota)
assert.Equal(tc.wantQuota, quota)
})
}
}

View File

@ -7,101 +7,16 @@ SPDX-License-Identifier: AGPL-3.0-only
// Package license provides functions to check a user's Constellation license.
package license
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// Action performed by Constellation.
type Action string
const (
// CommunityLicense is used by everyone who has not bought an enterprise license.
CommunityLicense = "00000000-0000-0000-0000-000000000000"
apiHost = "license.confidential.cloud"
licensePath = "api/v1/license"
)
type (
// Action performed by Constellation.
Action string
)
const (
// Init action denotes the initialization of a Constellation cluster.
Init Action = "init"
// test action is only to be used in testing.
test Action = "test"
// Apply action denotes an update of a Constellation cluster.
// It is used after a cluster has already been initialized once.
Apply Action = "apply"
)
// Client interacts with the ES license server.
type Client struct {
httpClient *http.Client
}
// NewClient creates a new client to interact with ES license server.
func NewClient() *Client {
return &Client{
httpClient: http.DefaultClient,
}
}
// QuotaCheckRequest is JSON request to license server to check quota for a given license and action.
type QuotaCheckRequest struct {
Action Action `json:"action"`
Provider string `json:"provider"`
License string `json:"license"`
}
// QuotaCheckResponse is JSON response by license server.
type QuotaCheckResponse struct {
Quota int `json:"quota"`
}
// QuotaCheck for a given license and action, passed via CheckQuotaRequest.
func (c *Client) QuotaCheck(ctx context.Context, checkRequest QuotaCheckRequest) (QuotaCheckResponse, error) {
reqBody, err := json.Marshal(checkRequest)
if err != nil {
return QuotaCheckResponse{}, fmt.Errorf("unable to marshal input: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, licenseURL().String(), bytes.NewBuffer(reqBody))
if err != nil {
return QuotaCheckResponse{}, fmt.Errorf("unable to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return QuotaCheckResponse{}, fmt.Errorf("unable to do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return QuotaCheckResponse{}, fmt.Errorf("http error %d", resp.StatusCode)
}
responseContentType := resp.Header.Get("Content-Type")
if responseContentType != "application/json" {
return QuotaCheckResponse{}, fmt.Errorf("expected server JSON response but got '%s'", responseContentType)
}
var parsedResponse QuotaCheckResponse
err = json.NewDecoder(resp.Body).Decode(&parsedResponse)
if err != nil {
return QuotaCheckResponse{}, fmt.Errorf("unable to parse response: %w", err)
}
return parsedResponse, nil
}
func licenseURL() *url.URL {
return &url.URL{
Scheme: "https",
Host: apiHost,
Path: licensePath,
}
}
// QuotaChecker checks the vCPU quota for a given license.
type QuotaChecker interface {
QuotaCheck(ctx context.Context, checkRequest QuotaCheckRequest) (QuotaCheckResponse, error)
}

View File

@ -84,6 +84,7 @@ See the [full list of CSPs](https://docs.edgeless.systems/constellation/overview
- `gcp` (Attributes) GCP-specific configuration. (see [below for nested schema](#nestedatt--gcp))
- `in_cluster_endpoint` (String) The endpoint of the cluster. When not set, the out-of-cluster endpoint is used.
- `kubernetes_version` (String) The Kubernetes version to use for the cluster. When not set, version v1.27.8 is used. The supported versions are [v1.26.11 v1.27.8 v1.28.4].
- `license_id` (String) Constellation license ID. When not set, the community license is used.
### Read-Only

View File

@ -33,6 +33,7 @@ go_library(
"//internal/grpc/dialer",
"//internal/imagefetcher",
"//internal/kms/uri",
"//internal/license",
"//internal/semver",
"//internal/sigstore",
"//internal/versions",

View File

@ -35,6 +35,7 @@ import (
"github.com/edgelesssys/constellation/v2/internal/constellation/state"
"github.com/edgelesssys/constellation/v2/internal/grpc/dialer"
"github.com/edgelesssys/constellation/v2/internal/kms/uri"
"github.com/edgelesssys/constellation/v2/internal/license"
"github.com/edgelesssys/constellation/v2/internal/semver"
"github.com/edgelesssys/constellation/v2/internal/versions"
datastruct "github.com/edgelesssys/constellation/v2/terraform-provider-constellation/internal/data"
@ -91,6 +92,7 @@ type ClusterResourceModel struct {
MasterSecretSalt types.String `tfsdk:"master_secret_salt"`
MeasurementSalt types.String `tfsdk:"measurement_salt"`
InitSecret types.String `tfsdk:"init_secret"`
LicenseID types.String `tfsdk:"license_id"`
Attestation types.Object `tfsdk:"attestation"`
GCP types.Object `tfsdk:"gcp"`
Azure types.Object `tfsdk:"azure"`
@ -258,7 +260,14 @@ func (r *ClusterResource) Schema(_ context.Context, _ resource.SchemaRequest, re
Description: "Secret used for initialization of the cluster.",
Required: true,
},
"license_id": schema.StringAttribute{
MarkdownDescription: "Constellation license ID. When not set, the community license is used.",
Description: "Constellation license ID. When not set, the community license is used.",
Optional: true,
},
"attestation": newAttestationConfigAttributeSchema(attributeInput),
// CSP specific inputs
"gcp": schema.SingleNestedAttribute{
MarkdownDescription: "GCP-specific configuration.",
Description: "GCP-specific configuration.",
@ -442,15 +451,36 @@ func (r *ClusterResource) Configure(_ context.Context, req resource.ConfigureReq
}
r.newApplier = func(ctx context.Context, validator atls.Validator) *constellation.Applier {
return constellation.NewApplier(&tfContextLogger{ctx: ctx}, &nopSpinner{}, newDialer)
return constellation.NewApplier(&tfContextLogger{ctx: ctx}, &nopSpinner{}, constellation.ApplyContextTerraform, newDialer)
}
}
// ModifyPlan is called when the resource is planned for creation, updates, or deletion. This allows to set pre-apply
// warnings and errors.
func (r *ClusterResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
if req.Plan.Raw.IsNull() {
return
}
// Read plannedState supplied by Terraform runtime into the model
var plannedState ClusterResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plannedState)...)
if resp.Diagnostics.HasError() {
return
}
licenseID := plannedState.LicenseID.ValueString()
if licenseID == "" {
resp.Diagnostics.AddWarning("Constellation license not found.",
"Using community license.\nFor details, see https://docs.edgeless.systems/constellation/overview/license")
}
if licenseID == license.CommunityLicense {
resp.Diagnostics.AddWarning("Using community license.",
"For details, see https://docs.edgeless.systems/constellation/overview/license")
}
// Checks running on updates to the resource. (i.e. state and plan != nil)
if !req.Plan.Raw.IsNull() && !req.State.Raw.IsNull() {
if !req.State.Raw.IsNull() {
// Read currentState supplied by Terraform runtime into the model
var currentState ClusterResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &currentState)...)
@ -458,13 +488,6 @@ func (r *ClusterResource) ModifyPlan(ctx context.Context, req resource.ModifyPla
return
}
// Read plannedState supplied by Terraform runtime into the model
var plannedState ClusterResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plannedState)...)
if resp.Diagnostics.HasError() {
return
}
// Warn the user about possibly destructive changes in case microservice changes are to be applied.
currVer, diags := r.getMicroserviceVersion(ctx, &currentState)
resp.Diagnostics.Append(diags...)
@ -714,6 +737,17 @@ func (r *ClusterResource) apply(ctx context.Context, data *ClusterResourceModel,
return diags
}
// parse license ID
licenseID := data.LicenseID.ValueString()
if licenseID == "" {
licenseID = license.CommunityLicense
}
// license ID can be base64-encoded
licenseIDFromB64, err := base64.StdEncoding.DecodeString(licenseID)
if err == nil {
licenseID = string(licenseIDFromB64)
}
// Parse in-cluster service account info.
serviceAccPayload := constellation.ServiceAccountPayload{}
var gcpConfig gcpAttribute
@ -802,6 +836,16 @@ func (r *ClusterResource) apply(ctx context.Context, data *ClusterResourceModel,
}
}
// Check license
quota, err := applier.CheckLicense(ctx, csp, !skipInitRPC, licenseID)
if err != nil {
diags.AddWarning("Unable to contact license server.", "Please keep your vCPU quota in mind.")
} else if licenseID == license.CommunityLicense {
diags.AddWarning("Using community license.", "For details, see https://docs.edgeless.systems/constellation/overview/license")
} else {
tflog.Info(ctx, fmt.Sprintf("Please keep your vCPU quota (%d) in mind.", quota))
}
// Now, we perform the actual applying.
// Run init RPC