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(account): support API token operations #363

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.22

require (
github.com/UpCloudLtd/progress v1.0.2
github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.0
github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.1-0.20250130171841-f32ab8d1a73c
github.com/adrg/xdg v0.3.2
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
github.com/gemalto/flume v0.12.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/UpCloudLtd/progress v1.0.2 h1:CTr1bBuFuXop9TEhR1PakbUMPTlUVL7Bgae9JgqXwPg=
github.com/UpCloudLtd/progress v1.0.2/go.mod h1:iGxOnb9HvHW0yrLGUjHr0lxHhn7TehgWwh7a8NqK6iQ=
github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.0 h1:bJozr/MtrSl4P3ynq4Nkr8kGPQfPAGpGJ7/S/iVI1cc=
github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.0/go.mod h1:bFnrOkfsDDmsb94nnBV5eSQjjsfDnwAzLnCt9+b4t/4=
github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.1-0.20250130171841-f32ab8d1a73c h1:X88TqqpSTbJno235xQ8lKyj+6nLeaHVLYw85nRXh/AM=
github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.1-0.20250130171841-f32ab8d1a73c/go.mod h1:bFnrOkfsDDmsb94nnBV5eSQjjsfDnwAzLnCt9+b4t/4=
github.com/adrg/xdg v0.3.2 h1:GUSGQ5pHdev83AYhDSS1A/CX+0JIsxbiWtow2DSA+RU=
github.com/adrg/xdg v0.3.2/go.mod h1:7I2hH/IT30IsupOpKZ5ue7/qNi3CoKzD6tL3HwpaRMQ=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
Expand Down
8 changes: 8 additions & 0 deletions internal/commands/all/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/servergroup"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/storage"
storagebackup "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/storage/backup"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/tokens"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/zone"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/config"

Expand Down Expand Up @@ -122,6 +123,13 @@ func BuildCommands(rootCmd *cobra.Command, conf *config.Config) {
permissionsCommand := commands.BuildCommand(permissions.BasePermissionsCommand(), accountCommand.Cobra(), conf)
commands.BuildCommand(permissions.ListCommand(), permissionsCommand.Cobra(), conf)

// Token
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These could be under account.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was on the verge with this, so up to you. If we want to have the tokens hierarchically under accounts like they are in the API (makes sense), we should have tokens under account as a subcommand, i.e. upctl account token create ....

On the other hand, it just feels better as a top level command/resource, i.e. upctl token create ....

In either case, we should follow the same hierarchy in code (style, packaging...).

tokenCommand := commands.BuildCommand(tokens.BaseTokensCommand(), rootCmd, conf)
commands.BuildCommand(tokens.CreateCommand(), tokenCommand.Cobra(), conf)
commands.BuildCommand(tokens.ListCommand(), tokenCommand.Cobra(), conf)
commands.BuildCommand(tokens.ShowCommand(), tokenCommand.Cobra(), conf)
commands.BuildCommand(tokens.DeleteCommand(), tokenCommand.Cobra(), conf)

// Zone
zoneCommand := commands.BuildCommand(zone.BaseZoneCommand(), rootCmd, conf)
commands.BuildCommand(zone.ListCommand(), zoneCommand.Cobra(), conf)
Expand Down
5 changes: 5 additions & 0 deletions internal/commands/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Executor interface {
Firewall() service.Firewall
IPAddress() service.IPAddress
Account() service.Account
Token() service.Token
All() internal.AllServices
Debug(msg string, args ...interface{})
WithLogger(args ...interface{}) Executor
Expand Down Expand Up @@ -135,6 +136,10 @@ func (e executorImpl) Account() service.Account {
return e.service
}

func (e executorImpl) Token() service.Token {
return e.service
}

func (e executorImpl) All() internal.AllServices {
return e.service
}
Expand Down
125 changes: 125 additions & 0 deletions internal/commands/tokens/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package tokens

import (
"fmt"
"time"

"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/ui"
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
"github.com/spf13/pflag"
)

// CreateCommand creates the "token create" command
func CreateCommand() commands.Command {
return &createCommand{
BaseCommand: commands.New(
"create",
"Create an API token",
`upctl token create --name test --expires-in 1h`,
`upctl token create --name test --expires-in 1h --allowed-ip-ranges "0.0.0.0/0" --allowed-ip-ranges "::/0"`,
),
}
}

var defaultCreateParams = &createParams{
CreateTokenRequest: request.CreateTokenRequest{},
name: "",
expiresIn: 0,
allowedIPRanges: []string{}, // TODO: should we default to empty or "0.0.0.0/0", "::/0"?
canCreateTokens: false,
}

func newCreateParams() createParams {
return createParams{
CreateTokenRequest: request.CreateTokenRequest{},
}
}

type createParams struct {
request.CreateTokenRequest
name string
expiresIn time.Duration
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could use support for d unit, e.g. 90d or 365d could be common values defined by user 🤔

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely should use as hours is not convenient. I suppose one could do it with some custom parser, but the current approach uses flags.DurationVarP which uses time.ParseDuration and only supports up to hours.

expiresAt string
canCreateTokens bool
allowedIPRanges []string
}

func (s *createParams) processParams() error {
if s.expiresIn == 0 && s.expiresAt == "" {
return fmt.Errorf("either expires-in or expires-at must be set")
}
if s.expiresAt != "" {
var err error
s.ExpiresAt, err = time.Parse(time.RFC3339, s.expiresAt)
if err != nil {
return fmt.Errorf("invalid expires-at: %w", err)
}
} else {
s.ExpiresAt = time.Now().Add(s.expiresIn)
}
s.Name = s.name
s.CanCreateSubTokens = s.canCreateTokens
s.AllowedIPRanges = s.allowedIPRanges
return nil
}

type createCommand struct {
*commands.BaseCommand
params createParams
flagSet *pflag.FlagSet
}

func applyCreateFlags(fs *pflag.FlagSet, dst, def *createParams) {
fs.StringVar(&dst.name, "name", def.name, "Name for the token.")
fs.StringVar(&dst.expiresAt, "expires-at", def.expiresAt, "Exact time when the token expires in RFC3339 format. e.g. 2025-01-01T00:00:00Z")
fs.DurationVar(&dst.expiresIn, "expires-in", def.expiresIn, "Duration until the token expires. e.g. 24h")
fs.BoolVar(&dst.canCreateTokens, "can-create-tokens", def.canCreateTokens, "Allow token to be used to create further tokens.")
fs.StringArrayVar(&dst.allowedIPRanges, "allowed-ip-ranges", def.allowedIPRanges, "Allowed IP ranges for the token.")
Copy link
Contributor Author

@kangasta kangasta Jan 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs more documentation as token with empty list is invalid(?). Maybe this could be made required as well 🤔

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is technically not invalid, it just can't be used from anywhere to access API. We deliberately allow creating such tokens if one would like to use them as canary tokens. We could give a warning that the token will not be able to access any API though.


commands.Must(fs.SetAnnotation("name", commands.FlagAnnotationNoFileCompletions, nil))
commands.Must(fs.SetAnnotation("expires-at", commands.FlagAnnotationNoFileCompletions, nil))
commands.Must(fs.SetAnnotation("expires-in", commands.FlagAnnotationNoFileCompletions, nil))
commands.Must(fs.SetAnnotation("allowed-ip-ranges", commands.FlagAnnotationNoFileCompletions, nil))
}

// InitCommand implements Command.InitCommand
func (s *createCommand) InitCommand() {
s.flagSet = &pflag.FlagSet{}
s.params = newCreateParams()
applyCreateFlags(s.flagSet, &s.params, defaultCreateParams)

s.AddFlags(s.flagSet)
commands.Must(s.Cobra().MarkFlagRequired("name"))
}

// ExecuteWithoutArguments implements commands.NoArgumentCommand
func (s *createCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) {
svc := exec.Token()

if err := s.params.processParams(); err != nil {
return nil, err
}

msg := fmt.Sprintf("Creating token %s", s.params.Name)
exec.PushProgressStarted(msg)

res, err := svc.CreateToken(exec.Context(), &s.params.CreateTokenRequest)
if err != nil {
return commands.HandleError(exec, msg, err)
}

exec.PushProgressSuccess(msg)

return output.MarshaledWithHumanDetails{Value: res, Details: []output.DetailRow{
{Title: "API Token", Value: res.APIToken, Colour: ui.DefaultNoteColours},
{Title: "Name", Value: res.Name},
{Title: "ID", Value: res.ID, Colour: ui.DefaultUUUIDColours},
{Title: "Type", Value: res.Type},
{Title: "Created At", Value: res.CreatedAt.Format(time.RFC3339)},
{Title: "Expires At", Value: res.ExpiresAt.Format(time.RFC3339)},
{Title: "Allowed IP Ranges", Value: res.AllowedIPRanges},
{Title: "Can Create Sub Tokens", Value: res.CanCreateSubTokens},
}}, nil
}
115 changes: 115 additions & 0 deletions internal/commands/tokens/create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package tokens

import (
"testing"
"time"

"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/config"
smock "github.com/UpCloudLtd/upcloud-cli/v3/internal/mock"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/mockexecute"

"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud"
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

func TestCreateToken(t *testing.T) {
created := time.Now()

for _, test := range []struct {
name string
resp *upcloud.Token
args []string
req request.CreateTokenRequest
errFn assert.ErrorAssertionFunc
}{
{
name: "defaults",
args: []string{
"--name", "test",
"--expires-in", "1h",
},
req: request.CreateTokenRequest{
Name: "test",
ExpiresAt: created.Add(1 * time.Hour),
CanCreateSubTokens: false,
AllowedIPRanges: nil,
},
resp: &upcloud.Token{
APIToken: "ucat_01JH5D3ZZJVZS6JC713FA11CB8",
ID: "0cd8eab4-ecb7-445b-a457-6019b0a00496",
Name: "test",
Type: "workspace",
CreatedAt: created,
ExpiresAt: created.Add(1 * time.Hour),
LastUsed: nil,
CanCreateSubTokens: false,
AllowedIPRanges: []string{"0.0.0.0/0", "::/0"},
},
errFn: assert.NoError,
},
{
name: "missing name",
args: []string{
"--expires-in", "1h",
},
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool {
return assert.ErrorContains(t, err, `required flag(s) "name" not set`)
},
},
{
name: "invalid expires-in",
args: []string{
"--name", "test",
"--expires-in", "seppo",
},
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool {
return assert.ErrorContains(t, err, `invalid argument "seppo" for "--expires-in"`)
},
},
{
name: "invalid expires-at",
args: []string{
"--name", "test",
"--expires-at", "seppo",
},
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool {
return assert.ErrorContains(t, err, `invalid expires-at: `)
},
},
{
name: "missing expiry",
args: []string{
"--name", "test",
},
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool {
return assert.ErrorContains(t, err, `either expires-in or expires-at must be set`)
},
},
} {
t.Run(test.name, func(t *testing.T) {
conf := config.New()
testCmd := CreateCommand()
mService := new(smock.Service)

if test.resp != nil {
mService.On("CreateToken", mock.MatchedBy(func(req *request.CreateTokenRequest) bool {
// service uses time.Now() with "expires-in" added to it to set ExpiresAt, so we can't set a mock to any
// static value. Instead, we'll just check that the request has the correct name and that the ExpiresAt
// is within 1 second of "now".
return assert.Equal(t, test.req.Name, req.Name) && assert.InDelta(t, test.req.ExpiresAt.UnixMilli(), req.ExpiresAt.UnixMilli(), 1000)
})).Once().Return(test.resp, nil)
}

c := commands.BuildCommand(testCmd, nil, conf)
c.Cobra().SetArgs(test.args)
_, err := mockexecute.MockExecute(c, mService, conf)

if test.errFn(t, err) {
mService.AssertExpectations(t)
}
})
}
}
47 changes: 47 additions & 0 deletions internal/commands/tokens/delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package tokens

import (
"fmt"

"github.com/UpCloudLtd/upcloud-cli/v3/internal/completion"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/resolver"

"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
)

// DeleteCommand creates the "token delete" command
func DeleteCommand() commands.Command {
return &deleteCommand{
BaseCommand: commands.New(
"delete",
"Delete an API token",
"upctl token delete 0c0e2abf-cd89-490b-abdb-d06db6e8d816",
),
}
}

type deleteCommand struct {
*commands.BaseCommand
resolver.CachingToken
completion.Token
}

// Execute implements commands.MultipleArgumentCommand
func (c *deleteCommand) Execute(exec commands.Executor, arg string) (output.Output, error) {
svc := exec.Token()
msg := fmt.Sprintf("Deleting API token %v", arg)
exec.PushProgressStarted(msg)

err := svc.DeleteToken(exec.Context(), &request.DeleteTokenRequest{
ID: arg,
})
if err != nil {
return commands.HandleError(exec, msg, err)
}

exec.PushProgressSuccess(msg)

return output.None{}, nil
}
26 changes: 26 additions & 0 deletions internal/commands/tokens/delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package tokens

import (
"testing"

"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
"github.com/UpCloudLtd/upcloud-cli/v3/internal/config"
smock "github.com/UpCloudLtd/upcloud-cli/v3/internal/mock"
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
"github.com/gemalto/flume"
"github.com/stretchr/testify/assert"
)

func TestDeleteCommand(t *testing.T) {
svc := &smock.Service{}
conf := config.New()
dr := &request.DeleteTokenRequest{ID: "0cdabbf9-090b-4fc5-a6ae-3f76801ed171"}

svc.On("DeleteToken", dr).Once().Return(nil)

command := commands.BuildCommand(DeleteCommand(), nil, conf)
_, err := command.(commands.MultipleArgumentCommand).Execute(commands.NewExecutor(conf, svc, flume.New("test")), dr.ID)
assert.NoError(t, err)

svc.AssertExpectations(t)
}
Loading
Loading