Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add image look up data source (fixes: #49). #69

Merged
merged 1 commit into from
Sep 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
main
dist/*
packer-plugin-digitalocean
.vscode/
.vscode/
docs-partials/
docs.zip
4 changes: 2 additions & 2 deletions GNUmakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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 ./...
Expand Down
2 changes: 1 addition & 1 deletion builder/digitalocean/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion builder/digitalocean/builder_acc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions builder/digitalocean/token_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
271 changes: 271 additions & 0 deletions datasource/image/data.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading