Skip to content

Commit

Permalink
feat: allow JSON output on resource creation (#609)
Browse files Browse the repository at this point in the history
This PR adds the possibility to use the `-o=json` flag on resource
creation and also adds the corresponding tests.

Closes #470
  • Loading branch information
phm07 committed Nov 14, 2023
1 parent ca4706f commit d7241fe
Show file tree
Hide file tree
Showing 36 changed files with 1,431 additions and 116 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions internal/cmd/base/create.go
Original file line number Diff line number Diff line change
@@ -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
}
57 changes: 31 additions & 26 deletions internal/cmd/certificate/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand All @@ -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
Expand All @@ -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{
Expand All @@ -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
Expand All @@ -109,26 +113,27 @@ 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{
Name: name,
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
}
129 changes: 129 additions & 0 deletions internal/cmd/certificate/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}

0 comments on commit d7241fe

Please sign in to comment.