From 0bc73eb58171a0160377345a2debe6da5d918e59 Mon Sep 17 00:00:00 2001 From: Jonas L Date: Thu, 4 Jan 2024 17:11:24 +0100 Subject: [PATCH] test: implement new step test framework (#141) - Improve the test framework to test steps - Rebuild the Hetzner Cloud mocking server - Add tests for the create ssh step --- builder/hcloud/step_create_server_test.go | 274 ++++++----------- builder/hcloud/step_create_snapshot_test.go | 322 +++++++++++--------- builder/hcloud/step_create_sshkey_test.go | 65 ++++ builder/hcloud/step_pre_validate_test.go | 251 +++++++-------- builder/hcloud/step_test.go | 134 ++++++++ go.mod | 4 + go.sum | 3 + 7 files changed, 582 insertions(+), 471 deletions(-) create mode 100644 builder/hcloud/step_create_sshkey_test.go create mode 100644 builder/hcloud/step_test.go diff --git a/builder/hcloud/step_create_server_test.go b/builder/hcloud/step_create_server_test.go index 40e3bcd2..c083398b 100644 --- a/builder/hcloud/step_create_server_test.go +++ b/builder/hcloud/step_create_server_test.go @@ -4,203 +4,117 @@ package hcloud import ( - "context" "encoding/json" - "fmt" - "io" "net/http" - "net/http/httptest" "testing" "github.com/hashicorp/packer-plugin-sdk/multistep" - packersdk "github.com/hashicorp/packer-plugin-sdk/packer" + "github.com/stretchr/testify/assert" - "github.com/hetznercloud/hcloud-go/v2/hcloud" "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) -type Checker func(requestBody string, path string) error - func TestStepCreateServer(t *testing.T) { - const snapName = "dummy-snap" - const imageName = "dummy-image" - const name = "dummy-name" - const location = "nbg1" - const serverType = "cpx11" - networks := []int64{1} - - testCases := []struct { - name string - config Config - check Checker - wantAction multistep.StepAction - }{ + RunStepTestCases(t, []StepTestCase{ { - name: "happy path", - wantAction: multistep.ActionContinue, - check: func(r string, path string) error { - if path == "/servers" { - payload := schema.ServerCreateRequest{} - err := json.Unmarshal([]byte(r), &payload) - if err != nil { - t.Errorf("server request not a json: got: (%s)", err) - } - - if payload.Name != name { - t.Errorf("Incorrect name in request, expected '%s' found '%s'", name, payload.Name) - } - - if payload.Image != imageName { - t.Errorf("Incorrect image in request, expected '%s' found '%s'", imageName, payload.Image) - } - - if payload.Location != location { - t.Errorf("Incorrect location in request, expected '%s' found '%s'", location, payload.Location) - } - - if payload.ServerType != serverType { - t.Errorf("Incorrect serverType in request, expected '%s' found '%s'", serverType, payload.ServerType) - } - if payload.Networks != nil { - t.Error("Networks should not be specified") - } - } - return nil + Name: "happy", + Step: &stepCreateServer{}, + SetupStateFunc: func(state multistep.StateBag) { + state.Put(StateSSHKeyID, int64(1)) + }, + WantRequests: []Request{ + {"GET", "/ssh_keys/1", nil, + 200, `{ + "ssh_key": { "id": 1 } + }`, + }, + {"POST", "/servers", + func(t *testing.T, r *http.Request, body []byte) { + payload := schema.ServerCreateRequest{} + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "dummy-server", payload.Name) + assert.Equal(t, "debian-12", payload.Image) + assert.Equal(t, "nbg1", payload.Location) + assert.Equal(t, "cpx11", payload.ServerType) + assert.Nil(t, payload.Networks) + }, + 201, `{ + "server": { "id": 8, "name": "dummy-server", "public_net": { "ipv4": { "ip": "1.2.3.4" }}}, + "action": { "id": 3, "status": "progress" } + }`, + }, + {"GET", "/actions/3", nil, + 200, `{ + "action": { "id": 3, "status": "success" } + }`, + }, + }, + WantStepAction: multistep.ActionContinue, + WantStateFunc: func(t *testing.T, state multistep.StateBag) { + serverID, ok := state.Get(StateServerID).(int64) + assert.True(t, ok) + assert.Equal(t, int64(8), serverID) + + instanceID, ok := state.Get(StateInstanceID).(int64) + assert.True(t, ok) + assert.Equal(t, int64(8), instanceID) + + serverIP, ok := state.Get(StateServerIP).(string) + assert.True(t, ok) + assert.Equal(t, "1.2.3.4", serverIP) }, }, { - name: "with netowork", - wantAction: multistep.ActionContinue, - config: Config{ - Networks: networks, + Name: "happy with network", + Step: &stepCreateServer{}, + SetupConfigFunc: func(c *Config) { + c.Networks = []int64{12} }, - check: func(r string, path string) error { - if path == "/servers" { - payload := schema.ServerCreateRequest{} - err := json.Unmarshal([]byte(r), &payload) - if err != nil { - t.Errorf("server request not a json: (%s)", err) - } - if payload.Networks[0] != networks[0] { - t.Errorf("network not set") - } - } - return nil + SetupStateFunc: func(state multistep.StateBag) { + state.Put(StateSSHKeyID, int64(1)) + }, + WantRequests: []Request{ + {"GET", "/ssh_keys/1", nil, + 200, `{ + "ssh_key": { "id": 1 } + }`, + }, + {"POST", "/servers", + func(t *testing.T, r *http.Request, body []byte) { + payload := schema.ServerCreateRequest{} + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "dummy-server", payload.Name) + assert.Equal(t, "debian-12", payload.Image) + assert.Equal(t, "nbg1", payload.Location) + assert.Equal(t, "cpx11", payload.ServerType) + assert.Equal(t, []int64{12}, payload.Networks) + }, + 201, `{ + "server": { "id": 8, "name": "dummy-server", "public_net": { "ipv4": { "ip": "1.2.3.4" }}}, + "action": { "id": 3, "status": "progress" } + }`, + }, + {"GET", "/actions/3", nil, + 200, `{ + "action": { "id": 3, "status": "success" } + }`, + }, }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - errors := make(chan error, 1) - state, teardown := setupStepCreateServer(errors, tc.check) - defer teardown() - - step := &stepCreateServer{} - - baseConfig := Config{ - ServerName: name, - Image: imageName, - SnapshotName: snapName, - ServerType: serverType, - Location: location, - SSHKeys: []string{"1"}, - } - - config := baseConfig - config.Networks = tc.config.Networks - - if testing.Verbose() { - state.Put(StateUI, packersdk.TestUi(t)) - } else { - // do not output to stdout or console - state.Put(StateUI, &packersdk.MockUi{}) - } - state.Put(StateConfig, &config) - state.Put(StateSSHKeyID, int64(1)) - - if action := step.Run(context.Background(), state); action != tc.wantAction { - t.Errorf("step.Run: want: %v; got: %v", tc.wantAction, action) - } - - select { - case err := <-errors: - t.Errorf("server: got: %s", err) - default: - } - }) - } -} - -// Configure a httptest server to reply to the requests done by stepCreateSnapshot. -// React with the appropriate failCause. -// Report errors on the errors channel (cannot use testing.T, it runs on a different goroutine). -// Return a tuple (state, teardown) where: -// - state (containing the client) is ready to be passed to the step.Run() method. -// - teardown is a function meant to be deferred from the test. -func setupStepCreateServer( - errors chan<- error, - checker Checker, -) (*multistep.BasicStateBag, func()) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - buf, err := io.ReadAll(r.Body) - if err != nil { - errors <- fmt.Errorf("fake server: reading request: %s", err) - return - } - reqDump := fmt.Sprintf("fake server: request:\n %s %s\n body: %s", - r.Method, r.URL.Path, string(buf)) - if testing.Verbose() { - fmt.Println(reqDump) - } - - enc := json.NewEncoder(w) - var response interface{} - action := schema.Action{ - ID: 1, - Status: "success", - } - - if r.Method == http.MethodPost && r.URL.Path == "/servers" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - response = schema.ServerCreateResponse{Action: action} - } - - if r.Method == http.MethodGet && r.URL.Path == "/actions/1" { - w.Header().Set("Content-Type", "application/json") - response = schema.ActionGetResponse{Action: action} - } - - if r.Method == http.MethodGet && r.URL.Path == "/ssh_keys/1" { - w.Header().Set("Content-Type", "application/json") - response = schema.SSHKeyGetResponse{ - SSHKey: schema.SSHKey{ID: 1}, - } - } - - if err := checker(string(buf), r.URL.Path); err != nil { - errors <- fmt.Errorf("Error in checker") - } - - if response != nil { - if err := enc.Encode(response); err != nil { - errors <- fmt.Errorf("fake server: encoding reply: %s", err) - } - return - } - // no match: report error - w.WriteHeader(http.StatusBadRequest) - errors <- fmt.Errorf(reqDump) - })) + WantStepAction: multistep.ActionContinue, + WantStateFunc: func(t *testing.T, state multistep.StateBag) { + serverID, ok := state.Get(StateServerID).(int64) + assert.True(t, ok) + assert.Equal(t, int64(8), serverID) - state := multistep.BasicStateBag{} - client := hcloud.NewClient(hcloud.WithEndpoint(ts.URL)) - state.Put(StateHCloudClient, client) + instanceID, ok := state.Get(StateInstanceID).(int64) + assert.True(t, ok) + assert.Equal(t, int64(8), instanceID) - teardown := func() { - ts.Close() - } - return &state, teardown + serverIP, ok := state.Get(StateServerIP).(string) + assert.True(t, ok) + assert.Equal(t, "1.2.3.4", serverIP) + }, + }, + }) } diff --git a/builder/hcloud/step_create_snapshot_test.go b/builder/hcloud/step_create_snapshot_test.go index bb532f79..93a95485 100644 --- a/builder/hcloud/step_create_snapshot_test.go +++ b/builder/hcloud/step_create_snapshot_test.go @@ -4,175 +4,195 @@ package hcloud import ( - "context" "encoding/json" - "fmt" - "io" "net/http" - "net/http/httptest" "testing" "github.com/hashicorp/packer-plugin-sdk/multistep" - packersdk "github.com/hashicorp/packer-plugin-sdk/packer" + "github.com/stretchr/testify/assert" - "github.com/hetznercloud/hcloud-go/v2/hcloud" "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) -type FailCause int - -const ( - Pass FailCause = iota - FailCreateImage - FailWatchProgress - FailDeleteImage -) - func TestStepCreateSnapshot(t *testing.T) { - const serverID = int64(42) - const snapName = "dummy-snap" - - testCases := []struct { - name string - oldSnapID int64 // zero value: no old snap will be injected - failCause FailCause // zero value: pass - wantAction multistep.StepAction - }{ + RunStepTestCases(t, []StepTestCase{ { - name: "happy path", - wantAction: multistep.ActionContinue, + Name: "happy", + Step: &stepCreateSnapshot{}, + SetupStateFunc: func(state multistep.StateBag) { + state.Put(StateServerID, int64(8)) + }, + WantRequests: []Request{ + {"POST", "/servers/8/actions/create_image", + func(t *testing.T, r *http.Request, body []byte) { + payload := schema.ServerActionCreateImageRequest{} + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "dummy-snapshot", *payload.Description) + assert.Equal(t, "snapshot", *payload.Type) + }, + 201, `{ + "image": { "id": 16, "description": "dummy-snapshot", "type": "snapshot" }, + "action": { "id": 3, "status": "progress" } + }`, + }, + {"GET", "/actions/3", nil, + 200, `{ + "action": { "id": 3, "status": "success" } + }`, + }, + }, + WantStepAction: multistep.ActionContinue, + WantStateFunc: func(t *testing.T, state multistep.StateBag) { + snapshotID, ok := state.Get(StateSnapshotID).(int64) + assert.True(t, ok) + assert.Equal(t, int64(16), snapshotID) + + snapshotName, ok := state.Get(StateSnapshotName).(string) + assert.True(t, ok) + assert.Equal(t, "dummy-snapshot", snapshotName) + }, }, { - name: "create image, failure", - failCause: FailCreateImage, - wantAction: multistep.ActionHalt, + Name: "fail create image", + Step: &stepCreateSnapshot{}, + SetupStateFunc: func(state multistep.StateBag) { + state.Put(StateServerID, int64(8)) + }, + WantRequests: []Request{ + {"POST", "/servers/8/actions/create_image", + func(t *testing.T, r *http.Request, body []byte) { + payload := schema.ServerActionCreateImageRequest{} + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "dummy-snapshot", *payload.Description) + assert.Equal(t, "snapshot", *payload.Type) + }, + 400, "", + }, + }, + WantStepAction: multistep.ActionHalt, + WantStateFunc: func(t *testing.T, state multistep.StateBag) { + err, ok := state.Get(StateError).(error) + assert.True(t, ok) + assert.NotNil(t, err) + assert.Regexp(t, "Could not create snapshot: .*", err.Error()) + }, }, { - name: "watch progress, failure", - failCause: FailWatchProgress, - wantAction: multistep.ActionHalt, + Name: "fail action", + Step: &stepCreateSnapshot{}, + SetupStateFunc: func(state multistep.StateBag) { + state.Put(StateServerID, int64(8)) + }, + WantRequests: []Request{ + {"POST", "/servers/8/actions/create_image", + func(t *testing.T, r *http.Request, body []byte) { + payload := schema.ServerActionCreateImageRequest{} + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "dummy-snapshot", *payload.Description) + assert.Equal(t, "snapshot", *payload.Type) + }, + 201, `{ + "image": { "id": 16, "description": "dummy-snapshot", "type": "snapshot" }, + "action": { "id": 3, "status": "progress" } + }`, + }, + {"GET", "/actions/3", nil, + 200, `{ + "action": { + "id": 3, + "status": "error", + "error": { + "code": "action_failed", + "message": "Action failed" + } + } + }`, + }, + }, + WantStepAction: multistep.ActionHalt, + WantStateFunc: func(t *testing.T, state multistep.StateBag) { + err, ok := state.Get(StateError).(error) + assert.True(t, ok) + assert.NotNil(t, err) + assert.Regexp(t, "Could not create snapshot: .*", err.Error()) + }, }, { - name: "delete old snapshot, success", - oldSnapID: 33, - wantAction: multistep.ActionContinue, + Name: "happy with old snapshot", + Step: &stepCreateSnapshot{}, + SetupStateFunc: func(state multistep.StateBag) { + state.Put(StateServerID, int64(8)) + state.Put(StateSnapshotIDOld, int64(20)) + }, + WantRequests: []Request{ + {"POST", "/servers/8/actions/create_image", + func(t *testing.T, r *http.Request, body []byte) { + payload := schema.ServerActionCreateImageRequest{} + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "dummy-snapshot", *payload.Description) + assert.Equal(t, "snapshot", *payload.Type) + }, + 201, `{ + "image": { "id": 16, "description": "dummy-snapshot", "type": "snapshot" }, + "action": { "id": 3, "status": "progress" } + }`, + }, + {"GET", "/actions/3", nil, + 200, `{ + "action": { "id": 3, "status": "success" } + }`, + }, + {"DELETE", "/images/20", nil, + 204, "", + }, + }, + WantStepAction: multistep.ActionContinue, + WantStateFunc: func(t *testing.T, state multistep.StateBag) { + snapshotID, ok := state.Get(StateSnapshotID).(int64) + assert.True(t, ok) + assert.Equal(t, int64(16), snapshotID) + + snapshotName, ok := state.Get(StateSnapshotName).(string) + assert.True(t, ok) + assert.Equal(t, "dummy-snapshot", snapshotName) + }, }, { - name: "delete old snapshot, failure", - oldSnapID: 33, - failCause: FailDeleteImage, - wantAction: multistep.ActionHalt, + Name: "fail with old snapshot", + Step: &stepCreateSnapshot{}, + SetupStateFunc: func(state multistep.StateBag) { + state.Put(StateServerID, int64(8)) + state.Put(StateSnapshotIDOld, int64(20)) + }, + WantRequests: []Request{ + {"POST", "/servers/8/actions/create_image", + func(t *testing.T, r *http.Request, body []byte) { + payload := schema.ServerActionCreateImageRequest{} + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "dummy-snapshot", *payload.Description) + assert.Equal(t, "snapshot", *payload.Type) + }, + 201, `{ + "image": { "id": 16, "description": "dummy-snapshot", "type": "snapshot" }, + "action": { "id": 3, "status": "progress" } + }`, + }, + {"GET", "/actions/3", nil, + 200, `{ + "action": { "id": 3, "status": "success" } + }`, + }, + {"DELETE", "/images/20", nil, + 400, "", + }, + }, + WantStepAction: multistep.ActionHalt, + WantStateFunc: func(t *testing.T, state multistep.StateBag) { + err, ok := state.Get(StateError).(error) + assert.True(t, ok) + assert.NotNil(t, err) + assert.Regexp(t, "Could not delete old snapshot id=20: .*", err.Error()) + }, }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - errors := make(chan error, 1) - state, teardown := setupStepCreateSnapshot(errors, tc.failCause) - defer teardown() - - step := &stepCreateSnapshot{} - config := Config{SnapshotName: snapName} - if testing.Verbose() { - state.Put(StateUI, packersdk.TestUi(t)) - } else { - // do not output to stdout or console - state.Put(StateUI, &packersdk.MockUi{}) - } - state.Put(StateConfig, &config) - state.Put(StateServerID, serverID) - if tc.oldSnapID != 0 { - state.Put(StateSnapshotIDOld, tc.oldSnapID) - } - - if action := step.Run(context.Background(), state); action != tc.wantAction { - t.Errorf("step.Run: want: %v; got: %v", tc.wantAction, action) - } - - select { - case err := <-errors: - t.Errorf("server: got: %s", err) - default: - } - }) - } -} - -// Configure a httptest server to reply to the requests done by stepCreateSnapshot. -// React with the appropriate failCause. -// Report errors on the errors channel (cannot use testing.T, it runs on a different goroutine). -// Return a tuple (state, teardown) where: -// - state (containing the client) is ready to be passed to the step.Run() method. -// - teardown is a function meant to be deferred from the test. -func setupStepCreateSnapshot( - errors chan<- error, - failCause FailCause, -) (*multistep.BasicStateBag, func()) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - buf, err := io.ReadAll(r.Body) - if err != nil { - errors <- fmt.Errorf("fake server: reading request: %s", err) - return - } - reqDump := fmt.Sprintf("fake server: request:\n %s %s\n body: %s", - r.Method, r.URL.Path, string(buf)) - if testing.Verbose() { - fmt.Println(reqDump) - } - - enc := json.NewEncoder(w) - var response interface{} - action := schema.Action{ - ID: 13, - Progress: 100, - Status: "success", - } - - switch { - case r.Method == http.MethodPost && r.URL.Path == "/servers/42/actions/create_image": - if failCause == FailCreateImage { - w.WriteHeader(http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - response = schema.ServerActionCreateImageResponse{Action: action} - case r.Method == http.MethodGet && r.URL.Path == "/actions/13": - if failCause == FailWatchProgress { - w.WriteHeader(http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - response = schema.ActionGetResponse{Action: action} - case r.Method == http.MethodDelete && r.URL.Path == "/images/33": - if failCause == FailDeleteImage { - w.WriteHeader(http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusNoContent) - return - default: - } - - if response != nil { - if err := enc.Encode(response); err != nil { - errors <- fmt.Errorf("fake server: encoding reply: %s", err) - } - return - } - - // no match: report error - w.WriteHeader(http.StatusBadRequest) - errors <- fmt.Errorf(reqDump) - })) - - state := multistep.BasicStateBag{} - client := hcloud.NewClient(hcloud.WithEndpoint(ts.URL)) - state.Put(StateHCloudClient, client) - - teardown := func() { - ts.Close() - } - return &state, teardown + }) } diff --git a/builder/hcloud/step_create_sshkey_test.go b/builder/hcloud/step_create_sshkey_test.go new file mode 100644 index 00000000..71263fd0 --- /dev/null +++ b/builder/hcloud/step_create_sshkey_test.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package hcloud + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/hashicorp/packer-plugin-sdk/multistep" + "github.com/stretchr/testify/assert" + + "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" +) + +func TestStepCreateSSHKey(t *testing.T) { + RunStepTestCases(t, []StepTestCase{ + { + Name: "happy", + Step: &stepCreateSSHKey{}, + SetupConfigFunc: func(c *Config) { + c.Comm.SSHPublicKey = []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILBN85MgkHac/Q+iyPS8+88eBDn2SEGnU4/uLvj6lbT0") + }, + WantRequests: []Request{ + {"POST", "/ssh_keys", + func(t *testing.T, r *http.Request, body []byte) { + payload := schema.SSHKeyCreateRequest{} + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Regexp(t, "packer([a-z0-9-]+)$", payload.Name) + assert.Equal(t, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILBN85MgkHac/Q+iyPS8+88eBDn2SEGnU4/uLvj6lbT0", payload.PublicKey) + }, + 201, `{ + "ssh_key": { + "id": 8, + "name": "packer-659596d1-93df-3868-8170-42139065172e", + "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILBN85MgkHac/Q+iyPS8+88eBDn2SEGnU4/uLvj6lbT0" + } + }`, + }, + }, + WantStepAction: multistep.ActionContinue, + WantStateFunc: func(t *testing.T, state multistep.StateBag) { + sshKeyID, ok := state.Get(StateSSHKeyID).(int64) + assert.True(t, ok) + assert.Equal(t, int64(8), sshKeyID) + }, + }, + }) +} + +func TestStepCleanupSSHKey(t *testing.T) { + RunStepTestCases(t, []StepTestCase{ + { + Name: "happy", + Step: &stepCreateSSHKey{keyId: 1}, + StepFuncName: "cleanup", + WantRequests: []Request{ + {"DELETE", "/ssh_keys/1", nil, + 204, "", + }, + }, + }, + }) +} diff --git a/builder/hcloud/step_pre_validate_test.go b/builder/hcloud/step_pre_validate_test.go index b266c889..c336c61e 100644 --- a/builder/hcloud/step_pre_validate_test.go +++ b/builder/hcloud/step_pre_validate_test.go @@ -4,158 +4,129 @@ package hcloud import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" "testing" "github.com/hashicorp/packer-plugin-sdk/multistep" - packersdk "github.com/hashicorp/packer-plugin-sdk/packer" + "github.com/stretchr/testify/assert" "github.com/hetznercloud/hcloud-go/v2/hcloud" - "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) func TestStepPreValidate(t *testing.T) { - fakeSnapNames := []string{"snapshot-old"} - - testCases := []struct { - name string - // zero value: assert that state OldSnapshotID is NOT present - // non-zero value: assert that state OldSnapshotID is present AND has this value - wantOldSnapID int64 - step stepPreValidate - wantAction multistep.StepAction - }{ + RunStepTestCases(t, []StepTestCase{ { - name: "snapshot name new, success", - step: stepPreValidate{SnapshotName: "snapshot-new"}, - wantAction: multistep.ActionContinue, + Name: "happy", + Step: &stepPreValidate{ + SnapshotName: "dummy-snapshot", + Force: false, + }, + SetupConfigFunc: func(c *Config) { + c.UpgradeServerType = "cpx21" + }, + WantRequests: []Request{ + {"GET", "/server_types?name=cpx11", nil, + 200, `{ + "server_types": [{ "id": 9, "name": "cpx11", "architecture": "x86"}] + }`, + }, + {"GET", "/server_types?name=cpx21", nil, + 200, `{ + "server_types": [{ "id": 10, "name": "cpx21", "architecture": "x86"}] + }`, + }, + {"GET", "/images?architecture=x86&page=1&type=snapshot", nil, + 200, `{ + "images": [] + }`, + }, + }, + WantStepAction: multistep.ActionContinue, + WantStateFunc: func(t *testing.T, state multistep.StateBag) { + serverType, ok := state.Get(StateServerType).(*hcloud.ServerType) + assert.True(t, ok) + assert.Equal(t, hcloud.ServerType{ID: 9, Name: "cpx11", Architecture: "x86"}, *serverType) + + _, ok = state.Get(StateSnapshotIDOld).(int64) + assert.False(t, ok) + }, }, { - name: "snapshot name old, failure", - step: stepPreValidate{SnapshotName: "snapshot-old"}, - wantAction: multistep.ActionHalt, + Name: "fail with existing snapshot", + Step: &stepPreValidate{ + SnapshotName: "dummy-snapshot", + Force: false, + }, + SetupConfigFunc: func(c *Config) { + c.UpgradeServerType = "cpx21" + }, + WantRequests: []Request{ + {"GET", "/server_types?name=cpx11", nil, + 200, `{ + "server_types": [{ "id": 9, "name": "cpx11", "architecture": "x86"}] + }`, + }, + {"GET", "/server_types?name=cpx21", nil, + 200, `{ + "server_types": [{ "id": 10, "name": "cpx21", "architecture": "x86"}] + }`, + }, + {"GET", "/images?architecture=x86&page=1&type=snapshot", nil, + 200, `{ + "images": [{ "id": 1, "description": "dummy-snapshot"}] + }`, + }, + }, + WantStepAction: multistep.ActionHalt, + WantStateFunc: func(t *testing.T, state multistep.StateBag) { + serverType, ok := state.Get(StateServerType).(*hcloud.ServerType) + assert.True(t, ok) + assert.Equal(t, hcloud.ServerType{ID: 9, Name: "cpx11", Architecture: "x86"}, *serverType) + + _, ok = state.Get(StateSnapshotIDOld).(int64) + assert.False(t, ok) + + err, ok := state.Get(StateError).(error) + assert.True(t, ok) + assert.NotNil(t, err) + assert.Equal(t, "Found existing snapshot (id=1, arch=x86) with name 'dummy-snapshot'", err.Error()) + }, }, { - name: "snapshot name old, force flag, success", - wantOldSnapID: 1000, - step: stepPreValidate{SnapshotName: "snapshot-old", Force: true}, - wantAction: multistep.ActionContinue, + Name: "happy with existing snapshot", + Step: &stepPreValidate{ + SnapshotName: "dummy-snapshot", + Force: true, + }, + SetupConfigFunc: func(c *Config) { + c.UpgradeServerType = "cpx21" + }, + WantRequests: []Request{ + {"GET", "/server_types?name=cpx11", nil, + 200, `{ + "server_types": [{ "id": 9, "name": "cpx11", "architecture": "x86"}] + }`, + }, + {"GET", "/server_types?name=cpx21", nil, + 200, `{ + "server_types": [{ "id": 10, "name": "cpx21", "architecture": "x86"}] + }`, + }, + {"GET", "/images?architecture=x86&page=1&type=snapshot", nil, + 200, `{ + "images": [{ "id": 1, "description": "dummy-snapshot"}] + }`, + }, + }, + WantStepAction: multistep.ActionContinue, + WantStateFunc: func(t *testing.T, state multistep.StateBag) { + serverType, ok := state.Get(StateServerType).(*hcloud.ServerType) + assert.True(t, ok) + assert.Equal(t, hcloud.ServerType{ID: 9, Name: "cpx11", Architecture: "x86"}, *serverType) + + snapshotIDOld, ok := state.Get(StateSnapshotIDOld).(int64) + assert.True(t, ok) + assert.Equal(t, int64(1), snapshotIDOld) + }, }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - errors := make(chan error, 1) - state, teardown := setupStepPreValidate(errors, fakeSnapNames) - defer teardown() - - if testing.Verbose() { - state.Put(StateUI, packersdk.TestUi(t)) - } else { - // do not output to stdout or console - state.Put(StateUI, &packersdk.MockUi{}) - } - - if action := tc.step.Run(context.Background(), state); action != tc.wantAction { - t.Errorf("step.Run: want: %v; got: %v", tc.wantAction, action) - } - - oldSnap, found := state.GetOk(StateSnapshotIDOld) - if found { - oldSnapID := oldSnap.(int64) - if tc.wantOldSnapID == 0 { - t.Errorf("OldSnapshotID: got: present with value %d; want: not present", oldSnapID) - } else if oldSnapID != tc.wantOldSnapID { - t.Errorf("OldSnapshotID: got: %d; want: %d", oldSnapID, tc.wantOldSnapID) - } - } else if tc.wantOldSnapID != 0 { - t.Errorf("OldSnapshotID: got: not present; want: present, with value %d", - tc.wantOldSnapID) - } - - select { - case err := <-errors: - t.Errorf("server: got: %s", err) - default: - } - }) - } -} - -// Configure a httptest server to reply to the requests done by stepPrevalidate. -// Report errors on the errors channel (cannot use testing.T, it runs on a different goroutine). -// Return a tuple (state, teardown) where: -// - state (containing the client) is ready to be passed to the step.Run() method. -// - teardown is a function meant to be deferred from the test. -func setupStepPreValidate(errors chan<- error, fakeSnapNames []string) (*multistep.BasicStateBag, func()) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - buf, err := io.ReadAll(r.Body) - if err != nil { - errors <- fmt.Errorf("fake server: reading request: %s", err) - return - } - reqDump := fmt.Sprintf("fake server: request:\n %s %s\n body: %s", - r.Method, r.URL.Path, string(buf)) - if testing.Verbose() { - fmt.Println(reqDump) - } - - w.Header().Set("Content-Type", "application/json") - enc := json.NewEncoder(w) - var response interface{} - - if r.Method == http.MethodGet && r.URL.Path == "/images" { - w.WriteHeader(http.StatusOK) - images := make([]schema.Image, 0, len(fakeSnapNames)) - for i, fakeDesc := range fakeSnapNames { - img := schema.Image{ - ID: int64(1000 + i), - Type: string(hcloud.ImageTypeSnapshot), - Description: fakeDesc, - } - images = append(images, img) - } - response = &schema.ImageListResponse{Images: images} - } - - if r.Method == http.MethodGet && r.URL.Path == "/server_types" && r.URL.RawQuery == "name=cx11" { - w.WriteHeader(http.StatusOK) - serverTypes := []schema.ServerType{{ - Name: "cx11", - Architecture: "x86", - }} - response = &schema.ServerTypeListResponse{ServerTypes: serverTypes} - } - - if response != nil { - if err := enc.Encode(response); err != nil { - errors <- fmt.Errorf("fake server: encoding reply: %s", err) - } - return - } - - // no match: report error - w.WriteHeader(http.StatusBadRequest) - errors <- fmt.Errorf(reqDump) - })) - - state := multistep.BasicStateBag{} - - client := hcloud.NewClient(hcloud.WithEndpoint(ts.URL), hcloud.WithDebugWriter(os.Stderr)) - state.Put(StateHCloudClient, client) - - config := &Config{ - ServerType: "cx11", - } - state.Put(StateConfig, config) - - teardown := func() { - ts.Close() - } - return &state, teardown + }) } diff --git a/builder/hcloud/step_test.go b/builder/hcloud/step_test.go new file mode 100644 index 00000000..113b4ef2 --- /dev/null +++ b/builder/hcloud/step_test.go @@ -0,0 +1,134 @@ +package hcloud + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/hashicorp/packer-plugin-sdk/multistep" + packersdk "github.com/hashicorp/packer-plugin-sdk/packer" + "github.com/stretchr/testify/assert" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +type StepTestCase struct { + Name string + Step multistep.Step + StepFuncName string + + SetupConfigFunc func(*Config) + SetupStateFunc func(multistep.StateBag) + + WantRequests []Request + + WantStepAction multistep.StepAction + WantStateFunc func(*testing.T, multistep.StateBag) +} + +type Request struct { + Method string + Path string + WantRequestBodyFunc func(t *testing.T, r *http.Request, body []byte) + + Status int + Body string +} + +func RunStepTestCases(t *testing.T, testCases []StepTestCase) { + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + config := &Config{ + ServerName: "dummy-server", + Image: "debian-12", + SnapshotName: "dummy-snapshot", + ServerType: "cpx11", + Location: "nbg1", + SSHKeys: []string{"1"}, + } + + if tc.SetupConfigFunc != nil { + tc.SetupConfigFunc(config) + } + + server := NewTestServer(t, tc.WantRequests) + defer server.Close() + client := hcloud.NewClient(hcloud.WithEndpoint(server.URL)) + + state := NewTestState(t) + state.Put(StateConfig, config) + state.Put(StateHCloudClient, client) + + if tc.SetupStateFunc != nil { + tc.SetupStateFunc(state) + } + + switch strings.ToLower(tc.StepFuncName) { + case "cleanup": + tc.Step.Cleanup(state) + default: + action := tc.Step.Run(context.Background(), state) + assert.Equal(t, tc.WantStepAction, action) + } + + if tc.WantStateFunc != nil { + tc.WantStateFunc(t, state) + } + }) + } +} + +func NewTestServer(t *testing.T, requests []Request) *httptest.Server { + index := 0 + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if testing.Verbose() { + t.Logf("request %d: %s %s\n", index, r.Method, r.URL.Path) + } + + if index >= len(requests) { + t.Fatalf("received unknown request %d", index) + } + + response := requests[index] + assert.Equal(t, response.Method, r.Method) + assert.Equal(t, response.Path, r.RequestURI) + + if response.WantRequestBodyFunc != nil { + buffer, err := io.ReadAll(r.Body) + defer func() { + if err := r.Body.Close(); err != nil { + t.Fatal(err) + } + }() + if err != nil { + t.Fatal(err) + } + response.WantRequestBodyFunc(t, r, buffer) + } + + w.WriteHeader(response.Status) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(response.Body)) + if err != nil { + t.Fatal(err) + } + + index++ + })) +} + +func NewTestState(t *testing.T) multistep.StateBag { + state := &multistep.BasicStateBag{} + + if testing.Verbose() { + state.Put(StateUI, packersdk.TestUi(t)) + } else { + state.Put(StateUI, &packersdk.MockUi{}) + } + + return state +} diff --git a/go.mod b/go.mod index d74becd2..62d4d31e 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/hashicorp/packer-plugin-sdk v0.5.2 github.com/hetznercloud/hcloud-go/v2 v2.5.1 github.com/mitchellh/mapstructure v1.5.0 + github.com/stretchr/testify v1.8.4 github.com/zclconf/go-cty v1.13.3 ) @@ -27,6 +28,7 @@ require ( github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dylanmei/iso8601 v0.1.0 // indirect github.com/fatih/color v1.14.1 // indirect github.com/go-jose/go-jose/v3 v3.0.1 // indirect @@ -77,6 +79,7 @@ require ( github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/packer-community/winrmcp v0.0.0-20180921211025-c76d91c1e7db // indirect github.com/pkg/sftp v1.13.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.17.0 // indirect github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect github.com/prometheus/common v0.44.0 // indirect @@ -99,6 +102,7 @@ require ( google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect google.golang.org/grpc v1.56.3 // indirect google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/zclconf/go-cty => github.com/nywilken/go-cty v1.13.3 // added by packer-sdc fix as noted in github.com/hashicorp/packer-plugin-sdk/issues/187 diff --git a/go.sum b/go.sum index 0326208e..411be57e 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,7 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dylanmei/iso8601 v0.1.0 h1:812NGQDBcqquTfH5Yeo7lwR0nzx/cKdsmf3qMjPURUI= github.com/dylanmei/iso8601 v0.1.0/go.mod h1:w9KhXSgIyROl1DefbMYIE7UVSIvELTbMrCfx+QkYnoQ= github.com/dylanmei/winrmtest v0.0.0-20210303004826-fbc9ae56efb6 h1:zWydSUQBJApHwpQ4guHi+mGyQN/8yN6xbKWdDtL3ZNM= @@ -280,6 +281,7 @@ github.com/pkg/sftp v1.13.2 h1:taJnKntsWgU+qae21Rx52lIwndAdKrj0mfUNQsz1z4Q= github.com/pkg/sftp v1.13.2/go.mod h1:LzqnAvaD5TWeNBsZpfKxSYn1MbjWwOsCIAFFJbpIsK8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -326,6 +328,7 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0= github.com/ugorji/go/codec v1.2.6 h1:7kbGefxLoDBuYXOms4yD7223OpNMMPNPZxXk5TvFcyQ=