From 4d7e42220bbfab07ff7b9201495e9784da308c52 Mon Sep 17 00:00:00 2001 From: Marco Molteni Date: Fri, 18 Sep 2020 21:37:48 +0200 Subject: [PATCH] scaleway: add pre validate step (check image and snapshot names) (#9840) * Implement Stringer inteface for multistep.StepAction * scaleway: add pre validate step (check image and snapshot names) Before, it was possible to create multiple images with the same name, leading to a confusing and wasteful situation (same for snapshots). Now, we perform the same kind of checks done by the AWS EC2 builder, and refuse to proceed if there is an existing image with the same name (same for snapshots). As usual, invoking `packer build -force` will bypass the checks. Closes #9839. --- builder/scaleway/builder.go | 5 + builder/scaleway/step_pre_validate.go | 80 +++++++++++ builder/scaleway/step_pre_validate_test.go | 154 +++++++++++++++++++++ helper/multistep/multistep.go | 17 ++- 4 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 builder/scaleway/step_pre_validate.go create mode 100644 builder/scaleway/step_pre_validate_test.go diff --git a/builder/scaleway/builder.go b/builder/scaleway/builder.go index 018ff060d..e25d4cd0e 100644 --- a/builder/scaleway/builder.go +++ b/builder/scaleway/builder.go @@ -65,6 +65,11 @@ func (b *Builder) Run(ctx context.Context, ui packer.Ui, hook packer.Hook) (pack state.Put("ui", ui) steps := []multistep.Step{ + &stepPreValidate{ + Force: b.config.PackerForce, + ImageName: b.config.ImageName, + SnapshotName: b.config.SnapshotName, + }, &stepCreateSSHKey{ Debug: b.config.PackerDebug, DebugKeyPath: fmt.Sprintf("scw_%s.pem", b.config.PackerBuildName), diff --git a/builder/scaleway/step_pre_validate.go b/builder/scaleway/step_pre_validate.go new file mode 100644 index 000000000..0e32b2e5d --- /dev/null +++ b/builder/scaleway/step_pre_validate.go @@ -0,0 +1,80 @@ +package scaleway + +import ( + "context" + "fmt" + + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" + "github.com/scaleway/scaleway-sdk-go/api/instance/v1" + "github.com/scaleway/scaleway-sdk-go/scw" +) + +// StepPreValidate provides an opportunity to pre-validate any configuration for +// the build before actually doing any time consuming work +// +type stepPreValidate struct { + Force bool + ImageName string + SnapshotName string +} + +func (s *stepPreValidate) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + ui := state.Get("ui").(packer.Ui) + + if s.Force { + ui.Say("Force flag found, skipping prevalidating image name") + return multistep.ActionContinue + } + + ui.Say(fmt.Sprintf("Prevalidating image name: %s", s.ImageName)) + + instanceAPI := instance.NewAPI(state.Get("client").(*scw.Client)) + images, err := instanceAPI.ListImages( + &instance.ListImagesRequest{Name: &s.ImageName}, + scw.WithAllPages()) + if err != nil { + err := fmt.Errorf("Error: getting image list: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + for _, im := range images.Images { + if im.Name == s.ImageName { + err := fmt.Errorf("Error: image name: '%s' is used by existing image with ID %s", + s.ImageName, im.ID) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + } + + ui.Say(fmt.Sprintf("Prevalidating snapshot name: %s", s.SnapshotName)) + + snapshots, err := instanceAPI.ListSnapshots( + &instance.ListSnapshotsRequest{Name: &s.SnapshotName}, + scw.WithAllPages()) + if err != nil { + err := fmt.Errorf("Error: getting snapshot list: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + for _, sn := range snapshots.Snapshots { + if sn.Name == s.SnapshotName { + err := fmt.Errorf("Error: snapshot name: '%s' is used by existing snapshot with ID %s", + s.SnapshotName, sn.ID) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + } + + return multistep.ActionContinue +} + +func (s *stepPreValidate) Cleanup(multistep.StateBag) { +} diff --git a/builder/scaleway/step_pre_validate_test.go b/builder/scaleway/step_pre_validate_test.go new file mode 100644 index 000000000..51e7d927c --- /dev/null +++ b/builder/scaleway/step_pre_validate_test.go @@ -0,0 +1,154 @@ +package scaleway + +import ( + "bytes" + "context" + "encoding/json" + "math/rand" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" + "github.com/scaleway/scaleway-sdk-go/api/instance/v1" + "github.com/scaleway/scaleway-sdk-go/scw" +) + +// 1. Configure a httptest server to return the list of fakeImgNames or fakeSnapNames +// (depending on the endpoint). +// 2. Instantiate a Scaleway API client and wire it to send requests to the httptest +// server. +// 3. Return a state (containing the client) ready to be passed to the step.Run() method. +// 4. Return a teardown function meant to be deferred from the test. +func setup(t *testing.T, fakeImgNames []string, fakeSnapNames []string) (*multistep.BasicStateBag, func()) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + switch r.URL.Path { + case "/instance/v1/zones/fr-par-1/images": + var imgs instance.ListImagesResponse + for _, name := range fakeImgNames { + imgs.Images = append(imgs.Images, &instance.Image{ + ID: strconv.Itoa(rand.Int()), + Name: name, + Zone: "fr-par-1", + }) + } + imgs.TotalCount = uint32(len(fakeImgNames)) + if err := enc.Encode(imgs); err != nil { + t.Fatalf("fake server: encoding reply: %s", err) + } + case "/instance/v1/zones/fr-par-1/snapshots": + var snaps instance.ListSnapshotsResponse + for _, name := range fakeSnapNames { + snaps.Snapshots = append(snaps.Snapshots, &instance.Snapshot{ + ID: strconv.Itoa(rand.Int()), + Name: name, + Zone: "fr-par-1", + }) + } + snaps.TotalCount = uint32(len(fakeSnapNames)) + if err := enc.Encode(snaps); err != nil { + t.Fatalf("fake server: encoding reply: %s", err) + } + default: + t.Fatalf("fake server: unexpected path: %q", r.URL.Path) + } + })) + + clientOpts := []scw.ClientOption{ + scw.WithDefaultZone(scw.ZoneFrPar1), + scw.WithAPIURL(ts.URL), + } + + client, err := scw.NewClient(clientOpts...) + if err != nil { + ts.Close() + t.Fatalf("setup: client: %s", err) + } + + state := multistep.BasicStateBag{} + state.Put("ui", &packer.BasicUi{ + Reader: new(bytes.Buffer), + Writer: new(bytes.Buffer), + }) + state.Put("client", client) + + teardown := func() { + ts.Close() + } + return &state, teardown +} + +func TestStepPreValidate(t *testing.T) { + testCases := []struct { + name string + fakeImgNames []string + fakeSnapNames []string + step stepPreValidate + wantAction multistep.StepAction + }{ + {"happy path: both image name and snapshot name are new", + []string{"image-old"}, + []string{"snapshot-old"}, + stepPreValidate{ + Force: false, + ImageName: "image-new", + SnapshotName: "snapshot-new", + }, + multistep.ActionContinue, + }, + {"want failure: old image name", + []string{"image-old"}, + []string{"snapshot-old"}, + stepPreValidate{ + Force: false, + ImageName: "image-old", + SnapshotName: "snapshot-new", + }, + multistep.ActionHalt, + }, + {"want failure: old snapshot name", + []string{"image-old"}, + []string{"snapshot-old"}, + stepPreValidate{ + Force: false, + ImageName: "image-new", + SnapshotName: "snapshot-old", + }, + multistep.ActionHalt, + }, + {"old image name but force flag", + []string{"image-old"}, + []string{"snapshot-old"}, + stepPreValidate{ + Force: true, + ImageName: "image-old", + SnapshotName: "snapshot-new", + }, + multistep.ActionContinue, + }, + {"old snapshot name but force flag", + []string{"image-old"}, + []string{"snapshot-old"}, + stepPreValidate{ + Force: true, + ImageName: "image-new", + SnapshotName: "snapshot-old", + }, + multistep.ActionContinue, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + state, teardown := setup(t, tc.fakeImgNames, tc.fakeSnapNames) + defer teardown() + + if action := tc.step.Run(context.Background(), state); action != tc.wantAction { + t.Fatalf("step.Run: want: %v; got: %v", tc.wantAction, action) + } + }) + } +} diff --git a/helper/multistep/multistep.go b/helper/multistep/multistep.go index 6f0a198f8..a4629bacf 100644 --- a/helper/multistep/multistep.go +++ b/helper/multistep/multistep.go @@ -2,7 +2,10 @@ // discrete steps. package multistep -import "context" +import ( + "context" + "strconv" +) // A StepAction determines the next step to take regarding multi-step actions. type StepAction uint @@ -12,6 +15,18 @@ const ( ActionHalt ) +// Implement the stringer interface; useful for testing. +func (a StepAction) String() string { + switch a { + case ActionContinue: + return "ActionContinue" + case ActionHalt: + return "ActionHalt" + default: + return "Unexpected value: " + strconv.Itoa(int(a)) + } +} + // This is the key set in the state bag when using the basic runner to // signal that the step sequence was cancelled. const StateCancelled = "cancelled"