From 900a7046a853ddaff6128395d5f4b520e189b70d Mon Sep 17 00:00:00 2001 From: Andrew Starr-Bochicchio Date: Thu, 11 Aug 2022 13:34:20 -0400 Subject: [PATCH] Add image look up data source (fixes: #49). --- .gitignore | 4 +- GNUmakefile | 4 +- builder/digitalocean/builder.go | 2 +- builder/digitalocean/builder_acc_test.go | 2 +- builder/digitalocean/token_source.go | 4 +- datasource/image/data.go | 271 +++++++++++++++++++++++ datasource/image/data.hcl2spec.go | 68 ++++++ datasource/image/data_acc_test.go | 196 ++++++++++++++++ datasource/image/data_test.go | 122 ++++++++++ docs/datasources/digitalocen-image.mdx | 55 +++++ main.go | 2 + 11 files changed, 723 insertions(+), 7 deletions(-) create mode 100644 datasource/image/data.go create mode 100644 datasource/image/data.hcl2spec.go create mode 100644 datasource/image/data_acc_test.go create mode 100644 datasource/image/data_test.go create mode 100644 docs/datasources/digitalocen-image.mdx diff --git a/.gitignore b/.gitignore index 1894f78..7f658e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ main dist/* packer-plugin-digitalocean -.vscode/ \ No newline at end of file +.vscode/ +docs-partials/ +docs.zip \ No newline at end of file diff --git a/GNUmakefile b/GNUmakefile index 768f995..b25bfdc 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -15,7 +15,7 @@ dev: build @mv ${BINARY} ~/.packer.d/plugins/${BINARY} test: - @go test -v -race -count $(COUNT) $(TEST) -timeout=3m + @go test -v -race -count $(COUNT) ./... $(TEST) -timeout=3m install-packer-sdc: ## Install packer sofware development command @go install github.com/hashicorp/packer-plugin-sdk/cmd/packer-sdc@${HASHICORP_PACKER_PLUGIN_SDK_VERSION} @@ -28,7 +28,7 @@ plugin-check: install-packer-sdc build @packer-sdc plugin-check ${BINARY} testacc: dev - @PACKER_ACC=1 go test -count $(COUNT) -v $(TEST) -timeout=120m + @PACKER_ACC=1 go test -count $(COUNT) -v ./... $(TEST) -timeout=120m generate: install-packer-sdc @go generate ./... diff --git a/builder/digitalocean/builder.go b/builder/digitalocean/builder.go index 7b0087c..b67a8e9 100644 --- a/builder/digitalocean/builder.go +++ b/builder/digitalocean/builder.go @@ -58,7 +58,7 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) opts = append(opts, godo.SetBaseURL(b.config.APIURL)) } - client, err := godo.New(oauth2.NewClient(context.TODO(), &apiTokenSource{ + client, err := godo.New(oauth2.NewClient(context.TODO(), &APITokenSource{ AccessToken: b.config.APIToken, }), opts...) if err != nil { diff --git a/builder/digitalocean/builder_acc_test.go b/builder/digitalocean/builder_acc_test.go index 9749778..100cfd2 100644 --- a/builder/digitalocean/builder_acc_test.go +++ b/builder/digitalocean/builder_acc_test.go @@ -61,7 +61,7 @@ func makeTemplateWithImageId(t *testing.T) string { ua := useragent.String(version.PluginVersion.FormattedVersion()) opts := []godo.ClientOpt{godo.SetUserAgent(ua)} - client, err := godo.New(oauth2.NewClient(context.TODO(), &apiTokenSource{ + client, err := godo.New(oauth2.NewClient(context.TODO(), &APITokenSource{ AccessToken: token, }), opts...) if err != nil { diff --git a/builder/digitalocean/token_source.go b/builder/digitalocean/token_source.go index eab5a08..200f289 100644 --- a/builder/digitalocean/token_source.go +++ b/builder/digitalocean/token_source.go @@ -4,11 +4,11 @@ import ( "golang.org/x/oauth2" ) -type apiTokenSource struct { +type APITokenSource struct { AccessToken string } -func (t *apiTokenSource) Token() (*oauth2.Token, error) { +func (t *APITokenSource) Token() (*oauth2.Token, error) { return &oauth2.Token{ AccessToken: t.AccessToken, }, nil diff --git a/datasource/image/data.go b/datasource/image/data.go new file mode 100644 index 0000000..91d8807 --- /dev/null +++ b/datasource/image/data.go @@ -0,0 +1,271 @@ +//go:generate packer-sdc mapstructure-to-hcl2 -type Config,DatasourceOutput +//go:generate packer-sdc struct-markdown +package image + +import ( + "context" + "errors" + "fmt" + "log" + "net/url" + "os" + "regexp" + "sort" + "time" + + builder "github.com/digitalocean/packer-plugin-digitalocean/builder/digitalocean" + "github.com/digitalocean/packer-plugin-digitalocean/version" + + "github.com/digitalocean/godo" + "github.com/hashicorp/hcl/v2/hcldec" + "github.com/hashicorp/packer-plugin-sdk/hcl2helper" + packersdk "github.com/hashicorp/packer-plugin-sdk/packer" + "github.com/hashicorp/packer-plugin-sdk/template/config" + "github.com/hashicorp/packer-plugin-sdk/useragent" + "github.com/zclconf/go-cty/cty" + "golang.org/x/oauth2" +) + +var ( + validImageTypes = []string{"application", "distribution", "user"} +) + +type Config struct { + // The API token to used to access your account. It can also be specified via + // the DIGITALOCEAN_TOKEN or DIGITALOCEAN_ACCESS_TOKEN environment variables. + APIToken string `mapstructure:"api_token" required:"true"` + // A non-standard API endpoint URL. Set this if you are using a DigitalOcean API + // compatible service. It can also be specified via environment variable DIGITALOCEAN_API_URL. + APIURL string `mapstructure:"api_url"` + // The name of the image to return. Only one of `name` or `name_regex` may be provided. + Name string `mapstructure:"name"` + // A regex matching the name of the image to return. Only one of `name` or `name_regex` may be provided. + NameRegex string `mapstructure:"name_regex"` + // Filter the images searched by type. This may be one of `application`, `distribution`, or `user`. + // By default, all image types are searched. + Type string `mapstructure:"type"` + // A DigitalOcean region slug (e.g. `nyc3`). When provided, only images available in that region + // will be returned. + Region string `mapstructure:"region"` + // A boolean value determining how to handle multiple matching images. By default, multiple matching images + // results in an error. When set to `true`, the most recently created image is returned instead. + Latest bool `mapstructure:"latest"` +} + +type Datasource struct { + config Config +} + +type DatasourceOutput struct { + // The ID of the found image. + ImageID int `mapstructure:"image_id"` + // The regions the found image is availble in. + ImageRegions []string `mapstructure:"image_regions"` +} + +func (d *Datasource) ConfigSpec() hcldec.ObjectSpec { + return d.config.FlatMapstructure().HCL2Spec() +} + +func (d *Datasource) Configure(raws ...interface{}) error { + err := config.Decode(&d.config, nil, raws...) + if err != nil { + return err + } + + var errs *packersdk.MultiError + + if d.config.APIToken == "" { + d.config.APIToken = os.Getenv("DIGITALOCEAN_TOKEN") + if d.config.APIToken == "" { + d.config.APIToken = os.Getenv("DIGITALOCEAN_ACCESS_TOKEN") + } + } + if d.config.APIURL == "" { + d.config.APIURL = os.Getenv("DIGITALOCEAN_API_URL") + } + + if d.config.APIToken == "" { + errs = packersdk.MultiErrorAppend(errs, errors.New("api_token is required")) + } + + if d.config.Name == "" && d.config.NameRegex == "" { + errs = packersdk.MultiErrorAppend(errs, errors.New("one of name or name_regex is required")) + } + + if d.config.Name != "" && d.config.NameRegex != "" { + errs = packersdk.MultiErrorAppend(errs, errors.New("only one of name or name_regex can be set")) + } + + if d.config.Type != "" { + if !contains(validImageTypes, d.config.Type) { + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("invalid type; must be one of: %v", validImageTypes)) + } + } + + if errs != nil && len(errs.Errors) > 0 { + return errs + } + + return nil +} + +func (d *Datasource) OutputSpec() hcldec.ObjectSpec { + return (&DatasourceOutput{}).FlatMapstructure().HCL2Spec() +} + +func (d *Datasource) Execute() (cty.Value, error) { + ua := useragent.String(version.PluginVersion.FormattedVersion()) + clientOpts := []godo.ClientOpt{godo.SetUserAgent(ua)} + if d.config.APIURL != "" { + _, err := url.Parse(d.config.APIURL) + if err != nil { + return cty.NullVal(cty.EmptyObject), fmt.Errorf("invalid API URL, %s.", err) + } + + clientOpts = append(clientOpts, godo.SetBaseURL(d.config.APIURL)) + } + + oauthClient := oauth2.NewClient(context.TODO(), &builder.APITokenSource{ + AccessToken: d.config.APIToken, + }) + client, err := godo.New(oauthClient, clientOpts...) + if err != nil { + return cty.NullVal(cty.EmptyObject), err + } + + opts := &godo.ListOptions{ + Page: 1, + PerPage: 200, + } + + imageListFunc := client.Images.List + switch d.config.Type { + case "user": + imageListFunc = client.Images.ListUser + case "application": + imageListFunc = client.Images.ListApplication + case "distribution": + imageListFunc = client.Images.ListDistribution + } + + var imageList []godo.Image + for { + images, resp, err := imageListFunc(context.Background(), opts) + + if err != nil { + return cty.NullVal(cty.EmptyObject), err + } + + imageList = append(imageList, images...) + + if resp.Links == nil || resp.Links.IsLastPage() { + break + } + + page, err := resp.Links.CurrentPage() + if err != nil { + return cty.NullVal(cty.EmptyObject), err + } + + opts.Page = page + 1 + } + + result, err := filterImages(&d.config, imageList) + if err != nil { + return cty.NullVal(cty.EmptyObject), err + } + + output := DatasourceOutput{ + ImageID: result.ID, + ImageRegions: result.Regions, + } + + log.Printf("[DEBUG] found image: %v", result.ID) + + return hcl2helper.HCL2ValueFromConfig(output, d.OutputSpec()), nil +} + +func filterImages(c *Config, images []godo.Image) (godo.Image, error) { + result := make([]godo.Image, 0) + if c.Name != "" { + result = filterByName(images, c.Name) + } + + if c.NameRegex != "" { + result = filterByNameRegex(images, c.NameRegex) + } + + if c.Region != "" { + result = filterByRegion(result, c.Region) + } + + if len(result) > 1 { + if c.Latest { + return findLatest(result), nil + } + + return godo.Image{}, fmt.Errorf("More than one matching image found: %v", result) + } + if len(result) == 0 { + return godo.Image{}, errors.New("No image matching found") + } + + return result[0], nil +} + +func filterByName(images []godo.Image, name string) []godo.Image { + result := make([]godo.Image, 0) + for _, i := range images { + if i.Name == name { + result = append(result, i) + } + } + + return result +} + +func filterByNameRegex(images []godo.Image, name string) []godo.Image { + r := regexp.MustCompile(name) + result := make([]godo.Image, 0) + for _, i := range images { + if r.MatchString(i.Name) { + result = append(result, i) + } + } + + return result +} + +func filterByRegion(images []godo.Image, region string) []godo.Image { + result := make([]godo.Image, 0) + for _, i := range images { + for _, r := range i.Regions { + if r == region { + result = append(result, i) + break + } + } + } + + return result +} + +func findLatest(images []godo.Image) godo.Image { + sort.Slice(images, func(i, j int) bool { + itime, _ := time.Parse(time.RFC3339, images[i].Created) + jtime, _ := time.Parse(time.RFC3339, images[j].Created) + return itime.Unix() > jtime.Unix() + }) + + return images[0] +} + +func contains(list []string, term string) bool { + for _, t := range list { + if t == term { + return true + } + } + return false +} diff --git a/datasource/image/data.hcl2spec.go b/datasource/image/data.hcl2spec.go new file mode 100644 index 0000000..69192b0 --- /dev/null +++ b/datasource/image/data.hcl2spec.go @@ -0,0 +1,68 @@ +// Code generated by "packer-sdc mapstructure-to-hcl2"; DO NOT EDIT. + +package image + +import ( + "github.com/hashicorp/hcl/v2/hcldec" + "github.com/zclconf/go-cty/cty" +) + +// FlatConfig is an auto-generated flat version of Config. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatConfig struct { + APIToken *string `mapstructure:"api_token" required:"true" cty:"api_token" hcl:"api_token"` + APIURL *string `mapstructure:"api_url" cty:"api_url" hcl:"api_url"` + Name *string `mapstructure:"name" cty:"name" hcl:"name"` + NameRegex *string `mapstructure:"name_regex" cty:"name_regex" hcl:"name_regex"` + Type *string `mapstructure:"type" cty:"type" hcl:"type"` + Region *string `mapstructure:"region" cty:"region" hcl:"region"` + Latest *bool `mapstructure:"latest" cty:"latest" hcl:"latest"` +} + +// FlatMapstructure returns a new FlatConfig. +// FlatConfig is an auto-generated flat version of Config. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*Config) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatConfig) +} + +// HCL2Spec returns the hcl spec of a Config. +// This spec is used by HCL to read the fields of Config. +// The decoded values from this spec will then be applied to a FlatConfig. +func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "api_token": &hcldec.AttrSpec{Name: "api_token", Type: cty.String, Required: false}, + "api_url": &hcldec.AttrSpec{Name: "api_url", Type: cty.String, Required: false}, + "name": &hcldec.AttrSpec{Name: "name", Type: cty.String, Required: false}, + "name_regex": &hcldec.AttrSpec{Name: "name_regex", Type: cty.String, Required: false}, + "type": &hcldec.AttrSpec{Name: "type", Type: cty.String, Required: false}, + "region": &hcldec.AttrSpec{Name: "region", Type: cty.String, Required: false}, + "latest": &hcldec.AttrSpec{Name: "latest", Type: cty.Bool, Required: false}, + } + return s +} + +// FlatDatasourceOutput is an auto-generated flat version of DatasourceOutput. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatDatasourceOutput struct { + ImageID *int `mapstructure:"image_id" cty:"image_id" hcl:"image_id"` + ImageRegions []string `mapstructure:"image_regions" cty:"image_regions" hcl:"image_regions"` +} + +// FlatMapstructure returns a new FlatDatasourceOutput. +// FlatDatasourceOutput is an auto-generated flat version of DatasourceOutput. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*DatasourceOutput) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatDatasourceOutput) +} + +// HCL2Spec returns the hcl spec of a DatasourceOutput. +// This spec is used by HCL to read the fields of DatasourceOutput. +// The decoded values from this spec will then be applied to a FlatDatasourceOutput. +func (*FlatDatasourceOutput) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "image_id": &hcldec.AttrSpec{Name: "image_id", Type: cty.Number, Required: false}, + "image_regions": &hcldec.AttrSpec{Name: "image_regions", Type: cty.List(cty.String), Required: false}, + } + return s +} diff --git a/datasource/image/data_acc_test.go b/datasource/image/data_acc_test.go new file mode 100644 index 0000000..359b309 --- /dev/null +++ b/datasource/image/data_acc_test.go @@ -0,0 +1,196 @@ +package image + +import ( + "context" + _ "embed" + "fmt" + "io/ioutil" + "os" + "os/exec" + "regexp" + "testing" + + "github.com/digitalocean/godo" + "github.com/hashicorp/packer-plugin-sdk/acctest" +) + +func TestAccDatasource_Validations(t *testing.T) { + // store to reset in Teardown + doToken := os.Getenv("DIGITALOCEAN_TOKEN") + doAccessToken := os.Getenv("DIGITALOCEAN_ACCESS_TOKEN") + + tests := []*acctest.PluginTestCase{ + { + Name: "test missing required values", + Setup: func() error { + // unset to ensure failure of token check + os.Unsetenv("DIGITALOCEAN_TOKEN") + os.Unsetenv("DIGITALOCEAN_ACCESS_TOKEN") + return nil + }, + Teardown: func() error { + os.Setenv("DIGITALOCEAN_TOKEN", doToken) + os.Setenv("DIGITALOCEAN_ACCESS_TOKEN", doAccessToken) + return nil + }, + Template: `data "digitalocean-image" "test" {}`, + Type: "digitalocean-image", + Check: func(buildCommand *exec.Cmd, logfile string) error { + if buildCommand.ProcessState != nil { + if buildCommand.ProcessState.ExitCode() != 1 { + return fmt.Errorf("Unexpected exit code. Logfile: %s", logfile) + } + } + + tokenRequired := "api_token is required" + nameOrRegex := "one of name or name_regex is required" + + err := findInTestLog(t, logfile, tokenRequired) + if err != nil { + return err + } + err = findInTestLog(t, logfile, nameOrRegex) + if err != nil { + return err + } + + return nil + }, + }, + { + Name: "only one of name or name_regex can be set", + Setup: func() error { + return nil + }, + Teardown: func() error { + return nil + }, + Template: `data "digitalocean-image" "test" { + name = "foo" + name_regex = "foo.*" + }`, + Type: "digitalocean-image", + Check: func(buildCommand *exec.Cmd, logfile string) error { + if buildCommand.ProcessState != nil { + if buildCommand.ProcessState.ExitCode() != 1 { + return fmt.Errorf("Unexpected exit code. Logfile: %s", logfile) + } + } + + nameOrRegex := "only one of name or name_regex can be set" + err := findInTestLog(t, logfile, nameOrRegex) + if err != nil { + return err + } + + return nil + }, + }, + { + Name: "invalid image type", + Setup: func() error { + return nil + }, + Teardown: func() error { + return nil + }, + Template: `data "digitalocean-image" "test" { + name = "foo" + type = "1-click" + }`, + Type: "digitalocean-image", + Check: func(buildCommand *exec.Cmd, logfile string) error { + if buildCommand.ProcessState != nil { + if buildCommand.ProcessState.ExitCode() != 1 { + return fmt.Errorf("Unexpected exit code. Logfile: %s", logfile) + } + } + + invalid := `invalid type; must be one of` + err := findInTestLog(t, logfile, invalid) + if err != nil { + return err + } + + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + acctest.TestPlugin(t, tt) + }) + } +} + +func TestAccDatasource_Basic(t *testing.T) { + if os.Getenv("PACKER_ACC") == "" { + t.Skip("Acceptance tests skipped unless env 'PACKER_ACC' set") + } + + token := os.Getenv("DIGITALOCEAN_TOKEN") + if token == "" { + t.Fatal("DIGITALOCEAN_TOKEN environment variable required") + } + client := godo.NewFromToken(token) + images, _, err := client.Images.ListApplication(context.TODO(), nil) + if err != nil { + t.Error(err) + } + expectedImageID := images[0].ID + datsourceFixture := fmt.Sprintf(` + data "digitalocean-image" "test" { + name = "%s" + region = "nyc3" + type = "application" + }`, images[0].Name) + + testCase := &acctest.PluginTestCase{ + Name: "scaffolding_datasource_basic_test", + Setup: func() error { + return nil + }, + Teardown: func() error { + return nil + }, + Template: datsourceFixture, + Type: "digitalocean-image", + Check: func(buildCommand *exec.Cmd, logfile string) error { + if buildCommand.ProcessState != nil { + if buildCommand.ProcessState.ExitCode() != 0 { + return fmt.Errorf("Bad exit code. Logfile: %s", logfile) + } + } + + imageLog := fmt.Sprintf("found image: %d", expectedImageID) + err := findInTestLog(t, logfile, imageLog) + if err != nil { + return err + } + return nil + }, + } + + acctest.TestPlugin(t, testCase) +} + +func findInTestLog(t *testing.T, logfile string, expected string) error { + logs, err := os.Open(logfile) + if err != nil { + return fmt.Errorf("Unable find %s", logfile) + } + defer logs.Close() + + logsBytes, err := ioutil.ReadAll(logs) + if err != nil { + return fmt.Errorf("Unable to read %s", logfile) + } + logsString := string(logsBytes) + + if matched, _ := regexp.MatchString(expected+".*", logsString); !matched { + t.Fatalf("logs doesn't contain expected value %q", logsString) + } + + return nil +} diff --git a/datasource/image/data_test.go b/datasource/image/data_test.go new file mode 100644 index 0000000..d8802d6 --- /dev/null +++ b/datasource/image/data_test.go @@ -0,0 +1,122 @@ +package image + +import ( + "testing" + + "github.com/digitalocean/godo" + "github.com/stretchr/testify/require" +) + +func TestFilterImages(t *testing.T) { + tests := []struct { + name string + config *Config + images []godo.Image + expectedImage godo.Image + expectedError string + }{ + { + name: "by name - single match", + config: &Config{Name: "test-image"}, + images: []godo.Image{ + {ID: 1, Name: "test-image"}, + {ID: 2, Name: "test-image-01"}, + }, + expectedImage: godo.Image{ID: 1, Name: "test-image"}, + }, + { + name: "by name - multiple matches", + config: &Config{Name: "test-image"}, + images: []godo.Image{ + {ID: 1, Name: "test-image"}, + {ID: 2, Name: "test-image"}, + }, + expectedError: "More than one matching image found:", + }, + { + name: "by name - multiple matches - latest", + config: &Config{Name: "test-image", Latest: true}, + images: []godo.Image{ + {ID: 1, Name: "test-image", Created: "2022-08-08T21:31:54Z"}, + {ID: 2, Name: "test-image", Created: "2022-08-10T21:31:54Z"}, + }, + expectedImage: godo.Image{ID: 2, Name: "test-image", Created: "2022-08-10T21:31:54Z"}, + }, + { + name: "by name - multiple matches - region filter", + config: &Config{Name: "test-image", Region: "nyc3", Latest: true}, + images: []godo.Image{ + {ID: 1, Name: "test-image", Created: "2022-08-08T21:31:54Z", Regions: []string{"nyc3"}}, + {ID: 2, Name: "test-image", Created: "2022-08-10T21:31:54Z", Regions: []string{"nyc2"}}, + }, + expectedImage: godo.Image{ID: 1, Name: "test-image", Created: "2022-08-08T21:31:54Z", Regions: []string{"nyc3"}}, + }, + { + name: "by name - no matches", + config: &Config{Name: "test-image"}, + images: []godo.Image{ + {ID: 1, Name: "test-image-01", Created: "2022-08-08T21:31:54Z", Regions: []string{"nyc3"}}, + {ID: 2, Name: "test-image-02", Created: "2022-08-10T21:31:54Z", Regions: []string{"nyc2"}}, + }, + expectedError: "No image matching found", + }, + + { + name: "regex - single match", + config: &Config{NameRegex: "test-image-.*"}, + images: []godo.Image{ + {ID: 1, Name: "test-image"}, + {ID: 2, Name: "test-image-01"}, + }, + expectedImage: godo.Image{ID: 2, Name: "test-image-01"}, + }, + { + name: "regex - multiple matches", + config: &Config{NameRegex: "test-image-.*"}, + images: []godo.Image{ + {ID: 1, Name: "test-image-01"}, + {ID: 2, Name: "test-image-02"}, + }, + expectedError: "More than one matching image found:", + }, + { + name: "regex - multiple matches - latest", + config: &Config{NameRegex: "test-image-.*", Latest: true}, + images: []godo.Image{ + {ID: 1, Name: "test-image-01", Created: "2022-08-08T21:31:54Z"}, + {ID: 2, Name: "test-image-02", Created: "2022-08-10T21:31:54Z"}, + }, + expectedImage: godo.Image{ID: 2, Name: "test-image-02", Created: "2022-08-10T21:31:54Z"}, + }, + { + name: "regex - multiple matches - region filter", + config: &Config{NameRegex: "test-image-.*", Region: "nyc3", Latest: true}, + images: []godo.Image{ + {ID: 1, Name: "test-image-01", Created: "2022-08-08T21:31:54Z", Regions: []string{"nyc3"}}, + {ID: 2, Name: "test-image-02", Created: "2022-08-10T21:31:54Z", Regions: []string{"nyc2"}}, + }, + expectedImage: godo.Image{ID: 1, Name: "test-image-01", Created: "2022-08-08T21:31:54Z", Regions: []string{"nyc3"}}, + }, + { + name: "regex - no matches", + config: &Config{NameRegex: "test-image-.*"}, + images: []godo.Image{ + {ID: 1, Name: "test-image01", Created: "2022-08-08T21:31:54Z", Regions: []string{"nyc3"}}, + {ID: 2, Name: "test-image02", Created: "2022-08-10T21:31:54Z", Regions: []string{"nyc2"}}, + }, + expectedError: "No image matching found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := filterImages(tt.config, tt.images) + if tt.expectedError == "" { + require.NoError(t, err) + require.Equal(t, tt.expectedImage, out) + } else { + require.Contains(t, err.Error(), tt.expectedError) + } + }) + } +} diff --git a/docs/datasources/digitalocen-image.mdx b/docs/datasources/digitalocen-image.mdx new file mode 100644 index 0000000..7405219 --- /dev/null +++ b/docs/datasources/digitalocen-image.mdx @@ -0,0 +1,55 @@ +--- +description: > + The DigitalOcean image data source is used look up the ID of an existing DigitalOcean image. +page_title: DigitalOcean Image - Data Sources +nav_title: digitalocean-image +--- + +# DigitalOcean Image - Data Source + +Type: `digitalocean-image` + +The DigitalOcean image data source is used look up the ID of an existing DigitalOcean image +for use as a builder source. + +### Required: + +@include 'datasource/image/Config-required.mdx' + +### Optional: + +@include 'datasource/image/Config-not-required.mdx' + +### Output: + +@include 'datasource/image/DatasourceOutput.mdx' + +### Example Usage + +```hcl +data "digitalocean-image" "example" { + name_regex = "golden-image-2022.*" + region = "nyc3" + type = "user" + latest = true +} + +locals { + image_id = data.digitalocean-image.example.image_id +} + +source "digitalocean" "example" { + snapshot_name = "updated-golden-image" + image = local.image_id + region = "nyc3" + size = "s-1vcpu-1gb" + ssh_username = "root" +} + +build { + sources = ["source.digitalocean.example"] + provisioner "shell" { + inline = ["touch /root/provisioned-by-packer"] + } +} +``` \ No newline at end of file diff --git a/main.go b/main.go index 768ad11..2fb1803 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "os" "github.com/digitalocean/packer-plugin-digitalocean/builder/digitalocean" + "github.com/digitalocean/packer-plugin-digitalocean/datasource/image" digitaloceanPP "github.com/digitalocean/packer-plugin-digitalocean/post-processor/digitalocean-import" "github.com/digitalocean/packer-plugin-digitalocean/version" @@ -15,6 +16,7 @@ func main() { pps := plugin.NewSet() pps.RegisterBuilder(plugin.DEFAULT_NAME, new(digitalocean.Builder)) pps.RegisterPostProcessor("import", new(digitaloceanPP.PostProcessor)) + pps.RegisterDatasource("image", new(image.Datasource)) pps.SetVersion(version.PluginVersion) err := pps.Run() if err != nil {