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"