api: move hack/configapi into internal/api

The tool has an e2e test and is part of our production pipeline.
This commit is contained in:
Otto Bittner 2023-08-31 10:46:50 +02:00
parent 97dc15b1d1
commit 376bc6d39f
7 changed files with 19 additions and 18 deletions

View file

@ -0,0 +1,50 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
load("//bazel/go:go_test.bzl", "go_test")
load("//bazel/sh:def.bzl", "sh_template")
go_binary(
name = "cli",
embed = [":cli_lib"],
visibility = ["//visibility:public"],
)
go_library(
name = "cli_lib",
srcs = [
"delete.go",
"main.go",
],
importpath = "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi/cli",
visibility = ["//visibility:private"],
deps = [
"//internal/api/attestationconfigapi",
"//internal/constants",
"//internal/logger",
"//internal/staticupload",
"@com_github_spf13_cobra//:cobra",
"@org_uber_go_zap//:zap",
],
)
go_test(
name = "cli_test",
srcs = [
"delete_test.go",
"main_test.go",
],
embed = [":cli_lib"],
deps = [
"//internal/api/attestationconfigapi",
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
],
)
sh_template(
name = "cli_e2e_test",
data = [":cli"],
substitutions = {
"@@CONFIGAPI_CLI@@": "$(rootpath :cli)",
},
template = "e2e/test.sh.in",
)

View file

@ -0,0 +1,87 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package main
import (
"context"
"errors"
"fmt"
"github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/edgelesssys/constellation/v2/internal/staticupload"
"github.com/spf13/cobra"
"go.uber.org/zap"
)
// newDeleteCmd creates the delete command.
func newDeleteCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: "delete a specific version from the config api",
PreRunE: envCheck,
RunE: runDelete,
}
cmd.Flags().StringP("version", "v", "", "Name of the version to delete (without .json suffix)")
must(cmd.MarkFlagRequired("version"))
return cmd
}
type deleteCmd struct {
attestationClient deleteClient
}
type deleteClient interface {
DeleteAzureSEVSNPVersion(ctx context.Context, versionStr string) error
}
func (d deleteCmd) delete(cmd *cobra.Command) error {
version, err := cmd.Flags().GetString("version")
if err != nil {
return err
}
return d.attestationClient.DeleteAzureSEVSNPVersion(cmd.Context(), version)
}
func runDelete(cmd *cobra.Command, _ []string) (retErr error) {
log := logger.New(logger.PlainLog, zap.DebugLevel).Named("attestationconfigapi")
region, err := cmd.Flags().GetString("region")
if err != nil {
return fmt.Errorf("getting region: %w", err)
}
bucket, err := cmd.Flags().GetString("bucket")
if err != nil {
return fmt.Errorf("getting bucket: %w", err)
}
distribution, err := cmd.Flags().GetString("distribution")
if err != nil {
return fmt.Errorf("getting distribution: %w", err)
}
cfg := staticupload.Config{
Bucket: bucket,
Region: region,
DistributionID: distribution,
}
client, clientClose, err := attestationconfigapi.NewClient(cmd.Context(), cfg, []byte(cosignPwd), []byte(privateKey), false, log)
if err != nil {
return fmt.Errorf("create attestation client: %w", err)
}
defer func() {
err := clientClose(cmd.Context())
if err != nil {
retErr = errors.Join(retErr, fmt.Errorf("failed to invalidate cache: %w", err))
}
}()
deleteCmd := deleteCmd{
attestationClient: client,
}
return deleteCmd.delete(cmd)
}

View file

@ -0,0 +1,38 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package main
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDeleteVersion(t *testing.T) {
client := &fakeAttestationClient{}
sut := deleteCmd{
attestationClient: client,
}
cmd := newDeleteCmd()
require.NoError(t, cmd.Flags().Set("version", "2021-01-01"))
assert.NoError(t, sut.delete(cmd))
assert.True(t, client.isCalled)
}
type fakeAttestationClient struct {
isCalled bool
}
func (f *fakeAttestationClient) DeleteAzureSEVSNPVersion(_ context.Context, version string) error {
if version == "2021-01-01" {
f.isCalled = true
return nil
}
return errors.New("version does not exist")
}

View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Try to upload a file to S3 and then delete it using the configapi cli.
# Check the file exists after uploading it.
# Check the file does not exist after deleting it.
###### script header ######
lib=$(realpath @@BASE_LIB@@) || exit 1
stat "${lib}" >> /dev/null || exit 1
# shellcheck source=../../../../../bazel/sh/lib.bash
if ! source "${lib}"; then
echo "Error: could not find import"
exit 1
fi
configapi_cli=$(realpath @@CONFIGAPI_CLI@@)
stat "${configapi_cli}" >> /dev/null
###### script body ######
readonly region="eu-west-1"
readonly bucket="resource-api-testing"
readonly distribution="ETZGUP1CWRC2P"
tmpdir=$(mktemp -d)
readonly tmpdir
registerExitHandler "rm -rf $tmpdir"
readonly claim_path="$tmpdir/maaClaim.json"
cat << EOF > "$claim_path"
{
"x-ms-isolation-tee": {
"x-ms-sevsnpvm-tee-svn": 1,
"x-ms-sevsnpvm-snpfw-svn": 9,
"x-ms-sevsnpvm-microcode-svn": 116,
"x-ms-sevsnpvm-bootloader-svn": 4
}
}
EOF
readonly date="2023-02-02-03-04"
${configapi_cli} --maa-claims-path "$claim_path" --upload-date "$date" --region "$region" --bucket "$bucket" --distribution "$distribution"
baseurl="https://d33dzgxuwsgbpw.cloudfront.net/constellation/v1/attestation/azure-sev-snp"
if ! curl -fsSL ${baseurl}/${date}.json > /dev/null; then
echo "Checking for uploaded version file constellation/v1/attestation/azure-sev-snp/${date}.json: request returned ${?}"
exit 1
fi
if ! curl -fsSL ${baseurl}/${date}.json.sig > /dev/null; then
echo "Checking for uploaded version signature file constellation/v1/attestation/azure-sev-snp/${date}.json.sig: request returned ${?}"
exit 1
fi
if ! curl -fsSL ${baseurl}/list > /dev/null; then
echo "Checking for uploaded list file constellation/v1/attestation/azure-sev-snp/list: request returned ${?}"
exit 1
fi
${configapi_cli} delete --version "$date" --region "$region" --bucket "$bucket" --distribution "$distribution"
# Omit -f to check for 404. We want to check that a file was deleted, therefore we expect the query to fail.
http_code=$(curl -sSL -w '%{http_code}\n' -o /dev/null ${baseurl}/${date}.json)
if [[ $http_code -ne 404 ]]; then
echo "Expected HTTP code 404 for: constellation/v1/attestation/azure-sev-snp/${date}.json, but got ${http_code}"
exit 1
fi
# Omit -f to check for 404. We want to check that a file was deleted, therefore we expect the query to fail.
http_code=$(curl -sSL -w '%{http_code}\n' -o /dev/null ${baseurl}/${date}.json.sig)
if [[ $http_code -ne 404 ]]; then
echo "Expected HTTP code 404 for: constellation/v1/attestation/azure-sev-snp/${date}.json, but got ${http_code}"
exit 1
fi

View file

@ -0,0 +1,242 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
/*
This package provides a CLI to interact with the Attestationconfig API, a sub API of the Resource API.
You can execute an e2e test by running: `bazel run //hack/configapi:configapi_e2e_test`.
The CLI is used in the CI pipeline. Actions that change the bucket's data shouldn't be executed manually.
Notice that there is no synchronization on API operations.
*/
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"time"
"github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/edgelesssys/constellation/v2/internal/staticupload"
"go.uber.org/zap"
"github.com/spf13/cobra"
)
const (
awsRegion = "eu-central-1"
awsBucket = "cdn-constellation-backend"
distributionID = constants.CDNDefaultDistributionID
envCosignPwd = "COSIGN_PASSWORD"
envCosignPrivateKey = "COSIGN_PRIVATE_KEY"
)
var (
// Cosign credentials.
cosignPwd string
privateKey string
)
func main() {
if err := newRootCmd().Execute(); err != nil {
os.Exit(1)
}
os.Exit(0)
}
// newRootCmd creates the root command.
func newRootCmd() *cobra.Command {
rootCmd := &cobra.Command{
Use: "COSIGN_PASSWORD=$CPWD COSIGN_PRIVATE_KEY=$CKEY upload --version-file $FILE",
Short: "Upload a set of versions specific to the azure-sev-snp attestation variant to the config api.",
Long: fmt.Sprintf("Upload a set of versions specific to the azure-sev-snp attestation variant to the config api."+
"Please authenticate with AWS through your preferred method (e.g. environment variables, CLI)"+
"to be able to upload to S3. Set the %s and %s environment variables to authenticate with cosign.",
envCosignPrivateKey, envCosignPwd,
),
PreRunE: envCheck,
RunE: runCmd,
}
rootCmd.Flags().StringP("maa-claims-path", "t", "", "File path to a json file containing the MAA claims.")
rootCmd.Flags().StringP("upload-date", "d", "", "upload a version with this date as version name.")
rootCmd.PersistentFlags().StringP("region", "r", awsRegion, "region of the targeted bucket.")
rootCmd.PersistentFlags().StringP("bucket", "b", awsBucket, "bucket targeted by all operations.")
rootCmd.PersistentFlags().StringP("distribution", "i", distributionID, "cloudflare distribution used.")
must(rootCmd.MarkFlagRequired("maa-claims-path"))
rootCmd.AddCommand(newDeleteCmd())
return rootCmd
}
func envCheck(_ *cobra.Command, _ []string) error {
if os.Getenv(envCosignPrivateKey) == "" || os.Getenv(envCosignPwd) == "" {
return fmt.Errorf("please set both %s and %s environment variables", envCosignPrivateKey, envCosignPwd)
}
cosignPwd = os.Getenv(envCosignPwd)
privateKey = os.Getenv(envCosignPrivateKey)
return nil
}
func runCmd(cmd *cobra.Command, _ []string) (retErr error) {
ctx := cmd.Context()
log := logger.New(logger.PlainLog, zap.DebugLevel).Named("attestationconfigapi")
flags, err := parseCliFlags(cmd)
if err != nil {
return fmt.Errorf("parsing cli flags: %w", err)
}
cfg := staticupload.Config{
Bucket: flags.bucket,
Region: flags.region,
DistributionID: flags.distribution,
}
log.Infof("Reading MAA claims from file: %s", flags.maaFilePath)
maaClaimsBytes, err := os.ReadFile(flags.maaFilePath)
if err != nil {
return fmt.Errorf("reading MAA claims file: %w", err)
}
var maaTCB maaTokenTCBClaims
if err = json.Unmarshal(maaClaimsBytes, &maaTCB); err != nil {
return fmt.Errorf("unmarshalling MAA claims file: %w", err)
}
inputVersion := maaTCB.ToAzureSEVSNPVersion()
log.Infof("Input version: %+v", inputVersion)
latestAPIVersionAPI, err := attestationconfigapi.NewFetcher().FetchAzureSEVSNPVersionLatest(ctx, flags.uploadDate)
if err != nil {
return fmt.Errorf("fetching latest version: %w", err)
}
latestAPIVersion := latestAPIVersionAPI.AzureSEVSNPVersion
isNewer, err := isInputNewerThanLatestAPI(inputVersion, latestAPIVersion)
if err != nil {
return fmt.Errorf("comparing versions: %w", err)
}
if !isNewer {
log.Infof("Input version: %+v is not newer than latest API version: %+v", inputVersion, latestAPIVersion)
return nil
}
log.Infof("Input version: %+v is newer than latest API version: %+v", inputVersion, latestAPIVersion)
client, clientClose, err := attestationconfigapi.NewClient(ctx, cfg, []byte(cosignPwd), []byte(privateKey), false, log)
defer func() {
err := clientClose(cmd.Context())
if err != nil {
retErr = errors.Join(retErr, fmt.Errorf("failed to invalidate cache: %w", err))
}
}()
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
if err := client.UploadAzureSEVSNPVersion(ctx, inputVersion, flags.uploadDate); err != nil {
return fmt.Errorf("uploading version: %w", err)
}
cmd.Printf("Successfully uploaded new Azure SEV-SNP version: %+v\n", inputVersion)
return nil
}
type cliFlags struct {
maaFilePath string
uploadDate time.Time
region string
bucket string
distribution string
}
func parseCliFlags(cmd *cobra.Command) (cliFlags, error) {
maaFilePath, err := cmd.Flags().GetString("maa-claims-path")
if err != nil {
return cliFlags{}, fmt.Errorf("getting maa claims path: %w", err)
}
dateStr, err := cmd.Flags().GetString("upload-date")
if err != nil {
return cliFlags{}, fmt.Errorf("getting upload date: %w", err)
}
uploadDate := time.Now()
if dateStr != "" {
uploadDate, err = time.Parse(attestationconfigapi.VersionFormat, dateStr)
if err != nil {
return cliFlags{}, fmt.Errorf("parsing date: %w", err)
}
}
region, err := cmd.Flags().GetString("region")
if err != nil {
return cliFlags{}, fmt.Errorf("getting region: %w", err)
}
bucket, err := cmd.Flags().GetString("bucket")
if err != nil {
return cliFlags{}, fmt.Errorf("getting bucket: %w", err)
}
distribution, err := cmd.Flags().GetString("distribution")
if err != nil {
return cliFlags{}, fmt.Errorf("getting distribution: %w", err)
}
return cliFlags{
maaFilePath: maaFilePath,
uploadDate: uploadDate,
region: region,
bucket: bucket,
distribution: distribution,
}, nil
}
// maaTokenTCBClaims describes the TCB information in a MAA token.
type maaTokenTCBClaims struct {
IsolationTEE struct {
TEESvn uint8 `json:"x-ms-sevsnpvm-tee-svn"`
SNPFwSvn uint8 `json:"x-ms-sevsnpvm-snpfw-svn"`
MicrocodeSvn uint8 `json:"x-ms-sevsnpvm-microcode-svn"`
BootloaderSvn uint8 `json:"x-ms-sevsnpvm-bootloader-svn"`
} `json:"x-ms-isolation-tee"`
}
func (c maaTokenTCBClaims) ToAzureSEVSNPVersion() attestationconfigapi.AzureSEVSNPVersion {
return attestationconfigapi.AzureSEVSNPVersion{
TEE: c.IsolationTEE.TEESvn,
SNP: c.IsolationTEE.SNPFwSvn,
Microcode: c.IsolationTEE.MicrocodeSvn,
Bootloader: c.IsolationTEE.BootloaderSvn,
}
}
// isInputNewerThanLatestAPI compares all version fields with the latest API version and returns true if any input field is newer.
func isInputNewerThanLatestAPI(input, latest attestationconfigapi.AzureSEVSNPVersion) (bool, error) {
if input == latest {
return false, nil
}
if input.TEE < latest.TEE {
return false, fmt.Errorf("input TEE version: %d is older than latest API version: %d", input.TEE, latest.TEE)
}
if input.SNP < latest.SNP {
return false, fmt.Errorf("input SNP version: %d is older than latest API version: %d", input.SNP, latest.SNP)
}
if input.Microcode < latest.Microcode {
return false, fmt.Errorf("input Microcode version: %d is older than latest API version: %d", input.Microcode, latest.Microcode)
}
if input.Bootloader < latest.Bootloader {
return false, fmt.Errorf("input Bootloader version: %d is older than latest API version: %d", input.Bootloader, latest.Bootloader)
}
return true, nil
}
func must(err error) {
if err != nil {
panic(err)
}
}

View file

@ -0,0 +1,77 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package main
import (
"testing"
"github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi"
"github.com/stretchr/testify/assert"
)
func TestIsInputNewerThanLatestAPI(t *testing.T) {
newTestCfg := func() attestationconfigapi.AzureSEVSNPVersion {
return attestationconfigapi.AzureSEVSNPVersion{
Microcode: 93,
TEE: 0,
SNP: 6,
Bootloader: 2,
}
}
testCases := map[string]struct {
latest attestationconfigapi.AzureSEVSNPVersion
input attestationconfigapi.AzureSEVSNPVersion
expect bool
errMsg string
}{
"input is older than latest": {
input: func(c attestationconfigapi.AzureSEVSNPVersion) attestationconfigapi.AzureSEVSNPVersion {
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.AzureSEVSNPVersion) attestationconfigapi.AzureSEVSNPVersion {
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.AzureSEVSNPVersion) attestationconfigapi.AzureSEVSNPVersion {
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 := isInputNewerThanLatestAPI(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)
}
})
}
}