diff --git a/go.mod b/go.mod index 661fc0c5cb9..2ab9e3fca55 100644 --- a/go.mod +++ b/go.mod @@ -172,7 +172,7 @@ require ( github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1 // indirect - github.com/BurntSushi/toml v1.3.2 // indirect + github.com/BurntSushi/toml v1.3.2 github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect diff --git a/internal/osimage/uplosi/BUILD.bazel b/internal/osimage/uplosi/BUILD.bazel new file mode 100644 index 00000000000..ad50cd8653f --- /dev/null +++ b/internal/osimage/uplosi/BUILD.bazel @@ -0,0 +1,16 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "uplosi", + srcs = ["uplosiupload.go"], + embedsrcs = ["uplosi.conf.in"], + importpath = "github.com/edgelesssys/constellation/v2/internal/osimage/uplosi", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/api/versionsapi", + "//internal/cloud/cloudprovider", + "//internal/logger", + "//internal/osimage", + "@com_github_burntsushi_toml//:toml", + ], +) diff --git a/internal/osimage/uplosi/uplosi.conf.in b/internal/osimage/uplosi/uplosi.conf.in new file mode 100644 index 00000000000..9e5ff3576c2 --- /dev/null +++ b/internal/osimage/uplosi/uplosi.conf.in @@ -0,0 +1,21 @@ +[base] +name = "constellation" + +[base.aws] +region = "eu-central-1" +replicationRegions = ["eu-west-1", "eu-west-3", "us-east-2", "ap-south-1"] +bucket = "constellation-images" +publish = true + +[base.azure] +subscriptionID = "0d202bbb-4fa7-4af8-8125-58c269a05435" +location = "northeurope" +resourceGroup = "constellation-images" +sharingNamePrefix = "constellation" +sku = "constellation" +publisher = "edgelesssys" + +[base.gcp] +project = "constellation-images" +location = "europe-west3" +bucket = "constellation-os-images" diff --git a/internal/osimage/uplosi/uplosiupload.go b/internal/osimage/uplosi/uplosiupload.go new file mode 100644 index 00000000000..ea5054db67f --- /dev/null +++ b/internal/osimage/uplosi/uplosiupload.go @@ -0,0 +1,258 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +// package uplosi implements uploading os images using uplosi. +package uplosi + +import ( + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/BurntSushi/toml" + "github.com/edgelesssys/constellation/v2/internal/api/versionsapi" + "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/logger" + "github.com/edgelesssys/constellation/v2/internal/osimage" +) + +//go:embed uplosi.conf.in +var uplosiConfigTemplate string + +const timestampFormat = "20060102150405" + +// Uploader can upload os images using uplosi. +type Uploader struct { + uplosiPath string + + log *logger.Logger +} + +// New creates a new Uploader. +func New(uplosiPath string, log *logger.Logger) *Uploader { + return &Uploader{ + uplosiPath: uplosiPath, + log: log, + } +} + +// Upload uploads the given os image using uplosi. +func (u *Uploader) Upload(ctx context.Context, req *osimage.UploadRequest) ([]versionsapi.ImageInfoEntry, error) { + config, err := prepareUplosiConfig(req) + if err != nil { + return nil, err + } + + workspace, err := prepareWorkspace(config) + if err != nil { + return nil, err + } + defer os.RemoveAll(workspace) + + uplosiOutput, err := runUplosi(ctx, u.uplosiPath, workspace, req.ImagePath) + if err != nil { + return nil, err + } + + return parseUplosiOutput(uplosiOutput, req.Provider, req.AttestationVariant) +} + +func prepareUplosiConfig(req *osimage.UploadRequest) ([]byte, error) { + var config map[string]any + if _, err := toml.Decode(uplosiConfigTemplate, &config); err != nil { + return nil, err + } + + imageVersionStr, err := imageVersion(req.Provider, req.Version, req.Timestamp) + if err != nil { + return nil, err + } + baseConfig := config["base"].(map[string]any) + awsConfig := baseConfig["aws"].(map[string]any) + azureConfig := baseConfig["azure"].(map[string]any) + gcpConfig := baseConfig["gcp"].(map[string]any) + + baseConfig["imageVersion"] = imageVersionStr + baseConfig["provider"] = strings.ToLower(req.Provider.String()) + extendAWSConfig(awsConfig, req.Version, req.AttestationVariant, req.Timestamp) + extendAzureConfig(azureConfig, req.Version, req.AttestationVariant, req.Timestamp) + extendGCPConfig(gcpConfig, req.Version, req.AttestationVariant) + + buf := new(bytes.Buffer) + if err := toml.NewEncoder(buf).Encode(config); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func prepareWorkspace(config []byte) (string, error) { + workspace, err := os.MkdirTemp("", "uplosi-") + if err != nil { + return "", err + } + // write config to workspace + configPath := filepath.Join(workspace, "uplosi.conf") + if err := os.WriteFile(configPath, config, 0o644); err != nil { + return "", err + } + return workspace, nil +} + +func runUplosi(ctx context.Context, uplosiPath string, workspace string, rawImage string) ([]byte, error) { + imagePath, err := filepath.Abs(rawImage) + if err != nil { + return nil, err + } + + uplosiCmd := exec.CommandContext(ctx, uplosiPath, "upload", imagePath) + uplosiCmd.Dir = workspace + uplosiCmd.Stderr = os.Stderr + return uplosiCmd.Output() +} + +func parseUplosiOutput(output []byte, csp cloudprovider.Provider, attestationVariant string) ([]versionsapi.ImageInfoEntry, error) { + lines := strings.Split(string(output), "\n") + var imageReferences []versionsapi.ImageInfoEntry + for _, line := range lines { + if len(line) == 0 { + continue + } + var region, reference string + if csp == cloudprovider.AWS { + var err error + region, reference, err = awsParseAMIARN(line) + if err != nil { + return nil, err + } + } else { + reference = line + } + imageReferences = append(imageReferences, versionsapi.ImageInfoEntry{ + CSP: strings.ToLower(csp.String()), + AttestationVariant: attestationVariant, + Reference: reference, + Region: region, + }) + } + return imageReferences, nil +} + +func imageVersion(csp cloudprovider.Provider, version versionsapi.Version, timestamp time.Time) (string, error) { + cleanSemver := strings.TrimPrefix(regexp.MustCompile(`^v\d+\.\d+\.\d+`).FindString(version.Version()), "v") + if csp != cloudprovider.Azure { + return cleanSemver, nil + } + + switch { + case version.Stream() == "stable": + fallthrough + case version.Stream() == "debug" && version.Ref() == "-": + return cleanSemver, nil + } + + formattedTime := timestamp.Format(timestampFormat) + if len(formattedTime) != len(timestampFormat) { + return "", errors.New("invalid timestamp") + } + // ..