Skip to content

Commit 8af31fe

Browse files
kangastariksascop
authored
feat(account): support API token operations (#363)
Co-authored-by: riku salkia <riku.salkia@upcloud.com> Co-authored-by: Ville Skyttä <ville.skytta@upcloud.com>
1 parent b6e9f86 commit 8af31fe

File tree

20 files changed

+804
-32
lines changed

20 files changed

+804
-32
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Added support for Valkey properties
1313
- Add termination_protection to upctl database show output
1414
- Experimental support for token authentication by defining token in `UPCLOUD_TOKEN` environment variable.
15+
- Experimental support for managing tokens with `account token` commands.
1516

1617
### Changed
1718

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package token
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
8+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
9+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/ui"
10+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
11+
"github.com/spf13/pflag"
12+
)
13+
14+
// CreateCommand creates the "token create" command
15+
func CreateCommand() commands.Command {
16+
return &createCommand{
17+
BaseCommand: commands.New(
18+
"create",
19+
"Create an API token",
20+
`upctl account token create --name test --expires-in 1h`,
21+
`upctl account token create --name test --expires-in 1h --allow-ip-range "0.0.0.0/0" --allow-ip-range "::/0"`,
22+
),
23+
}
24+
}
25+
26+
var defaultCreateParams = &createParams{
27+
CreateTokenRequest: request.CreateTokenRequest{},
28+
name: "",
29+
expiresIn: 0,
30+
allowedIPRanges: []string{}, // TODO: should we default to empty or "0.0.0.0/0", "::/0"?
31+
canCreateTokens: false,
32+
}
33+
34+
func newCreateParams() createParams {
35+
return createParams{
36+
CreateTokenRequest: request.CreateTokenRequest{},
37+
}
38+
}
39+
40+
type createParams struct {
41+
request.CreateTokenRequest
42+
name string
43+
expiresIn time.Duration
44+
expiresAt string
45+
canCreateTokens bool
46+
allowedIPRanges []string
47+
}
48+
49+
func (s *createParams) processParams() error {
50+
if s.expiresIn == 0 && s.expiresAt == "" {
51+
return fmt.Errorf("either expires-in or expires-at must be set")
52+
}
53+
if s.expiresAt != "" {
54+
var err error
55+
s.ExpiresAt, err = time.Parse(time.RFC3339, s.expiresAt)
56+
if err != nil {
57+
return fmt.Errorf("invalid expires-at: %w", err)
58+
}
59+
} else {
60+
s.ExpiresAt = time.Now().Add(s.expiresIn)
61+
}
62+
s.Name = s.name
63+
s.CanCreateSubTokens = s.canCreateTokens
64+
s.AllowedIPRanges = s.allowedIPRanges
65+
return nil
66+
}
67+
68+
type createCommand struct {
69+
*commands.BaseCommand
70+
params createParams
71+
flagSet *pflag.FlagSet
72+
}
73+
74+
func applyCreateFlags(fs *pflag.FlagSet, dst, def *createParams) {
75+
fs.StringVar(&dst.name, "name", def.name, "Name for the token.")
76+
fs.StringVar(&dst.expiresAt, "expires-at", def.expiresAt, "Exact time when the token expires in RFC3339 format. e.g. 2025-01-01T00:00:00Z")
77+
fs.DurationVar(&dst.expiresIn, "expires-in", def.expiresIn, "Duration until the token expires. e.g. 24h")
78+
fs.BoolVar(&dst.canCreateTokens, "can-create-tokens", def.canCreateTokens, "Allow token to be used to create further tokens.")
79+
fs.StringArrayVar(&dst.allowedIPRanges, "allow-ip-range", def.allowedIPRanges, "Allowed IP ranges for the token. If not defined, the token can not be used from any IP. To allow access from all IPs, use `0.0.0.0/0` as the value.")
80+
81+
commands.Must(fs.SetAnnotation("name", commands.FlagAnnotationNoFileCompletions, nil))
82+
commands.Must(fs.SetAnnotation("expires-at", commands.FlagAnnotationNoFileCompletions, nil))
83+
commands.Must(fs.SetAnnotation("expires-in", commands.FlagAnnotationNoFileCompletions, nil))
84+
commands.Must(fs.SetAnnotation("allow-ip-range", commands.FlagAnnotationNoFileCompletions, nil))
85+
}
86+
87+
// InitCommand implements Command.InitCommand
88+
func (s *createCommand) InitCommand() {
89+
s.flagSet = &pflag.FlagSet{}
90+
s.params = newCreateParams()
91+
applyCreateFlags(s.flagSet, &s.params, defaultCreateParams)
92+
93+
s.AddFlags(s.flagSet)
94+
commands.Must(s.Cobra().MarkFlagRequired("name"))
95+
}
96+
97+
// ExecuteWithoutArguments implements commands.NoArgumentCommand
98+
func (s *createCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) {
99+
svc := exec.Token()
100+
101+
if err := s.params.processParams(); err != nil {
102+
return nil, err
103+
}
104+
105+
msg := fmt.Sprintf("Creating token %s", s.params.Name)
106+
exec.PushProgressStarted(msg)
107+
108+
res, err := svc.CreateToken(exec.Context(), &s.params.CreateTokenRequest)
109+
if err != nil {
110+
return commands.HandleError(exec, msg, err)
111+
}
112+
113+
exec.PushProgressSuccess(msg)
114+
115+
return output.MarshaledWithHumanDetails{Value: res, Details: []output.DetailRow{
116+
{Title: "API Token", Value: res.APIToken, Colour: ui.DefaultNoteColours},
117+
{Title: "Name", Value: res.Name},
118+
{Title: "ID", Value: res.ID, Colour: ui.DefaultUUUIDColours},
119+
{Title: "Type", Value: res.Type},
120+
{Title: "Created At", Value: res.CreatedAt.Format(time.RFC3339)},
121+
{Title: "Expires At", Value: res.ExpiresAt.Format(time.RFC3339)},
122+
{Title: "Allowed IP Ranges", Value: res.AllowedIPRanges},
123+
{Title: "Can Create Sub Tokens", Value: res.CanCreateSubTokens},
124+
}}, nil
125+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package token
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
8+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/config"
9+
smock "github.com/UpCloudLtd/upcloud-cli/v3/internal/mock"
10+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/mockexecute"
11+
12+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud"
13+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/mock"
16+
)
17+
18+
func TestCreateToken(t *testing.T) {
19+
created := time.Now()
20+
21+
for _, test := range []struct {
22+
name string
23+
resp *upcloud.Token
24+
args []string
25+
req request.CreateTokenRequest
26+
errFn assert.ErrorAssertionFunc
27+
}{
28+
{
29+
name: "defaults",
30+
args: []string{
31+
"--name", "test",
32+
"--expires-in", "1h",
33+
},
34+
req: request.CreateTokenRequest{
35+
Name: "test",
36+
ExpiresAt: created.Add(1 * time.Hour),
37+
CanCreateSubTokens: false,
38+
AllowedIPRanges: nil,
39+
},
40+
resp: &upcloud.Token{
41+
APIToken: "ucat_01JH5D3ZZJVZS6JC713FA11CB8",
42+
ID: "0cd8eab4-ecb7-445b-a457-6019b0a00496",
43+
Name: "test",
44+
Type: "workspace",
45+
CreatedAt: created,
46+
ExpiresAt: created.Add(1 * time.Hour),
47+
LastUsed: nil,
48+
CanCreateSubTokens: false,
49+
AllowedIPRanges: []string{"0.0.0.0/0", "::/0"},
50+
},
51+
errFn: assert.NoError,
52+
},
53+
{
54+
name: "missing name",
55+
args: []string{
56+
"--expires-in", "1h",
57+
},
58+
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool {
59+
return assert.ErrorContains(t, err, `required flag(s) "name" not set`)
60+
},
61+
},
62+
{
63+
name: "invalid expires-in",
64+
args: []string{
65+
"--name", "test",
66+
"--expires-in", "seppo",
67+
},
68+
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool {
69+
return assert.ErrorContains(t, err, `invalid argument "seppo" for "--expires-in"`)
70+
},
71+
},
72+
{
73+
name: "invalid expires-at",
74+
args: []string{
75+
"--name", "test",
76+
"--expires-at", "seppo",
77+
},
78+
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool {
79+
return assert.ErrorContains(t, err, `invalid expires-at: `)
80+
},
81+
},
82+
{
83+
name: "missing expiry",
84+
args: []string{
85+
"--name", "test",
86+
},
87+
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool {
88+
return assert.ErrorContains(t, err, `either expires-in or expires-at must be set`)
89+
},
90+
},
91+
} {
92+
t.Run(test.name, func(t *testing.T) {
93+
conf := config.New()
94+
testCmd := CreateCommand()
95+
mService := new(smock.Service)
96+
97+
if test.resp != nil {
98+
mService.On("CreateToken", mock.MatchedBy(func(req *request.CreateTokenRequest) bool {
99+
// service uses time.Now() with "expires-in" added to it to set ExpiresAt, so we can't set a mock to any
100+
// static value. Instead, we'll just check that the request has the correct name and that the ExpiresAt
101+
// is within 1 second of "now".
102+
return assert.Equal(t, test.req.Name, req.Name) && assert.InDelta(t, test.req.ExpiresAt.UnixMilli(), req.ExpiresAt.UnixMilli(), 1000)
103+
})).Once().Return(test.resp, nil)
104+
}
105+
106+
c := commands.BuildCommand(testCmd, nil, conf)
107+
c.Cobra().SetArgs(test.args)
108+
_, err := mockexecute.MockExecute(c, mService, conf)
109+
110+
if test.errFn(t, err) {
111+
mService.AssertExpectations(t)
112+
}
113+
})
114+
}
115+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package token
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/completion"
7+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/resolver"
8+
9+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
10+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
11+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
12+
)
13+
14+
// DeleteCommand creates the "token delete" command
15+
func DeleteCommand() commands.Command {
16+
return &deleteCommand{
17+
BaseCommand: commands.New(
18+
"delete",
19+
"Delete an API token",
20+
"upctl account token delete 0c0e2abf-cd89-490b-abdb-d06db6e8d816",
21+
),
22+
}
23+
}
24+
25+
type deleteCommand struct {
26+
*commands.BaseCommand
27+
resolver.CachingToken
28+
completion.Token
29+
}
30+
31+
// Execute implements commands.MultipleArgumentCommand
32+
func (c *deleteCommand) Execute(exec commands.Executor, arg string) (output.Output, error) {
33+
svc := exec.Token()
34+
msg := fmt.Sprintf("Deleting API token %v", arg)
35+
exec.PushProgressStarted(msg)
36+
37+
err := svc.DeleteToken(exec.Context(), &request.DeleteTokenRequest{
38+
ID: arg,
39+
})
40+
if err != nil {
41+
return commands.HandleError(exec, msg, err)
42+
}
43+
44+
exec.PushProgressSuccess(msg)
45+
46+
return output.None{}, nil
47+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package token
2+
3+
import (
4+
"testing"
5+
6+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
7+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/config"
8+
smock "github.com/UpCloudLtd/upcloud-cli/v3/internal/mock"
9+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
10+
"github.com/gemalto/flume"
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
func TestDeleteCommand(t *testing.T) {
15+
svc := &smock.Service{}
16+
conf := config.New()
17+
dr := &request.DeleteTokenRequest{ID: "0cdabbf9-090b-4fc5-a6ae-3f76801ed171"}
18+
19+
svc.On("DeleteToken", dr).Once().Return(nil)
20+
21+
command := commands.BuildCommand(DeleteCommand(), nil, conf)
22+
_, err := command.(commands.MultipleArgumentCommand).Execute(commands.NewExecutor(conf, svc, flume.New("test")), dr.ID)
23+
assert.NoError(t, err)
24+
25+
svc.AssertExpectations(t)
26+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package token
2+
3+
import (
4+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
5+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
6+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/paging"
7+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/ui"
8+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
9+
10+
"github.com/spf13/pflag"
11+
)
12+
13+
// ListCommand creates the 'token list' command
14+
func ListCommand() commands.Command {
15+
return &listCommand{
16+
BaseCommand: commands.New("list", "List API tokens", "upctl account token list"),
17+
}
18+
}
19+
20+
func (l *listCommand) InitCommand() {
21+
fs := &pflag.FlagSet{}
22+
l.ConfigureFlags(fs)
23+
l.AddFlags(fs)
24+
}
25+
26+
type listCommand struct {
27+
*commands.BaseCommand
28+
paging.PageParameters
29+
}
30+
31+
func (l *listCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) {
32+
svc := exec.All()
33+
tokens, err := svc.GetTokens(exec.Context(), &request.GetTokensRequest{
34+
Page: l.Page(),
35+
})
36+
if err != nil {
37+
return nil, err
38+
}
39+
40+
var rows []output.TableRow
41+
for _, token := range *tokens {
42+
rows = append(rows, output.TableRow{
43+
token.Name,
44+
token.ID,
45+
token.Type,
46+
token.LastUsed,
47+
token.ExpiresAt,
48+
})
49+
}
50+
return output.MarshaledWithHumanOutput{
51+
Value: tokens,
52+
Output: output.Table{
53+
Columns: []output.TableColumn{
54+
{Key: "id", Header: "UUID", Colour: ui.DefaultUUUIDColours},
55+
{Key: "name", Header: "Name"},
56+
{Key: "type", Header: "Type"},
57+
{Key: "last_used", Header: "Last Used"},
58+
{Key: "expires_at", Header: "Expires At"},
59+
},
60+
Rows: rows,
61+
},
62+
}, nil
63+
}

0 commit comments

Comments
 (0)