Move upload/delete code to its own package

Signed-off-by: Daniel Weiße <dw@edgeless.systems>
This commit is contained in:
Daniel Weiße 2024-06-11 14:50:38 +02:00 committed by Daniel Weiße
parent 5654e76f7e
commit 52a65c20ac
13 changed files with 129 additions and 100 deletions

View file

@ -0,0 +1,35 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
load("//bazel/go:go_test.bzl", "go_test")
go_library(
name = "client",
srcs = [
"client.go",
"reporter.go",
],
importpath = "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi/cli/client",
visibility = ["//:__subpackages__"],
deps = [
"//internal/api/attestationconfigapi",
"//internal/api/client",
"//internal/attestation/variant",
"//internal/sigstore",
"//internal/staticupload",
"@com_github_aws_aws_sdk_go//aws",
"@com_github_aws_aws_sdk_go_v2_service_s3//:s3",
],
)
go_test(
name = "client_test",
srcs = [
"client_test.go",
"reporter_test.go",
],
embed = [":client"],
deps = [
"//internal/api/attestationconfigapi",
"//internal/attestation/variant",
"@com_github_stretchr_testify//assert",
],
)

View file

@ -0,0 +1,190 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
/*
package client contains code to manage CVM versions in Constellation's CDN API.
It is used to upload and delete "latest" versions for AMD SEV-SNP and Intel TDX.
*/
package client
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi"
apiclient "github.com/edgelesssys/constellation/v2/internal/api/client"
"github.com/edgelesssys/constellation/v2/internal/attestation/variant"
"github.com/edgelesssys/constellation/v2/internal/sigstore"
"github.com/edgelesssys/constellation/v2/internal/staticupload"
)
// VersionFormat is the format of the version name in the S3 bucket.
const VersionFormat = "2006-01-02-15-04"
// Client manages (modifies) the version information for the attestation variants.
type Client struct {
s3Client *apiclient.Client
s3ClientClose func(ctx context.Context) error
bucketID string
signer sigstore.Signer
cacheWindowSize int
}
// New returns a new Client.
func New(ctx context.Context, cfg staticupload.Config, cosignPwd, privateKey []byte, dryRun bool, versionWindowSize int, log *slog.Logger) (*Client, apiclient.CloseFunc, error) {
s3Client, clientClose, err := apiclient.NewClient(ctx, cfg.Region, cfg.Bucket, cfg.DistributionID, dryRun, log)
if err != nil {
return nil, nil, fmt.Errorf("failed to create s3 storage: %w", err)
}
repo := &Client{
s3Client: s3Client,
s3ClientClose: clientClose,
signer: sigstore.NewSigner(cosignPwd, privateKey),
bucketID: cfg.Bucket,
cacheWindowSize: versionWindowSize,
}
return repo, clientClose, nil
}
// uploadSEVSNPVersion uploads the latest version numbers of the SEVSNP. Then version name is the UTC timestamp of the date. The /list entry stores the version name + .json suffix.
func (a Client) uploadSEVSNPVersion(ctx context.Context, attestation variant.Variant, version attestationconfigapi.SEVSNPVersion, date time.Time) error {
versions, err := a.List(ctx, attestation)
if err != nil {
return fmt.Errorf("fetch version list: %w", err)
}
ops := a.constructUploadCmd(attestation, version, versions, date)
return executeAllCmds(ctx, a.s3Client, ops)
}
// DeleteSEVSNPVersion deletes the given version (without .json suffix) from the API.
func (a Client) DeleteSEVSNPVersion(ctx context.Context, attestation variant.Variant, versionStr string) error {
versions, err := a.List(ctx, attestation)
if err != nil {
return fmt.Errorf("fetch version list: %w", err)
}
ops, err := a.deleteSEVSNPVersion(versions, versionStr)
if err != nil {
return err
}
return executeAllCmds(ctx, a.s3Client, ops)
}
// List returns the list of versions for the given attestation variant.
func (a Client) List(ctx context.Context, attestation variant.Variant) (attestationconfigapi.SEVSNPVersionList, error) {
if !attestation.Equal(variant.AzureSEVSNP{}) &&
!attestation.Equal(variant.AWSSEVSNP{}) &&
!attestation.Equal(variant.GCPSEVSNP{}) {
return attestationconfigapi.SEVSNPVersionList{}, fmt.Errorf("unsupported attestation variant: %s", attestation)
}
versions, err := apiclient.Fetch(ctx, a.s3Client, attestationconfigapi.SEVSNPVersionList{Variant: attestation})
if err != nil {
var notFoundErr *apiclient.NotFoundError
if errors.As(err, &notFoundErr) {
return attestationconfigapi.SEVSNPVersionList{Variant: attestation}, nil
}
return attestationconfigapi.SEVSNPVersionList{}, err
}
versions.Variant = attestation
return versions, nil
}
func (a Client) deleteSEVSNPVersion(versions attestationconfigapi.SEVSNPVersionList, versionStr string) (ops []crudCmd, err error) {
versionStr = versionStr + ".json"
ops = append(ops, deleteCmd{
apiObject: attestationconfigapi.SEVSNPVersionAPI{
Variant: versions.Variant,
Version: versionStr,
},
})
removedVersions, err := removeVersion(versions, versionStr)
if err != nil {
return nil, err
}
ops = append(ops, putCmd{
apiObject: removedVersions,
signer: a.signer,
})
return ops, nil
}
func (a Client) constructUploadCmd(attestation variant.Variant, version attestationconfigapi.SEVSNPVersion, versionNames attestationconfigapi.SEVSNPVersionList, date time.Time) []crudCmd {
if !attestation.Equal(versionNames.Variant) {
return nil
}
dateStr := date.Format(VersionFormat) + ".json"
var res []crudCmd
res = append(res, putCmd{
apiObject: attestationconfigapi.SEVSNPVersionAPI{Version: dateStr, Variant: attestation, SEVSNPVersion: version},
signer: a.signer,
})
versionNames.AddVersion(dateStr)
res = append(res, putCmd{
apiObject: versionNames,
signer: a.signer,
})
return res
}
func removeVersion(list attestationconfigapi.SEVSNPVersionList, versionStr string) (removedVersions attestationconfigapi.SEVSNPVersionList, err error) {
versions := list.List
for i, v := range versions {
if v == versionStr {
if i == len(versions)-1 {
removedVersions = attestationconfigapi.SEVSNPVersionList{List: versions[:i], Variant: list.Variant}
} else {
removedVersions = attestationconfigapi.SEVSNPVersionList{List: append(versions[:i], versions[i+1:]...), Variant: list.Variant}
}
return removedVersions, nil
}
}
return attestationconfigapi.SEVSNPVersionList{}, fmt.Errorf("version %s not found in list %v", versionStr, versions)
}
type crudCmd interface {
Execute(ctx context.Context, c *apiclient.Client) error
}
type deleteCmd struct {
apiObject apiclient.APIObject
}
func (d deleteCmd) Execute(ctx context.Context, c *apiclient.Client) error {
return apiclient.DeleteWithSignature(ctx, c, d.apiObject)
}
type putCmd struct {
apiObject apiclient.APIObject
signer sigstore.Signer
}
func (p putCmd) Execute(ctx context.Context, c *apiclient.Client) error {
return apiclient.SignAndUpdate(ctx, c, p.apiObject, p.signer)
}
func executeAllCmds(ctx context.Context, client *apiclient.Client, cmds []crudCmd) error {
for _, cmd := range cmds {
if err := cmd.Execute(ctx, client); err != nil {
return fmt.Errorf("execute operation %+v: %w", cmd, err)
}
}
return nil
}

View file

@ -0,0 +1,66 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package client
import (
"testing"
"time"
"github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi"
"github.com/edgelesssys/constellation/v2/internal/attestation/variant"
"github.com/stretchr/testify/assert"
)
func TestUploadAzureSEVSNP(t *testing.T) {
sut := Client{
bucketID: "bucket",
signer: fakeSigner{},
}
version := attestationconfigapi.SEVSNPVersion{}
date := time.Date(2023, 1, 1, 1, 1, 1, 1, time.UTC)
ops := sut.constructUploadCmd(variant.AzureSEVSNP{}, version, attestationconfigapi.SEVSNPVersionList{List: []string{"2021-01-01-01-01.json", "2019-01-01-01-01.json"}, Variant: variant.AzureSEVSNP{}}, date)
dateStr := "2023-01-01-01-01.json"
assert := assert.New(t)
assert.Contains(ops, putCmd{
apiObject: attestationconfigapi.SEVSNPVersionAPI{
Variant: variant.AzureSEVSNP{},
Version: dateStr,
SEVSNPVersion: version,
},
signer: fakeSigner{},
})
assert.Contains(ops, putCmd{
apiObject: attestationconfigapi.SEVSNPVersionList{Variant: variant.AzureSEVSNP{}, List: []string{"2023-01-01-01-01.json", "2021-01-01-01-01.json", "2019-01-01-01-01.json"}},
signer: fakeSigner{},
})
}
func TestDeleteAzureSEVSNPVersions(t *testing.T) {
sut := Client{
bucketID: "bucket",
}
versions := attestationconfigapi.SEVSNPVersionList{List: []string{"2023-01-01.json", "2021-01-01.json", "2019-01-01.json"}}
ops, err := sut.deleteSEVSNPVersion(versions, "2021-01-01")
assert := assert.New(t)
assert.NoError(err)
assert.Contains(ops, deleteCmd{
apiObject: attestationconfigapi.SEVSNPVersionAPI{
Version: "2021-01-01.json",
},
})
assert.Contains(ops, putCmd{
apiObject: attestationconfigapi.SEVSNPVersionList{List: []string{"2023-01-01.json", "2019-01-01.json"}},
})
}
type fakeSigner struct{}
func (fakeSigner) Sign(_ []byte) ([]byte, error) {
return []byte("signature"), nil
}

View file

@ -0,0 +1,185 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package client
import (
"context"
"errors"
"fmt"
"path"
"sort"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go/aws"
"github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi"
"github.com/edgelesssys/constellation/v2/internal/api/client"
"github.com/edgelesssys/constellation/v2/internal/attestation/variant"
)
// cachedVersionsSubDir is the subdirectory in the bucket where the cached versions are stored.
const cachedVersionsSubDir = "cached-versions"
// ErrNoNewerVersion is returned if the input version is not newer than the latest API version.
var ErrNoNewerVersion = errors.New("input version is not newer than latest API version")
func reportVersionDir(attestation variant.Variant) string {
return path.Join(attestationconfigapi.AttestationURLPath, attestation.String(), cachedVersionsSubDir)
}
// UploadSEVSNPVersionLatest saves the given version to the cache, determines the smallest
// TCB version in the cache among the last cacheWindowSize versions and updates
// the latest version in the API if there is an update.
// force can be used to bypass the validation logic against the cached versions.
func (c Client) UploadSEVSNPVersionLatest(ctx context.Context, attestation variant.Variant, inputVersion,
latestAPIVersion attestationconfigapi.SEVSNPVersion, now time.Time, force bool,
) error {
if err := c.cacheSEVSNPVersion(ctx, attestation, inputVersion, now); err != nil {
return fmt.Errorf("reporting version: %w", err)
}
if force {
return c.uploadSEVSNPVersion(ctx, attestation, inputVersion, now)
}
versionDates, err := c.listCachedVersions(ctx, attestation)
if err != nil {
return fmt.Errorf("list reported versions: %w", err)
}
if len(versionDates) < c.cacheWindowSize {
c.s3Client.Logger.Warn(fmt.Sprintf("Skipping version update, found %d, expected %d reported versions.", len(versionDates), c.cacheWindowSize))
return nil
}
minVersion, minDate, err := c.findMinVersion(ctx, attestation, versionDates)
if err != nil {
return fmt.Errorf("get minimal version: %w", err)
}
c.s3Client.Logger.Info(fmt.Sprintf("Found minimal version: %+v with date: %s", minVersion, minDate))
shouldUpdateAPI, err := isInputNewerThanOtherVersion(minVersion, latestAPIVersion)
if err != nil {
return ErrNoNewerVersion
}
if !shouldUpdateAPI {
c.s3Client.Logger.Info(fmt.Sprintf("Input version: %+v is not newer than latest API version: %+v", minVersion, latestAPIVersion))
return nil
}
c.s3Client.Logger.Info(fmt.Sprintf("Input version: %+v is newer than latest API version: %+v", minVersion, latestAPIVersion))
t, err := time.Parse(VersionFormat, minDate)
if err != nil {
return fmt.Errorf("parsing date: %w", err)
}
if err := c.uploadSEVSNPVersion(ctx, attestation, minVersion, t); err != nil {
return fmt.Errorf("uploading version: %w", err)
}
c.s3Client.Logger.Info(fmt.Sprintf("Successfully uploaded new SEV-SNP version: %+v", minVersion))
return nil
}
// cacheSEVSNPVersion uploads the latest observed version numbers of the SEVSNP. This version is used to later report the latest version numbers to the API.
func (c Client) cacheSEVSNPVersion(ctx context.Context, attestation variant.Variant, version attestationconfigapi.SEVSNPVersion, date time.Time) error {
dateStr := date.Format(VersionFormat) + ".json"
res := putCmd{
apiObject: reportedSEVSNPVersionAPI{Version: dateStr, variant: attestation, SEVSNPVersion: version},
signer: c.signer,
}
return res.Execute(ctx, c.s3Client)
}
func (c Client) listCachedVersions(ctx context.Context, attestation variant.Variant) ([]string, error) {
list, err := c.s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(c.bucketID),
Prefix: aws.String(reportVersionDir(attestation)),
})
if err != nil {
return nil, fmt.Errorf("list objects: %w", err)
}
var dates []string
for _, obj := range list.Contents {
fileName := path.Base(*obj.Key)
if strings.HasSuffix(fileName, ".json") {
dates = append(dates, fileName[:len(fileName)-5])
}
}
return dates, nil
}
// findMinVersion finds the minimal version of the given version dates among the latest values in the version window size.
func (c Client) findMinVersion(ctx context.Context, attesation variant.Variant, versionDates []string) (attestationconfigapi.SEVSNPVersion, string, error) {
var minimalVersion *attestationconfigapi.SEVSNPVersion
var minimalDate string
sort.Sort(sort.Reverse(sort.StringSlice(versionDates))) // sort in reverse order to slice the latest versions
versionDates = versionDates[:c.cacheWindowSize]
sort.Strings(versionDates) // sort with oldest first to to take the minimal version with the oldest date
for _, date := range versionDates {
obj, err := client.Fetch(ctx, c.s3Client, reportedSEVSNPVersionAPI{Version: date + ".json", variant: attesation})
if err != nil {
return attestationconfigapi.SEVSNPVersion{}, "", fmt.Errorf("get object: %w", err)
}
// Need to set this explicitly as the variant is not part of the marshalled JSON.
obj.variant = attesation
if minimalVersion == nil {
minimalVersion = &obj.SEVSNPVersion
minimalDate = date
} else {
shouldUpdateMinimal, err := isInputNewerThanOtherVersion(*minimalVersion, obj.SEVSNPVersion)
if err != nil {
continue
}
if shouldUpdateMinimal {
minimalVersion = &obj.SEVSNPVersion
minimalDate = date
}
}
}
return *minimalVersion, minimalDate, nil
}
// isInputNewerThanOtherVersion compares all version fields and returns true if any input field is newer.
func isInputNewerThanOtherVersion(input, other attestationconfigapi.SEVSNPVersion) (bool, error) {
if input == other {
return false, nil
}
if input.TEE < other.TEE {
return false, fmt.Errorf("input TEE version: %d is older than latest API version: %d", input.TEE, other.TEE)
}
if input.SNP < other.SNP {
return false, fmt.Errorf("input SNP version: %d is older than latest API version: %d", input.SNP, other.SNP)
}
if input.Microcode < other.Microcode {
return false, fmt.Errorf("input Microcode version: %d is older than latest API version: %d", input.Microcode, other.Microcode)
}
if input.Bootloader < other.Bootloader {
return false, fmt.Errorf("input Bootloader version: %d is older than latest API version: %d", input.Bootloader, other.Bootloader)
}
return true, nil
}
// reportedSEVSNPVersionAPI is the request to get the version information of the specific version in the config api.
type reportedSEVSNPVersionAPI struct {
Version string `json:"-"`
variant variant.Variant `json:"-"`
attestationconfigapi.SEVSNPVersion
}
// JSONPath returns the path to the JSON file for the request to the config api.
func (i reportedSEVSNPVersionAPI) JSONPath() string {
return path.Join(reportVersionDir(i.variant), i.Version)
}
// ValidateRequest validates the request.
func (i reportedSEVSNPVersionAPI) ValidateRequest() error {
if !strings.HasSuffix(i.Version, ".json") {
return fmt.Errorf("version has no .json suffix")
}
return nil
}
// Validate is a No-Op at the moment.
func (i reportedSEVSNPVersionAPI) Validate() error {
return nil
}

View file

@ -0,0 +1,75 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package client
import (
"testing"
"github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi"
"github.com/stretchr/testify/assert"
)
func TestIsInputNewerThanLatestAPI(t *testing.T) {
newTestCfg := func() attestationconfigapi.SEVSNPVersion {
return attestationconfigapi.SEVSNPVersion{
Microcode: 93,
TEE: 0,
SNP: 6,
Bootloader: 2,
}
}
testCases := map[string]struct {
latest attestationconfigapi.SEVSNPVersion
input attestationconfigapi.SEVSNPVersion
expect bool
errMsg string
}{
"input is older than latest": {
input: func(c attestationconfigapi.SEVSNPVersion) attestationconfigapi.SEVSNPVersion {
c.Microcode--
return c
}(newTestCfg()),
latest: newTestCfg(),
expect: false,
errMsg: "input Microcode version: 92 is older than latest API version: 93",
},
"input has greater and smaller version field than latest": {
input: func(c attestationconfigapi.SEVSNPVersion) attestationconfigapi.SEVSNPVersion {
c.Microcode++
c.Bootloader--
return c
}(newTestCfg()),
latest: newTestCfg(),
expect: false,
errMsg: "input Bootloader version: 1 is older than latest API version: 2",
},
"input is newer than latest": {
input: func(c attestationconfigapi.SEVSNPVersion) attestationconfigapi.SEVSNPVersion {
c.TEE++
return c
}(newTestCfg()),
latest: newTestCfg(),
expect: true,
},
"input is equal to latest": {
input: newTestCfg(),
latest: newTestCfg(),
expect: false,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
isNewer, err := isInputNewerThanOtherVersion(tc.input, tc.latest)
assert := assert.New(t)
if tc.errMsg != "" {
assert.EqualError(err, tc.errMsg)
} else {
assert.NoError(err)
assert.Equal(tc.expect, isNewer)
}
})
}
}