diff --git a/bazel/toolchains/go_module_deps.bzl b/bazel/toolchains/go_module_deps.bzl index 284e44547..10e629abf 100644 --- a/bazel/toolchains/go_module_deps.bzl +++ b/bazel/toolchains/go_module_deps.bzl @@ -604,8 +604,8 @@ def go_dependencies(): build_file_generation = "on", build_file_proto_mode = "disable_global", importpath = "github.com/Azure/azure-sdk-for-go/sdk/azcore", - sum = "h1:rTnT/Jrcm+figWlYz4Ixzt0SJVR2cMC8lvZcimipiEY=", - version = "v1.4.0", + sum = "h1:xGLAFFd9D3iLGxYiUGPdITSzsFmU1K8VtfuUHWAoN7M=", + version = "v1.5.0", ) go_repository( name = "com_github_azure_azure_sdk_for_go_sdk_azidentity", @@ -620,8 +620,8 @@ def go_dependencies(): build_file_generation = "on", build_file_proto_mode = "disable_global", importpath = "github.com/Azure/azure-sdk-for-go/sdk/internal", - sum = "h1:leh5DwKv6Ihwi+h60uHtn6UWAxBbZ0q8DwQVMzf61zw=", - version = "v1.2.0", + sum = "h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY=", + version = "v1.3.0", ) go_repository( @@ -701,8 +701,8 @@ def go_dependencies(): build_file_generation = "on", build_file_proto_mode = "disable_global", importpath = "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob", - sum = "h1:YvQv9Mz6T8oR5ypQOL6erY0Z5t71ak1uHV4QFokCOZk=", - version = "v0.6.1", + sum = "h1:u/LLAOFgsMv7HmNL4Qufg58y+qElGOt5qv0z1mURkRY=", + version = "v1.0.0", ) go_repository( name = "com_github_azure_azure_service_bus_go", diff --git a/go.mod b/go.mod index df2887e7a..c4584d648 100644 --- a/go.mod +++ b/go.mod @@ -45,13 +45,13 @@ require ( cloud.google.com/go/secretmanager v1.10.0 cloud.google.com/go/storage v1.30.1 github.com/Azure/azure-sdk-for-go v68.0.0+incompatible - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.5.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2 github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.11.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/applicationinsights/armapplicationinsights v1.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v4 v4.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2 v2.1.0 - github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.6.1 + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 github.com/Azure/go-autorest/autorest/to v0.4.0 github.com/aws/aws-sdk-go-v2 v1.17.7 github.com/aws/aws-sdk-go-v2/config v1.18.19 @@ -129,7 +129,7 @@ require ( cloud.google.com/go/iam v0.12.0 // indirect cloud.google.com/go/longrunning v0.4.1 // indirect code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect diff --git a/go.sum b/go.sum index 8878572cd..0932eccf3 100644 --- a/go.sum +++ b/go.sum @@ -89,12 +89,12 @@ github.com/Azure/azure-sdk-for-go v29.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo github.com/Azure/azure-sdk-for-go v30.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 h1:rTnT/Jrcm+figWlYz4Ixzt0SJVR2cMC8lvZcimipiEY= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.5.0 h1:xGLAFFd9D3iLGxYiUGPdITSzsFmU1K8VtfuUHWAoN7M= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.5.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2 h1:uqM+VoHjVH6zdlkLF2b6O0ZANcHoj3rO0PoQ3jglUJA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2/go.mod h1:twTKAa1E6hLmSDjLhaCkbTMQKc7p/rNLU40rLxGEOCI= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0 h1:leh5DwKv6Ihwi+h60uHtn6UWAxBbZ0q8DwQVMzf61zw= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.11.0 h1:82w8tzLcOwDP/Q35j/wEBPt0n0kVC3cjtPdD62G8UAk= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.11.0/go.mod h1:S78i9yTr4o/nXlH76bKjGUye9Z2wSxO5Tz7GoDr4vfI= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.0 h1:Lg6BW0VPmCwcMlvOviL3ruHFO+H9tZNqscK0AeuFjGM= @@ -110,8 +110,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0/ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2 v2.1.0 h1:mk57wRUA8fyjFxVcPPGv4shLcWDXPFYokTJL9zJxQtE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2 v2.1.0/go.mod h1:mU96hbp8qJDA9OzTV1Ji7wCyPyaqC5kI6ZPsZfJ8sE4= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0 h1:ECsQtyERDVz3NP3kvDOTLvbQhqWp/x9EsGKtb4ogUr8= -github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.6.1 h1:YvQv9Mz6T8oR5ypQOL6erY0Z5t71ak1uHV4QFokCOZk= -github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.6.1/go.mod h1:c6WvOhtmjNUWbLfOG1qxM/q0SPvQNSVJvolm+C52dIU= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 h1:u/LLAOFgsMv7HmNL4Qufg58y+qElGOt5qv0z1mURkRY= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0/go.mod h1:2e8rMJtl2+2j+HXbTBwnyGpm5Nou7KhvSfxOq8JpTag= github.com/Azure/azure-service-bus-go v0.9.1/go.mod h1:yzBx6/BUGfjfeqbRZny9AQIbIe3AcV9WZbAdpkoXOa0= github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= diff --git a/hack/go.mod b/hack/go.mod index 32dea1cdb..a17ec16aa 100644 --- a/hack/go.mod +++ b/hack/go.mod @@ -60,9 +60,9 @@ require ( cloud.google.com/go/compute/metadata v0.2.3 // indirect code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c // indirect github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.5.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/applicationinsights/armapplicationinsights v1.0.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v4 v4.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2 v2.1.0 // indirect diff --git a/hack/go.sum b/hack/go.sum index d93fa0744..28ba7ef91 100644 --- a/hack/go.sum +++ b/hack/go.sum @@ -81,12 +81,12 @@ github.com/Azure/azure-sdk-for-go v29.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo github.com/Azure/azure-sdk-for-go v30.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 h1:rTnT/Jrcm+figWlYz4Ixzt0SJVR2cMC8lvZcimipiEY= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.5.0 h1:xGLAFFd9D3iLGxYiUGPdITSzsFmU1K8VtfuUHWAoN7M= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.5.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2 h1:uqM+VoHjVH6zdlkLF2b6O0ZANcHoj3rO0PoQ3jglUJA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2/go.mod h1:twTKAa1E6hLmSDjLhaCkbTMQKc7p/rNLU40rLxGEOCI= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0 h1:leh5DwKv6Ihwi+h60uHtn6UWAxBbZ0q8DwQVMzf61zw= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/applicationinsights/armapplicationinsights v1.0.0 h1:BpGGvzarSyE7kQF1x1hptUcGmNzZEE3yYI+uqBSNRxk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/applicationinsights/armapplicationinsights v1.0.0/go.mod h1:1ijUM40peD7YK5MFEJja2wjjp4eimFNWv0NXoY3nsZM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v3 v3.0.1 h1:H3g2mkmu105ON0c/Gqx3Bm+bzoIijLom8LmV9Gjn7X0= diff --git a/image/BUILD.bazel b/image/BUILD.bazel new file mode 100644 index 000000000..e69de29bb diff --git a/image/README.md b/image/README.md index 4bbf42c5e..2f9619821 100644 --- a/image/README.md +++ b/image/README.md @@ -57,7 +57,6 @@ jq \ mtools \ ovmf \ - python3-crc32c \ python3-pefile \ python3-pyelftools \ python3-setuptools \ @@ -177,6 +176,8 @@ secure-boot/azure/delete.sh --name "${AZURE_DISK_NAME}-setup-secure-boot" ## Upload to CSP +Warning! Never set `--version` to a value that is already used for a release image. +
AWS @@ -188,19 +189,9 @@ secure-boot/azure/delete.sh --name "${AZURE_DISK_NAME}-setup-secure-boot" - `pki_prod` is used for release images ```sh -# set these variables -export AWS_IMAGE_NAME= # e.g. "constellation-v1.0.0" -export PKI=${PWD}/pki - -export AWS_REGION=eu-central-1 -export AWS_REPLICATION_REGIONS="us-east-2" -export AWS_BUCKET=constellation-images -export AWS_EFIVARS_PATH=${PWD}/mkosi.output.aws/fedora~37/efivars.bin -export AWS_IMAGE_PATH=${PWD}/mkosi.output.aws/fedora~37/image.raw -export AWS_IMAGE_FILENAME=image-$(date +%s).raw -export AWS_JSON_OUTPUT=${PWD}/mkosi.output.aws/fedora~37/image-upload.json -secure-boot/aws/create_uefivars.sh "${AWS_EFIVARS_PATH}" -upload/upload_aws.sh +# Warning! Never set `--version` to a value that is already used for a release image. +# Instead, use a `ref` that corresponds to your branch name. +bazel run //image/upload -- aws --verbose --raw-image mkosi.output.aws/fedora~37/image.raw --variant "" --version ref/foo/stream/nightly/v2.7.0-pre-asdf ```
@@ -216,20 +207,12 @@ upload/upload_aws.sh - `pki_prod` is used for release images ```sh -# set these variables -export GCP_IMAGE_FAMILY= # e.g. "constellation" -export GCP_IMAGE_NAME= # e.g. "constellation-v1.0.0" -export PKI=${PWD}/pki - -export GCP_PROJECT=constellation-images -export GCP_REGION=europe-west3 -export GCP_BUCKET=constellation-images export GCP_RAW_IMAGE_PATH=${PWD}/mkosi.output.gcp/fedora~37/image.raw -export GCP_IMAGE_FILENAME=$(date +%s).tar.gz export GCP_IMAGE_PATH=${PWD}/mkosi.output.gcp/fedora~37/image.tar.gz -export GCP_JSON_OUTPUT=${PWD}/mkosi.output.gcp/fedora~37/image-upload.json upload/pack.sh gcp ${GCP_RAW_IMAGE_PATH} ${GCP_IMAGE_PATH} -upload/upload_gcp.sh +# Warning! Never set `--version` to a value that is already used for a release image. +# Instead, use a `ref` that corresponds to your branch name. +bazel run //image/upload -- gcp --verbose --raw-image "${GCP_IMAGE_PATH}" --variant "sev-es" --version ref/foo/stream/nightly/v2.7.0-pre-asdf ``` @@ -247,31 +230,12 @@ Note: - Optional (if Secure Boot should be enabled) [Prepare virtual machine guest state (VMGS) with customized NVRAM or use existing VMGS blob](#azure-secure-boot) ```sh -# set these variables -export AZURE_GALLERY_NAME= # e.g. "Constellation" -export AZURE_IMAGE_DEFINITION= # e.g. "constellation" -export AZURE_IMAGE_VERSION= # e.g. "1.0.0" -# Set this variable to a path if you want to use Secure Boot. -# Otherwise, set it to export AZURE_VMGS_PATH= -export AZURE_VMGS_PATH= # e.g. nothing OR "path/to/ConfidentialVM.vmgs" -# AZURE_SECURITY_TYPE can be one of -# - "ConfidentialVMSupported" (ConfidentialVM with secure boot disabled), -# - "ConfidentialVM" (ConfidentialVM with Secure Boot) or -# - TrustedLaunch" (Trusted Launch with or without Secure Boot) -export AZURE_SECURITY_TYPE=ConfidentialVMSupported - -export AZURE_RESOURCE_GROUP_NAME=constellation-images -export AZURE_REGION=northeurope -export AZURE_REPLICATION_REGIONS="northeurope eastus westeurope westus" -export AZURE_IMAGE_OFFER=constellation -export AZURE_SKU=${AZURE_IMAGE_DEFINITION} -export AZURE_PUBLISHER=edgelesssys -export AZURE_DISK_NAME=constellation-$(date +%s) export AZURE_RAW_IMAGE_PATH=${PWD}/mkosi.output.azure/fedora~37/image.raw export AZURE_IMAGE_PATH=${PWD}/mkosi.output.azure/fedora~37/image.vhd -export AZURE_JSON_OUTPUT=${PWD}/mkosi.output.azure/fedora~37/image-upload.json upload/pack.sh azure "${AZURE_RAW_IMAGE_PATH}" "${AZURE_IMAGE_PATH}" -upload/upload_azure.sh -g --disk-name "${AZURE_DISK_NAME}" "${AZURE_VMGS_PATH}" +# Warning! Never set `--version` to a value that is already used for a release image. +# Instead, use a `ref` that corresponds to your branch name. +bazel run //image/upload -- azure --verbose --raw-image "${AZURE_IMAGE_PATH}" --variant "cvm" --version ref/foo/stream/nightly/v2.7.0-pre-asdf ``` @@ -288,15 +252,9 @@ Note: - Login to AWS (see [here](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html)) ```sh -# set these variables -export REF= # e.g. feat-xyz (branch name encoded with dashes) -export STREAM= # e.g. "nightly", "debug", "stable" (depends on the type of image and if it is a release) -export IMAGE_VERSION= # e.g. v2.1.0" or output of pseudo-version tool -export OPENSTACK_BUCKET=cdn-constellation-backend -export OPENSTACK_BASE_URL="https://cdn.confidential.cloud" -export OPENSTACK_IMAGE_PATH=${PWD}/mkosi.output.qemu/fedora~37/image.raw -export OPENSTACK_JSON_OUTPUT=${PWD}/mkosi.output.qemu/fedora~37/image-upload.json -upload/upload_openstack.sh +# Warning! Never set `--version` to a value that is already used for a release image. +# Instead, use a `ref` that corresponds to your branch name. +bazel run //image/upload -- openstack --verbose --raw-image mkosi.output.openstack/fedora~37/image.raw --variant "sev" --version ref/foo/stream/nightly/v2.7.0-pre-asdf ``` @@ -308,15 +266,9 @@ upload/upload_openstack.sh - Login to AWS (see [here](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html)) ```sh -# set these variables -export REF= # e.g. feat-xyz (branch name encoded with dashes) -export STREAM= # e.g. "nightly", "debug", "stable" (depends on the type of image and if it is a release) -export IMAGE_VERSION= # e.g. v2.1.0" or output of pseudo-version tool -export QEMU_BUCKET=cdn-constellation-backend -export QEMU_BASE_URL="https://cdn.confidential.cloud" -export QEMU_IMAGE_PATH=${PWD}/mkosi.output.qemu/fedora~37/image.raw -export QEMU_JSON_OUTPUT=${PWD}/mkosi.output.qemu/fedora~37/image-upload.json -upload/upload_qemu.sh +# Warning! Never set `--version` to a value that is already used for a release image. +# Instead, use a `ref` that corresponds to your branch name. +bazel run //image/upload -- qemu --verbose --raw-image mkosi.output.qemu/fedora~37/image.raw --variant "default" --version ref/foo/stream/nightly/v2.7.0-pre-asdf ``` diff --git a/image/upload/BUILD.bazel b/image/upload/BUILD.bazel new file mode 100644 index 000000000..8eef54f0e --- /dev/null +++ b/image/upload/BUILD.bazel @@ -0,0 +1,18 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") + +go_library( + name = "upload_lib", + srcs = ["upload.go"], + importpath = "github.com/edgelesssys/constellation/v2/image/upload", + visibility = ["//visibility:private"], + deps = [ + "//image/upload/internal/cmd", + "@com_github_spf13_cobra//:cobra", + ], +) + +go_binary( + name = "upload", + embed = [":upload_lib"], + visibility = ["//visibility:public"], +) diff --git a/image/upload/delete_aws.sh b/image/upload/delete_aws.sh new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/image/upload/delete_aws.sh @@ -0,0 +1 @@ + diff --git a/image/upload/internal/cmd/BUILD.bazel b/image/upload/internal/cmd/BUILD.bazel new file mode 100644 index 000000000..5b1d77371 --- /dev/null +++ b/image/upload/internal/cmd/BUILD.bazel @@ -0,0 +1,35 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "cmd", + srcs = [ + "api.go", + "aws.go", + "azure.go", + "flags.go", + "gcp.go", + "must.go", + "nop.go", + "openstack.go", + "qemu.go", + "secureboot.go", + "upload.go", + ], + importpath = "github.com/edgelesssys/constellation/v2/image/upload/internal/cmd", + visibility = ["//image/upload:__subpackages__"], + deps = [ + "//internal/cloud/cloudprovider", + "//internal/logger", + "//internal/osimage", + "//internal/osimage/archive", + "//internal/osimage/aws", + "//internal/osimage/azure", + "//internal/osimage/gcp", + "//internal/osimage/nop", + "//internal/osimage/secureboot", + "//internal/versionsapi", + "@com_github_spf13_afero//:afero", + "@com_github_spf13_cobra//:cobra", + "@org_uber_go_zap//zapcore", + ], +) diff --git a/image/upload/internal/cmd/api.go b/image/upload/internal/cmd/api.go new file mode 100644 index 000000000..f2b3445b3 --- /dev/null +++ b/image/upload/internal/cmd/api.go @@ -0,0 +1,25 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "context" + "io" + + "github.com/edgelesssys/constellation/v2/internal/osimage" + "github.com/edgelesssys/constellation/v2/internal/versionsapi" +) + +type archivist interface { + Archive(ctx context.Context, + version versionsapi.Version, csp, variant string, img io.Reader, + ) (string, error) +} + +type uploader interface { + Upload(ctx context.Context, req *osimage.UploadRequest) (map[string]string, error) +} diff --git a/image/upload/internal/cmd/aws.go b/image/upload/internal/cmd/aws.go new file mode 100644 index 000000000..9817420db --- /dev/null +++ b/image/upload/internal/cmd/aws.go @@ -0,0 +1,97 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/edgelesssys/constellation/v2/internal/logger" + "github.com/edgelesssys/constellation/v2/internal/osimage" + "github.com/edgelesssys/constellation/v2/internal/osimage/archive" + awsupload "github.com/edgelesssys/constellation/v2/internal/osimage/aws" + "github.com/spf13/cobra" +) + +// NewAWSCmd returns the command that uploads an OS image to AWS. +func NewAWSCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "aws", + Short: "Upload OS image to AWS", + Long: "Upload OS image to AWS.", + Args: cobra.ExactArgs(0), + RunE: runAWS, + } + + cmd.Flags().String("aws-region", "eu-central-1", "AWS region used during AMI creation") + cmd.Flags().String("aws-bucket", "constellation-images", "S3 bucket used during AMI creation") + return cmd +} + +func runAWS(cmd *cobra.Command, _ []string) error { + workdir := os.Getenv("BUILD_WORKING_DIRECTORY") + if len(workdir) > 0 { + must(os.Chdir(workdir)) + } + + flags, err := parseAWSFlags(cmd) + if err != nil { + return err + } + log := logger.New(logger.PlainLog, flags.logLevel) + log.Debugf("Parsed flags: %+v", flags) + + archiveC, err := archive.New(cmd.Context(), flags.region, flags.bucket, log) + if err != nil { + return err + } + + uploadC, err := awsupload.New(flags.awsRegion, flags.awsBucket, log) + if err != nil { + return fmt.Errorf("uploading image: %w", err) + } + + file, err := os.Open(flags.rawImage) + if err != nil { + return fmt.Errorf("uploading image: opening image file %w", err) + } + defer file.Close() + size, err := file.Seek(0, io.SeekEnd) + if err != nil { + return err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + out := cmd.OutOrStdout() + if len(flags.out) > 0 { + outF, err := os.Create(flags.out) + if err != nil { + return fmt.Errorf("uploading image: opening output file %w", err) + } + defer outF.Close() + out = outF + } + + sbDatabase, uefiVarStore, err := loadSecureBootKeys(flags.pki) + if err != nil { + return err + } + + uploadReq := &osimage.UploadRequest{ + Provider: flags.provider, + Version: flags.version, + Variant: flags.variant, + SBDatabase: sbDatabase, + UEFIVarStore: uefiVarStore, + Size: size, + Timestamp: flags.timestamp, + Image: file, + } + return uploadImage(cmd.Context(), archiveC, uploadC, uploadReq, out) +} diff --git a/image/upload/internal/cmd/azure.go b/image/upload/internal/cmd/azure.go new file mode 100644 index 000000000..aa760c069 --- /dev/null +++ b/image/upload/internal/cmd/azure.go @@ -0,0 +1,98 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/edgelesssys/constellation/v2/internal/logger" + "github.com/edgelesssys/constellation/v2/internal/osimage" + "github.com/edgelesssys/constellation/v2/internal/osimage/archive" + azureupload "github.com/edgelesssys/constellation/v2/internal/osimage/azure" + "github.com/spf13/cobra" +) + +// NewAzureCmd returns the command that uploads an OS image to Azure. +func NewAzureCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "azure", + Short: "Upload OS image to Azure", + Long: "Upload OS image to Azure.", + Args: cobra.ExactArgs(0), + RunE: runAzure, + } + + cmd.Flags().String("az-subscription", "0d202bbb-4fa7-4af8-8125-58c269a05435", "Azure subscription to use") + cmd.Flags().String("az-location", "northeurope", "Azure location to use") + cmd.Flags().String("az-resource-group", "constellation-images", "Azure resource group to use") + return cmd +} + +func runAzure(cmd *cobra.Command, _ []string) error { + workdir := os.Getenv("BUILD_WORKING_DIRECTORY") + if len(workdir) > 0 { + must(os.Chdir(workdir)) + } + + flags, err := parseAzureFlags(cmd) + if err != nil { + return err + } + log := logger.New(logger.PlainLog, flags.logLevel) + log.Debugf("Parsed flags: %+v", flags) + + archiveC, err := archive.New(cmd.Context(), flags.region, flags.bucket, log) + if err != nil { + return err + } + + uploadC, err := azureupload.New(flags.azSubscription, flags.azLocation, flags.azResourceGroup, log) + if err != nil { + return fmt.Errorf("uploading image: %w", err) + } + + file, err := os.Open(flags.rawImage) + if err != nil { + return fmt.Errorf("uploading image: opening image file %w", err) + } + defer file.Close() + size, err := file.Seek(0, io.SeekEnd) + if err != nil { + return err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + out := cmd.OutOrStdout() + if len(flags.out) > 0 { + outF, err := os.Create(flags.out) + if err != nil { + return fmt.Errorf("uploading image: opening output file %w", err) + } + defer outF.Close() + out = outF + } + + sbDatabase, uefiVarStore, err := loadSecureBootKeys(flags.pki) + if err != nil { + return err + } + + uploadReq := &osimage.UploadRequest{ + Provider: flags.provider, + Version: flags.version, + Variant: flags.variant, + SBDatabase: sbDatabase, + UEFIVarStore: uefiVarStore, + Size: size, + Timestamp: flags.timestamp, + Image: file, + } + return uploadImage(cmd.Context(), archiveC, uploadC, uploadReq, out) +} diff --git a/image/upload/internal/cmd/flags.go b/image/upload/internal/cmd/flags.go new file mode 100644 index 000000000..06b3fbf42 --- /dev/null +++ b/image/upload/internal/cmd/flags.go @@ -0,0 +1,200 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "os" + "path/filepath" + "time" + + "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/versionsapi" + "github.com/spf13/cobra" + "go.uber.org/zap/zapcore" +) + +type commonFlags struct { + rawImage string + pki string + provider cloudprovider.Provider + variant string + version versionsapi.Version + timestamp time.Time + region string + bucket string + out string + logLevel zapcore.Level +} + +func parseCommonFlags(cmd *cobra.Command) (commonFlags, error) { + workspaceDir := os.Getenv("BUILD_WORKSPACE_DIRECTORY") + rawImage, err := cmd.Flags().GetString("raw-image") + if err != nil { + return commonFlags{}, err + } + pki, err := cmd.Flags().GetString("pki") + if err != nil { + return commonFlags{}, err + } + if pki == "" { + pki = filepath.Join(workspaceDir, "image/pki") + } + variant, err := cmd.Flags().GetString("variant") + if err != nil { + return commonFlags{}, err + } + version, err := cmd.Flags().GetString("version") + if err != nil { + return commonFlags{}, err + } + ver, err := versionsapi.NewVersionFromShortPath(version, versionsapi.VersionKindImage) + if err != nil { + return commonFlags{}, err + } + timestamp, err := cmd.Flags().GetString("timestamp") + if err != nil { + return commonFlags{}, err + } + if timestamp == "" { + timestamp = time.Now().Format("2006-01-02T15:04:05Z07:00") + } + timestmp, err := time.Parse("2006-01-02T15:04:05Z07:00", timestamp) + if err != nil { + return commonFlags{}, err + } + region, err := cmd.Flags().GetString("region") + if err != nil { + return commonFlags{}, err + } + bucket, err := cmd.Flags().GetString("bucket") + if err != nil { + return commonFlags{}, err + } + out, err := cmd.Flags().GetString("out") + if err != nil { + return commonFlags{}, err + } + verbose, err := cmd.Flags().GetBool("verbose") + if err != nil { + return commonFlags{}, err + } + logLevel := zapcore.InfoLevel + if verbose { + logLevel = zapcore.DebugLevel + } + + return commonFlags{ + rawImage: rawImage, + pki: pki, + variant: variant, + version: ver, + timestamp: timestmp, + region: region, + bucket: bucket, + out: out, + logLevel: logLevel, + }, nil +} + +type awsFlags struct { + commonFlags + awsRegion string + awsBucket string +} + +func parseAWSFlags(cmd *cobra.Command) (awsFlags, error) { + common, err := parseCommonFlags(cmd) + if err != nil { + return awsFlags{}, err + } + + awsRegion, err := cmd.Flags().GetString("aws-region") + if err != nil { + return awsFlags{}, err + } + awsBucket, err := cmd.Flags().GetString("aws-bucket") + if err != nil { + return awsFlags{}, err + } + + common.provider = cloudprovider.AWS + return awsFlags{ + commonFlags: common, + awsRegion: awsRegion, + awsBucket: awsBucket, + }, nil +} + +type azureFlags struct { + commonFlags + azSubscription string + azLocation string + azResourceGroup string +} + +func parseAzureFlags(cmd *cobra.Command) (azureFlags, error) { + common, err := parseCommonFlags(cmd) + if err != nil { + return azureFlags{}, err + } + + azSubscription, err := cmd.Flags().GetString("az-subscription") + if err != nil { + return azureFlags{}, err + } + azLocation, err := cmd.Flags().GetString("az-location") + if err != nil { + return azureFlags{}, err + } + azResourceGroup, err := cmd.Flags().GetString("az-resource-group") + if err != nil { + return azureFlags{}, err + } + + common.provider = cloudprovider.Azure + return azureFlags{ + commonFlags: common, + azSubscription: azSubscription, + azLocation: azLocation, + azResourceGroup: azResourceGroup, + }, nil +} + +type gcpFlags struct { + commonFlags + gcpProject string + gcpLocation string + gcpBucket string +} + +func parseGCPFlags(cmd *cobra.Command) (gcpFlags, error) { + common, err := parseCommonFlags(cmd) + if err != nil { + return gcpFlags{}, err + } + + gcpProject, err := cmd.Flags().GetString("gcp-project") + if err != nil { + return gcpFlags{}, err + } + gcpLocation, err := cmd.Flags().GetString("gcp-location") + if err != nil { + return gcpFlags{}, err + } + gcpBucket, err := cmd.Flags().GetString("gcp-bucket") + if err != nil { + return gcpFlags{}, err + } + + common.provider = cloudprovider.GCP + return gcpFlags{ + commonFlags: common, + gcpProject: gcpProject, + gcpLocation: gcpLocation, + gcpBucket: gcpBucket, + }, nil +} diff --git a/image/upload/internal/cmd/gcp.go b/image/upload/internal/cmd/gcp.go new file mode 100644 index 000000000..b562e9ee1 --- /dev/null +++ b/image/upload/internal/cmd/gcp.go @@ -0,0 +1,98 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/edgelesssys/constellation/v2/internal/logger" + "github.com/edgelesssys/constellation/v2/internal/osimage" + "github.com/edgelesssys/constellation/v2/internal/osimage/archive" + gcpupload "github.com/edgelesssys/constellation/v2/internal/osimage/gcp" + "github.com/spf13/cobra" +) + +// NewGCPCommand returns the command that uploads an OS image to GCP. +func NewGCPCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "gcp", + Short: "Upload OS image to GCP", + Long: "Upload OS image to GCP.", + Args: cobra.ExactArgs(0), + RunE: runGCP, + } + + cmd.Flags().String("gcp-project", "constellation-images", "GCP project to use") + cmd.Flags().String("gcp-location", "europe-west3", "GCP location to use") + cmd.Flags().String("gcp-bucket", "constellation-images", "GCP bucket to use") + return cmd +} + +func runGCP(cmd *cobra.Command, _ []string) error { + workdir := os.Getenv("BUILD_WORKING_DIRECTORY") + if len(workdir) > 0 { + must(os.Chdir(workdir)) + } + + flags, err := parseGCPFlags(cmd) + if err != nil { + return err + } + log := logger.New(logger.PlainLog, flags.logLevel) + log.Debugf("Parsed flags: %+v", flags) + + archiveC, err := archive.New(cmd.Context(), flags.region, flags.bucket, log) + if err != nil { + return err + } + + uploadC, err := gcpupload.New(cmd.Context(), flags.gcpProject, flags.gcpLocation, flags.gcpBucket, log) + if err != nil { + return fmt.Errorf("uploading image: %w", err) + } + + file, err := os.Open(flags.rawImage) + if err != nil { + return fmt.Errorf("uploading image: opening image file %w", err) + } + defer file.Close() + size, err := file.Seek(0, io.SeekEnd) + if err != nil { + return err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + out := cmd.OutOrStdout() + if len(flags.out) > 0 { + outF, err := os.Create(flags.out) + if err != nil { + return fmt.Errorf("uploading image: opening output file %w", err) + } + defer outF.Close() + out = outF + } + + sbDatabase, uefiVarStore, err := loadSecureBootKeys(flags.pki) + if err != nil { + return err + } + + uploadReq := &osimage.UploadRequest{ + Provider: flags.provider, + Version: flags.version, + Variant: flags.variant, + SBDatabase: sbDatabase, + UEFIVarStore: uefiVarStore, + Size: size, + Timestamp: flags.timestamp, + Image: file, + } + return uploadImage(cmd.Context(), archiveC, uploadC, uploadReq, out) +} diff --git a/image/upload/internal/cmd/must.go b/image/upload/internal/cmd/must.go new file mode 100644 index 000000000..fb26f2df0 --- /dev/null +++ b/image/upload/internal/cmd/must.go @@ -0,0 +1,13 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +func must(err error) { + if err != nil { + panic(err) + } +} diff --git a/image/upload/internal/cmd/nop.go b/image/upload/internal/cmd/nop.go new file mode 100644 index 000000000..abc415392 --- /dev/null +++ b/image/upload/internal/cmd/nop.go @@ -0,0 +1,81 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/logger" + "github.com/edgelesssys/constellation/v2/internal/osimage" + "github.com/edgelesssys/constellation/v2/internal/osimage/archive" + nopupload "github.com/edgelesssys/constellation/v2/internal/osimage/nop" + "github.com/spf13/cobra" +) + +func runNOP(cmd *cobra.Command, provider cloudprovider.Provider, _ []string) error { + workdir := os.Getenv("BUILD_WORKING_DIRECTORY") + if len(workdir) > 0 { + must(os.Chdir(workdir)) + } + + flags, err := parseCommonFlags(cmd) + if err != nil { + return err + } + flags.provider = provider + log := logger.New(logger.PlainLog, flags.logLevel) + log.Debugf("Parsed flags: %+v", flags) + + archiveC, err := archive.New(cmd.Context(), flags.region, flags.bucket, log) + if err != nil { + return err + } + + uploadC := nopupload.New(log) + + file, err := os.Open(flags.rawImage) + if err != nil { + return fmt.Errorf("uploading image: opening image file %w", err) + } + defer file.Close() + size, err := file.Seek(0, io.SeekEnd) + if err != nil { + return err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + out := cmd.OutOrStdout() + if len(flags.out) > 0 { + outF, err := os.Create(flags.out) + if err != nil { + return fmt.Errorf("uploading image: opening output file %w", err) + } + defer outF.Close() + out = outF + } + + sbDatabase, uefiVarStore, err := loadSecureBootKeys(flags.pki) + if err != nil { + return err + } + + uploadReq := &osimage.UploadRequest{ + Provider: flags.provider, + Version: flags.version, + Variant: flags.variant, + SBDatabase: sbDatabase, + UEFIVarStore: uefiVarStore, + Size: size, + Timestamp: flags.timestamp, + Image: file, + } + return uploadImage(cmd.Context(), archiveC, uploadC, uploadReq, out) +} diff --git a/image/upload/internal/cmd/openstack.go b/image/upload/internal/cmd/openstack.go new file mode 100644 index 000000000..6696f4310 --- /dev/null +++ b/image/upload/internal/cmd/openstack.go @@ -0,0 +1,29 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/spf13/cobra" +) + +// NewOpenStackCmd returns the command that uploads an OS image to OpenStack. +func NewOpenStackCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "openstack", + Short: "Upload OS image to OpenStack", + Long: "Upload OS image to OpenStack.", + Args: cobra.ExactArgs(0), + RunE: runOpenStack, + } + + return cmd +} + +func runOpenStack(cmd *cobra.Command, args []string) error { + return runNOP(cmd, cloudprovider.OpenStack, args) +} diff --git a/image/upload/internal/cmd/qemu.go b/image/upload/internal/cmd/qemu.go new file mode 100644 index 000000000..b250620d4 --- /dev/null +++ b/image/upload/internal/cmd/qemu.go @@ -0,0 +1,29 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/spf13/cobra" +) + +// NewQEMUCmd returns the command that uploads an OS image to QEMU. +func NewQEMUCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "qemu", + Short: "Upload OS image to QEMU", + Long: "Upload OS image to QEMU.", + Args: cobra.ExactArgs(0), + RunE: runQEMU, + } + + return cmd +} + +func runQEMU(cmd *cobra.Command, args []string) error { + return runNOP(cmd, cloudprovider.QEMU, args) +} diff --git a/image/upload/internal/cmd/secureboot.go b/image/upload/internal/cmd/secureboot.go new file mode 100644 index 000000000..a73f2af0f --- /dev/null +++ b/image/upload/internal/cmd/secureboot.go @@ -0,0 +1,44 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/edgelesssys/constellation/v2/internal/osimage/secureboot" + "github.com/spf13/afero" +) + +func loadSecureBootKeys(basePath string) (secureboot.Database, secureboot.UEFIVarStore, error) { + platformKeyCert := filepath.Join(basePath, "PK.cer") + keyExchangeKeyCerts := []string{ + filepath.Join(basePath, "KEK.cer"), + filepath.Join(basePath, "MicCorKEKCA2011_2011-06-24.crt"), + } + signatureDBCerts := []string{ + filepath.Join(basePath, "db.cer"), + filepath.Join(basePath, "MicWinProPCA2011_2011-10-19.crt"), + filepath.Join(basePath, "MicCorUEFCA2011_2011-06-27.crt"), + } + sbDatabase, err := secureboot.DatabaseFromFiles(afero.NewOsFs(), platformKeyCert, keyExchangeKeyCerts, signatureDBCerts) + if err != nil { + return secureboot.Database{}, + secureboot.UEFIVarStore{}, + fmt.Errorf("preparing secure boot database: %w", err) + } + platformKeyESL := filepath.Join(basePath, "PK.esl") + keyExchangeKeyESL := filepath.Join(basePath, "KEK.esl") + signatureDBESL := filepath.Join(basePath, "db.esl") + uefiVarStore, err := secureboot.VarStoreFromFiles(afero.NewOsFs(), platformKeyESL, keyExchangeKeyESL, signatureDBESL, "") + if err != nil { + return secureboot.Database{}, + secureboot.UEFIVarStore{}, + fmt.Errorf("preparing secure boot variable store: %w", err) + } + return sbDatabase, uefiVarStore, nil +} diff --git a/image/upload/internal/cmd/upload.go b/image/upload/internal/cmd/upload.go new file mode 100644 index 000000000..719daf293 --- /dev/null +++ b/image/upload/internal/cmd/upload.go @@ -0,0 +1,66 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/osimage" + "github.com/edgelesssys/constellation/v2/internal/versionsapi" +) + +func uploadImage(ctx context.Context, archiveC archivist, uploadC uploader, req *osimage.UploadRequest, out io.Writer) error { + // upload to S3 archive + archiveURL, err := archiveC.Archive(ctx, req.Version, req.Provider.String(), req.Variant, req.Image) + if err != nil { + return err + } + // rewind reader so we can read again + if _, err := req.Image.Seek(0, io.SeekStart); err != nil { + return err + } + // upload to CSP + imageReferences, err := uploadC.Upload(ctx, req) + if err != nil { + return err + } + if len(imageReferences) == 0 { + imageReferences = map[string]string{ + req.Variant: archiveURL, + } + } + + imageInfo := versionsapi.ImageInfo{ + Ref: req.Version.Ref, + Stream: req.Version.Stream, + Version: req.Version.Version, + } + switch req.Provider { + case cloudprovider.AWS: + imageInfo.AWS = imageReferences + case cloudprovider.Azure: + imageInfo.Azure = imageReferences + case cloudprovider.GCP: + imageInfo.GCP = imageReferences + case cloudprovider.OpenStack: + imageInfo.OpenStack = imageReferences + case cloudprovider.QEMU: + imageInfo.QEMU = imageReferences + default: + return fmt.Errorf("uploading image: cloud provider %s is not yet supported", req.Provider.String()) + } + + if err := json.NewEncoder(out).Encode(imageInfo); err != nil { + return fmt.Errorf("uploading image: marshaling output: %w", err) + } + + return nil +} diff --git a/image/upload/upload.go b/image/upload/upload.go new file mode 100644 index 000000000..fe0b79e8e --- /dev/null +++ b/image/upload/upload.go @@ -0,0 +1,100 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +// upload uploads os images. +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/edgelesssys/constellation/v2/image/upload/internal/cmd" + "github.com/spf13/cobra" +) + +func main() { + if err := execute(); err != nil { + os.Exit(1) + } +} + +func execute() error { + rootCmd := newRootCmd() + ctx, cancel := signalContext(context.Background(), os.Interrupt) + defer cancel() + return rootCmd.ExecuteContext(ctx) +} + +func newRootCmd() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "upload", + Short: "Uploads OS images to supported CSPs", + Long: "Uploads OS images to supported CSPs.", + PersistentPreRun: preRunRoot, + } + + rootCmd.SetOut(os.Stdout) + + rootCmd.PersistentFlags().String("raw-image", "", "Path to os image in CSP specific format that should be uploaded.") + rootCmd.PersistentFlags().String("pki", "", "Base path to the PKI (secure boot signing) files.") + rootCmd.PersistentFlags().String("variant", "", "Variant of the image being uploaded.") + rootCmd.PersistentFlags().String("version", "", "Shortname of the os image version.") + rootCmd.PersistentFlags().String("timestamp", "", "Optional timestamp to use for resource names. Uses format 2006-01-02T15:04:05Z07:00.") + rootCmd.PersistentFlags().String("region", "eu-central-1", "AWS region of the archive S3 bucket") + rootCmd.PersistentFlags().String("bucket", "cdn-constellation-backend", "S3 bucket name of the archive") + rootCmd.PersistentFlags().String("out", "", "Optional path to write the upload result to. If not set, the result is written to stdout.") + rootCmd.PersistentFlags().Bool("verbose", false, "Enable verbose output") + must(rootCmd.MarkPersistentFlagRequired("raw-image")) + must(rootCmd.MarkPersistentFlagRequired("variant")) + must(rootCmd.MarkPersistentFlagRequired("version")) + + rootCmd.AddCommand(cmd.NewAWSCmd()) + rootCmd.AddCommand(cmd.NewAzureCmd()) + rootCmd.AddCommand(cmd.NewGCPCommand()) + rootCmd.AddCommand(cmd.NewOpenStackCmd()) + rootCmd.AddCommand(cmd.NewQEMUCmd()) + + return rootCmd +} + +// signalContext returns a context that is canceled on the handed signal. +// The signal isn't watched after its first occurrence. Call the cancel +// function to ensure the internal goroutine is stopped and the signal isn't +// watched any longer. +func signalContext(ctx context.Context, sig os.Signal) (context.Context, context.CancelFunc) { + sigCtx, stop := signal.NotifyContext(ctx, sig) + done := make(chan struct{}, 1) + stopDone := make(chan struct{}, 1) + + go func() { + defer func() { stopDone <- struct{}{} }() + defer stop() + select { + case <-sigCtx.Done(): + fmt.Println(" Signal caught. Press ctrl+c again to terminate the program immediately.") + case <-done: + } + }() + + cancelFunc := func() { + done <- struct{}{} + <-stopDone + } + + return sigCtx, cancelFunc +} + +func preRunRoot(cmd *cobra.Command, _ []string) { + cmd.SilenceUsage = true +} + +func must(err error) { + if err != nil { + panic(err) + } +} diff --git a/internal/kms/config/config.go b/internal/kms/config/config.go index 9dcaa5918..5af6d3e39 100644 --- a/internal/kms/config/config.go +++ b/internal/kms/config/config.go @@ -19,10 +19,14 @@ var ( "component": "constellation-kek", } // StorageTags are the default tags for kms client created storage solutions. - StorageTags = map[string]string{ - "createdBy": "constellation-kms-client", - "component": "constellation-dek-store", + StorageTags = map[string]*string{ + "createdBy": toPtr("constellation-kms-client"), + "component": toPtr("constellation-dek-store"), } // AWSS3Tag is the default tag string for kms client created AWS S3 storage solutions. AWSS3Tag = "createdBy=constellation-kms-client&component=constellation-dek-store" ) + +func toPtr[T any](v T) *T { + return &v +} diff --git a/internal/osimage/BUILD.bazel b/internal/osimage/BUILD.bazel new file mode 100644 index 000000000..111c5884b --- /dev/null +++ b/internal/osimage/BUILD.bazel @@ -0,0 +1,13 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "osimage", + srcs = ["osimage.go"], + importpath = "github.com/edgelesssys/constellation/v2/internal/osimage", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/cloud/cloudprovider", + "//internal/osimage/secureboot", + "//internal/versionsapi", + ], +) diff --git a/internal/osimage/archive/BUILD.bazel b/internal/osimage/archive/BUILD.bazel new file mode 100644 index 000000000..2a23a3efb --- /dev/null +++ b/internal/osimage/archive/BUILD.bazel @@ -0,0 +1,16 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "archive", + srcs = ["archive.go"], + importpath = "github.com/edgelesssys/constellation/v2/internal/osimage/archive", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/logger", + "//internal/versionsapi", + "@com_github_aws_aws_sdk_go_v2_config//:config", + "@com_github_aws_aws_sdk_go_v2_feature_s3_manager//:manager", + "@com_github_aws_aws_sdk_go_v2_service_s3//:s3", + "@com_github_aws_aws_sdk_go_v2_service_s3//types", + ], +) diff --git a/internal/osimage/archive/archive.go b/internal/osimage/archive/archive.go new file mode 100644 index 000000000..3937e61fc --- /dev/null +++ b/internal/osimage/archive/archive.go @@ -0,0 +1,68 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +// package archive is used to archive OS images in S3. +package archive + +import ( + "context" + "io" + "net/url" + + awsconfig "github.com/aws/aws-sdk-go-v2/config" + s3manager "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/edgelesssys/constellation/v2/internal/logger" + "github.com/edgelesssys/constellation/v2/internal/versionsapi" +) + +// Archivist uploads OS images to S3. +type Archivist struct { + uploadClient uploadClient + // bucket is the name of the S3 bucket to use. + bucket string + + log *logger.Logger +} + +// New creates a new Archivist. +func New(ctx context.Context, region, bucket string, log *logger.Logger) (*Archivist, error) { + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return nil, err + } + s3client := s3.NewFromConfig(cfg) + uploadClient := s3manager.NewUploader(s3client) + + return &Archivist{ + uploadClient: uploadClient, + bucket: bucket, + log: log, + }, nil +} + +// Archive reads the OS image in img and uploads it as key. +func (a *Archivist) Archive(ctx context.Context, version versionsapi.Version, csp, variant string, img io.Reader) (string, error) { + key, err := url.JoinPath(version.ArtifactPath(), version.Kind.String(), "csp", csp, variant, "image.raw") + if err != nil { + return "", err + } + a.log.Debugf("Archiving OS image %s %s %v to s3://%v/%v", csp, variant, version.ShortPath(), a.bucket, key) + _, err = a.uploadClient.Upload(ctx, &s3.PutObjectInput{ + Bucket: &a.bucket, + Key: &key, + Body: img, + ChecksumAlgorithm: s3types.ChecksumAlgorithmSha256, + }) + return baseURL + key, err +} + +type uploadClient interface { + Upload(ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3manager.Uploader)) (*s3manager.UploadOutput, error) +} + +const baseURL = "https://cdn.confidential.cloud/" diff --git a/internal/osimage/aws/BUILD.bazel b/internal/osimage/aws/BUILD.bazel new file mode 100644 index 000000000..7721504b3 --- /dev/null +++ b/internal/osimage/aws/BUILD.bazel @@ -0,0 +1,21 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "aws", + srcs = ["awsupload.go"], + importpath = "github.com/edgelesssys/constellation/v2/internal/osimage/aws", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/logger", + "//internal/osimage", + "//internal/osimage/secureboot", + "//internal/versionsapi", + "@com_github_aws_aws_sdk_go_v2_config//:config", + "@com_github_aws_aws_sdk_go_v2_feature_s3_manager//:manager", + "@com_github_aws_aws_sdk_go_v2_service_ec2//:ec2", + "@com_github_aws_aws_sdk_go_v2_service_ec2//types", + "@com_github_aws_aws_sdk_go_v2_service_s3//:s3", + "@com_github_aws_aws_sdk_go_v2_service_s3//types", + "@com_github_aws_smithy_go//:smithy-go", + ], +) diff --git a/internal/osimage/aws/awsupload.go b/internal/osimage/aws/awsupload.go new file mode 100644 index 000000000..ca903c69c --- /dev/null +++ b/internal/osimage/aws/awsupload.go @@ -0,0 +1,590 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +// package aws implements uploading os images to aws. +package aws + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + awsconfig "github.com/aws/aws-sdk-go-v2/config" + s3manager "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/smithy-go" + + "github.com/edgelesssys/constellation/v2/internal/logger" + "github.com/edgelesssys/constellation/v2/internal/osimage" + "github.com/edgelesssys/constellation/v2/internal/osimage/secureboot" + "github.com/edgelesssys/constellation/v2/internal/versionsapi" +) + +// Uploader can upload and remove os images on GCP. +type Uploader struct { + region string + bucketName string + ec2 func(ctx context.Context, region string) (ec2API, error) + s3 func(ctx context.Context, region string) (s3API, error) + s3uploader func(ctx context.Context, region string) (s3UploaderAPI, error) + + log *logger.Logger +} + +// New creates a new Uploader. +func New(region, bucketName string, log *logger.Logger) (*Uploader, error) { + return &Uploader{ + region: region, + bucketName: bucketName, + ec2: func(ctx context.Context, region string) (ec2API, error) { + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return nil, err + } + return ec2.NewFromConfig(cfg), nil + }, + s3: func(ctx context.Context, region string) (s3API, error) { + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return nil, err + } + return s3.NewFromConfig(cfg), nil + }, + s3uploader: func(ctx context.Context, region string) (s3UploaderAPI, error) { + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return nil, err + } + return s3manager.NewUploader(s3.NewFromConfig(cfg)), nil + }, + + log: log, + }, nil +} + +// Upload uploads an OS image to AWS. +func (u *Uploader) Upload(ctx context.Context, req *osimage.UploadRequest) (map[string]string, error) { + blobName := fmt.Sprintf("image-%s-%s-%d.raw", req.Version.Stream, req.Version.Version, req.Timestamp.Unix()) + imageName := imageName(req.Version, req.Timestamp) + allRegions := []string{u.region} + allRegions = append(allRegions, replicationRegions...) + // TODO(malt3): make this configurable + publish := true + amiIDs := make(map[string]string, len(allRegions)) + if err := u.ensureBucket(ctx); err != nil { + return nil, fmt.Errorf("ensuring bucket %s exists: %w", u.bucketName, err) + } + + // pre-cleaning + for _, region := range allRegions { + if err := u.ensureImageDeleted(ctx, imageName, region); err != nil { + return nil, fmt.Errorf("pre-cleaning: ensuring no image under the name %s in region %s: %w", imageName, region, err) + } + } + if err := u.ensureSnapshotDeleted(ctx, imageName, u.region); err != nil { + return nil, fmt.Errorf("pre-cleaning: ensuring no snapshot using the same name exists: %w", err) + } + if err := u.ensureBlobDeleted(ctx, blobName); err != nil { + return nil, fmt.Errorf("pre-cleaning: ensuring no blob using the same name exists: %w", err) + } + + // create primary image + if err := u.uploadBlob(ctx, blobName, req.Image); err != nil { + return nil, fmt.Errorf("uploading image to s3: %w", err) + } + defer func() { + if err := u.ensureBlobDeleted(ctx, blobName); err != nil { + u.log.Errorf("post-cleaning: deleting temporary blob from s3", err) + } + }() + snapshotID, err := u.importSnapshot(ctx, blobName, imageName) + if err != nil { + return nil, fmt.Errorf("importing snapshot: %w", err) + } + primaryAMIID, err := u.createImageFromSnapshot(ctx, req.Version, imageName, snapshotID, req.UEFIVarStore) + if err != nil { + return nil, fmt.Errorf("creating image from snapshot: %w", err) + } + amiIDs[u.region] = primaryAMIID + if err := u.waitForImage(ctx, primaryAMIID, u.region); err != nil { + return nil, fmt.Errorf("waiting for primary image to become available: %w", err) + } + + // replicate image + for _, region := range replicationRegions { + amiID, err := u.replicateImage(ctx, imageName, primaryAMIID, region) + if err != nil { + return nil, fmt.Errorf("replicating image to region %s: %w", region, err) + } + amiIDs[region] = amiID + } + + // wait for replication, tag, publish + for _, region := range allRegions { + if err := u.waitForImage(ctx, amiIDs[region], region); err != nil { + return nil, fmt.Errorf("waiting for image to become available in region %s: %w", region, err) + } + if err := u.tagImageAndSnapshot(ctx, imageName, amiIDs[region], region); err != nil { + return nil, fmt.Errorf("tagging image in region %s: %w", region, err) + } + if !publish { + continue + } + if err := u.publishImage(ctx, amiIDs[region], region); err != nil { + return nil, fmt.Errorf("publishing image in region %s: %w", region, err) + } + } + return amiIDs, nil +} + +func (u *Uploader) ensureBucket(ctx context.Context) error { + s3C, err := u.s3(ctx, u.region) + if err != nil { + return fmt.Errorf("determining if bucket %s exists: %w", u.bucketName, err) + } + _, err = s3C.HeadBucket(ctx, &s3.HeadBucketInput{ + Bucket: &u.bucketName, + }) + if err == nil { + u.log.Debugf("Bucket %s exists", u.bucketName) + return nil + } + var noSuchBucketErr *types.NoSuchBucket + if !errors.As(err, &noSuchBucketErr) { + return fmt.Errorf("determining if bucket %s exists: %w", u.bucketName, err) + } + u.log.Debugf("Creating bucket %s", u.bucketName) + _, err = s3C.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: &u.bucketName, + }) + if err != nil { + return fmt.Errorf("creating bucket %s: %w", u.bucketName, err) + } + return nil +} + +func (u *Uploader) uploadBlob(ctx context.Context, blobName string, img io.Reader) error { + u.log.Debugf("Uploading os image as %s", blobName) + uploadC, err := u.s3uploader(ctx, u.region) + if err != nil { + return err + } + _, err = uploadC.Upload(ctx, &s3.PutObjectInput{ + Bucket: &u.bucketName, + Key: &blobName, + Body: img, + ChecksumAlgorithm: s3types.ChecksumAlgorithmSha256, + }) + return err +} + +func (u *Uploader) ensureBlobDeleted(ctx context.Context, blobName string) error { + s3C, err := u.s3(ctx, u.region) + if err != nil { + return err + } + _, err = s3C.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &u.bucketName, + Key: &blobName, + }) + var apiError smithy.APIError + if errors.As(err, &apiError) && apiError.ErrorCode() == "NotFound" { + u.log.Debugf("Blob %s in %s doesn't exist. Nothing to clean up.", blobName, u.bucketName) + return nil + } + if err != nil { + return err + } + u.log.Debugf("Deleting blob %s", blobName) + _, err = s3C.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &u.bucketName, + Key: &blobName, + }) + return err +} + +func (u *Uploader) findSnapshots(ctx context.Context, snapshotName, region string) ([]string, error) { + ec2C, err := u.ec2(ctx, region) + if err != nil { + return nil, fmt.Errorf("creating ec2 client: %w", err) + } + snapshots, err := ec2C.DescribeSnapshots(ctx, &ec2.DescribeSnapshotsInput{ + Filters: []ec2types.Filter{ + { + Name: toPtr("tag:Name"), + Values: []string{snapshotName}, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("describing snapshots: %w", err) + } + var snapshotIDs []string + for _, s := range snapshots.Snapshots { + if s.SnapshotId == nil { + continue + } + snapshotIDs = append(snapshotIDs, *s.SnapshotId) + } + return snapshotIDs, nil +} + +func (u *Uploader) importSnapshot(ctx context.Context, blobName, snapshotName string) (string, error) { + u.log.Debugf("Importing %s as snapshot %s", blobName, snapshotName) + ec2C, err := u.ec2(ctx, u.region) + if err != nil { + return "", fmt.Errorf("creating ec2 client: %w", err) + } + importResp, err := ec2C.ImportSnapshot(ctx, &ec2.ImportSnapshotInput{ + ClientData: &ec2types.ClientData{ + Comment: &snapshotName, + }, + Description: &snapshotName, + DiskContainer: &ec2types.SnapshotDiskContainer{ + Description: &snapshotName, + Format: toPtr(string(ec2types.DiskImageFormatRaw)), + UserBucket: &ec2types.UserBucket{ + S3Bucket: &u.bucketName, + S3Key: &blobName, + }, + }, + }) + if err != nil { + return "", fmt.Errorf("importing snapshot: %w", err) + } + if importResp.ImportTaskId == nil { + return "", fmt.Errorf("importing snapshot: no import task ID returned") + } + u.log.Debugf("Waiting for snapshot %s to be ready", snapshotName) + return waitForSnapshotImport(ctx, ec2C, *importResp.ImportTaskId) +} + +func (u *Uploader) ensureSnapshotDeleted(ctx context.Context, snapshotName, region string) error { + ec2C, err := u.ec2(ctx, region) + if err != nil { + return fmt.Errorf("creating ec2 client: %w", err) + } + snapshots, err := u.findSnapshots(ctx, snapshotName, region) + if err != nil { + return fmt.Errorf("finding snapshots: %w", err) + } + for _, snapshot := range snapshots { + u.log.Debugf("Deleting snapshot %s in %s", snapshot, region) + _, err = ec2C.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{ + SnapshotId: toPtr(snapshot), + }) + if err != nil { + return fmt.Errorf("deleting snapshot %s: %w", snapshot, err) + } + } + return nil +} + +func (u *Uploader) createImageFromSnapshot(ctx context.Context, version versionsapi.Version, imageName, snapshotID string, uefiVarStore secureboot.UEFIVarStore) (string, error) { + u.log.Debugf("Creating image %s in %s", imageName, u.region) + ec2C, err := u.ec2(ctx, u.region) + if err != nil { + return "", fmt.Errorf("creating ec2 client: %w", err) + } + uefiData, err := uefiVarStore.ToAWS() + if err != nil { + return "", fmt.Errorf("creating uefi data: %w", err) + } + createReq, err := ec2C.RegisterImage(ctx, &ec2.RegisterImageInput{ + Name: &imageName, + Architecture: ec2types.ArchitectureValuesX8664, + BlockDeviceMappings: []ec2types.BlockDeviceMapping{ + { + DeviceName: toPtr("/dev/xvda"), + Ebs: &ec2types.EbsBlockDevice{ + DeleteOnTermination: toPtr(true), + SnapshotId: &snapshotID, + }, + }, + }, + BootMode: ec2types.BootModeValuesUefi, + Description: toPtr("Constellation " + version.ShortPath()), + EnaSupport: toPtr(true), + RootDeviceName: toPtr("/dev/xvda"), + TpmSupport: ec2types.TpmSupportValuesV20, + UefiData: &uefiData, + VirtualizationType: toPtr("hvm"), + }) + if err != nil { + return "", fmt.Errorf("creating image: %w", err) + } + if createReq.ImageId == nil { + return "", fmt.Errorf("creating image: no image ID returned") + } + return *createReq.ImageId, nil +} + +func (u *Uploader) replicateImage(ctx context.Context, imageName, amiID string, region string) (string, error) { + u.log.Debugf("Replicating image %s to %s", imageName, region) + ec2C, err := u.ec2(ctx, region) + if err != nil { + return "", fmt.Errorf("creating ec2 client: %w", err) + } + replicateReq, err := ec2C.CopyImage(ctx, &ec2.CopyImageInput{ + Name: &imageName, + SourceImageId: &amiID, + SourceRegion: &u.region, + }) + if err != nil { + return "", fmt.Errorf("replicating image: %w", err) + } + if replicateReq.ImageId == nil { + return "", fmt.Errorf("replicating image: no image ID returned") + } + return *replicateReq.ImageId, nil +} + +func (u *Uploader) findImage(ctx context.Context, imageName, region string) (string, error) { + ec2C, err := u.ec2(ctx, region) + if err != nil { + return "", fmt.Errorf("creating ec2 client: %w", err) + } + snapshots, err := ec2C.DescribeImages(ctx, &ec2.DescribeImagesInput{ + Filters: []ec2types.Filter{ + { + Name: toPtr("name"), + Values: []string{imageName}, + }, + }, + }) + if err != nil { + return "", fmt.Errorf("describing images: %w", err) + } + if len(snapshots.Images) == 0 { + return "", errAMIDoesNotExist + } + if len(snapshots.Images) != 1 { + return "", fmt.Errorf("expected 1 image, got %d", len(snapshots.Images)) + } + if snapshots.Images[0].ImageId == nil { + return "", fmt.Errorf("image ID is nil") + } + return *snapshots.Images[0].ImageId, nil +} + +func (u *Uploader) waitForImage(ctx context.Context, amiID, region string) error { + u.log.Debugf("Waiting for image %s in %s to be created", amiID, region) + ec2C, err := u.ec2(ctx, region) + if err != nil { + return fmt.Errorf("creating ec2 client: %w", err) + } + waiter := ec2.NewImageAvailableWaiter(ec2C) + err = waiter.Wait(ctx, &ec2.DescribeImagesInput{ + ImageIds: []string{amiID}, + }, maxWait) + if err != nil { + return fmt.Errorf("waiting for image: %w", err) + } + return nil +} + +func (u *Uploader) tagImageAndSnapshot(ctx context.Context, imageName, amiID, region string) error { + u.log.Debugf("Tagging backing snapshot of image %s in %s", amiID, region) + ec2C, err := u.ec2(ctx, region) + if err != nil { + return fmt.Errorf("creating ec2 client: %w", err) + } + snapshotID, err := getBackingSnapshotID(ctx, ec2C, amiID) + if err != nil { + return fmt.Errorf("getting backing snapshot ID: %w", err) + } + _, err = ec2C.CreateTags(ctx, &ec2.CreateTagsInput{ + Resources: []string{amiID, snapshotID}, + Tags: []ec2types.Tag{ + { + Key: toPtr("Name"), + Value: toPtr(imageName), + }, + }, + }) + if err != nil { + return fmt.Errorf("tagging ami and snapshot: %w", err) + } + return nil +} + +func (u *Uploader) publishImage(ctx context.Context, imageName, region string) error { + u.log.Debugf("Publishing image %s in %s", imageName, region) + ec2C, err := u.ec2(ctx, region) + if err != nil { + return fmt.Errorf("creating ec2 client: %w", err) + } + _, err = ec2C.ModifyImageAttribute(ctx, &ec2.ModifyImageAttributeInput{ + ImageId: &imageName, + LaunchPermission: &ec2types.LaunchPermissionModifications{ + Add: []ec2types.LaunchPermission{ + { + Group: ec2types.PermissionGroupAll, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("publishing image: %w", err) + } + return nil +} + +func (u *Uploader) ensureImageDeleted(ctx context.Context, imageName, region string) error { + ec2C, err := u.ec2(ctx, region) + if err != nil { + return fmt.Errorf("creating ec2 client: %w", err) + } + amiID, err := u.findImage(ctx, imageName, region) + if err == errAMIDoesNotExist { + u.log.Debugf("Image %s in %s doesn't exist. Nothing to clean up.", imageName, region) + return nil + } + snapshotID, err := getBackingSnapshotID(ctx, ec2C, amiID) + if err == errAMIDoesNotExist { + u.log.Debugf("Image %s doesn't exist. Nothing to clean up.", amiID) + return nil + } + u.log.Debugf("Deleting image %s in %s with backing snapshot", amiID, region) + _, err = ec2C.DeregisterImage(ctx, &ec2.DeregisterImageInput{ + ImageId: &amiID, + }) + if err != nil { + return fmt.Errorf("deleting image: %w", err) + } + _, err = ec2C.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{ + SnapshotId: &snapshotID, + }) + if err != nil { + return fmt.Errorf("deleting snapshot: %w", err) + } + return nil +} + +func imageName(version versionsapi.Version, timestamp time.Time) string { + if version.Stream == "stable" { + return fmt.Sprintf("constellation-%s", version.Version) + } + return fmt.Sprintf("constellation-%s-%s-%s", version.Stream, version.Version, timestamp.Format(timestampFormat)) +} + +func waitForSnapshotImport(ctx context.Context, ec2C ec2API, importTaskID string) (string, error) { + for { + taskResp, err := ec2C.DescribeImportSnapshotTasks(ctx, &ec2.DescribeImportSnapshotTasksInput{ + ImportTaskIds: []string{importTaskID}, + }) + if err != nil { + return "", fmt.Errorf("describing import snapshot task: %w", err) + } + if len(taskResp.ImportSnapshotTasks) == 0 { + return "", fmt.Errorf("describing import snapshot task: no tasks returned") + } + if taskResp.ImportSnapshotTasks[0].SnapshotTaskDetail == nil { + return "", fmt.Errorf("describing import snapshot task: no snapshot task detail returned") + } + if taskResp.ImportSnapshotTasks[0].SnapshotTaskDetail.Status == nil { + return "", fmt.Errorf("describing import snapshot task: no status returned") + } + switch *taskResp.ImportSnapshotTasks[0].SnapshotTaskDetail.Status { + case string(ec2types.SnapshotStateCompleted): + return *taskResp.ImportSnapshotTasks[0].SnapshotTaskDetail.SnapshotId, nil + case string(ec2types.SnapshotStateError): + return "", fmt.Errorf("importing snapshot: task failed") + } + time.Sleep(waitInterval) + } +} + +func getBackingSnapshotID(ctx context.Context, ec2C ec2API, amiID string) (string, error) { + describeResp, err := ec2C.DescribeImages(ctx, &ec2.DescribeImagesInput{ + ImageIds: []string{amiID}, + }) + if err != nil || len(describeResp.Images) == 0 { + return "", errAMIDoesNotExist + } + if len(describeResp.Images) != 1 { + return "", fmt.Errorf("describing image: expected 1 image, got %d", len(describeResp.Images)) + } + image := describeResp.Images[0] + if len(image.BlockDeviceMappings) != 1 { + return "", fmt.Errorf("found %d block device mappings for image %s, expected 1", len(image.BlockDeviceMappings), amiID) + } + if image.BlockDeviceMappings[0].Ebs == nil { + return "", fmt.Errorf("image %s does not have an EBS block device mapping", amiID) + } + ebs := image.BlockDeviceMappings[0].Ebs + if ebs.SnapshotId == nil { + return "", fmt.Errorf("image %s does not have an EBS snapshot", amiID) + } + return *ebs.SnapshotId, nil +} + +type ec2API interface { + DescribeImages(ctx context.Context, params *ec2.DescribeImagesInput, + optFns ...func(*ec2.Options), + ) (*ec2.DescribeImagesOutput, error) + ModifyImageAttribute(ctx context.Context, params *ec2.ModifyImageAttributeInput, + optFns ...func(*ec2.Options), + ) (*ec2.ModifyImageAttributeOutput, error) + RegisterImage(ctx context.Context, params *ec2.RegisterImageInput, + optFns ...func(*ec2.Options), + ) (*ec2.RegisterImageOutput, error) + CopyImage(ctx context.Context, params *ec2.CopyImageInput, optFns ...func(*ec2.Options), + ) (*ec2.CopyImageOutput, error) + DeregisterImage(ctx context.Context, params *ec2.DeregisterImageInput, + optFns ...func(*ec2.Options), + ) (*ec2.DeregisterImageOutput, error) + ImportSnapshot(ctx context.Context, params *ec2.ImportSnapshotInput, + optFns ...func(*ec2.Options), + ) (*ec2.ImportSnapshotOutput, error) + DescribeImportSnapshotTasks(ctx context.Context, params *ec2.DescribeImportSnapshotTasksInput, + optFns ...func(*ec2.Options), + ) (*ec2.DescribeImportSnapshotTasksOutput, error) + DescribeSnapshots(ctx context.Context, params *ec2.DescribeSnapshotsInput, + optFns ...func(*ec2.Options), + ) (*ec2.DescribeSnapshotsOutput, error) + DeleteSnapshot(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options), + ) (*ec2.DeleteSnapshotOutput, error) + CreateTags(ctx context.Context, params *ec2.CreateTagsInput, optFns ...func(*ec2.Options), + ) (*ec2.CreateTagsOutput, error) +} + +type s3API interface { + HeadBucket(ctx context.Context, params *s3.HeadBucketInput, optFns ...func(*s3.Options), + ) (*s3.HeadBucketOutput, error) + CreateBucket(ctx context.Context, params *s3.CreateBucketInput, optFns ...func(*s3.Options), + ) (*s3.CreateBucketOutput, error) + HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options), + ) (*s3.HeadObjectOutput, error) + DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options), + ) (*s3.DeleteObjectOutput, error) +} + +type s3UploaderAPI interface { + Upload(ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3manager.Uploader), + ) (*s3manager.UploadOutput, error) +} + +func toPtr[T any](v T) *T { + return &v +} + +const ( + waitInterval = 15 * time.Second + maxWait = 15 * time.Minute + timestampFormat = "20060102150405" +) + +var ( + errAMIDoesNotExist = errors.New("ami does not exist") + replicationRegions = []string{"us-east-2", "ap-south-1"} +) diff --git a/internal/osimage/azure/BUILD.bazel b/internal/osimage/azure/BUILD.bazel new file mode 100644 index 000000000..86dec0251 --- /dev/null +++ b/internal/osimage/azure/BUILD.bazel @@ -0,0 +1,21 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "azure", + srcs = [ + "azureupload.go", + "disktype_string.go", + ], + importpath = "github.com/edgelesssys/constellation/v2/internal/osimage/azure", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/logger", + "//internal/osimage", + "//internal/versionsapi", + "@com_github_azure_azure_sdk_for_go_sdk_azcore//runtime", + "@com_github_azure_azure_sdk_for_go_sdk_azidentity//:azidentity", + "@com_github_azure_azure_sdk_for_go_sdk_resourcemanager_compute_armcompute_v4//:armcompute", + "@com_github_azure_azure_sdk_for_go_sdk_storage_azblob//blob", + "@com_github_azure_azure_sdk_for_go_sdk_storage_azblob//pageblob", + ], +) diff --git a/internal/osimage/azure/azureupload.go b/internal/osimage/azure/azureupload.go new file mode 100644 index 000000000..bc8154ada --- /dev/null +++ b/internal/osimage/azure/azureupload.go @@ -0,0 +1,701 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +// package azure implements uploading os images to azure. +package azure + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + armcomputev4 "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v4" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/pageblob" + "github.com/edgelesssys/constellation/v2/internal/logger" + "github.com/edgelesssys/constellation/v2/internal/osimage" + "github.com/edgelesssys/constellation/v2/internal/versionsapi" +) + +// Uploader can upload and remove os images on Azure. +type Uploader struct { + subscription string + location string + resourceGroup string + pollingFrequency time.Duration + disks azureDiskAPI + managedImages azureManagedImageAPI + blob sasBlobUploader + galleries azureGalleriesAPI + image azureGalleriesImageAPI + imageVersions azureGalleriesImageVersionAPI + communityVersions azureCommunityGalleryImageVersionAPI + + log *logger.Logger +} + +// New creates a new Uploader. +func New(subscription, location, resourceGroup string, log *logger.Logger) (*Uploader, error) { + cred, err := azidentity.NewDefaultAzureCredential(nil) + if err != nil { + return nil, err + } + diskClient, err := armcomputev4.NewDisksClient(subscription, cred, nil) + if err != nil { + return nil, err + } + managedImagesClient, err := armcomputev4.NewImagesClient(subscription, cred, nil) + if err != nil { + return nil, err + } + galleriesClient, err := armcomputev4.NewGalleriesClient(subscription, cred, nil) + if err != nil { + return nil, err + } + galleriesImageClient, err := armcomputev4.NewGalleryImagesClient(subscription, cred, nil) + if err != nil { + return nil, err + } + galleriesImageVersionClient, err := armcomputev4.NewGalleryImageVersionsClient(subscription, cred, nil) + if err != nil { + return nil, err + } + communityImageVersionClient, err := armcomputev4.NewCommunityGalleryImageVersionsClient(subscription, cred, nil) + if err != nil { + return nil, err + } + + return &Uploader{ + subscription: subscription, + location: location, + resourceGroup: resourceGroup, + pollingFrequency: pollingFrequency, + disks: diskClient, + managedImages: managedImagesClient, + blob: func(sasBlobURL string) (azurePageblobAPI, error) { + return pageblob.NewClientWithNoCredential(sasBlobURL, nil) + }, + galleries: galleriesClient, + image: galleriesImageClient, + imageVersions: galleriesImageVersionClient, + communityVersions: communityImageVersionClient, + log: log, + }, nil +} + +// Upload uploads an OS image to Azure. +func (u *Uploader) Upload(ctx context.Context, req *osimage.UploadRequest) (map[string]string, error) { + formattedTime := req.Timestamp.Format(timestampFormat) + diskName := fmt.Sprintf("constellation-%s-%s-%s", req.Version.Stream, formattedTime, req.Variant) + var sigName string + switch req.Version.Stream { + case "stable": + sigName = sigNameStable + case "debug": + sigName = sigNameDebug + default: + sigName = sigNameDefault + } + definitionName := imageOffer(req.Version) + versionName, err := imageVersion(req.Version, req.Timestamp) + if err != nil { + return nil, fmt.Errorf("determining image version name: %w", err) + } + + // ensure new image can be uploaded by deleting existing resources using the same name + if err := u.ensureImageVersionDeleted(ctx, sigName, definitionName, versionName); err != nil { + return nil, fmt.Errorf("pre-cleaning: ensuring no image version using the same name exists: %w", err) + } + if err := u.ensureManagedImageDeleted(ctx, diskName); err != nil { + return nil, fmt.Errorf("pre-cleaning: ensuring no managed image using the same name exists: %w", err) + } + if err := u.ensureDiskDeleted(ctx, diskName); err != nil { + return nil, fmt.Errorf("pre-cleaning: ensuring no temporary disk using the same name exists: %w", err) + } + + diskID, err := u.createDisk(ctx, diskName, DiskTypeNormal, req.Image, nil, req.Size) + if err != nil { + return nil, fmt.Errorf("creating disk: %w", err) + } + defer func() { + // cleanup temp disk + err := u.ensureDiskDeleted(ctx, diskName) + if err != nil { + u.log.Errorf("post-cleaning: deleting disk image: %v", err) + } + }() + managedImageID, err := u.createManagedImage(ctx, diskName, diskID) + if err != nil { + return nil, fmt.Errorf("creating managed image: %w", err) + } + if err := u.ensureSIG(ctx, sigName); err != nil { + return nil, fmt.Errorf("ensuring sig exists: %w", err) + } + if err := u.ensureImageDefinition(ctx, sigName, definitionName, req.Version, req.Variant); err != nil { + return nil, fmt.Errorf("ensuring image definition exists: %w", err) + } + + unsharedImageVersionID, err := u.createImageVersion(ctx, sigName, definitionName, versionName, managedImageID) + if err != nil { + return nil, fmt.Errorf("creating image version: %w", err) + } + + imageReference, err := u.getImageReference(ctx, sigName, definitionName, versionName, unsharedImageVersionID) + if err != nil { + return nil, fmt.Errorf("getting image reference: %w", err) + } + + return map[string]string{ + req.Variant: imageReference, + }, nil +} + +// createDisk creates and initializes (uploads contents of) an azure disk. +func (u *Uploader) createDisk(ctx context.Context, diskName string, diskType DiskType, img io.ReadSeeker, vmgs io.ReadSeeker, size int64) (string, error) { + u.log.Debugf("Creating disk %s in %s", diskName, u.resourceGroup) + if diskType == DiskTypeWithVMGS && vmgs == nil { + return "", errors.New("cannot create disk with vmgs: vmgs reader is nil") + } + var createOption armcomputev4.DiskCreateOption + var requestVMGSSAS bool + switch diskType { + case DiskTypeNormal: + createOption = armcomputev4.DiskCreateOptionUpload + case DiskTypeWithVMGS: + createOption = armcomputev4.DiskCreateOptionUploadPreparedSecure + requestVMGSSAS = true + } + disk := armcomputev4.Disk{ + Location: &u.location, + Properties: &armcomputev4.DiskProperties{ + CreationData: &armcomputev4.CreationData{ + CreateOption: &createOption, + UploadSizeBytes: toPtr(size), + }, + HyperVGeneration: toPtr(armcomputev4.HyperVGenerationV2), + OSType: toPtr(armcomputev4.OperatingSystemTypesLinux), + }, + } + createPoller, err := u.disks.BeginCreateOrUpdate(ctx, u.resourceGroup, diskName, disk, &armcomputev4.DisksClientBeginCreateOrUpdateOptions{}) + if err != nil { + return "", fmt.Errorf("creating disk: %w", err) + } + createdDisk, err := createPoller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: u.pollingFrequency}) + if err != nil { + return "", fmt.Errorf("waiting for disk to be created: %w", err) + } + + u.log.Debugf("Granting temporary upload permissions via SAS token") + accessGrant := armcomputev4.GrantAccessData{ + Access: toPtr(armcomputev4.AccessLevelWrite), + DurationInSeconds: toPtr(int32(uploadAccessDuration)), + GetSecureVMGuestStateSAS: &requestVMGSSAS, + } + accessPoller, err := u.disks.BeginGrantAccess(ctx, u.resourceGroup, diskName, accessGrant, &armcomputev4.DisksClientBeginGrantAccessOptions{}) + if err != nil { + return "", fmt.Errorf("generating disk sas token: %w", err) + } + accesPollerResp, err := accessPoller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: u.pollingFrequency}) + if err != nil { + return "", fmt.Errorf("waiting for sas token: %w", err) + } + + if requestVMGSSAS { + u.log.Debugf("Uploading vmgs") + vmgsSize, err := vmgs.Seek(0, io.SeekEnd) + if err != nil { + return "", err + } + if _, err := vmgs.Seek(0, io.SeekStart); err != nil { + return "", err + } + if accesPollerResp.SecurityDataAccessSAS == nil { + return "", errors.New("uploading vmgs: grant access returned no vmgs sas") + } + if err := uploadBlob(ctx, *accesPollerResp.SecurityDataAccessSAS, vmgs, vmgsSize, u.blob); err != nil { + return "", fmt.Errorf("uploading vmgs: %w", err) + } + } + u.log.Debugf("Uploading os image") + if accesPollerResp.AccessSAS == nil { + return "", errors.New("uploading disk: grant access returned no disk sas") + } + if err := uploadBlob(ctx, *accesPollerResp.AccessSAS, img, size, u.blob); err != nil { + return "", fmt.Errorf("uploading image: %w", err) + } + revokePoller, err := u.disks.BeginRevokeAccess(ctx, u.resourceGroup, diskName, &armcomputev4.DisksClientBeginRevokeAccessOptions{}) + if err != nil { + return "", fmt.Errorf("revoking disk sas token: %w", err) + } + if _, err := revokePoller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: u.pollingFrequency}); err != nil { + return "", fmt.Errorf("waiting for sas token revocation: %w", err) + } + if createdDisk.ID == nil { + return "", errors.New("created disk has no id") + } + return *createdDisk.ID, nil +} + +func (u *Uploader) ensureDiskDeleted(ctx context.Context, diskName string) error { + _, err := u.disks.Get(ctx, u.resourceGroup, diskName, &armcomputev4.DisksClientGetOptions{}) + if err != nil { + u.log.Debugf("Disk %s in %s doesn't exist. Nothing to clean up.", diskName, u.resourceGroup) + return nil + } + u.log.Debugf("Deleting disk %s in %s", diskName, u.resourceGroup) + deletePoller, err := u.disks.BeginDelete(ctx, u.resourceGroup, diskName, &armcomputev4.DisksClientBeginDeleteOptions{}) + if err != nil { + return fmt.Errorf("deleting disk: %w", err) + } + if _, err = deletePoller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: u.pollingFrequency}); err != nil { + return fmt.Errorf("waiting for disk to be deleted: %w", err) + } + return nil +} + +func (u *Uploader) createManagedImage(ctx context.Context, imageName string, diskID string) (string, error) { + u.log.Debugf("Creating managed image %s in %s", imageName, u.resourceGroup) + image := armcomputev4.Image{ + Location: &u.location, + Properties: &armcomputev4.ImageProperties{ + HyperVGeneration: toPtr(armcomputev4.HyperVGenerationTypesV2), + StorageProfile: &armcomputev4.ImageStorageProfile{ + OSDisk: &armcomputev4.ImageOSDisk{ + OSState: toPtr(armcomputev4.OperatingSystemStateTypesGeneralized), + OSType: toPtr(armcomputev4.OperatingSystemTypesLinux), + ManagedDisk: &armcomputev4.SubResource{ + ID: &diskID, + }, + }, + }, + }, + } + createPoller, err := u.managedImages.BeginCreateOrUpdate( + ctx, u.resourceGroup, imageName, image, + &armcomputev4.ImagesClientBeginCreateOrUpdateOptions{}, + ) + if err != nil { + return "", fmt.Errorf("creating managed image: %w", err) + } + createdImage, err := createPoller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: u.pollingFrequency}) + if err != nil { + return "", fmt.Errorf("waiting for image to be created: %w", err) + } + if createdImage.ID == nil { + return "", errors.New("created image has no id") + } + return *createdImage.ID, nil +} + +func (u *Uploader) ensureManagedImageDeleted(ctx context.Context, imageName string) error { + _, err := u.managedImages.Get(ctx, u.resourceGroup, imageName, &armcomputev4.ImagesClientGetOptions{}) + if err != nil { + u.log.Debugf("Managed image %s in %s doesn't exist. Nothing to clean up.", imageName, u.resourceGroup) + return nil + } + u.log.Debugf("Deleting managed image %s in %s", imageName, u.resourceGroup) + deletePoller, err := u.managedImages.BeginDelete(ctx, u.resourceGroup, imageName, &armcomputev4.ImagesClientBeginDeleteOptions{}) + if err != nil { + return fmt.Errorf("deleting image: %w", err) + } + if _, err = deletePoller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: u.pollingFrequency}); err != nil { + return fmt.Errorf("waiting for image to be deleted: %w", err) + } + return nil +} + +// ensureSIG creates a SIG if it does not exist yet. +func (u *Uploader) ensureSIG(ctx context.Context, sigName string) error { + _, err := u.galleries.Get(ctx, u.resourceGroup, sigName, &armcomputev4.GalleriesClientGetOptions{}) + if err == nil { + u.log.Debugf("Image gallery %s in %s exists", sigName, u.resourceGroup) + return nil + } + u.log.Debugf("Creating image gallery %s in %s", sigName, u.resourceGroup) + gallery := armcomputev4.Gallery{ + Location: &u.location, + } + createPoller, err := u.galleries.BeginCreateOrUpdate(ctx, u.resourceGroup, sigName, gallery, + &armcomputev4.GalleriesClientBeginCreateOrUpdateOptions{}, + ) + if err != nil { + return fmt.Errorf("creating image gallery: %w", err) + } + if _, err = createPoller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: u.pollingFrequency}); err != nil { + return fmt.Errorf("waiting for image gallery to be created: %w", err) + } + return nil +} + +// ensureImageDefinition creates an image definition (component of a SIG) if it does not exist yet. +func (u *Uploader) ensureImageDefinition(ctx context.Context, sigName, definitionName string, version versionsapi.Version, variant string) error { + _, err := u.image.Get(ctx, u.resourceGroup, sigName, definitionName, &armcomputev4.GalleryImagesClientGetOptions{}) + if err == nil { + u.log.Debugf("Image definition %s/%s in %s exists", sigName, definitionName, u.resourceGroup) + return nil + } + u.log.Debugf("Creating image definition %s/%s in %s", sigName, definitionName, u.resourceGroup) + var securityType string + // TODO(malt3): This needs to allow the *Supported or the normal variant + // based on wether a VMGS was provided or not. + // VMGS provided: ConfidentialVM + // No VMGS provided: ConfidentialVMSupported + switch strings.ToLower(variant) { + case "cvm": + securityType = string("ConfidentialVMSupported") + case "trustedlaunch": + securityType = string(armcomputev4.SecurityTypesTrustedLaunch) + } + offer := imageOffer(version) + + galleryImage := armcomputev4.GalleryImage{ + Location: &u.location, + Properties: &armcomputev4.GalleryImageProperties{ + Identifier: &armcomputev4.GalleryImageIdentifier{ + Offer: &offer, + Publisher: toPtr(imageDefinitionPublisher), + SKU: toPtr(imageDefinitionSKU), + }, + OSState: toPtr(armcomputev4.OperatingSystemStateTypesGeneralized), + OSType: toPtr(armcomputev4.OperatingSystemTypesLinux), + Architecture: toPtr(armcomputev4.ArchitectureX64), + Features: []*armcomputev4.GalleryImageFeature{ + { + Name: toPtr("SecurityType"), + Value: &securityType, + }, + }, + HyperVGeneration: toPtr(armcomputev4.HyperVGenerationV2), + }, + } + createPoller, err := u.image.BeginCreateOrUpdate(ctx, u.resourceGroup, sigName, definitionName, galleryImage, + &armcomputev4.GalleryImagesClientBeginCreateOrUpdateOptions{}, + ) + if err != nil { + return fmt.Errorf("creating image definition: %w", err) + } + if _, err = createPoller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: u.pollingFrequency}); err != nil { + return fmt.Errorf("waiting for image definition to be created: %w", err) + } + return nil +} + +func (u *Uploader) createImageVersion(ctx context.Context, sigName, definitionName, versionName, imageID string) (string, error) { + u.log.Debugf("Creating image version %s/%s/%s in %s", sigName, definitionName, versionName, u.resourceGroup) + imageVersion := armcomputev4.GalleryImageVersion{ + Location: &u.location, + Properties: &armcomputev4.GalleryImageVersionProperties{ + StorageProfile: &armcomputev4.GalleryImageVersionStorageProfile{ + OSDiskImage: &armcomputev4.GalleryOSDiskImage{ + HostCaching: toPtr(armcomputev4.HostCachingReadOnly), + }, + Source: &armcomputev4.GalleryArtifactVersionFullSource{ + ID: &imageID, + }, + }, + PublishingProfile: &armcomputev4.GalleryImageVersionPublishingProfile{ + ReplicaCount: toPtr[int32](1), + ReplicationMode: toPtr(armcomputev4.ReplicationModeFull), + TargetRegions: targetRegions, + }, + }, + } + createPoller, err := u.imageVersions.BeginCreateOrUpdate(ctx, u.resourceGroup, sigName, definitionName, versionName, imageVersion, + &armcomputev4.GalleryImageVersionsClientBeginCreateOrUpdateOptions{}, + ) + if err != nil { + return "", fmt.Errorf("creating image version: %w", err) + } + createdImage, err := createPoller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: u.pollingFrequency}) + if err != nil { + return "", fmt.Errorf("waiting for image version to be created: %w", err) + } + if createdImage.ID == nil { + return "", errors.New("created image has no id") + } + return *createdImage.ID, nil +} + +func (u *Uploader) ensureImageVersionDeleted(ctx context.Context, sigName, definitionName, versionName string) error { + _, err := u.imageVersions.Get(ctx, u.resourceGroup, sigName, definitionName, versionName, &armcomputev4.GalleryImageVersionsClientGetOptions{}) + if err != nil { + u.log.Debugf("Image version %s in %s/%s/%s doesn't exist. Nothing to clean up.", versionName, u.resourceGroup, sigName, definitionName) + return nil + } + u.log.Debugf("Deleting image version %s in %s/%s/%s", versionName, u.resourceGroup, sigName, definitionName) + deletePoller, err := u.imageVersions.BeginDelete(ctx, u.resourceGroup, sigName, definitionName, versionName, &armcomputev4.GalleryImageVersionsClientBeginDeleteOptions{}) + if err != nil { + return fmt.Errorf("deleting image version: %w", err) + } + if _, err = deletePoller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: u.pollingFrequency}); err != nil { + return fmt.Errorf("waiting for image version to be deleted: %w", err) + } + return nil +} + +// getImageReference returns the image reference to use for the image version. +// If the shared image gallery is a community gallery, the community identifier is returned. +// Otherwise, the unshared identifier is returned. +func (u *Uploader) getImageReference(ctx context.Context, sigName, definitionName, versionName, unsharedID string) (string, error) { + galleryResp, err := u.galleries.Get(ctx, u.resourceGroup, sigName, &armcomputev4.GalleriesClientGetOptions{}) + if err != nil { + return "", fmt.Errorf("getting image gallery %s: %w", sigName, err) + } + if galleryResp.Properties == nil || + galleryResp.Properties.SharingProfile == nil || + galleryResp.Properties.SharingProfile.CommunityGalleryInfo == nil || + galleryResp.Properties.SharingProfile.CommunityGalleryInfo.CommunityGalleryEnabled == nil || + !*galleryResp.Properties.SharingProfile.CommunityGalleryInfo.CommunityGalleryEnabled { + u.log.Warnf("Image gallery %s in %s is not shared. Using private identifier", sigName, u.resourceGroup) + return unsharedID, nil + } + if galleryResp.Properties == nil || + galleryResp.Properties.SharingProfile == nil || + galleryResp.Properties.SharingProfile.CommunityGalleryInfo == nil || + galleryResp.Properties.SharingProfile.CommunityGalleryInfo.PublicNames == nil || + len(galleryResp.Properties.SharingProfile.CommunityGalleryInfo.PublicNames) < 1 || + galleryResp.Properties.SharingProfile.CommunityGalleryInfo.PublicNames[0] == nil { + return "", fmt.Errorf("image gallery %s in %s is a community gallery but has no public names", sigName, u.resourceGroup) + } + communityGalleryName := *galleryResp.Properties.SharingProfile.CommunityGalleryInfo.PublicNames[0] + u.log.Debugf("Image gallery %s in %s is shared. Using community identifier in %s", sigName, u.resourceGroup, communityGalleryName) + communityVersionResp, err := u.communityVersions.Get(ctx, u.location, communityGalleryName, + definitionName, versionName, + &armcomputev4.CommunityGalleryImageVersionsClientGetOptions{}, + ) + if err != nil { + return "", fmt.Errorf("getting community image version %s/%s/%s: %w", communityGalleryName, definitionName, versionName, err) + } + if communityVersionResp.Identifier == nil || communityVersionResp.Identifier.UniqueID == nil { + return "", fmt.Errorf("community image version %s/%s/%s has no id", communityGalleryName, definitionName, versionName) + } + return *communityVersionResp.Identifier.UniqueID, nil +} + +func uploadBlob(ctx context.Context, sasURL string, disk io.ReadSeeker, size int64, uploader sasBlobUploader) error { + uploadClient, err := uploader(sasURL) + if err != nil { + return fmt.Errorf("uploading blob: %w", err) + } + var offset int64 + var chunksize int + chunk := make([]byte, pageSizeMax) + var readErr error + for offset < size { + chunksize, readErr = io.ReadAtLeast(disk, chunk, 1) + if readErr != nil { + return fmt.Errorf("reading from disk: %w", err) + } + if err := uploadChunk(ctx, uploadClient, bytes.NewReader(chunk[:chunksize]), offset, int64(chunksize)); err != nil { + return fmt.Errorf("uploading chunk: %w", err) + } + offset += int64(chunksize) + } + return nil +} + +func uploadChunk(ctx context.Context, uploader azurePageblobAPI, chunk io.ReadSeeker, offset, chunksize int64) error { + _, err := uploader.UploadPages(ctx, &readSeekNopCloser{chunk}, blob.HTTPRange{ + Offset: offset, + Count: chunksize, + }, nil) + return err +} + +func imageOffer(version versionsapi.Version) string { + switch { + case version.Stream == "stable": + return "constellation" + case version.Stream == "debug" && version.Ref == "-": + return version.Version + } + return version.Ref + "-" + version.Stream +} + +// imageVersion determines the semantic version string used inside a sig image. +// For releases, the actual semantic version of the image (without leading v) is used (major.minor.patch). +// Otherwise, the version is derived from the commit timestamp. +func imageVersion(version versionsapi.Version, timestamp time.Time) (string, error) { + switch { + case version.Stream == "stable": + fallthrough + case version.Stream == "debug" && version.Ref == "-": + return strings.TrimLeft(version.Version, "v"), nil + } + + formattedTime := timestamp.Format(timestampFormat) + if len(formattedTime) != len(timestampFormat) { + return "", errors.New("invalid timestamp") + } + // ..