From d7241fed634bdba2598197fe458a08205f6e0f36 Mon Sep 17 00:00:00 2001 From: pauhull Date: Tue, 14 Nov 2023 08:51:23 +0100 Subject: [PATCH] feat: allow JSON output on resource creation (#609) This PR adds the possibility to use the `-o=json` flag on resource creation and also adds the corresponding tests. Closes #470 --- README.md | 5 + internal/cmd/base/create.go | 81 ++++++++++ internal/cmd/certificate/create.go | 57 ++++---- internal/cmd/certificate/create_test.go | 129 ++++++++++++++++ .../testdata/managed_create_response.json | 34 +++++ .../testdata/uploaded_create_response.json | 23 +++ internal/cmd/firewall/create.go | 18 +-- internal/cmd/firewall/create_test.go | 68 +++++++++ .../firewall/testdata/create_response.json | 23 +++ internal/cmd/floatingip/create.go | 30 ++-- internal/cmd/floatingip/create_test.go | 61 ++++++++ .../floatingip/testdata/create_response.json | 27 ++++ internal/cmd/loadbalancer/create.go | 22 +-- internal/cmd/loadbalancer/create_test.go | 76 ++++++++++ .../testdata/create_response.json | 51 +++++++ internal/cmd/network/create.go | 15 +- internal/cmd/network/create_test.go | 58 ++++++++ .../cmd/network/testdata/create_response.json | 20 +++ internal/cmd/placementgroup/create.go | 15 +- internal/cmd/placementgroup/create_test.go | 67 +++++++++ .../testdata/create_response.json | 14 ++ internal/cmd/primaryip/create.go | 21 +-- internal/cmd/primaryip/create_test.go | 78 ++++++++++ .../primaryip/testdata/create_response.json | 37 +++++ internal/cmd/server/create.go | 37 +++-- internal/cmd/server/create_test.go | 138 +++++++++++++++++- internal/cmd/server/shutdown_test.go | 2 +- .../cmd/server/testdata/create_response.json | 104 +++++++++++++ internal/cmd/sshkey/create.go | 15 +- internal/cmd/sshkey/create_test.go | 55 +++++++ .../cmd/sshkey/testdata/create_response.json | 10 ++ internal/cmd/volume/create.go | 23 +-- internal/cmd/volume/create_test.go | 74 ++++++++++ .../cmd/volume/testdata/create_response.json | 25 ++++ internal/state/helpers.go | 9 +- internal/testutil/testing.go | 25 ++++ 36 files changed, 1431 insertions(+), 116 deletions(-) create mode 100644 internal/cmd/base/create.go create mode 100644 internal/cmd/certificate/testdata/managed_create_response.json create mode 100644 internal/cmd/certificate/testdata/uploaded_create_response.json create mode 100644 internal/cmd/firewall/testdata/create_response.json create mode 100644 internal/cmd/floatingip/testdata/create_response.json create mode 100644 internal/cmd/loadbalancer/testdata/create_response.json create mode 100644 internal/cmd/network/testdata/create_response.json create mode 100644 internal/cmd/placementgroup/testdata/create_response.json create mode 100644 internal/cmd/primaryip/testdata/create_response.json create mode 100644 internal/cmd/server/testdata/create_response.json create mode 100644 internal/cmd/sshkey/testdata/create_response.json create mode 100644 internal/cmd/volume/testdata/create_response.json diff --git a/README.md b/README.md index d176b666..e564420c 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,11 @@ You can control output via the `-o` option: of the resource. The schema is identical to those in the Hetzner Cloud API which are documented at [docs.hetzner.cloud](https://docs.hetzner.cloud). +* For `create` commands, you can specify `-o json` to get a JSON representation + of the API response. API responses are documented at [docs.hetzner.cloud](https://docs.hetzner.cloud). + In contrast to `describe` commands, `create` commands can return extra information, for example + the initial root password of a server. + * For `describe` commands, you can specify `-o format={{.ID}}` to format output according to the given [Go template](https://golang.org/pkg/text/template/). The template’s input is the resource’s corresponding struct in the diff --git a/internal/cmd/base/create.go b/internal/cmd/base/create.go new file mode 100644 index 00000000..ac1b9289 --- /dev/null +++ b/internal/cmd/base/create.go @@ -0,0 +1,81 @@ +package base + +import ( + "context" + "encoding/json" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/cmd/output" + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/cli/internal/hcapi2" + "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +// CreateCmd allows defining commands for resource creation +type CreateCmd struct { + BaseCobraCommand func(hcapi2.Client) *cobra.Command + Run func(context.Context, hcapi2.Client, state.ActionWaiter, *cobra.Command, []string) (*hcloud.Response, any, error) + PrintResource func(context.Context, hcapi2.Client, *cobra.Command, any) +} + +// CobraCommand creates a command that can be registered with cobra. +func (cc *CreateCmd) CobraCommand( + ctx context.Context, client hcapi2.Client, tokenEnsurer state.TokenEnsurer, actionWaiter state.ActionWaiter, +) *cobra.Command { + cmd := cc.BaseCobraCommand(client) + + output.AddFlag(cmd, output.OptionJSON()) + + if cmd.Args == nil { + cmd.Args = cobra.NoArgs + } + + cmd.TraverseChildren = true + cmd.DisableFlagsInUseLine = true + + if cmd.PreRunE != nil { + cmd.PreRunE = util.ChainRunE(cmd.PreRunE, tokenEnsurer.EnsureToken) + } else { + cmd.PreRunE = tokenEnsurer.EnsureToken + } + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + outputFlags := output.FlagsForCommand(cmd) + + isJson := outputFlags.IsSet("json") + if isJson { + cmd.SetOut(os.Stderr) + } else { + cmd.SetOut(os.Stdout) + } + + response, resource, err := cc.Run(ctx, client, actionWaiter, cmd, args) + if err != nil { + return err + } + + if isJson { + bytes, _ := io.ReadAll(response.Body) + + var data map[string]any + if err := json.Unmarshal(bytes, &data); err != nil { + return err + } + + delete(data, "action") + delete(data, "actions") + delete(data, "next_actions") + + return util.DescribeJSON(data) + } else if resource != nil { + cc.PrintResource(ctx, client, cmd, resource) + } + return nil + } + + return cmd +} diff --git a/internal/cmd/certificate/create.go b/internal/cmd/certificate/create.go index 2668df5f..dafd5fbd 100644 --- a/internal/cmd/certificate/create.go +++ b/internal/cmd/certificate/create.go @@ -15,7 +15,7 @@ import ( "github.com/hetznercloud/hcloud-go/v2/hcloud" ) -var CreateCmd = base.Cmd{ +var CreateCmd = base.CreateCmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create [FLAGS]", @@ -41,23 +41,26 @@ var CreateCmd = base.Cmd{ return cmd }, - Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, strings []string) error { + Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, strings []string) (*hcloud.Response, any, error) { certType, err := cmd.Flags().GetString("type") if err != nil { - return err + return nil, nil, err } switch hcloud.CertificateType(certType) { - case hcloud.CertificateTypeUploaded: - return createUploaded(ctx, client, cmd) case hcloud.CertificateTypeManaged: - return createManaged(ctx, client, waiter, cmd) - default: - return createUploaded(ctx, client, cmd) + response, err := createManaged(ctx, client, waiter, cmd) + return response, nil, err + default: // Uploaded + response, err := createUploaded(ctx, client, cmd) + return response, nil, err } }, + PrintResource: func(_ context.Context, _ hcapi2.Client, _ *cobra.Command, _ any) { + // no-op + }, } -func createUploaded(ctx context.Context, client hcapi2.Client, cmd *cobra.Command) error { +func createUploaded(ctx context.Context, client hcapi2.Client, cmd *cobra.Command) (*hcloud.Response, error) { var ( name string certFile, keyFile string @@ -68,23 +71,23 @@ func createUploaded(ctx context.Context, client hcapi2.Client, cmd *cobra.Comman ) if err = util.ValidateRequiredFlags(cmd.Flags(), "cert-file", "key-file"); err != nil { - return err + return nil, err } if name, err = cmd.Flags().GetString("name"); err != nil { - return err + return nil, err } if certFile, err = cmd.Flags().GetString("cert-file"); err != nil { - return err + return nil, err } if keyFile, err = cmd.Flags().GetString("key-file"); err != nil { - return err + return nil, err } if certPEM, err = os.ReadFile(certFile); err != nil { - return err + return nil, err } if keyPEM, err = os.ReadFile(keyFile); err != nil { - return err + return nil, err } createOpts := hcloud.CertificateCreateOpts{ @@ -93,14 +96,15 @@ func createUploaded(ctx context.Context, client hcapi2.Client, cmd *cobra.Comman Certificate: string(certPEM), PrivateKey: string(keyPEM), } - if cert, _, err = client.Certificate().Create(ctx, createOpts); err != nil { - return err + cert, response, err := client.Certificate().Create(ctx, createOpts) + if err != nil { + return nil, err } cmd.Printf("Certificate %d created\n", cert.ID) - return nil + return response, nil } -func createManaged(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command) error { +func createManaged(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command) (*hcloud.Response, error) { var ( name string domains []string @@ -109,13 +113,13 @@ func createManaged(ctx context.Context, client hcapi2.Client, waiter state.Actio ) if name, err = cmd.Flags().GetString("name"); err != nil { - return nil + return nil, nil } if err = util.ValidateRequiredFlags(cmd.Flags(), "domain"); err != nil { - return err + return nil, err } if domains, err = cmd.Flags().GetStringSlice("domain"); err != nil { - return nil + return nil, nil } createOpts := hcloud.CertificateCreateOpts{ @@ -123,12 +127,13 @@ func createManaged(ctx context.Context, client hcapi2.Client, waiter state.Actio Type: hcloud.CertificateTypeManaged, DomainNames: domains, } - if res, _, err = client.Certificate().CreateCertificate(ctx, createOpts); err != nil { - return err + res, response, err := client.Certificate().CreateCertificate(ctx, createOpts) + if err != nil { + return nil, err } if err := waiter.ActionProgress(ctx, res.Action); err != nil { - return err + return nil, err } cmd.Printf("Certificate %d created\n", res.Certificate.ID) - return nil + return response, nil } diff --git a/internal/cmd/certificate/create_test.go b/internal/cmd/certificate/create_test.go index e5114d90..78befde7 100644 --- a/internal/cmd/certificate/create_test.go +++ b/internal/cmd/certificate/create_test.go @@ -2,15 +2,24 @@ package certificate import ( "context" + _ "embed" "testing" + "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) +//go:embed testdata/managed_create_response.json +var managedCreateResponseJson string + +//go:embed testdata/uploaded_create_response.json +var uploadedCreateResponseJson string + func TestCreateManaged(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -48,6 +57,72 @@ func TestCreateManaged(t *testing.T) { assert.Equal(t, expOut, out) } +func TestCreateManagedJSON(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + cmd := CreateCmd.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + response, err := testutil.MockResponse(&schema.CertificateCreateResponse{ + Certificate: schema.Certificate{ + ID: 123, + Name: "test", + Type: string(hcloud.CertificateTypeManaged), + Created: time.Date(2020, 8, 24, 12, 0, 0, 0, time.UTC), + NotValidBefore: time.Date(2020, 8, 24, 12, 0, 0, 0, time.UTC), + NotValidAfter: time.Date(2036, 8, 12, 12, 0, 0, 0, time.UTC), + DomainNames: []string{"example.com"}, + Labels: map[string]string{"key": "value"}, + UsedBy: []schema.CertificateUsedByRef{{ + ID: 123, + Type: string(hcloud.CertificateUsedByRefTypeLoadBalancer), + }}, + Status: &schema.CertificateStatusRef{ + Error: &schema.Error{ + Code: "cert_error", + Message: "Certificate error", + }, + }, + }, + }) + + if err != nil { + t.Fatal(err) + } + + fx.Client.CertificateClient.EXPECT(). + CreateCertificate(gomock.Any(), hcloud.CertificateCreateOpts{ + Name: "test", + Type: hcloud.CertificateTypeManaged, + DomainNames: []string{"example.com"}, + }). + Return(hcloud.CertificateCreateResult{ + Certificate: &hcloud.Certificate{ + ID: 123, + Name: "test", + Type: hcloud.CertificateTypeManaged, + DomainNames: []string{"example.com"}, + }, + Action: &hcloud.Action{ID: 321}, + }, response, nil) + fx.ActionWaiter.EXPECT(). + ActionProgress(gomock.Any(), &hcloud.Action{ID: 321}) + + jsonOut, out, err := fx.Run(cmd, []string{"-o=json", "--name", "test", "--type", "managed", "--domain", "example.com"}) + + expOut := "Certificate 123 created\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) + + assert.JSONEq(t, managedCreateResponseJson, jsonOut) +} + func TestCreateUploaded(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -79,3 +154,57 @@ func TestCreateUploaded(t *testing.T) { assert.NoError(t, err) assert.Equal(t, expOut, out) } + +func TestCreateUploadedJSON(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + cmd := CreateCmd.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + response, err := testutil.MockResponse(&schema.CertificateCreateResponse{ + Certificate: schema.Certificate{ + ID: 123, + Name: "test", + Type: string(hcloud.CertificateTypeUploaded), + Created: time.Date(2020, 8, 24, 12, 0, 0, 0, time.UTC), + NotValidBefore: time.Date(2020, 8, 24, 12, 0, 0, 0, time.UTC), + NotValidAfter: time.Date(2036, 8, 12, 12, 0, 0, 0, time.UTC), + Labels: map[string]string{"key": "value"}, + Fingerprint: "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", + UsedBy: []schema.CertificateUsedByRef{{ + ID: 123, + Type: string(hcloud.CertificateUsedByRefTypeLoadBalancer), + }}, + }, + }) + + if err != nil { + t.Fatal(err) + } + + fx.Client.CertificateClient.EXPECT(). + Create(gomock.Any(), hcloud.CertificateCreateOpts{ + Name: "test", + Type: hcloud.CertificateTypeUploaded, + Certificate: "certificate file content", + PrivateKey: "key file content", + }). + Return(&hcloud.Certificate{ + ID: 123, + Name: "test", + Type: hcloud.CertificateTypeUploaded, + }, response, nil) + + jsonOut, out, err := fx.Run(cmd, []string{"-o=json", "--name", "test", "--key-file", "testdata/key.pem", "--cert-file", "testdata/cert.pem"}) + + expOut := "Certificate 123 created\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) + assert.JSONEq(t, uploadedCreateResponseJson, jsonOut) +} diff --git a/internal/cmd/certificate/testdata/managed_create_response.json b/internal/cmd/certificate/testdata/managed_create_response.json new file mode 100644 index 00000000..c4aa326b --- /dev/null +++ b/internal/cmd/certificate/testdata/managed_create_response.json @@ -0,0 +1,34 @@ +{ + "certificate": { + "certificate": "", + "created": "2020-08-24T12:00:00Z", + "domain_names": [ + "example.com" + ], + "fingerprint": "", + "id": 123, + "labels": { + "key": "value" + }, + "name": "test", + "not_valid_after": "2036-08-12T12:00:00Z", + "not_valid_before": "2020-08-24T12:00:00Z", + "status": { + "error": { + "Details": null, + "code": "cert_error", + "details": null, + "message": "Certificate error" + }, + "issuance": "", + "renewal": "" + }, + "type": "managed", + "used_by": [ + { + "id": 123, + "type": "load_balancer" + } + ] + } +} \ No newline at end of file diff --git a/internal/cmd/certificate/testdata/uploaded_create_response.json b/internal/cmd/certificate/testdata/uploaded_create_response.json new file mode 100644 index 00000000..ca99508c --- /dev/null +++ b/internal/cmd/certificate/testdata/uploaded_create_response.json @@ -0,0 +1,23 @@ +{ + "certificate": { + "certificate": "", + "created": "2020-08-24T12:00:00Z", + "domain_names": null, + "fingerprint": "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", + "id": 123, + "labels": { + "key": "value" + }, + "name": "test", + "not_valid_after": "2036-08-12T12:00:00Z", + "not_valid_before": "2020-08-24T12:00:00Z", + "status": null, + "type": "uploaded", + "used_by": [ + { + "id": 123, + "type": "load_balancer" + } + ] + } +} \ No newline at end of file diff --git a/internal/cmd/firewall/create.go b/internal/cmd/firewall/create.go index 20f7505b..bacf839c 100644 --- a/internal/cmd/firewall/create.go +++ b/internal/cmd/firewall/create.go @@ -17,7 +17,7 @@ import ( "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) -var CreateCmd = base.Cmd{ +var CreateCmd = base.CreateCmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create FLAGS", @@ -32,7 +32,7 @@ var CreateCmd = base.Cmd{ cmd.Flags().String("rules-file", "", "JSON file containing your routes (use - to read from stdin). The structure of the file needs to be the same as within the API: https://docs.hetzner.cloud/#firewalls-get-a-firewall ") return cmd }, - Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, strings []string) error { + Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, strings []string) (*hcloud.Response, any, error) { name, _ := cmd.Flags().GetString("name") labels, _ := cmd.Flags().GetStringToString("label") @@ -52,19 +52,19 @@ var CreateCmd = base.Cmd{ data, err = ioutil.ReadFile(rulesFile) } if err != nil { - return err + return nil, nil, err } var rules []schema.FirewallRule err = json.Unmarshal(data, &rules) if err != nil { - return err + return nil, nil, err } for _, rule := range rules { var sourceNets []net.IPNet for i, sourceIP := range rule.SourceIPs { _, sourceNet, err := net.ParseCIDR(sourceIP) if err != nil { - return fmt.Errorf("invalid CIDR on index %d : %s", i, err) + return nil, nil, fmt.Errorf("invalid CIDR on index %d : %s", i, err) } sourceNets = append(sourceNets, *sourceNet) } @@ -78,17 +78,17 @@ var CreateCmd = base.Cmd{ } } - result, _, err := client.Firewall().Create(ctx, opts) + result, response, err := client.Firewall().Create(ctx, opts) if err != nil { - return err + return nil, nil, err } if err := waiter.WaitForActions(ctx, result.Actions); err != nil { - return err + return nil, nil, err } cmd.Printf("Firewall %d created\n", result.Firewall.ID) - return nil + return response, nil, err }, } diff --git a/internal/cmd/firewall/create_test.go b/internal/cmd/firewall/create_test.go index 309c79ce..e1ac5c63 100644 --- a/internal/cmd/firewall/create_test.go +++ b/internal/cmd/firewall/create_test.go @@ -2,15 +2,21 @@ package firewall import ( "context" + _ "embed" "testing" + "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) +//go:embed testdata/create_response.json +var createResponseJson string + func TestCreate(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -44,3 +50,65 @@ func TestCreate(t *testing.T) { assert.NoError(t, err) assert.Equal(t, expOut, out) } + +func TestCreateJSON(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + cmd := CreateCmd.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + response, err := testutil.MockResponse(&schema.FirewallCreateResponse{ + Firewall: schema.Firewall{ + ID: 123, + Name: "test", + Created: time.Date(2016, 1, 30, 23, 50, 0, 0, time.UTC), + AppliedTo: []schema.FirewallResource{ + {Type: "server", Server: &schema.FirewallResourceServer{ + ID: 1, + }}, + }, + Labels: make(map[string]string), + Rules: []schema.FirewallRule{ + { + Direction: "in", + SourceIPs: make([]string, 0), + Protocol: "tcp", + Port: hcloud.Ptr("22"), + }, + }, + }, + Actions: make([]schema.Action, 0), + }) + + if err != nil { + t.Fatal(err) + } + + fx.Client.FirewallClient.EXPECT(). + Create(gomock.Any(), hcloud.FirewallCreateOpts{ + Name: "test", + Labels: make(map[string]string), + }). + Return(hcloud.FirewallCreateResult{ + Firewall: &hcloud.Firewall{ + ID: 123, + Name: "test", + }, + Actions: []*hcloud.Action{{ID: 321}}, + }, response, nil) + fx.ActionWaiter.EXPECT(). + WaitForActions(gomock.Any(), []*hcloud.Action{{ID: 321}}) + + jsonOut, out, err := fx.Run(cmd, []string{"-o=json", "--name", "test"}) + + expOut := "Firewall 123 created\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) + assert.JSONEq(t, createResponseJson, jsonOut) +} diff --git a/internal/cmd/firewall/testdata/create_response.json b/internal/cmd/firewall/testdata/create_response.json new file mode 100644 index 00000000..74f49203 --- /dev/null +++ b/internal/cmd/firewall/testdata/create_response.json @@ -0,0 +1,23 @@ +{ + "firewall": { + "applied_to": [ + { + "server": { + "id": 1 + }, + "type": "server" + } + ], + "created": "2016-01-30T23:50:00Z", + "id": 123, + "labels": {}, + "name": "test", + "rules": [ + { + "direction": "in", + "port": "22", + "protocol": "tcp" + } + ] + } +} \ No newline at end of file diff --git a/internal/cmd/floatingip/create.go b/internal/cmd/floatingip/create.go index 176b6666..3e2c2ca0 100644 --- a/internal/cmd/floatingip/create.go +++ b/internal/cmd/floatingip/create.go @@ -14,7 +14,7 @@ import ( "github.com/hetznercloud/hcloud-go/v2/hcloud" ) -var CreateCmd = base.Cmd{ +var CreateCmd = base.CreateCmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create FLAGS", @@ -44,16 +44,16 @@ var CreateCmd = base.Cmd{ return cmd }, - Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) error { + Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) (*hcloud.Response, any, error) { typ, _ := cmd.Flags().GetString("type") if typ == "" { - return errors.New("type is required") + return nil, nil, errors.New("type is required") } homeLocation, _ := cmd.Flags().GetString("home-location") server, _ := cmd.Flags().GetString("server") if homeLocation == "" && server == "" { - return errors.New("one of --home-location or --server is required") + return nil, nil, errors.New("one of --home-location or --server is required") } name, _ := cmd.Flags().GetString("name") @@ -64,7 +64,7 @@ var CreateCmd = base.Cmd{ protectionOps, err := getChangeProtectionOpts(true, protection) if err != nil { - return err + return nil, nil, err } createOpts := hcloud.FloatingIPCreateOpts{ @@ -81,32 +81,36 @@ var CreateCmd = base.Cmd{ if serverNameOrID != "" { server, _, err := client.Server().Get(ctx, serverNameOrID) if err != nil { - return err + return nil, nil, err } if server == nil { - return fmt.Errorf("server not found: %s", serverNameOrID) + return nil, nil, fmt.Errorf("server not found: %s", serverNameOrID) } createOpts.Server = server } - result, _, err := client.FloatingIP().Create(ctx, createOpts) + result, response, err := client.FloatingIP().Create(ctx, createOpts) if err != nil { - return err + return nil, nil, err } if result.Action != nil { if err := waiter.ActionProgress(ctx, result.Action); err != nil { - return err + return nil, nil, err } } cmd.Printf("Floating IP %d created\n", result.FloatingIP.ID) if err := changeProtection(ctx, client, waiter, cmd, result.FloatingIP, true, protectionOps); err != nil { - return err + return nil, nil, err } - cmd.Printf("IP%s: %s\n", result.FloatingIP.Type[2:], result.FloatingIP.IP) - return nil + return response, result.FloatingIP, nil + }, + + PrintResource: func(ctx context.Context, client hcapi2.Client, cmd *cobra.Command, resource any) { + floatingIP := resource.(*hcloud.FloatingIP) + cmd.Printf("IP%s: %s\n", floatingIP.Type[2:], floatingIP.IP) }, } diff --git a/internal/cmd/floatingip/create_test.go b/internal/cmd/floatingip/create_test.go index aa7a84e2..291b3676 100644 --- a/internal/cmd/floatingip/create_test.go +++ b/internal/cmd/floatingip/create_test.go @@ -2,6 +2,7 @@ package floatingip import ( "context" + _ "embed" "net" "testing" @@ -10,8 +11,12 @@ import ( "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) +//go:embed testdata/create_response.json +var createResponseJson string + func TestCreate(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -51,6 +56,62 @@ IPv4: 192.168.2.1 assert.Equal(t, expOut, out) } +func TestCreateJSON(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + cmd := CreateCmd.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + response, err := testutil.MockResponse(&schema.FloatingIPCreateResponse{ + FloatingIP: schema.FloatingIP{ + ID: 123, + Name: "myFloatingIP", + IP: "127.0.0.1", + Type: string(hcloud.FloatingIPTypeIPv4), + Labels: make(map[string]string), + Server: hcloud.Ptr(int64(1)), + }, + Action: &schema.Action{ + ID: 321, + }, + }) + + if err != nil { + t.Fatal(err) + } + + fx.Client.FloatingIPClient.EXPECT(). + Create(gomock.Any(), hcloud.FloatingIPCreateOpts{ + Name: hcloud.Ptr("myFloatingIP"), + Type: hcloud.FloatingIPTypeIPv4, + HomeLocation: &hcloud.Location{Name: "fsn1"}, + Labels: make(map[string]string), + Description: hcloud.Ptr(""), + }). + Return(hcloud.FloatingIPCreateResult{ + FloatingIP: &hcloud.FloatingIP{ + ID: 123, + Name: "myFloatingIP", + IP: net.ParseIP("192.168.2.1"), + Type: hcloud.FloatingIPTypeIPv4, + }, + Action: nil, + }, response, nil) + + jsonOut, out, err := fx.Run(cmd, []string{"-o=json", "--name", "myFloatingIP", "--type", "ipv4", "--home-location", "fsn1"}) + + expOut := "Floating IP 123 created\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) + assert.JSONEq(t, createResponseJson, jsonOut) +} + func TestCreateProtection(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() diff --git a/internal/cmd/floatingip/testdata/create_response.json b/internal/cmd/floatingip/testdata/create_response.json new file mode 100644 index 00000000..3a8bd407 --- /dev/null +++ b/internal/cmd/floatingip/testdata/create_response.json @@ -0,0 +1,27 @@ +{ + "floating_ip": { + "blocked": false, + "created": "0001-01-01T00:00:00Z", + "description": null, + "dns_ptr": null, + "home_location": { + "city": "", + "country": "", + "description": "", + "id": 0, + "latitude": 0, + "longitude": 0, + "name": "", + "network_zone": "" + }, + "id": 123, + "ip": "127.0.0.1", + "labels": {}, + "name": "myFloatingIP", + "protection": { + "delete": false + }, + "server": 1, + "type": "ipv4" + } +} \ No newline at end of file diff --git a/internal/cmd/loadbalancer/create.go b/internal/cmd/loadbalancer/create.go index 8c2da93b..fb4785b8 100644 --- a/internal/cmd/loadbalancer/create.go +++ b/internal/cmd/loadbalancer/create.go @@ -12,7 +12,7 @@ import ( "github.com/hetznercloud/hcloud-go/v2/hcloud" ) -var CreateCmd = base.Cmd{ +var CreateCmd = base.CreateCmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create [FLAGS]", @@ -47,7 +47,7 @@ var CreateCmd = base.Cmd{ return cmd }, - Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) error { + Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) (*hcloud.Response, any, error) { name, _ := cmd.Flags().GetString("name") serverType, _ := cmd.Flags().GetString("type") algorithmType, _ := cmd.Flags().GetString("algorithm-type") @@ -58,7 +58,7 @@ var CreateCmd = base.Cmd{ protectionOpts, err := getChangeProtectionOpts(true, protection) if err != nil { - return err + return nil, nil, err } createOpts := hcloud.LoadBalancerCreateOpts{ @@ -77,26 +77,30 @@ var CreateCmd = base.Cmd{ if location != "" { createOpts.Location = &hcloud.Location{Name: location} } - result, _, err := client.LoadBalancer().Create(ctx, createOpts) + result, response, err := client.LoadBalancer().Create(ctx, createOpts) if err != nil { - return err + return nil, nil, err } if err := waiter.ActionProgress(ctx, result.Action); err != nil { - return err + return nil, nil, err } loadBalancer, _, err := client.LoadBalancer().GetByID(ctx, result.LoadBalancer.ID) if err != nil { - return err + return nil, nil, err } cmd.Printf("Load Balancer %d created\n", loadBalancer.ID) if err := changeProtection(ctx, client, waiter, cmd, loadBalancer, true, protectionOpts); err != nil { - return err + return nil, nil, err } + return response, loadBalancer, nil + }, + + PrintResource: func(_ context.Context, _ hcapi2.Client, cmd *cobra.Command, resource any) { + loadBalancer := resource.(*hcloud.LoadBalancer) cmd.Printf("IPv4: %s\n", loadBalancer.PublicNet.IPv4.IP.String()) cmd.Printf("IPv6: %s\n", loadBalancer.PublicNet.IPv6.IP.String()) - return nil }, } diff --git a/internal/cmd/loadbalancer/create_test.go b/internal/cmd/loadbalancer/create_test.go index 393e301e..45b3cd6e 100644 --- a/internal/cmd/loadbalancer/create_test.go +++ b/internal/cmd/loadbalancer/create_test.go @@ -2,16 +2,22 @@ package loadbalancer import ( "context" + _ "embed" "net" "testing" + "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) +//go:embed testdata/create_response.json +var createResponseJson string + func TestCreate(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -60,6 +66,76 @@ IPv6: :: assert.Equal(t, expOut, out) } +func TestCreateJSON(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + cmd := CreateCmd.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + response, err := testutil.MockResponse(&schema.LoadBalancerCreateResponse{ + LoadBalancer: schema.LoadBalancer{ + ID: 123, + Name: "myLoadBalancer", + PublicNet: schema.LoadBalancerPublicNet{ + IPv4: schema.LoadBalancerPublicNetIPv4{ + IP: "192.168.2.1", + }, + IPv6: schema.LoadBalancerPublicNetIPv6{ + IP: "::", + }, + }, + Labels: make(map[string]string), + Created: time.Date(2016, 1, 30, 23, 50, 0, 0, time.UTC), + IncludedTraffic: 654321, + Services: make([]schema.LoadBalancerService, 0), + Targets: make([]schema.LoadBalancerTarget, 0), + }, + }) + + if err != nil { + t.Fatal(err) + } + + fx.Client.LoadBalancerClient.EXPECT(). + Create(gomock.Any(), hcloud.LoadBalancerCreateOpts{ + Name: "myLoadBalancer", + LoadBalancerType: &hcloud.LoadBalancerType{Name: "lb11"}, + Location: &hcloud.Location{Name: "fsn1"}, + Labels: make(map[string]string), + }). + Return(hcloud.LoadBalancerCreateResult{ + LoadBalancer: &hcloud.LoadBalancer{ID: 123}, + Action: &hcloud.Action{ID: 321}, + }, response, nil) + fx.ActionWaiter.EXPECT().ActionProgress(gomock.Any(), &hcloud.Action{ID: 321}).Return(nil) + fx.Client.LoadBalancerClient.EXPECT(). + GetByID(gomock.Any(), int64(123)). + Return(&hcloud.LoadBalancer{ + ID: 123, + PublicNet: hcloud.LoadBalancerPublicNet{ + IPv4: hcloud.LoadBalancerPublicNetIPv4{ + IP: net.ParseIP("192.168.2.1"), + }, + IPv6: hcloud.LoadBalancerPublicNetIPv6{ + IP: net.IPv6zero, + }, + }, + }, nil, nil) + + jsonOut, out, err := fx.Run(cmd, []string{"-o=json", "--name", "myLoadBalancer", "--type", "lb11", "--location", "fsn1"}) + + expOut := "Load Balancer 123 created\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) + assert.JSONEq(t, createResponseJson, jsonOut) +} + func TestCreateProtection(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() diff --git a/internal/cmd/loadbalancer/testdata/create_response.json b/internal/cmd/loadbalancer/testdata/create_response.json new file mode 100644 index 00000000..fcf364a2 --- /dev/null +++ b/internal/cmd/loadbalancer/testdata/create_response.json @@ -0,0 +1,51 @@ +{ + "load_balancer": { + "algorithm": { + "type": "" + }, + "created": "2016-01-30T23:50:00Z", + "id": 123, + "included_traffic": 654321, + "ingoing_traffic": null, + "labels": {}, + "load_balancer_type": { + "description": "", + "id": 0, + "max_assigned_certificates": 0, + "max_connections": 0, + "max_services": 0, + "max_targets": 0, + "name": "", + "prices": null + }, + "location": { + "city": "", + "country": "", + "description": "", + "id": 0, + "latitude": 0, + "longitude": 0, + "name": "", + "network_zone": "" + }, + "name": "myLoadBalancer", + "outgoing_traffic": null, + "private_net": null, + "protection": { + "delete": false + }, + "public_net": { + "enabled": false, + "ipv4": { + "dns_ptr": "", + "ip": "192.168.2.1" + }, + "ipv6": { + "dns_ptr": "", + "ip": "::" + } + }, + "services": [], + "targets": [] + } +} \ No newline at end of file diff --git a/internal/cmd/network/create.go b/internal/cmd/network/create.go index ebd546f1..77e6decd 100644 --- a/internal/cmd/network/create.go +++ b/internal/cmd/network/create.go @@ -13,7 +13,7 @@ import ( "github.com/hetznercloud/hcloud-go/v2/hcloud" ) -var CreateCmd = base.Cmd{ +var CreateCmd = base.CreateCmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create [FLAGS]", @@ -35,7 +35,7 @@ var CreateCmd = base.Cmd{ cmd.RegisterFlagCompletionFunc("enable-protection", cmpl.SuggestCandidates("delete")) return cmd }, - Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) error { + Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) (*hcloud.Response, any, error) { name, _ := cmd.Flags().GetString("name") ipRange, _ := cmd.Flags().GetIPNet("ip-range") labels, _ := cmd.Flags().GetStringToString("label") @@ -44,7 +44,7 @@ var CreateCmd = base.Cmd{ protectionOpts, err := getChangeProtectionOpts(true, protection) if err != nil { - return err + return nil, nil, err } createOpts := hcloud.NetworkCreateOpts{ @@ -54,13 +54,16 @@ var CreateCmd = base.Cmd{ ExposeRoutesToVSwitch: exposeRoutesToVSwitch, } - network, _, err := client.Network().Create(ctx, createOpts) + network, response, err := client.Network().Create(ctx, createOpts) if err != nil { - return err + return nil, nil, err } cmd.Printf("Network %d created\n", network.ID) - return changeProtection(ctx, client, waiter, cmd, network, true, protectionOpts) + return response, nil, changeProtection(ctx, client, waiter, cmd, network, true, protectionOpts) + }, + PrintResource: func(_ context.Context, _ hcapi2.Client, _ *cobra.Command, _ any) { + // no-op }, } diff --git a/internal/cmd/network/create_test.go b/internal/cmd/network/create_test.go index 41ed2165..ab8d99a9 100644 --- a/internal/cmd/network/create_test.go +++ b/internal/cmd/network/create_test.go @@ -2,16 +2,22 @@ package network import ( "context" + _ "embed" "net" "testing" + "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) +//go:embed testdata/create_response.json +var createResponseJson string + func TestCreate(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -44,6 +50,58 @@ func TestCreate(t *testing.T) { assert.Equal(t, expOut, out) } +func TestCreateJSON(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + time.Local = time.UTC + + cmd := CreateCmd.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + response, err := testutil.MockResponse(&schema.NetworkCreateResponse{ + Network: schema.Network{ + ID: 123, + Name: "myNetwork", + IPRange: "10.0.0.0/24", + Created: time.Date(2016, 1, 30, 23, 50, 0, 0, time.UTC), + Labels: make(map[string]string), + Servers: []int64{1, 2, 3}, + Routes: make([]schema.NetworkRoute, 0), + Subnets: make([]schema.NetworkSubnet, 0), + }, + }) + + if err != nil { + t.Fatal(err) + } + + _, ipRange, _ := net.ParseCIDR("10.0.0.0/24") + fx.Client.NetworkClient.EXPECT(). + Create(gomock.Any(), hcloud.NetworkCreateOpts{ + Name: "myNetwork", + IPRange: ipRange, + Labels: make(map[string]string), + }). + Return(&hcloud.Network{ + ID: 123, + Name: "myNetwork", + IPRange: ipRange, + }, response, nil) + + jsonOut, out, err := fx.Run(cmd, []string{"-o=json", "--name", "myNetwork", "--ip-range", "10.0.0.0/24"}) + + expOut := "Network 123 created\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) + assert.JSONEq(t, createResponseJson, jsonOut) +} + func TestCreateProtection(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() diff --git a/internal/cmd/network/testdata/create_response.json b/internal/cmd/network/testdata/create_response.json new file mode 100644 index 00000000..11983669 --- /dev/null +++ b/internal/cmd/network/testdata/create_response.json @@ -0,0 +1,20 @@ +{ + "network": { + "created": "2016-01-30T23:50:00Z", + "expose_routes_to_vswitch": false, + "id": 123, + "ip_range": "10.0.0.0/24", + "labels": {}, + "name": "myNetwork", + "protection": { + "delete": false + }, + "routes": [], + "servers": [ + 1, + 2, + 3 + ], + "subnets": [] + } +} \ No newline at end of file diff --git a/internal/cmd/placementgroup/create.go b/internal/cmd/placementgroup/create.go index 49be9996..7a4443f8 100644 --- a/internal/cmd/placementgroup/create.go +++ b/internal/cmd/placementgroup/create.go @@ -11,7 +11,7 @@ import ( "github.com/hetznercloud/hcloud-go/v2/hcloud" ) -var CreateCmd = base.Cmd{ +var CreateCmd = base.CreateCmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create FLAGS", @@ -26,7 +26,7 @@ var CreateCmd = base.Cmd{ cmd.MarkFlagRequired("type") return cmd }, - Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) error { + Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) (*hcloud.Response, any, error) { name, _ := cmd.Flags().GetString("name") labels, _ := cmd.Flags().GetStringToString("label") placementGroupType, _ := cmd.Flags().GetString("type") @@ -37,19 +37,22 @@ var CreateCmd = base.Cmd{ Type: hcloud.PlacementGroupType(placementGroupType), } - result, _, err := client.PlacementGroup().Create(ctx, opts) + result, response, err := client.PlacementGroup().Create(ctx, opts) if err != nil { - return err + return nil, nil, err } if result.Action != nil { if err := waiter.ActionProgress(ctx, result.Action); err != nil { - return err + return nil, nil, err } } cmd.Printf("Placement group %d created\n", result.PlacementGroup.ID) - return nil + return response, nil, nil + }, + PrintResource: func(_ context.Context, _ hcapi2.Client, _ *cobra.Command, _ any) { + // no-op }, } diff --git a/internal/cmd/placementgroup/create_test.go b/internal/cmd/placementgroup/create_test.go index a68f0fa3..244a8f73 100644 --- a/internal/cmd/placementgroup/create_test.go +++ b/internal/cmd/placementgroup/create_test.go @@ -2,6 +2,7 @@ package placementgroup_test import ( "context" + _ "embed" "testing" "time" @@ -11,8 +12,12 @@ import ( "github.com/hetznercloud/cli/internal/cmd/placementgroup" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) +//go:embed testdata/create_response.json +var createResponseJson string + func TestCreate(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -53,3 +58,65 @@ func TestCreate(t *testing.T) { assert.NoError(t, err) assert.Equal(t, expOut, out) } + +func TestCreateJSON(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + time.Local = time.UTC + + cmd := placementgroup.CreateCmd.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + response, err := testutil.MockResponse(&schema.PlacementGroupCreateResponse{ + PlacementGroup: schema.PlacementGroup{ + ID: 897, + Name: "myPlacementGroup", + Created: time.Date(2016, 1, 30, 23, 50, 0, 0, time.UTC), + Servers: []int64{1, 2, 3}, + Labels: make(map[string]string), + Type: string(hcloud.PlacementGroupTypeSpread), + }, + Action: &schema.Action{ID: 321}, + }) + + if err != nil { + t.Fatal(err) + } + + opts := hcloud.PlacementGroupCreateOpts{ + Name: "my Placement Group", + Labels: map[string]string{}, + Type: hcloud.PlacementGroupTypeSpread, + } + + placementGroup := hcloud.PlacementGroup{ + ID: 897, + Name: opts.Name, + Created: time.Now(), + Labels: opts.Labels, + Type: opts.Type, + } + + fx.Client.PlacementGroupClient.EXPECT(). + Create(gomock.Any(), opts). + Return(hcloud.PlacementGroupCreateResult{ + PlacementGroup: &placementGroup, + Action: &hcloud.Action{ID: 321}, + }, response, nil) + + fx.ActionWaiter.EXPECT(). + ActionProgress(gomock.Any(), &hcloud.Action{ID: 321}) + + jsonOut, out, err := fx.Run(cmd, []string{"-o=json", "--name", placementGroup.Name, "--type", string(placementGroup.Type)}) + + expOut := "Placement group 897 created\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) + assert.JSONEq(t, createResponseJson, jsonOut) +} diff --git a/internal/cmd/placementgroup/testdata/create_response.json b/internal/cmd/placementgroup/testdata/create_response.json new file mode 100644 index 00000000..52770f5c --- /dev/null +++ b/internal/cmd/placementgroup/testdata/create_response.json @@ -0,0 +1,14 @@ +{ + "placement_group": { + "created": "2016-01-30T23:50:00Z", + "id": 897, + "labels": {}, + "name": "myPlacementGroup", + "servers": [ + 1, + 2, + 3 + ], + "type": "spread" + } +} \ No newline at end of file diff --git a/internal/cmd/primaryip/create.go b/internal/cmd/primaryip/create.go index 524ce182..25efa20e 100644 --- a/internal/cmd/primaryip/create.go +++ b/internal/cmd/primaryip/create.go @@ -12,7 +12,7 @@ import ( "github.com/hetznercloud/hcloud-go/v2/hcloud" ) -var CreateCmd = base.Cmd{ +var CreateCmd = base.CreateCmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create FLAGS", @@ -40,7 +40,7 @@ var CreateCmd = base.Cmd{ return cmd }, - Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) error { + Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) (*hcloud.Response, any, error) { typ, _ := cmd.Flags().GetString("type") name, _ := cmd.Flags().GetString("name") assigneeID, _ := cmd.Flags().GetInt64("assignee-id") @@ -49,7 +49,7 @@ var CreateCmd = base.Cmd{ protectionOpts, err := getChangeProtectionOpts(true, protection) if err != nil { - return err + return nil, nil, err } createOpts := hcloud.PrimaryIPCreateOpts{ @@ -62,14 +62,14 @@ var CreateCmd = base.Cmd{ createOpts.AssigneeID = &assigneeID } - result, _, err := client.PrimaryIP().Create(ctx, createOpts) + result, response, err := client.PrimaryIP().Create(ctx, createOpts) if err != nil { - return err + return nil, nil, err } if result.Action != nil { if err := waiter.ActionProgress(ctx, result.Action); err != nil { - return err + return nil, nil, err } } @@ -77,11 +77,14 @@ var CreateCmd = base.Cmd{ if len(protection) > 0 { if err := changeProtection(ctx, client, waiter, cmd, result.PrimaryIP, true, protectionOpts); err != nil { - return err + return nil, nil, err } } - cmd.Printf("IP%s: %s\n", result.PrimaryIP.Type[2:], result.PrimaryIP.IP) - return nil + return response, result.PrimaryIP, nil + }, + PrintResource: func(_ context.Context, _ hcapi2.Client, cmd *cobra.Command, resource any) { + primaryIP := resource.(*hcloud.PrimaryIP) + cmd.Printf("IP%s: %s\n", primaryIP.Type[2:], primaryIP.IP) }, } diff --git a/internal/cmd/primaryip/create_test.go b/internal/cmd/primaryip/create_test.go index a433f8bc..8af91a8d 100644 --- a/internal/cmd/primaryip/create_test.go +++ b/internal/cmd/primaryip/create_test.go @@ -2,16 +2,22 @@ package primaryip import ( "context" + _ "embed" "net" "testing" + "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) +//go:embed testdata/create_response.json +var createResponseJson string + func TestCreate(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -53,3 +59,75 @@ IPv4: 192.168.2.1 assert.NoError(t, err) assert.Equal(t, expOut, out) } + +func TestCreateJSON(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + time.Local = time.UTC + + cmd := CreateCmd.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + response, err := testutil.MockResponse(&schema.PrimaryIPCreateResponse{ + PrimaryIP: schema.PrimaryIP{ + ID: 1, + Name: "my-ip", + IP: "192.168.2.1", + Type: "ipv4", + Datacenter: schema.Datacenter{ + ID: 1, + Name: "fsn1-dc14", + Location: schema.Location{ID: 1, Name: "fsn1"}, + }, + Created: time.Date(2016, 1, 30, 23, 50, 0, 0, time.UTC), + Labels: make(map[string]string), + AutoDelete: true, + AssigneeID: 1, + AssigneeType: "server", + DNSPtr: make([]schema.PrimaryIPDNSPTR, 0), + }, + Action: &schema.Action{ + ID: 321, + }, + }) + + if err != nil { + t.Fatal(err) + } + + fx.Client.PrimaryIPClient.EXPECT(). + Create( + gomock.Any(), + hcloud.PrimaryIPCreateOpts{ + Name: "my-ip", + Type: "ipv4", + Datacenter: "fsn1-dc14", + AssigneeType: "server", + }, + ). + Return( + &hcloud.PrimaryIPCreateResult{ + PrimaryIP: &hcloud.PrimaryIP{ + ID: 1, + IP: net.ParseIP("192.168.2.1"), + Type: hcloud.PrimaryIPTypeIPv4, + }, + Action: &hcloud.Action{ID: 321}, + }, response, nil) + + fx.ActionWaiter.EXPECT(). + ActionProgress(gomock.Any(), &hcloud.Action{ID: 321}) + + jsonOut, out, err := fx.Run(cmd, []string{"-o=json", "--name=my-ip", "--type=ipv4", "--datacenter=fsn1-dc14"}) + + expOut := "Primary IP 1 created\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) + assert.JSONEq(t, createResponseJson, jsonOut) +} diff --git a/internal/cmd/primaryip/testdata/create_response.json b/internal/cmd/primaryip/testdata/create_response.json new file mode 100644 index 00000000..ae901858 --- /dev/null +++ b/internal/cmd/primaryip/testdata/create_response.json @@ -0,0 +1,37 @@ +{ + "primary_ip": { + "assignee_id": 1, + "assignee_type": "server", + "auto_delete": true, + "blocked": false, + "created": "2016-01-30T23:50:00Z", + "datacenter": { + "description": "", + "id": 1, + "location": { + "city": "", + "country": "", + "description": "", + "id": 1, + "latitude": 0, + "longitude": 0, + "name": "fsn1", + "network_zone": "" + }, + "name": "fsn1-dc14", + "server_types": { + "available": null, + "supported": null + } + }, + "dns_ptr": [], + "id": 1, + "ip": "192.168.2.1", + "labels": {}, + "name": "my-ip", + "protection": { + "delete": false + }, + "type": "ipv4" + } +} \ No newline at end of file diff --git a/internal/cmd/server/create.go b/internal/cmd/server/create.go index 4cc935bc..92cd1957 100644 --- a/internal/cmd/server/create.go +++ b/internal/cmd/server/create.go @@ -22,8 +22,13 @@ import ( "github.com/hetznercloud/hcloud-go/v2/hcloud" ) +type createResult struct { + Server *hcloud.Server + RootPassword string +} + // CreateCmd defines a command for creating a server. -var CreateCmd = base.Cmd{ +var CreateCmd = base.CreateCmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create FLAGS", @@ -86,48 +91,56 @@ var CreateCmd = base.Cmd{ return cmd }, - Run: func(ctx context.Context, client hcapi2.Client, actionWaiter state.ActionWaiter, cmd *cobra.Command, args []string) error { + Run: func(ctx context.Context, client hcapi2.Client, actionWaiter state.ActionWaiter, cmd *cobra.Command, args []string) (*hcloud.Response, any, error) { createOpts, protectionOpts, err := createOptsFromFlags(ctx, client, cmd) if err != nil { - return err + return nil, nil, err } - result, _, err := client.Server().Create(ctx, createOpts) + result, response, err := client.Server().Create(ctx, createOpts) if err != nil { - return err + return nil, nil, err } if err := actionWaiter.ActionProgress(ctx, result.Action); err != nil { - return err + return nil, nil, err } if err := actionWaiter.WaitForActions(ctx, result.NextActions); err != nil { - return err + return nil, nil, err } server, _, err := client.Server().GetByID(ctx, result.Server.ID) if err != nil { - return err + return nil, nil, err } + cmd.Printf("Server %d created\n", result.Server.ID) if err := changeProtection(ctx, client, actionWaiter, cmd, server, true, protectionOpts); err != nil { - return err + return nil, nil, err } enableBackup, _ := cmd.Flags().GetBool("enable-backup") if enableBackup { action, _, err := client.Server().EnableBackup(ctx, server, "") if err != nil { - return err + return nil, nil, err } if err := actionWaiter.ActionProgress(ctx, action); err != nil { - return err + return nil, nil, err } cmd.Printf("Backups enabled for server %d\n", server.ID) } + return response, createResult{Server: server, RootPassword: result.RootPassword}, nil + }, + + PrintResource: func(_ context.Context, client hcapi2.Client, cmd *cobra.Command, resource any) { + result := resource.(createResult) + server := result.Server + if !server.PublicNet.IPv4.IsUnspecified() { cmd.Printf("IPv4: %s\n", server.PublicNet.IPv4.IP.String()) } @@ -147,8 +160,6 @@ var CreateCmd = base.Cmd{ if result.RootPassword != "" { cmd.Printf("Root password: %s\n", result.RootPassword) } - - return nil }, } diff --git a/internal/cmd/server/create_test.go b/internal/cmd/server/create_test.go index fd8967db..c0facce8 100644 --- a/internal/cmd/server/create_test.go +++ b/internal/cmd/server/create_test.go @@ -2,8 +2,10 @@ package server_test import ( "context" + _ "embed" "net" "testing" + "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" @@ -11,8 +13,12 @@ import ( "github.com/hetznercloud/cli/internal/cmd/server" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) +//go:embed testdata/create_response.json +var createResponseJson string + func TestCreate(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -72,6 +78,130 @@ IPv4: 192.0.2.1 assert.Equal(t, expOut, out) } +func TestCreateJSON(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + time.Local = time.UTC + + cmd := server.CreateCmd.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter, + ) + fx.ExpectEnsureToken() + + response, err := testutil.MockResponse(&schema.ServerCreateResponse{ + Server: schema.Server{ + ID: 1234, + Name: "cli-test", + PublicNet: schema.ServerPublicNet{ + IPv4: schema.ServerPublicNetIPv4{ + IP: "192.0.2.1", + }, + }, + Created: time.Date(2016, 1, 30, 23, 50, 0, 0, time.UTC), + Labels: make(map[string]string), + Datacenter: schema.Datacenter{ + ID: 1, + Name: "fsn1-dc14", + Location: schema.Location{ + ID: 1, + Name: "fsn1", + }, + }, + ServerType: schema.ServerType{ + ID: 1, + Name: "cx11", + Cores: 1, + CPUType: "shared", + Memory: 2, + Disk: 20, + StorageType: "local", + Architecture: string(hcloud.ArchitectureX86), + }, + Image: &schema.Image{ + ID: 1, + Type: "system", + Status: "available", + Name: hcloud.Ptr("ubuntu-20.04"), + Description: "Ubuntu 20.04", + Deprecated: time.Time{}, + Labels: make(map[string]string), + OSFlavor: "ubuntu", + OSVersion: hcloud.Ptr("20.04"), + RapidDeploy: true, + Protection: schema.ImageProtection{ + Delete: true, + }, + }, + ISO: &schema.ISO{ + ID: 1, + Name: "FreeBSD-11.0-RELEASE-amd64-dvd1", + Description: "FreeBSD 11.0 x64", + Type: "public", + Deprecated: time.Time{}, + }, + RescueEnabled: true, + Locked: true, + Status: string(hcloud.ServerStatusRunning), + }, + NextActions: make([]schema.Action, 0), + RootPassword: hcloud.Ptr("secret"), + Action: schema.Action{ID: 123}, + }) + + if err != nil { + t.Fatal(err) + } + + fx.Client.ServerTypeClient.EXPECT(). + Get(gomock.Any(), "cx11"). + Return(&hcloud.ServerType{Architecture: hcloud.ArchitectureX86}, nil, nil) + fx.Client.ImageClient.EXPECT(). + GetForArchitecture(gomock.Any(), "ubuntu-20.04", hcloud.ArchitectureX86). + Return(&hcloud.Image{}, nil, nil) + fx.Client.ServerClient.EXPECT(). + Create(gomock.Any(), gomock.Any()). + Do(func(_ context.Context, opts hcloud.ServerCreateOpts) { + assert.Equal(t, "cli-test", opts.Name) + }). + Return(hcloud.ServerCreateResult{ + Server: &hcloud.Server{ + ID: 1234, + PublicNet: hcloud.ServerPublicNet{ + IPv4: hcloud.ServerPublicNetIPv4{ + IP: net.ParseIP("192.0.2.1"), + }, + }, + }, + Action: &hcloud.Action{ID: 123}, + NextActions: []*hcloud.Action{{ID: 234}}, + }, response, nil) + fx.Client.ServerClient.EXPECT(). + GetByID(gomock.Any(), int64(1234)). + Return(&hcloud.Server{ + ID: 1234, + PublicNet: hcloud.ServerPublicNet{ + IPv4: hcloud.ServerPublicNetIPv4{ + IP: net.ParseIP("192.0.2.1"), + }, + }, + }, nil, nil) + fx.ActionWaiter.EXPECT().ActionProgress(gomock.Any(), &hcloud.Action{ID: 123}).Return(nil) + fx.ActionWaiter.EXPECT().WaitForActions(gomock.Any(), []*hcloud.Action{{ID: 234}}).Return(nil) + + args := []string{"-o=json", "--name", "cli-test", "--type", "cx11", "--image", "ubuntu-20.04"} + jsonOut, out, err := fx.Run(cmd, args) + + expOut := "Server 1234 created\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) + assert.JSONEq(t, createResponseJson, jsonOut) +} + func TestCreateProtectionBackup(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -109,7 +239,7 @@ func TestCreateProtectionBackup(t *testing.T) { NextActions: []*hcloud.Action{{ID: 234}}, }, nil, nil) - server := &hcloud.Server{ + srv := &hcloud.Server{ ID: 1234, PublicNet: hcloud.ServerPublicNet{ IPv4: hcloud.ServerPublicNetIPv4{ @@ -124,12 +254,12 @@ func TestCreateProtectionBackup(t *testing.T) { fx.Client.ServerClient.EXPECT(). GetByID(gomock.Any(), int64(1234)). - Return(server, nil, nil) + Return(srv, nil, nil) fx.ActionWaiter.EXPECT().ActionProgress(gomock.Any(), &hcloud.Action{ID: 123}).Return(nil) fx.ActionWaiter.EXPECT().WaitForActions(gomock.Any(), []*hcloud.Action{{ID: 234}}).Return(nil) fx.Client.ServerClient.EXPECT(). - ChangeProtection(gomock.Any(), server, hcloud.ServerChangeProtectionOpts{ + ChangeProtection(gomock.Any(), srv, hcloud.ServerChangeProtectionOpts{ Rebuild: hcloud.Ptr(true), Delete: hcloud.Ptr(true), }). Return(&hcloud.Action{ @@ -138,7 +268,7 @@ func TestCreateProtectionBackup(t *testing.T) { fx.ActionWaiter.EXPECT().ActionProgress(gomock.Any(), &hcloud.Action{ID: 1337}).Return(nil) fx.Client.ServerClient.EXPECT(). - EnableBackup(gomock.Any(), server, ""). + EnableBackup(gomock.Any(), srv, ""). Return(&hcloud.Action{ ID: 42, }, nil, nil) diff --git a/internal/cmd/server/shutdown_test.go b/internal/cmd/server/shutdown_test.go index 3d9a6868..54db2fd4 100644 --- a/internal/cmd/server/shutdown_test.go +++ b/internal/cmd/server/shutdown_test.go @@ -83,7 +83,7 @@ func TestShutdownWait(t *testing.T) { out, _, err := fx.Run(cmd, []string{server.Name, "--wait"}) - expOut := "Sent shutdown signal to server 42\nWaiting for server to shut down ... done\nServer 42 shut down\n" + expOut := "Sent shutdown signal to server 42\nServer 42 shut down\n" assert.NoError(t, err) assert.Equal(t, expOut, out) diff --git a/internal/cmd/server/testdata/create_response.json b/internal/cmd/server/testdata/create_response.json new file mode 100644 index 00000000..87f4a8d3 --- /dev/null +++ b/internal/cmd/server/testdata/create_response.json @@ -0,0 +1,104 @@ +{ + "root_password": "secret", + "server": { + "backup_window": null, + "created": "2016-01-30T23:50:00Z", + "datacenter": { + "description": "", + "id": 1, + "location": { + "city": "", + "country": "", + "description": "", + "id": 1, + "latitude": 0, + "longitude": 0, + "name": "fsn1", + "network_zone": "" + }, + "name": "fsn1-dc14", + "server_types": { + "available": null, + "supported": null + } + }, + "id": 1234, + "image": { + "architecture": "", + "bound_to": null, + "created": "0001-01-01T00:00:00Z", + "created_from": null, + "deleted": "0001-01-01T00:00:00Z", + "deprecated": "0001-01-01T00:00:00Z", + "description": "Ubuntu 20.04", + "disk_size": 0, + "id": 1, + "image_size": null, + "labels": {}, + "name": "ubuntu-20.04", + "os_flavor": "ubuntu", + "os_version": "20.04", + "protection": { + "delete": true + }, + "rapid_deploy": true, + "status": "available", + "type": "system" + }, + "included_traffic": 0, + "ingoing_traffic": null, + "iso": { + "architecture": null, + "deprecated": "0001-01-01T00:00:00Z", + "deprecation": null, + "description": "FreeBSD 11.0 x64", + "id": 1, + "name": "FreeBSD-11.0-RELEASE-amd64-dvd1", + "type": "public" + }, + "labels": {}, + "locked": true, + "name": "cli-test", + "outgoing_traffic": null, + "placement_group": null, + "primary_disk_size": 0, + "private_net": null, + "protection": { + "delete": false, + "rebuild": false + }, + "public_net": { + "firewalls": null, + "floating_ips": null, + "ipv4": { + "blocked": false, + "dns_ptr": "", + "id": 0, + "ip": "192.0.2.1" + }, + "ipv6": { + "blocked": false, + "dns_ptr": null, + "id": 0, + "ip": "" + } + }, + "rescue_enabled": true, + "server_type": { + "architecture": "x86", + "cores": 1, + "cpu_type": "shared", + "deprecation": null, + "description": "", + "disk": 20, + "id": 1, + "included_traffic": 0, + "memory": 2, + "name": "cx11", + "prices": null, + "storage_type": "local" + }, + "status": "running", + "volumes": null + } +} \ No newline at end of file diff --git a/internal/cmd/sshkey/create.go b/internal/cmd/sshkey/create.go index 6b5a7a24..df1b8644 100644 --- a/internal/cmd/sshkey/create.go +++ b/internal/cmd/sshkey/create.go @@ -13,7 +13,7 @@ import ( "github.com/hetznercloud/hcloud-go/v2/hcloud" ) -var CreateCmd = base.Cmd{ +var CreateCmd = base.CreateCmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create FLAGS", @@ -30,7 +30,7 @@ var CreateCmd = base.Cmd{ cmd.Flags().StringToString("label", nil, "User-defined labels ('key=value') (can be specified multiple times)") return cmd }, - Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) error { + Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) (*hcloud.Response, any, error) { name, _ := cmd.Flags().GetString("name") publicKey, _ := cmd.Flags().GetString("public-key") publicKeyFile, _ := cmd.Flags().GetString("public-key-from-file") @@ -47,7 +47,7 @@ var CreateCmd = base.Cmd{ data, err = os.ReadFile(publicKeyFile) } if err != nil { - return err + return nil, nil, err } publicKey = string(data) } @@ -57,13 +57,16 @@ var CreateCmd = base.Cmd{ PublicKey: publicKey, Labels: labels, } - sshKey, _, err := client.SSHKey().Create(ctx, opts) + sshKey, response, err := client.SSHKey().Create(ctx, opts) if err != nil { - return err + return nil, nil, err } cmd.Printf("SSH key %d created\n", sshKey.ID) - return nil + return response, nil, nil + }, + PrintResource: func(_ context.Context, _ hcapi2.Client, _ *cobra.Command, _ any) { + // no-op }, } diff --git a/internal/cmd/sshkey/create_test.go b/internal/cmd/sshkey/create_test.go index 8d62bd4f..6c21a5a2 100644 --- a/internal/cmd/sshkey/create_test.go +++ b/internal/cmd/sshkey/create_test.go @@ -2,15 +2,21 @@ package sshkey import ( "context" + _ "embed" "testing" + "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) +//go:embed testdata/create_response.json +var createResponseJson string + func TestCreate(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -41,3 +47,52 @@ func TestCreate(t *testing.T) { assert.NoError(t, err) assert.Equal(t, expOut, out) } + +func TestCreateJSON(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + time.Local = time.UTC + + cmd := CreateCmd.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + response, err := testutil.MockResponse(&schema.SSHKeyCreateResponse{ + SSHKey: schema.SSHKey{ + ID: 123, + Name: "test", + PublicKey: "test", + Created: time.Date(2016, 1, 30, 23, 50, 0, 0, time.UTC), + Labels: make(map[string]string), + Fingerprint: "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", + }, + }) + + if err != nil { + t.Fatal(err) + } + + fx.Client.SSHKeyClient.EXPECT(). + Create(gomock.Any(), hcloud.SSHKeyCreateOpts{ + Name: "test", + PublicKey: "test", + Labels: make(map[string]string), + }). + Return(&hcloud.SSHKey{ + ID: 123, + Name: "test", + PublicKey: "test", + }, response, nil) + + jsonOut, out, err := fx.Run(cmd, []string{"-o=json", "--name", "test", "--public-key", "test"}) + + expOut := "SSH key 123 created\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) + assert.JSONEq(t, createResponseJson, jsonOut) +} diff --git a/internal/cmd/sshkey/testdata/create_response.json b/internal/cmd/sshkey/testdata/create_response.json new file mode 100644 index 00000000..334c32ff --- /dev/null +++ b/internal/cmd/sshkey/testdata/create_response.json @@ -0,0 +1,10 @@ +{ + "ssh_key": { + "created": "2016-01-30T23:50:00Z", + "fingerprint": "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00", + "id": 123, + "labels": {}, + "name": "test", + "public_key": "test" + } +} \ No newline at end of file diff --git a/internal/cmd/volume/create.go b/internal/cmd/volume/create.go index 5c9e50f3..473a03f5 100644 --- a/internal/cmd/volume/create.go +++ b/internal/cmd/volume/create.go @@ -14,7 +14,7 @@ import ( "github.com/hetznercloud/hcloud-go/v2/hcloud" ) -var CreateCmd = base.Cmd{ +var CreateCmd = base.CreateCmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create FLAGS", @@ -47,7 +47,7 @@ var CreateCmd = base.Cmd{ return cmd }, - Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) error { + Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) (*hcloud.Response, any, error) { name, _ := cmd.Flags().GetString("name") serverIDOrName, _ := cmd.Flags().GetString("server") size, _ := cmd.Flags().GetInt("size") @@ -59,7 +59,7 @@ var CreateCmd = base.Cmd{ protectionOpts, err := getChangeProtectionOpts(true, protection) if err != nil { - return err + return nil, nil, err } createOpts := hcloud.VolumeCreateOpts{ @@ -79,10 +79,10 @@ var CreateCmd = base.Cmd{ if serverIDOrName != "" { server, _, err := client.Server().Get(ctx, serverIDOrName) if err != nil { - return err + return nil, nil, err } if server == nil { - return fmt.Errorf("server not found: %s", serverIDOrName) + return nil, nil, fmt.Errorf("server not found: %s", serverIDOrName) } createOpts.Server = server } @@ -93,19 +93,22 @@ var CreateCmd = base.Cmd{ createOpts.Format = &format } - result, _, err := client.Volume().Create(ctx, createOpts) + result, response, err := client.Volume().Create(ctx, createOpts) if err != nil { - return err + return nil, nil, err } if err := waiter.ActionProgress(ctx, result.Action); err != nil { - return err + return nil, nil, err } if err := waiter.WaitForActions(ctx, result.NextActions); err != nil { - return err + return nil, nil, err } cmd.Printf("Volume %d created\n", result.Volume.ID) - return changeProtection(ctx, client, waiter, cmd, result.Volume, true, protectionOpts) + return response, nil, changeProtection(ctx, client, waiter, cmd, result.Volume, true, protectionOpts) + }, + PrintResource: func(_ context.Context, _ hcapi2.Client, _ *cobra.Command, _ any) { + // no-op }, } diff --git a/internal/cmd/volume/create_test.go b/internal/cmd/volume/create_test.go index 837157c8..49d8cc57 100644 --- a/internal/cmd/volume/create_test.go +++ b/internal/cmd/volume/create_test.go @@ -2,15 +2,21 @@ package volume import ( "context" + _ "embed" "testing" + "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/hcloud-go/v2/hcloud/schema" ) +//go:embed testdata/create_response.json +var createResponseJson string + func TestCreate(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() @@ -52,6 +58,74 @@ func TestCreate(t *testing.T) { assert.Equal(t, expOut, out) } +func TestCreateJSON(t *testing.T) { + fx := testutil.NewFixture(t) + defer fx.Finish() + + time.Local = time.UTC + + cmd := CreateCmd.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + response, err := testutil.MockResponse(&schema.VolumeCreateResponse{ + Volume: schema.Volume{ + ID: 123, + Name: "test", + Size: 20, + Location: schema.Location{Name: "fsn1"}, + Labels: make(map[string]string), + Created: time.Date(2016, 1, 30, 23, 50, 0, 0, time.UTC), + Status: string(hcloud.VolumeStatusAvailable), + Protection: schema.VolumeProtection{ + Delete: true, + }, + Server: hcloud.Ptr(int64(123)), + }, + Action: &schema.Action{ + ID: 321, + }, + NextActions: make([]schema.Action, 0), + }) + + if err != nil { + t.Fatal(err) + } + + fx.Client.VolumeClient.EXPECT(). + Create(gomock.Any(), hcloud.VolumeCreateOpts{ + Name: "test", + Size: 20, + Location: &hcloud.Location{Name: "fsn1"}, + Labels: make(map[string]string), + }). + Return(hcloud.VolumeCreateResult{ + Volume: &hcloud.Volume{ + ID: 123, + Name: "test", + Size: 20, + Location: &hcloud.Location{Name: "fsn1"}, + }, + Action: &hcloud.Action{ID: 321}, + NextActions: []*hcloud.Action{{ID: 1}, {ID: 2}, {ID: 3}}, + }, response, nil) + fx.ActionWaiter.EXPECT(). + ActionProgress(gomock.Any(), &hcloud.Action{ID: 321}) + fx.ActionWaiter.EXPECT(). + WaitForActions(gomock.Any(), []*hcloud.Action{{ID: 1}, {ID: 2}, {ID: 3}}) + + jsonOut, out, err := fx.Run(cmd, []string{"-o=json", "--name", "test", "--size", "20", "--location", "fsn1"}) + + expOut := "Volume 123 created\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) + assert.JSONEq(t, createResponseJson, jsonOut) +} + func TestCreateProtection(t *testing.T) { fx := testutil.NewFixture(t) defer fx.Finish() diff --git a/internal/cmd/volume/testdata/create_response.json b/internal/cmd/volume/testdata/create_response.json new file mode 100644 index 00000000..d8b11854 --- /dev/null +++ b/internal/cmd/volume/testdata/create_response.json @@ -0,0 +1,25 @@ +{ + "volume": { + "created": "2016-01-30T23:50:00Z", + "id": 123, + "labels": {}, + "linux_device": "", + "location": { + "city": "", + "country": "", + "description": "", + "id": 0, + "latitude": 0, + "longitude": 0, + "name": "fsn1", + "network_zone": "" + }, + "name": "test", + "protection": { + "delete": true + }, + "server": 123, + "size": 20, + "status": "available" + } +} \ No newline at end of file diff --git a/internal/state/helpers.go b/internal/state/helpers.go index cbbf81bc..6c90c4a3 100644 --- a/internal/state/helpers.go +++ b/internal/state/helpers.go @@ -132,7 +132,8 @@ func DisplayProgressCircle(errCh <-chan error, waitingFor string) error { ) if StdoutIsTerminal() { - fmt.Println(waitingFor) + _, _ = fmt.Fprintln(os.Stderr, waitingFor) + progress := pb.New(1) // total progress of 1 will do since we use a circle here progress.SetTemplateString(progressCircleTpl) progress.Start() @@ -144,13 +145,13 @@ func DisplayProgressCircle(errCh <-chan error, waitingFor string) error { } progress.SetTemplateString(ellipsis + done) } else { - fmt.Print(waitingFor + ellipsis) + _, _ = fmt.Fprint(os.Stderr, waitingFor+ellipsis) if err := <-errCh; err != nil { - fmt.Println(failed) + _, _ = fmt.Fprintln(os.Stderr, failed) return err } - fmt.Println(done) + _, _ = fmt.Fprintln(os.Stderr, done) } return nil } diff --git a/internal/testutil/testing.go b/internal/testutil/testing.go index 5caa143f..1d7d4ffd 100644 --- a/internal/testutil/testing.go +++ b/internal/testutil/testing.go @@ -2,9 +2,13 @@ package testutil import ( "bytes" + "encoding/json" "fmt" "io" + "net/http/httptest" "os" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" ) // CaptureOutStreams redirects stdout & stderr while running fn and returns the outputs as a string. @@ -62,3 +66,24 @@ func CaptureOutStreams(fn func() error) (string, string, error) { return outBuf.String(), errBuf.String(), err } + +// MockResponse returns a *hcloud.Response with the given value as JSON body. +func MockResponse[V any](v V) (*hcloud.Response, error) { + + responseBytes, err := json.MarshalIndent(v, "", " ") + if err != nil { + return nil, err + } + + responseRecorder := httptest.NewRecorder() + responseRecorder.WriteHeader(200) + + _, err = responseRecorder.Write(responseBytes) + if err != nil { + return nil, err + } + + return &hcloud.Response{ + Response: responseRecorder.Result(), + }, nil +}