diff --git a/README.md b/README.md index 550430d..76f50f4 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,12 @@ project = "myproject-123456" location = "europe-west3" bucket = "my-bucket" +[base.openstack] +# OpenStack specific configuration that is applied to every variant. +cloud = "mycloud" +tags = ["tag-a", "tag-b"] +minDiskGB = 32 + [variant.foo] # Variant specific configuration that overrides the base configuration. provider = "aws" @@ -364,3 +370,67 @@ Will be created if it does not exist. - Template: yes Name of the temporary blob within `bucket`. Image is uploaded to this blob before being converted to an image. + +### `base.openstack.cloud` / `variant..openstack.cloud` + +- Default: none +- Required: yes + +Name in OpenStack's cloud.yaml used for authentication. + +### `base.openstack.imageName` / `variant..openstack.imageName` + +- Default: `"{{.Name}}-{{.Version}}"` +- Required: no +- Template: yes + +Name of the image to create. Example: `"my-image-1.0.0"`. + +### `base.openstack.visibility` / `variant..openstack.visibility` + +- Default: `"public"` +- Required: no + +Visibility of the image to create. Possible values are "public", "private", "shared", "community"`. + +### `base.openstack.hidden` / `variant..openstack.hidden` + +- Default: `false` +- Required: no + +Hidden status of the image in listings. + +### `base.openstack.tags` / `variant..openstack.tags` + +- Default: `[]` +- Required: no + +Tags added to the image. + +### `base.openstack.minDiskGB` / `variant..openstack.minDiskGB` + +- Default: `0` +- Required: no + +Minimum disk size of the image in GB. + +### `base.openstack.minRamMB` / `variant..openstack.minRamMB` + +- Default: `0` +- Required: no + +Minimum amount of RAM reserved for a VM created from this image. + +### `base.openstack.protected` / `variant..openstack.protected` + +- Default: `false` +- Required: no + +If set, prevents accidential deletion of the image. + +### `base.openstack.properties` / `variant..openstack.properties` + +- Default: `{}` +- Required: no + +Extra key-value pairs attached to the image. Example: `{"hw_firmware_type" = "uefi", "os_type" = "linux"}`. diff --git a/cmd.go b/cmd.go index 8e34a65..5eb7a70 100644 --- a/cmd.go +++ b/cmd.go @@ -22,6 +22,7 @@ import ( "github.com/edgelesssys/uplosi/azure" "github.com/edgelesssys/uplosi/config" "github.com/edgelesssys/uplosi/gcp" + "github.com/edgelesssys/uplosi/openstack" "github.com/spf13/cobra" "golang.org/x/mod/semver" ) @@ -158,6 +159,12 @@ func uploadVariant(ctx context.Context, imagePath, variant string, config config if err != nil { return nil, fmt.Errorf("creating gcp uploader: %w", err) } + case "openstack": + prepper = &openstack.Prepper{} + upload, err = openstack.NewUploader(config, logger) + if err != nil { + return nil, fmt.Errorf("creating openstack uploader: %w", err) + } default: return nil, fmt.Errorf("unknown provider: %s", config.Provider) } diff --git a/config/config.go b/config/config.go index 451907e..fab543d 100644 --- a/config/config.go +++ b/config/config.go @@ -44,16 +44,22 @@ var defaultConfig = Config{ ImageFamily: "{{.Name}}", BlobName: "{{.Name}}-{{replaceAll .Version \".\" \"-\"}}.tar.gz", }, + OpenStack: OpenStackConfig{ + ImageName: "{{.Name}}-{{.Version}}", + Visibility: "public", + Protected: Some(false), + }, } type Config struct { - Provider string `toml:"provider"` - ImageVersion string `toml:"imageVersion"` - ImageVersionFile string `toml:"imageVersionFile"` - Name string `toml:"name"` - AWS AWSConfig `toml:"aws,omitempty"` - Azure AzureConfig `toml:"azure,omitempty"` - GCP GCPConfig `toml:"gcp,omitempty"` + Provider string `toml:"provider"` + ImageVersion string `toml:"imageVersion"` + ImageVersionFile string `toml:"imageVersionFile"` + Name string `toml:"name"` + AWS AWSConfig `toml:"aws,omitempty"` + Azure AzureConfig `toml:"azure,omitempty"` + GCP GCPConfig `toml:"gcp,omitempty"` + OpenStack OpenStackConfig `toml:"openstack,omitempty"` } func (c *Config) Merge(other Config) error { @@ -82,6 +88,9 @@ func (c *Config) Render(fileLookup func(name string) ([]byte, error)) error { if err := c.renderTemplates(&c.GCP); err != nil { return err } + if err := c.renderTemplates(&c.OpenStack); err != nil { + return err + } v := Validator{} @@ -199,6 +208,18 @@ type GCPConfig struct { BlobName string `toml:"blobName,omitempty" template:"true"` } +type OpenStackConfig struct { + Cloud string `toml:"cloud"` + ImageName string `toml:"imageName,omitempty" template:"true"` + Visibility string `toml:"visibility,omitempty"` + Hidden Option[bool] `toml:"hidden,omitempty"` + Tags []string `toml:"tags,omitempty"` + MinDiskGB int `toml:"minDiskGB,omitempty"` + MinRamMB int `toml:"minRamMB,omitempty"` + Protected Option[bool] `toml:"protected,omitempty"` + Properties map[string]string `toml:"properties"` +} + type ConfigFile struct { Base Config `toml:"base"` Variants map[string]Config `toml:"variant"` diff --git a/config/validation.rego b/config/validation.rego index 9fbcfe7..288a7ae 100644 --- a/config/validation.rego +++ b/config/validation.rego @@ -365,6 +365,14 @@ deny[msg] { msg = sprintf("field bucket must be between 1 and 63 characters for provider gcp, got %d", [count(input.GCP.Bucket)]) } +deny[msg] { + input.Provider == "openstack" + input.OpenStack.Visibility != "" + allowed := ["public", "private", "shared", "community"] + not input.OpenStack.Visibility in allowed + + msg = sprintf("field visibility must be one of %s for provider openstack", allowed) +} deny[msg] { some provider in valid_csps @@ -395,7 +403,7 @@ begin_and_end_with(s, charset) = begin_and_end { ]) } -valid_csps := [ "aws", "azure", "gcp" ] +valid_csps := [ "aws", "azure", "gcp", "openstack" ] required_fields := { "aws": { @@ -429,6 +437,10 @@ required_fields := { "bucket": input.GCP.Bucket, "blobName": input.GCP.BlobName, }, + "openstack": { + "cloud": input.OpenStack.Cloud, + "imageName": input.OpenStack.ImageName, + }, } lowercase_letters := { diff --git a/flake.nix b/flake.nix index f6ff09a..d23d8f4 100644 --- a/flake.nix +++ b/flake.nix @@ -20,7 +20,7 @@ version = "0.1.2"; src = ./.; # this needs to be updated together with go.mod / go.sum - vendorHash = "sha256-eZ0/piSxMUC1ZM7qBhFW40l9p8ZPMIj1HyrS2Dy4wJQ="; + vendorHash = "sha256-XKVkZrvEuj3+6Sxl687Tce88VhRxEAKfjOJzBd2c/Ac="; CGO_ENABLED = 0; diff --git a/go.mod b/go.mod index a4b5dd2..8c90dc4 100644 --- a/go.mod +++ b/go.mod @@ -44,7 +44,9 @@ require ( github.com/google/s2a-go v0.1.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/gorilla/mux v1.8.1 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/prometheus/client_golang v1.16.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.42.0 // indirect @@ -92,6 +94,8 @@ require ( github.com/aws/smithy-go v1.19.0 github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.5.0 // indirect + github.com/gophercloud/gophercloud v1.9.0 + github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect diff --git a/go.sum b/go.sum index b0d416e..e6498a3 100644 --- a/go.sum +++ b/go.sum @@ -174,10 +174,17 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfF github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= +github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= +github.com/gophercloud/gophercloud v1.9.0 h1:zKvmHOmHuaZlnx9d2DJpEgbMxrGt/+CJ/bKOKQh9Xzo= +github.com/gophercloud/gophercloud v1.9.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= +github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 h1:sH7xkTfYzxIEgzq1tDHIMKRh1vThOEOGNsettdEeLbE= +github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56/go.mod h1:VSalo4adEk+3sNkmVJLnhHoOyOYYS8sTWLG4mv5BKto= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -196,6 +203,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zk github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/open-policy-agent/opa v0.60.0 h1:ZPoPt4yeNs5UXCpd/P/btpSyR8CR0wfhVoh9BOwgJNs= github.com/open-policy-agent/opa v0.60.0/go.mod h1:aD5IK6AiLNYBjNXn7E02++yC8l4Z+bRDvgM6Ss0bBzA= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= @@ -263,6 +272,7 @@ go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v8 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -280,6 +290,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= @@ -298,19 +309,23 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= diff --git a/openstack/prepper.go b/openstack/prepper.go new file mode 100644 index 0000000..c93ca92 --- /dev/null +++ b/openstack/prepper.go @@ -0,0 +1,18 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: Apache-2.0 +*/ + +package openstack + +import ( + "context" +) + +type Prepper struct{} + +func (p *Prepper) Prepare(_ context.Context, imagePath, _ string) (string, error) { + // OpenStack does not need any preparation. + return imagePath, nil +} diff --git a/openstack/uploader.go b/openstack/uploader.go new file mode 100644 index 0000000..269befa --- /dev/null +++ b/openstack/uploader.go @@ -0,0 +1,128 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: Apache-2.0 +*/ + +package openstack + +import ( + "context" + "errors" + "fmt" + "io" + "log" + + "github.com/edgelesssys/uplosi/config" + "github.com/gophercloud/gophercloud" + "github.com/gophercloud/gophercloud/openstack/imageservice/v2/imagedata" + "github.com/gophercloud/gophercloud/openstack/imageservice/v2/images" + "github.com/gophercloud/utils/openstack/clientconfig" +) + +const microversion = "2.42" + +type Uploader struct { + config config.Config + + image func(context.Context) (*gophercloud.ServiceClient, error) + + log *log.Logger +} + +func NewUploader(config config.Config, log *log.Logger) (*Uploader, error) { + clientOpts := &clientconfig.ClientOpts{ + Cloud: config.OpenStack.Cloud, + } + + return &Uploader{ + config: config, + image: func(ctx context.Context) (*gophercloud.ServiceClient, error) { + imageClient, err := clientconfig.NewServiceClient("image", clientOpts) + if err != nil { + return nil, err + } + imageClient.Microversion = microversion + return imageClient, nil + }, + log: log, + }, nil +} + +func (u *Uploader) Upload(ctx context.Context, image io.ReadSeeker, _ int64) (refs []string, retErr error) { + if err := u.ensureImageDeleted(ctx); err != nil { + return nil, fmt.Errorf("pre-cleaning: ensuring no image using the same name exists: %w", err) + } + imageID, err := u.createImage(ctx, image) + if err != nil { + return nil, fmt.Errorf("creating image: %w", err) + } + return []string{imageID}, nil +} + +func (u *Uploader) createImage(ctx context.Context, image io.ReadSeeker) (string, error) { + visibility := images.ImageVisibility(u.config.OpenStack.Visibility) + if visibility == images.ImageVisibility("") { + visibility = images.ImageVisibilityPublic + } + protected := u.config.OpenStack.Protected.UnwrapOr(false) + hidden := u.config.OpenStack.Hidden.UnwrapOr(false) + createOpts := images.CreateOpts{ + Name: u.config.OpenStack.ImageName, + ContainerFormat: "bare", + DiskFormat: "raw", + Visibility: &visibility, + Hidden: &hidden, + Tags: u.config.OpenStack.Tags, + Protected: &protected, + MinDisk: u.config.OpenStack.MinDiskGB, + MinRAM: u.config.OpenStack.MinRamMB, + Properties: u.config.OpenStack.Properties, + } + + imageClient, err := u.image(ctx) + if err != nil { + return "", err + } + + u.log.Printf("Creating image %q", u.config.OpenStack.ImageName) + + newImage, err := images.Create(imageClient, createOpts).Extract() + if err != nil { + return "", fmt.Errorf("creating image: %w", err) + } + + if err := imagedata.Upload(imageClient, newImage.ID, image).ExtractErr(); err != nil { + return "", fmt.Errorf("uploading image data: %w", err) + } + + return newImage.ID, nil +} + +func (u *Uploader) ensureImageDeleted(ctx context.Context) error { + imageClient, err := u.image(ctx) + if err != nil { + return err + } + + listOpts := images.ListOpts{ + Name: u.config.OpenStack.ImageName, + Limit: 1, + } + page, err := images.List(imageClient, listOpts).AllPages() + if err != nil { + return fmt.Errorf("listing images: %w", err) + } + imgs, err := images.ExtractImages(page) + if err != nil { + return fmt.Errorf("extracting images: %w", err) + } + if len(imgs) == 0 { + return nil + } + if len(imgs) != 1 { + return errors.New("multiple images with the same name found") + } + u.log.Printf("Deleting existing image %q (%s)", u.config.OpenStack.ImageName, imgs[0].ID) + return images.Delete(imageClient, imgs[0].ID).ExtractErr() +}