diff --git a/command/plugin.go b/command/plugin.go index f5e891d93..f07f5f861 100644 --- a/command/plugin.go +++ b/command/plugin.go @@ -63,6 +63,7 @@ import ( dockerpushpostprocessor "github.com/hashicorp/packer/post-processor/docker-push" dockersavepostprocessor "github.com/hashicorp/packer/post-processor/docker-save" dockertagpostprocessor "github.com/hashicorp/packer/post-processor/docker-tag" + exoscaleimportpostprocessor "github.com/hashicorp/packer/post-processor/exoscale-import" googlecomputeexportpostprocessor "github.com/hashicorp/packer/post-processor/googlecompute-export" googlecomputeimportpostprocessor "github.com/hashicorp/packer/post-processor/googlecompute-import" manifestpostprocessor "github.com/hashicorp/packer/post-processor/manifest" @@ -168,6 +169,7 @@ var PostProcessors = map[string]packer.PostProcessor{ "docker-push": new(dockerpushpostprocessor.PostProcessor), "docker-save": new(dockersavepostprocessor.PostProcessor), "docker-tag": new(dockertagpostprocessor.PostProcessor), + "exoscale-import": new(exoscaleimportpostprocessor.PostProcessor), "googlecompute-export": new(googlecomputeexportpostprocessor.PostProcessor), "googlecompute-import": new(googlecomputeimportpostprocessor.PostProcessor), "manifest": new(manifestpostprocessor.PostProcessor), diff --git a/go.mod b/go.mod index 83b13b892..3025d5cb3 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/docker/docker v0.0.0-20180422163414-57142e89befe // indirect github.com/dylanmei/iso8601 v0.1.0 // indirect github.com/dylanmei/winrmtest v0.0.0-20170819153634-c2fbb09e6c08 + github.com/exoscale/egoscale v0.18.1 github.com/go-ini/ini v1.25.4 github.com/gofrs/flock v0.7.1 github.com/google/go-cmp v0.2.0 diff --git a/go.sum b/go.sum index ac5b2ac3e..725159c26 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/dylanmei/winrmtest v0.0.0-20170819153634-c2fbb09e6c08/go.mod h1:VBVDF github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/exoscale/egoscale v0.18.1 h1:1FNZVk8jHUx0AvWhOZxLEDNlacTU0chMXUUNkm9EZaI= +github.com/exoscale/egoscale v0.18.1/go.mod h1:Z7OOdzzTOz1Q1PjQXumlz9Wn/CddH0zSYdCF3rnBKXE= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= @@ -121,6 +123,8 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gofrs/flock v0.7.1 h1:DP+LD/t0njgoPBvT5MJLeliUIVQR03hiKR6vezdwHlc= github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= diff --git a/post-processor/exoscale-import/artifact.go b/post-processor/exoscale-import/artifact.go new file mode 100644 index 000000000..cd3f9797b --- /dev/null +++ b/post-processor/exoscale-import/artifact.go @@ -0,0 +1,31 @@ +package exoscaleimport + +const BuilderId = "packer.post-processor.exoscale-import" + +type Artifact struct { + id string +} + +func (a *Artifact) BuilderId() string { + return BuilderId +} + +func (a *Artifact) Id() string { + return a.id +} + +func (a *Artifact) Files() []string { + return nil +} + +func (a *Artifact) String() string { + return a.id +} + +func (a *Artifact) State(name string) interface{} { + return nil +} + +func (a *Artifact) Destroy() error { + return nil +} diff --git a/post-processor/exoscale-import/post-processor.go b/post-processor/exoscale-import/post-processor.go new file mode 100644 index 000000000..1c41a52a8 --- /dev/null +++ b/post-processor/exoscale-import/post-processor.go @@ -0,0 +1,250 @@ +package exoscaleimport + +import ( + "context" + "crypto/md5" + "encoding/base64" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/aws/aws-sdk-go/service/s3/s3manager" + "github.com/exoscale/egoscale" + "github.com/hashicorp/packer/common" + "github.com/hashicorp/packer/helper/config" + "github.com/hashicorp/packer/packer" + "github.com/hashicorp/packer/version" +) + +var ( + defaultTemplateZone = "ch-gva-2" + defaultAPIEndpoint = "https://api.exoscale.com/compute" + defaultSOSEndpoint = "https://sos-" + defaultTemplateZone + ".exo.io" +) + +type Config struct { + common.PackerConfig `mapstructure:",squash"` + SkipClean bool `mapstructure:"skip_clean"` + + APIEndpoint string `mapstructure:"api_endpoint"` + SOSEndpoint string `mapstructure:"sos_endpoint"` + APIKey string `mapstructure:"api_key"` + APISecret string `mapstructure:"api_secret"` + ImageBucket string `mapstructure:"image_bucket"` + TemplateZone string `mapstructure:"template_zone"` + TemplateName string `mapstructure:"template_name"` + TemplateDescription string `mapstructure:"template_description"` + TemplateUsername string `mapstructure:"template_username"` + TemplateDisablePassword bool `mapstructure:"template_disable_password"` + TemplateDisableSSHKey bool `mapstructure:"template_disable_sshkey"` +} + +func init() { + egoscale.UserAgent = "Packer-Exoscale/" + version.FormattedVersion() + " " + egoscale.UserAgent +} + +type PostProcessor struct { + config Config +} + +func (p *PostProcessor) Configure(raws ...interface{}) error { + p.config.TemplateZone = defaultTemplateZone + p.config.APIEndpoint = defaultAPIEndpoint + p.config.SOSEndpoint = defaultSOSEndpoint + + if err := config.Decode(&p.config, nil, raws...); err != nil { + return err + } + + if p.config.APIKey == "" { + p.config.APIKey = os.Getenv("EXOSCALE_API_KEY") + } + + if p.config.APISecret == "" { + p.config.APISecret = os.Getenv("EXOSCALE_API_SECRET") + } + + requiredArgs := map[string]*string{ + "api_key": &p.config.APIKey, + "api_secret": &p.config.APISecret, + "api_endpoint": &p.config.APIEndpoint, + "sos_endpoint": &p.config.SOSEndpoint, + "image_bucket": &p.config.ImageBucket, + "template_zone": &p.config.TemplateZone, + "template_name": &p.config.TemplateName, + } + + errs := new(packer.MultiError) + for k, v := range requiredArgs { + if *v == "" { + errs = packer.MultiErrorAppend( + errs, fmt.Errorf("%s must be set", k)) + } + } + + if len(errs.Errors) > 0 { + return errs + } + + packer.LogSecretFilter.Set(p.config.APIKey, p.config.APISecret) + + return nil +} + +func (p *PostProcessor) PostProcess(ctx context.Context, ui packer.Ui, a packer.Artifact) (packer.Artifact, bool, bool, error) { + ui.Message("Uploading template image") + url, md5sum, err := p.uploadImage(ctx, ui, a) + if err != nil { + return nil, false, false, fmt.Errorf("unable to upload image: %s", err) + } + + ui.Message("Registering template") + id, err := p.registerTemplate(ctx, ui, url, md5sum) + if err != nil { + return nil, false, false, fmt.Errorf("unable to register template: %s", err) + } + + if !p.config.SkipClean { + ui.Message("Deleting uploaded template image") + if err = p.deleteImage(ctx, ui, a); err != nil { + return nil, false, false, fmt.Errorf("unable to delete uploaded template image: %s", err) + } + } + + return &Artifact{id}, false, false, nil +} + +func (p *PostProcessor) uploadImage(ctx context.Context, ui packer.Ui, a packer.Artifact) (string, string, error) { + var ( + imageFile = a.Files()[0] + bucketFile = filepath.Base(imageFile) + ) + + f, err := os.Open(imageFile) + if err != nil { + return "", "", err + } + defer f.Close() + + fileInfo, err := f.Stat() + if err != nil { + return "", "", err + } + + // For tracking image file upload progress + pf := ui.TrackProgress(imageFile, 0, fileInfo.Size(), f) + defer pf.Close() + + hash := md5.New() + if _, err := io.Copy(hash, f); err != nil { + return "", "", fmt.Errorf("image checksumming failed: %s", err) + } + if _, err := f.Seek(0, 0); err != nil { + return "", "", err + } + + sess := session.Must(session.NewSessionWithOptions(session.Options{Config: aws.Config{ + Region: aws.String(p.config.TemplateZone), + Endpoint: aws.String(p.config.SOSEndpoint), + Credentials: credentials.NewStaticCredentials(p.config.APIKey, p.config.APISecret, "")}})) + + uploader := s3manager.NewUploader(sess) + output, err := uploader.UploadWithContext(ctx, &s3manager.UploadInput{ + Body: pf, + Bucket: aws.String(p.config.ImageBucket), + Key: aws.String(bucketFile), + ContentMD5: aws.String(base64.StdEncoding.EncodeToString(hash.Sum(nil))), + ACL: aws.String("public-read"), + }) + if err != nil { + return "", "", err + } + + return output.Location, fmt.Sprintf("%x", hash.Sum(nil)), nil +} + +func (p *PostProcessor) deleteImage(ctx context.Context, ui packer.Ui, a packer.Artifact) error { + var ( + imageFile = a.Files()[0] + bucketFile = filepath.Base(imageFile) + ) + + sess := session.Must(session.NewSessionWithOptions(session.Options{Config: aws.Config{ + Region: aws.String(p.config.TemplateZone), + Endpoint: aws.String(p.config.SOSEndpoint), + Credentials: credentials.NewStaticCredentials(p.config.APIKey, p.config.APISecret, "")}})) + + svc := s3.New(sess) + if _, err := svc.DeleteObject(&s3.DeleteObjectInput{ + Bucket: aws.String(p.config.ImageBucket), + Key: aws.String(bucketFile), + }); err != nil { + return err + } + + return nil +} + +func (p *PostProcessor) registerTemplate(ctx context.Context, ui packer.Ui, url, md5sum string) (string, error) { + var ( + passwordEnabled = !p.config.TemplateDisablePassword + sshkeyEnabled = !p.config.TemplateDisableSSHKey + regErr error + ) + + exo := egoscale.NewClient(p.config.APIEndpoint, p.config.APIKey, p.config.APISecret) + exo.RetryStrategy = egoscale.FibonacciRetryStrategy + + zone := egoscale.Zone{Name: p.config.TemplateZone} + if resp, err := exo.GetWithContext(ctx, &zone); err != nil { + return "", fmt.Errorf("template zone lookup failed: %s", err) + } else { + zone.ID = resp.(*egoscale.Zone).ID + } + + req := egoscale.RegisterCustomTemplate{ + URL: url, + ZoneID: zone.ID, + Name: p.config.TemplateName, + Displaytext: p.config.TemplateDescription, + PasswordEnabled: &passwordEnabled, + SSHKeyEnabled: &sshkeyEnabled, + Details: map[string]string{"username": p.config.TemplateUsername}, + Checksum: md5sum, + } + + res := make([]egoscale.Template, 0) + + exo.AsyncRequestWithContext(ctx, req, func(jobRes *egoscale.AsyncJobResult, err error) bool { + if err != nil { + regErr = fmt.Errorf("request failed: %s", err) + return false + } else if jobRes.JobStatus == egoscale.Pending { + // Job is not completed yet + ui.Message("template registration in progress") + return true + } + + if err := jobRes.Result(&res); err != nil { + regErr = err + return false + } + + if len(res) != 1 { + regErr = fmt.Errorf("unexpected response from API (expected 1 item, got %d)", len(res)) + return false + } + + return false + }) + if regErr != nil { + return "", regErr + } + + return res[0].ID.String(), nil +} diff --git a/website/source/docs/post-processors/exoscale-import.html.md b/website/source/docs/post-processors/exoscale-import.html.md new file mode 100644 index 000000000..ed3cb9f01 --- /dev/null +++ b/website/source/docs/post-processors/exoscale-import.html.md @@ -0,0 +1,89 @@ +--- +description: | + The Packer Exoscale Import post-processor takes an image artifact + from various builders and imports it to Exoscale. +layout: docs +page_title: 'Exoscale Import - Post-Processors' +sidebar_current: 'docs-post-processors-exoscale-import' +--- + +# Exoscale Import Post-Processor + +Type: `exoscale-import` + +The Packer Exoscale Import post-processor takes an image artifact from +various builders and imports it to Exoscale. + +## How Does it Work? + +The import process operates uploading a temporary copy of the image to +Exoscale's [Object Storage](https://www.exoscale.com/object-storage/) (SOS) +and then importing it as a Private Template via the Exoscale API. The +temporary copy in SOS can be discarded after the import is complete. + +For more information about Exoscale Private Templates, see the +[documentation](https://community.exoscale.com/documentation/compute/private-templates/). + +## Configuration + +There are some configuration options available for the post-processor. + +Required: + +- `api_key` (string) - The API key used to communicate with Exoscale + services. This may also be set using the `EXOSCALE_API_KEY` environmental + variable. + +- `api_secret` (string) - The API secret used to communicate with Exoscale + services. This may also be set using the `EXOSCALE_API_SECRET` + environmental variable. + +- `image_bucket` (string) - The name of the bucket in which to upload the + template image to SOS. The bucket must exist when the post-processor is + run. + +- `template_name` (string) - The name to be used for registering the template. + +Optional: + +- `api_endpoint` (string) - The API endpoint used to communicate with the + Exoscale API. Defaults to `https://api.exoscale.com/compute`. + +- `sos_endpoint` (string) - The endpoint used to communicate with SOS. + Defaults to `https://sos-ch-gva-2.exo.io`. + +- `template_zone` (string) - The Exoscale [zone](https://www.exoscale.com/datacenters/) + in which to register the template. Defaults to `ch-gva-2`. + +- `template_description` (string) - An optional text description for the + registered template. + +- `template_username` (string) - An optional username to be used to log into + Compute instances using this template. + +- `template_disable_password` (boolean) - Whether the registered template + should disable Compute instance password reset. Defaults to `false`. + +- `template_disable_sshkey` (boolean) - Whether the registered template + should disable SSH key installation during Compute instance creation. + Defaults to `false`. + +- `skip_clean` (boolean) - Whether we should skip removing the image file + uploaded to SOS after the import process has completed. "true" means that + we should leave it in the bucket, "false" means deleting it. + Defaults to `false`. + +## Basic Example + +Here is a basic example: + +``` json +{ + "type": "exoscale-import", + "api_key": "{{user `exoscale_api_key`}}", + "api_secret": "{{user `exoscale_api_secret`}}", + "image_bucket": "my-templates", + "template_name": "myapp", + "template_username": "admin" +} +``` diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index b410f479d..0f2665f56 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -302,6 +302,9 @@ > Docker Tag + > + Exoscale Import + > Google Compute Export