Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow JSON output on resource creation #609

Merged
merged 3 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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())
phm07 marked this conversation as resolved.
Show resolved Hide resolved

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")
phm07 marked this conversation as resolved.
Show resolved Hide resolved
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)
}
Loading
Loading