diff --git a/commands/displayers/access_keys.go b/commands/displayers/access_keys.go new file mode 100644 index 000000000..d9ad31f37 --- /dev/null +++ b/commands/displayers/access_keys.go @@ -0,0 +1,99 @@ +/* +Copyright 2018 The Doctl Authors All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package displayers + +import ( + "io" + + "github.com/digitalocean/doctl/do" +) + +type AccessKeys struct { + AccessKeys []do.AccessKey + ShowFullSecret bool // When true, shows full secret (for creation), otherwise truncates/hides +} + +var _ Displayable = &AccessKeys{} + +// JSON implements Displayable. +func (ak *AccessKeys) JSON(out io.Writer) error { + return writeJSON(ak.AccessKeys, out) +} + +// Cols implements Displayable. +func (ak *AccessKeys) Cols() []string { + return []string{ + "ID", + "Name", + "Secret", + "CreatedAt", + "ExpiresAt", + } +} + +// ColMap implements Displayable. +func (ak *AccessKeys) ColMap() map[string]string { + return map[string]string{ + "ID": "ID", + "Name": "Name", + "Secret": "Secret", + "CreatedAt": "Created At", + "ExpiresAt": "Expires At", + } +} + +// KV implements Displayable. +func (ak *AccessKeys) KV() []map[string]any { + out := make([]map[string]any, 0, len(ak.AccessKeys)) + + for _, key := range ak.AccessKeys { + // Show full secret during creation, hidden otherwise + secret := "" + if key.Secret != "" && ak.ShowFullSecret { + // During creation: show the full secret + secret = key.Secret + } + // For all other cases (listing, etc.): always show "" + + // Format optional timestamp fields + expiresAt := "" + if key.ExpiresAt != nil { + expiresAt = key.ExpiresAt.Format("2006-01-02 15:04:05 UTC") + } + + // Truncate long IDs for display + displayID := key.ID + if len(displayID) > 12 { + displayID = displayID[:12] + "..." + } + + m := map[string]any{ + "ID": displayID, + "Name": key.Name, + "Secret": secret, + "CreatedAt": key.CreatedAt.Format("2006-01-02 15:04:05 UTC"), + "ExpiresAt": expiresAt, + } + + out = append(out, m) + } + + return out +} + +// ForCreate returns a displayer optimized for showing newly created access keys +// This version shows the full secret since it's only displayed once +func (ak *AccessKeys) ForCreate() *AccessKeys { + return &AccessKeys{AccessKeys: ak.AccessKeys, ShowFullSecret: true} +} diff --git a/commands/keys.go b/commands/keys.go new file mode 100644 index 000000000..955f50ced --- /dev/null +++ b/commands/keys.go @@ -0,0 +1,201 @@ +/* +Copyright 2018 The Doctl Authors All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package commands + +import ( + "context" + "fmt" + + "github.com/digitalocean/doctl" + "github.com/digitalocean/doctl/commands/displayers" + "github.com/digitalocean/doctl/do" + "github.com/spf13/cobra" +) + +// Keys generates the serverless 'keys' subtree for addition to the doctl command +func Keys() *Command { + cmd := &Command{ + Command: &cobra.Command{ + Use: "key", + Short: "Manage access keys for functions namespaces", + Long: `Access keys provide secure authentication for serverless operations without using your main DigitalOcean token. + +These commands allow you to create, list, and delete namespace-specific access keys. +Keys operate on the currently connected namespace by default, but can target any namespace using the --namespace flag.`, + Aliases: []string{"keys"}, + }, + } + + create := CmdBuilder(cmd, RunAccessKeyCreate, "create", "Creates a new access key", + `Creates a new access key for the specified namespace. The secret is displayed only once upon creation. + +Examples: + doctl serverless key create --name "my-laptop-key" + doctl serverless key create --name "ci-cd-key" --namespace fn-abc123`, + Writer) + AddStringFlag(create, "name", "n", "", "name for the access key", requiredOpt()) + AddStringFlag(create, "namespace", "", "", "target namespace (uses connected namespace if not specified)") + + list := CmdBuilder(cmd, RunAccessKeyList, "list", "Lists access keys", + `Lists all access keys for the specified namespace with their metadata. + +Examples: + doctl serverless key list + doctl serverless key list --namespace fn-abc123`, + Writer, aliasOpt("ls"), displayerType(&displayers.AccessKeys{})) + AddStringFlag(list, "namespace", "", "", "target namespace (uses connected namespace if not specified)") + + delete := CmdBuilder(cmd, RunAccessKeyDelete, "delete ", "Deletes an access key", + `Permanently deletes an existing access key. This action cannot be undone. + +Examples: + doctl serverless key delete + doctl serverless key delete --force`, + Writer, aliasOpt("rm")) + AddStringFlag(delete, "namespace", "", "", "target namespace (uses connected namespace if not specified)") + AddBoolFlag(delete, "force", "f", false, "skip confirmation prompt") + + return cmd +} + +// RunAccessKeyCreate handles the access key create command +func RunAccessKeyCreate(c *CmdConfig) error { + name, _ := c.Doit.GetString(c.NS, "name") + namespace, _ := c.Doit.GetString(c.NS, "namespace") + + // Resolve target namespace + targetNamespace, err := resolveTargetNamespace(c, namespace) + if err != nil { + return err + } + + // Create the access key + ss := c.Serverless() + ctx := context.TODO() + + accessKey, err := ss.CreateNamespaceAccessKey(ctx, targetNamespace, name) + if err != nil { + return err + } + + // Display with security warning + fmt.Fprintf(c.Out, "Notice: The secret key for \"%s\" is shown below.\n", name) + fmt.Fprintf(c.Out, "Please save this secret. You will not be able to see it again.\n\n") + + // Display table with full secret (using ForCreate to show complete secret) + displayKeys := &displayers.AccessKeys{AccessKeys: []do.AccessKey{accessKey}} + return c.Display(displayKeys.ForCreate()) +} + +// RunAccessKeyList handles the access key list command +func RunAccessKeyList(c *CmdConfig) error { + if len(c.Args) > 0 { + return doctl.NewTooManyArgsErr(c.NS) + } + namespace, _ := c.Doit.GetString(c.NS, "namespace") + + // Resolve target namespace + targetNamespace, err := resolveTargetNamespace(c, namespace) + if err != nil { + return err + } + + // List access keys + ss := c.Serverless() + ctx := context.TODO() + + keys, err := ss.ListNamespaceAccessKeys(ctx, targetNamespace) + if err != nil { + return err + } + + return c.Display(&displayers.AccessKeys{AccessKeys: keys}) +} + +// RunAccessKeyDelete handles the access key delete command +func RunAccessKeyDelete(c *CmdConfig) error { + err := ensureOneArg(c) + if err != nil { + return err + } + + keyID := c.Args[0] + namespace, _ := c.Doit.GetString(c.NS, "namespace") + force, _ := c.Doit.GetBool(c.NS, "force") + + // Resolve target namespace + targetNamespace, err := resolveTargetNamespace(c, namespace) + if err != nil { + return err + } + + // Confirmation prompt unless --force + if !force { + fmt.Fprintf(c.Out, "Warning: Deleting this key is a permanent action.\n") + if err := AskForConfirm(fmt.Sprintf("delete key %s", keyID)); err != nil { + return err + } + } + + // Delete the key + ss := c.Serverless() + ctx := context.TODO() + + err = ss.DeleteNamespaceAccessKey(ctx, targetNamespace, keyID) + if err != nil { + return err + } + + fmt.Fprintf(c.Out, "Key %s has been deleted.\n", keyID) + return nil +} + +// resolveTargetNamespace determines which namespace to operate on +// If explicitNamespace is provided, use it; otherwise use the currently connected namespace +func resolveTargetNamespace(c *CmdConfig, explicitNamespace string) (string, error) { + ss := c.Serverless() + + if explicitNamespace != "" { + // Match namespace by exact ID or exact label match + ctx := context.TODO() + allNamespaces, err := ss.ListNamespaces(ctx) + if err != nil { + return "", err + } + + // Look for exact match by namespace ID or label + for _, ns := range allNamespaces.Namespaces { + if ns.Namespace == explicitNamespace || ns.Label == explicitNamespace { + return ns.Namespace, nil + } + } + + return "", fmt.Errorf("namespace '%s' not found. Use exact namespace ID or label", explicitNamespace) + } + + // Use connected namespace + if err := ss.CheckServerlessStatus(); err != nil { + return "", err + } + creds, err := ss.ReadCredentials() + if err != nil { + return "", fmt.Errorf("not connected to any namespace. Use --namespace flag or run 'doctl serverless connect' first") + } + + if creds.Namespace == "" { + return "", fmt.Errorf("not connected to any namespace. Use --namespace flag or run 'doctl serverless connect' first") + } + + return creds.Namespace, nil +} diff --git a/commands/keys_test.go b/commands/keys_test.go new file mode 100644 index 000000000..5146d0a79 --- /dev/null +++ b/commands/keys_test.go @@ -0,0 +1,464 @@ +/* +Copyright 2018 The Doctl Authors All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package commands + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/digitalocean/doctl/do" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + testAccessKey = do.AccessKey{ + ID: "dof_v1_abc123def456", + Name: "test-key", + CreatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + ExpiresAt: nil, + Secret: "secret123", // Only present during creation + } + + testAccessKeyWithoutSecret = do.AccessKey{ + ID: "dof_v1_abc123def456", + Name: "test-key", + CreatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + ExpiresAt: nil, + Secret: "", // Empty for list operations + } + + testAccessKeyList = []do.AccessKey{testAccessKeyWithoutSecret} + + testServerlessCredentials = do.ServerlessCredentials{ + Namespace: "fn-test-namespace", + APIHost: "https://test-api.co", + } +) + +func TestKeysCommand(t *testing.T) { + cmd := Keys() + assert.NotNil(t, cmd) + expected := []string{"create", "list", "delete"} + + names := []string{} + for _, c := range cmd.Commands() { + names = append(names, c.Name()) + } + + assert.ElementsMatch(t, expected, names) + + // Test command properties + assert.Equal(t, "key", cmd.Use) + assert.Equal(t, "Manage access keys for functions namespaces", cmd.Short) + assert.Contains(t, cmd.Long, "Access keys provide secure authentication") + assert.Contains(t, cmd.Aliases, "keys") +} + +func TestAccessKeyCreate(t *testing.T) { + tests := []struct { + name string + args []string + flags map[string]any + expectedCalls func(*tcMocks) + expectedError string + }{ + { + name: "create with connected namespace", + flags: map[string]any{ + "name": "my-key", + }, + expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) + tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key").Return(testAccessKey, nil) + }, + }, + { + name: "create with explicit namespace", + flags: map[string]any{ + "name": "my-key", + "namespace": "fn-explicit-namespace", + }, + expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().ListNamespaces(context.TODO()).Return(do.NamespaceListResponse{ + Namespaces: []do.OutputNamespace{{Namespace: "fn-explicit-namespace", Label: "explicit-label"}}, + }, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-explicit-namespace", "my-key").Return(testAccessKey, nil) + }, + }, + { + name: "create without name flag", + flags: map[string]any{ + // name is required, but we'll pass empty string + "name": "", + }, + expectedCalls: func(tm *tcMocks) { + // It will still try to resolve namespace and then call create with empty name + tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) + tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "").Return(do.AccessKey{}, assert.AnError) + }, + expectedError: "assert.AnError", // API will reject empty name + }, + { + name: "create with disconnected namespace", + flags: map[string]any{ + "name": "my-key", + }, + expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().CheckServerlessStatus().Return(do.ErrServerlessNotConnected) + }, + expectedError: "serverless support is installed but not connected to a functions namespace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + if tt.expectedCalls != nil { + tt.expectedCalls(tm) + } + + // Set flags + for key, value := range tt.flags { + config.Doit.Set(config.NS, key, value) + } + + // Set args + config.Args = tt.args + + err := RunAccessKeyCreate(config) + + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + } + }) + }) + } +} + +func TestAccessKeyList(t *testing.T) { + tests := []struct { + name string + args []string + flags map[string]any + expectedCalls func(*tcMocks) + expectedError string + }{ + { + name: "list with connected namespace", + expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) + tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) + tm.serverless.EXPECT().ListNamespaceAccessKeys(context.TODO(), "fn-test-namespace").Return(testAccessKeyList, nil) + }, + }, + { + name: "list with explicit namespace", + flags: map[string]any{ + "namespace": "fn-explicit-namespace", + }, + expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().ListNamespaces(context.TODO()).Return(do.NamespaceListResponse{ + Namespaces: []do.OutputNamespace{{Namespace: "fn-explicit-namespace", Label: "explicit-label"}}, + }, nil) + tm.serverless.EXPECT().ListNamespaceAccessKeys(context.TODO(), "fn-explicit-namespace").Return(testAccessKeyList, nil) + }, + }, + { + name: "list with too many args", + args: []string{"extra-arg"}, + expectedError: "command contains unsupported arguments", + }, + { + name: "list with disconnected namespace", + expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().CheckServerlessStatus().Return(do.ErrServerlessNotConnected) + }, + expectedError: "serverless support is installed but not connected to a functions namespace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + if tt.expectedCalls != nil { + tt.expectedCalls(tm) + } + + // Set flags + for key, value := range tt.flags { + config.Doit.Set(config.NS, key, value) + } + + // Set args + config.Args = tt.args + + err := RunAccessKeyList(config) + + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + } + }) + }) + } +} + +func TestAccessKeyDelete(t *testing.T) { + tests := []struct { + name string + args []string + flags map[string]any + expectedCalls func(*tcMocks) + expectedError string + }{ + { + name: "delete with connected namespace and force", + args: []string{"dof_v1_abc123def456"}, + flags: map[string]any{ + "force": true, + }, + expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) + tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) + tm.serverless.EXPECT().DeleteNamespaceAccessKey(context.TODO(), "fn-test-namespace", "dof_v1_abc123def456").Return(nil) + }, + }, + { + name: "delete with explicit namespace", + args: []string{"dof_v1_abc123def456"}, + flags: map[string]any{ + "namespace": "fn-explicit-namespace", + "force": true, + }, + expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().ListNamespaces(context.TODO()).Return(do.NamespaceListResponse{ + Namespaces: []do.OutputNamespace{{Namespace: "fn-explicit-namespace", Label: "explicit-label"}}, + }, nil) + tm.serverless.EXPECT().DeleteNamespaceAccessKey(context.TODO(), "fn-explicit-namespace", "dof_v1_abc123def456").Return(nil) + }, + }, + { + name: "delete without key ID", + args: []string{}, + expectedError: "command is missing required arguments", + }, + { + name: "delete with too many args", + args: []string{"key1", "key2"}, + expectedError: "command contains unsupported arguments", + }, + { + name: "delete with disconnected namespace", + args: []string{"dof_v1_abc123def456"}, + flags: map[string]any{ + "force": true, + }, + expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().CheckServerlessStatus().Return(do.ErrServerlessNotConnected) + }, + expectedError: "serverless support is installed but not connected to a functions namespace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + if tt.expectedCalls != nil { + tt.expectedCalls(tm) + } + + // Set flags + for key, value := range tt.flags { + config.Doit.Set(config.NS, key, value) + } + + // Set args + config.Args = tt.args + + err := RunAccessKeyDelete(config) + + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + } + }) + }) + } +} + +func TestResolveTargetNamespace(t *testing.T) { + tests := []struct { + name string + explicitNamespace string + namespaceList []do.OutputNamespace + credentialsReturn do.ServerlessCredentials + credentialsError error + statusError error + expectedNamespace string + expectedError string + }{ + { + name: "explicit namespace by ID", + explicitNamespace: "fn-explicit", + namespaceList: []do.OutputNamespace{{Namespace: "fn-explicit", Label: "my-label"}}, + expectedNamespace: "fn-explicit", + }, + { + name: "explicit namespace by label", + explicitNamespace: "example1", + namespaceList: []do.OutputNamespace{{Namespace: "fn-567e4303-277c-4394-a729-69295d71a5df", Label: "example1"}}, + expectedNamespace: "fn-567e4303-277c-4394-a729-69295d71a5df", + }, + { + name: "namespace not found", + explicitNamespace: "nonexistent", + namespaceList: []do.OutputNamespace{{Namespace: "fn-other", Label: "other-label"}}, + expectedError: "namespace 'nonexistent' not found. Use exact namespace ID or label", + }, + { + name: "use connected namespace", + explicitNamespace: "", + credentialsReturn: do.ServerlessCredentials{Namespace: "fn-connected"}, + expectedNamespace: "fn-connected", + }, + { + name: "not connected to serverless", + explicitNamespace: "", + statusError: do.ErrServerlessNotConnected, + expectedError: "serverless support is installed but not connected to a functions namespace", + }, + { + name: "credentials read error", + explicitNamespace: "", + credentialsError: assert.AnError, + expectedError: "not connected to any namespace", + }, + { + name: "empty namespace in credentials", + explicitNamespace: "", + credentialsReturn: do.ServerlessCredentials{Namespace: ""}, + expectedError: "not connected to any namespace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + if tt.explicitNamespace == "" { + if tt.statusError != nil { + tm.serverless.EXPECT().CheckServerlessStatus().Return(tt.statusError) + } else { + tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) + if tt.credentialsError != nil { + tm.serverless.EXPECT().ReadCredentials().Return(do.ServerlessCredentials{}, tt.credentialsError) + } else { + tm.serverless.EXPECT().ReadCredentials().Return(tt.credentialsReturn, nil) + } + } + } else { + // For explicit namespace, we now need to mock ListNamespaces for pattern matching + tm.serverless.EXPECT().ListNamespaces(context.TODO()).Return(do.NamespaceListResponse{ + Namespaces: tt.namespaceList, + }, nil) + } + + namespace, err := resolveTargetNamespace(config, tt.explicitNamespace) + + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedNamespace, namespace) + } + }) + }) + } +} + +func TestAccessKeyListOutput(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + buf := &bytes.Buffer{} + config.Out = buf + + // Test data for output formatting + keys := []do.AccessKey{ + { + ID: "dof_v1_abc123def456ghi789", + Name: "laptop-key", + CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + ExpiresAt: nil, + Secret: "", // Empty for list operations + }, + { + ID: "dof_v1_xyz789abc123def456", + Name: "ci-cd-key", + CreatedAt: time.Date(2023, 2, 15, 9, 30, 0, 0, time.UTC), + ExpiresAt: func() *time.Time { t := time.Date(2024, 2, 15, 9, 30, 0, 0, time.UTC); return &t }(), + Secret: "", // Empty for list operations + }, + } + + tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) + tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) + tm.serverless.EXPECT().ListNamespaceAccessKeys(context.TODO(), "fn-test-namespace").Return(keys, nil) + + err := RunAccessKeyList(config) + + require.NoError(t, err) + + // Test output contains expected elements + output := buf.String() + assert.Contains(t, output, "dof_v1_abc12...") // ID truncated to 12 chars + ... + assert.Contains(t, output, "laptop-key") + assert.Contains(t, output, "dof_v1_xyz78...") // ID truncated to 12 chars + ... + assert.Contains(t, output, "ci-cd-key") + assert.Contains(t, output, "") + assert.Contains(t, output, "2023-01-01 12:00:00 UTC") + assert.Contains(t, output, "2023-02-15 09:30:00 UTC") + assert.Contains(t, output, "2024-02-15 09:30:00 UTC") + }) +} + +func TestAccessKeyDeleteOutput(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + buf := &bytes.Buffer{} + config.Out = buf + + config.Args = []string{"dof_v1_abc123def456"} + config.Doit.Set(config.NS, "force", true) + + expectedOutput := "Key dof_v1_abc123def456 has been deleted.\n" + + tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) + tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) + tm.serverless.EXPECT().DeleteNamespaceAccessKey(context.TODO(), "fn-test-namespace", "dof_v1_abc123def456").Return(nil) + + err := RunAccessKeyDelete(config) + require.NoError(t, err) + assert.Equal(t, expectedOutput, buf.String()) + }) +} diff --git a/commands/serverless.go b/commands/serverless.go index 1290f015b..6ed0e7947 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -40,6 +40,9 @@ var ( // errUndeployTrigPkg is the error returned when both --packages and --triggers are specified on undeploy errUndeployTrigPkg = errors.New("the `--packages` and `--triggers` flags are mutually exclusive") + // accessKeyFormat defines the expected format for serverless access keys + accessKeyFormat = "dof_v1_:" + // languageKeywords maps the backend's runtime category names to keywords accepted as languages // Note: this table has all languages for which we possess samples. Only those with currently // active runtimes will display. @@ -98,6 +101,7 @@ list your namespaces.`, // and hence are unknown to the portal. AddStringFlag(connect, "apihost", "", "", "") AddStringFlag(connect, "auth", "", "", "") + AddStringFlag(connect, "access-key", "", "", "Access key for direct serverless connection") connect.Flags().MarkHidden("apihost") connect.Flags().MarkHidden("auth") @@ -138,6 +142,7 @@ the entire packages are removed.`, Writer) cmd.AddCommand(Functions()) cmd.AddCommand(Namespaces()) cmd.AddCommand(Triggers()) + cmd.AddCommand(Keys()) ServerlessExtras(cmd) return cmd } @@ -230,6 +235,8 @@ func RunServerlessConnect(c *CmdConfig) error { // The presence of 'auth' and 'apihost' flags trumps other parts of the syntax, but both must be present. apihost, _ := c.Doit.GetString(c.NS, "apihost") auth, _ := c.Doit.GetString(c.NS, "auth") + accessKey, _ := c.Doit.GetString(c.NS, "access-key") + if len(apihost) > 0 && len(auth) > 0 { namespace, err := sls.GetNamespaceFromCluster(apihost, auth) if err != nil { @@ -260,9 +267,56 @@ func RunServerlessConnect(c *CmdConfig) error { ctx := context.TODO() + if len(accessKey) > 0 { + // Validate access-key format - support new "dof_v1_" formats + if err := validateAccessKeyFormat(accessKey); err != nil { + return err + } + + // If namespace argument provided, use it directly + if len(c.Args) > 0 { + // Get the specific namespace the user requested + list, err := getMatchingNamespaces(ctx, sls, c.Args[0]) + if err != nil { + return err + } + if len(list) == 0 { + return fmt.Errorf("no namespace found matching '%s'", c.Args[0]) + } + if len(list) > 1 { + return fmt.Errorf("multiple namespaces match '%s', please be more specific", c.Args[0]) + } + + // Use the found namespace with the provided access-key + ns := list[0] + return connectWithAccessKey(sls, ns, accessKey, c.Out) + } else { + // No namespace specified, show menu + list, err := getMatchingNamespaces(ctx, sls, "") + if err != nil { + return err + } + if len(list) == 0 { + return errors.New("you must create a namespace first") + } + + // Let user choose, then connect with access-key + ns := chooseFromList(list, c.Out) + if ns.Namespace == "" { + return nil // User chose to exit + } + return connectWithAccessKey(sls, ns, accessKey, c.Out) + } + } + // If an arg is specified, retrieve the namespaces that match and proceed according to whether there // are 0, 1, or >1 matches. if len(c.Args) > 0 { + // Show deprecation warning for the legacy connection method + fmt.Fprintf(c.Out, "Warning: Connecting to serverless namespaces via DigitalOcean API is deprecated.\n") + fmt.Fprintf(c.Out, "Please use 'doctl serverless connect %s --access-key <%s>' instead.\n", c.Args[0], accessKeyFormat) + fmt.Fprintf(c.Out, "This method will be removed in a future version.\n\n") + list, err := getMatchingNamespaces(ctx, sls, c.Args[0]) if err != nil { return err @@ -272,6 +326,11 @@ func RunServerlessConnect(c *CmdConfig) error { } return connectFromList(ctx, sls, list, c.Out) } + // Show deprecation warning for the legacy connection method + fmt.Fprintf(c.Out, "Warning: Connecting to serverless namespaces via DigitalOcean API is deprecated.\n") + fmt.Fprintf(c.Out, "Please use 'doctl serverless connect --access-key <%s>' instead.\n", accessKeyFormat) + fmt.Fprintf(c.Out, "This method will be removed in a future version.\n\n") + list, err := getMatchingNamespaces(ctx, sls, "") if err != nil { return err @@ -282,6 +341,41 @@ func RunServerlessConnect(c *CmdConfig) error { return connectFromList(ctx, sls, list, c.Out) } +// validateAccessKeyFormat validates that the access key follows the expected format +func validateAccessKeyFormat(accessKey string) error { + // Check for proper dof_v1_ prefix first (most specific check) + if !strings.HasPrefix(accessKey, "dof_v1_") { + return fmt.Errorf("access-key must start with 'dof_v1_' prefix (expected format: %s)", accessKeyFormat) + } + + // Check for required colon separator + if !strings.Contains(accessKey, ":") { + return fmt.Errorf("access-key must contain ':' separator (expected format: %s)", accessKeyFormat) + } + + // Split and validate both parts exist and are non-empty + parts := strings.Split(accessKey, ":") + if len(parts) != 2 { + return fmt.Errorf("access-key must contain exactly one ':' separator (expected format: %s)", accessKeyFormat) + } + + token := parts[0] + secret := parts[1] + + // Validate token part (after dof_v1_ prefix) + tokenPart := strings.TrimPrefix(token, "dof_v1_") + if len(tokenPart) == 0 { + return fmt.Errorf("access-key token part cannot be empty after 'dof_v1_' prefix (expected format: %s)", accessKeyFormat) + } + + // Validate secret part is non-empty + if len(secret) == 0 { + return fmt.Errorf("access-key secret part cannot be empty (expected format: %s)", accessKeyFormat) + } + + return nil +} + // connectFromList connects a namespace based on a non-empty list of namespaces. If the list is // singular that determines the namespace that will be connected. Otherwise, this is determined // via a prompt. @@ -513,3 +607,27 @@ func RunServerlessUndeploy(c *CmdConfig) error { template.Print(`{{success checkmark}} The requested resources have been undeployed.{{nl}}`, nil) return nil } + +func connectWithAccessKey(sls do.ServerlessService, ns do.OutputNamespace, accessKey string, out io.Writer) error { + // Test if the access key works with this namespace's API host + namespace, err := sls.GetNamespaceFromCluster(ns.APIHost, accessKey) + if err != nil { + return fmt.Errorf("failed to connect with provided access-key: %w", err) + } + + // Verify it matches the expected namespace + if namespace != ns.Namespace { + return fmt.Errorf("access-key does not match namespace '%s'", ns.Namespace) + } + + // Save credentials using the provided access-key and namespace's API host + credential := do.ServerlessCredential{Auth: accessKey} + creds := do.ServerlessCredentials{ + APIHost: ns.APIHost, + Namespace: ns.Namespace, + Label: ns.Label, + Credentials: map[string]map[string]do.ServerlessCredential{ns.APIHost: {ns.Namespace: credential}}, + } + + return finishConnecting(sls, creds, out) +} diff --git a/commands/serverless_test.go b/commands/serverless_test.go index 5d6a3aba0..a1c24efd8 100644 --- a/commands/serverless_test.go +++ b/commands/serverless_test.go @@ -51,7 +51,7 @@ func TestServerlessConnect(t *testing.T) { Label: "something", }, }, - expectedOutput: "Connected to functions namespace 'ns1' on API host 'https://api.example.com' (label=something)\n\n", + expectedOutput: "Warning: Connecting to serverless namespaces via DigitalOcean API is deprecated.\nPlease use 'doctl serverless connect --access-key :>' instead.\nThis method will be removed in a future version.\n\nConnected to functions namespace 'ns1' on API host 'https://api.example.com' (label=something)\n\n", }, { name: "two namespaces", @@ -67,7 +67,7 @@ func TestServerlessConnect(t *testing.T) { Label: "another", }, }, - expectedOutput: "0: ns1 in nyc1, label=something\n1: ns2 in lon1, label=another\nChoose a namespace by number or 'x' to exit\nConnected to functions namespace 'ns1' on API host 'https://api.example.com' (label=something)\n\n", + expectedOutput: "Warning: Connecting to serverless namespaces via DigitalOcean API is deprecated.\nPlease use 'doctl serverless connect --access-key :>' instead.\nThis method will be removed in a future version.\n\n0: ns1 in nyc1, label=something\n1: ns2 in lon1, label=another\nChoose a namespace by number or 'x' to exit\nConnected to functions namespace 'ns1' on API host 'https://api.example.com' (label=something)\n\n", }, { name: "use argument", @@ -84,7 +84,7 @@ func TestServerlessConnect(t *testing.T) { }, }, doctlArg: "thing", - expectedOutput: "Connected to functions namespace 'ns1' on API host 'https://api.example.com' (label=something)\n\n", + expectedOutput: "Warning: Connecting to serverless namespaces via DigitalOcean API is deprecated.\nPlease use 'doctl serverless connect thing --access-key :>' instead.\nThis method will be removed in a future version.\n\nConnected to functions namespace 'ns1' on API host 'https://api.example.com' (label=something)\n\n", }, } for _, tt := range tests { @@ -122,6 +122,205 @@ func TestServerlessConnect(t *testing.T) { } } +func TestServerlessConnectWithAccessKey(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + buf := &bytes.Buffer{} + config.Out = buf + config.Args = []string{"ns1"} + + config.Doit.Set(config.NS, "access-key", "dof_v1_abc123:xyz789") + + // Follow existing pattern: OutputNamespace has APIHost for access-key functionality + nsResponse := do.NamespaceListResponse{ + Namespaces: []do.OutputNamespace{ + { + Namespace: "ns1", + Region: "nyc1", + Label: "test-label", + APIHost: "https://api.example.com", + }, + }, + } + + tm.serverless.EXPECT().CheckServerlessStatus().Return(do.ErrServerlessNotConnected) + ctx := context.TODO() + tm.serverless.EXPECT().ListNamespaces(ctx).Return(nsResponse, nil) + tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", "dof_v1_abc123:xyz789").Return("ns1", nil) + + // Note: WriteCredentials expects the credentials object that will be created + creds := do.ServerlessCredentials{ + APIHost: "https://api.example.com", + Namespace: "ns1", + Label: "test-label", + Credentials: map[string]map[string]do.ServerlessCredential{ + "https://api.example.com": { + "ns1": do.ServerlessCredential{Auth: "dof_v1_abc123:xyz789"}, + }, + }, + } + tm.serverless.EXPECT().WriteCredentials(creds).Return(nil) + + err := RunServerlessConnect(config) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Connected to functions namespace 'ns1' on API host 'https://api.example.com' (label=test-label)") + }) +} + +func TestServerlessConnectWithInvalidAccessKey(t *testing.T) { + tests := []struct { + name string + accessKey string + args []string + wantError string + setupMocks bool + }{ + { + name: "no colon separator", + accessKey: "invalid-key-no-colon", + args: []string{"ns1"}, + wantError: "access-key must start with 'dof_v1_' prefix", + setupMocks: true, + }, + { + name: "empty access key with args", + accessKey: "", + args: []string{"ns1"}, + wantError: "", // Should follow legacy path and show deprecation warning + setupMocks: true, + }, + { + name: "only colon", + accessKey: ":", + args: []string{"ns1"}, + wantError: "access-key must start with 'dof_v1_' prefix", + setupMocks: true, + }, + { + name: "wrong prefix", + accessKey: "wrong_prefix_token:secret", + args: []string{"ns1"}, + wantError: "access-key must start with 'dof_v1_' prefix", + setupMocks: true, + }, + { + name: "correct prefix but empty token", + accessKey: "dof_v1_:secret", + args: []string{"ns1"}, + wantError: "access-key token part cannot be empty after 'dof_v1_' prefix", + setupMocks: true, + }, + { + name: "correct prefix but empty secret", + accessKey: "dof_v1_token:", + args: []string{"ns1"}, + wantError: "access-key secret part cannot be empty", + setupMocks: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + buf := &bytes.Buffer{} + config.Out = buf + + if len(tt.args) > 0 { + config.Args = tt.args + } + + if tt.accessKey != "" { + config.Doit.Set(config.NS, "access-key", tt.accessKey) + } + + if tt.setupMocks { + tm.serverless.EXPECT().CheckServerlessStatus().Return(do.ErrServerlessNotConnected) + + // Only expect ListNamespaces if we get past validation + if tt.wantError == "" { + ctx := context.TODO() + nsResponse := do.NamespaceListResponse{ + Namespaces: []do.OutputNamespace{ + { + Namespace: "ns1", + Region: "nyc1", + Label: "test-label", + APIHost: "https://api.example.com", + }, + }, + } + tm.serverless.EXPECT().ListNamespaces(ctx).Return(nsResponse, nil) + + var creds do.ServerlessCredentials + + if tt.accessKey != "" { + // Access-key path: validate with cluster and create credentials + tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", tt.accessKey).Return("ns1", nil) + + creds = do.ServerlessCredentials{ + APIHost: "https://api.example.com", + Namespace: "ns1", + Label: "test-label", + Credentials: map[string]map[string]do.ServerlessCredential{ + "https://api.example.com": { + "ns1": do.ServerlessCredential{Auth: tt.accessKey}, + }, + }, + } + } else { + // Legacy path: use DigitalOcean API to get namespace credentials + creds = do.ServerlessCredentials{ + APIHost: "https://api.example.com", + Namespace: "ns1", + Label: "test-label", + } + tm.serverless.EXPECT().GetNamespace(ctx, "ns1").Return(creds, nil) + } + + tm.serverless.EXPECT().WriteCredentials(creds).Return(nil) + } + } + + err := RunServerlessConnect(config) + + if tt.wantError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + } else { + require.NoError(t, err) + } + }) + }) + } +} + +func TestServerlessConnectWithFailingAccessKey(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + buf := &bytes.Buffer{} + config.Out = buf + config.Args = []string{"ns1"} + config.Doit.Set(config.NS, "access-key", "dof_v1_bad:key") + + nsResponse := do.NamespaceListResponse{ + Namespaces: []do.OutputNamespace{{ + Namespace: "ns1", + Region: "nyc1", + Label: "test-label", + APIHost: "https://api.example.com", + }}, + } + tm.serverless.EXPECT().CheckServerlessStatus().Return(do.ErrServerlessNotConnected) + ctx := context.TODO() + tm.serverless.EXPECT().ListNamespaces(ctx).Return(nsResponse, nil) + // This is where the access-key fails - GetNamespaceFromCluster returns an error + tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", "dof_v1_bad:key").Return("", errors.New("invalid credentials")) + + err := RunServerlessConnect(config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to connect with provided access-key") + assert.Contains(t, err.Error(), "invalid credentials") + }) +} + func TestServerlessStatusWhenConnected(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { buf := &bytes.Buffer{} diff --git a/do/mocks/ServerlessService.go b/do/mocks/ServerlessService.go index f26c5c7ad..007170dc5 100644 --- a/do/mocks/ServerlessService.go +++ b/do/mocks/ServerlessService.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: serverless.go +// Source: do/serverless.go // // Generated by this command: // -// mockgen -source serverless.go -package=mocks ServerlessService +// mockgen -source do/serverless.go -package=mocks -destination do/mocks/ServerlessService.go ServerlessService // // Package mocks is a generated GoMock package. @@ -101,6 +101,21 @@ func (mr *MockServerlessServiceMockRecorder) CreateNamespace(arg0, arg1, arg2 an return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNamespace", reflect.TypeOf((*MockServerlessService)(nil).CreateNamespace), arg0, arg1, arg2) } +// CreateNamespaceAccessKey mocks base method. +func (m *MockServerlessService) CreateNamespaceAccessKey(arg0 context.Context, arg1, arg2 string) (do.AccessKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateNamespaceAccessKey", arg0, arg1, arg2) + ret0, _ := ret[0].(do.AccessKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateNamespaceAccessKey indicates an expected call of CreateNamespaceAccessKey. +func (mr *MockServerlessServiceMockRecorder) CreateNamespaceAccessKey(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNamespaceAccessKey", reflect.TypeOf((*MockServerlessService)(nil).CreateNamespaceAccessKey), arg0, arg1, arg2) +} + // CredentialsPath mocks base method. func (m *MockServerlessService) CredentialsPath() string { m.ctrl.T.Helper() @@ -143,6 +158,20 @@ func (mr *MockServerlessServiceMockRecorder) DeleteNamespace(arg0, arg1 any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNamespace", reflect.TypeOf((*MockServerlessService)(nil).DeleteNamespace), arg0, arg1) } +// DeleteNamespaceAccessKey mocks base method. +func (m *MockServerlessService) DeleteNamespaceAccessKey(arg0 context.Context, arg1, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNamespaceAccessKey", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNamespaceAccessKey indicates an expected call of DeleteNamespaceAccessKey. +func (mr *MockServerlessServiceMockRecorder) DeleteNamespaceAccessKey(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNamespaceAccessKey", reflect.TypeOf((*MockServerlessService)(nil).DeleteNamespaceAccessKey), arg0, arg1, arg2) +} + // DeletePackage mocks base method. func (m *MockServerlessService) DeletePackage(arg0 string, arg1 bool) error { m.ctrl.T.Helper() @@ -425,6 +454,21 @@ func (mr *MockServerlessServiceMockRecorder) ListFunctions(arg0, arg1, arg2 any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFunctions", reflect.TypeOf((*MockServerlessService)(nil).ListFunctions), arg0, arg1, arg2) } +// ListNamespaceAccessKeys mocks base method. +func (m *MockServerlessService) ListNamespaceAccessKeys(arg0 context.Context, arg1 string) ([]do.AccessKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListNamespaceAccessKeys", arg0, arg1) + ret0, _ := ret[0].([]do.AccessKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListNamespaceAccessKeys indicates an expected call of ListNamespaceAccessKeys. +func (mr *MockServerlessServiceMockRecorder) ListNamespaceAccessKeys(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListNamespaceAccessKeys", reflect.TypeOf((*MockServerlessService)(nil).ListNamespaceAccessKeys), arg0, arg1) +} + // ListNamespaces mocks base method. func (m *MockServerlessService) ListNamespaces(arg0 context.Context) (do.NamespaceListResponse, error) { m.ctrl.T.Helper() diff --git a/do/serverless.go b/do/serverless.go index 9790a119e..fd97ff50b 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -194,6 +194,24 @@ type ServerlessTrigger struct { ScheduledRuns *TriggerScheduledRuns `json:"scheduled_runs,omitempty"` } +// AccessKey represents a namespace access key for serverless operations +type AccessKey struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + Secret string `json:"secret,omitempty"` // Only populated when creating/regenerating +} + +type AccessKeyListResponse struct { + Keys []AccessKey `json:"keys"` +} + +type AccessKeyResponse struct { + Key AccessKey `json:"key"` +} + type TriggerScheduledDetails struct { Cron string `json:"cron,omitempty"` Body map[string]any `json:"body,omitempty"` @@ -243,6 +261,9 @@ type ServerlessService interface { WriteProject(ServerlessProject) (string, error) SetEffectiveCredentials(auth string, apihost string) CredentialsPath() string + CreateNamespaceAccessKey(context.Context, string, string) (AccessKey, error) + ListNamespaceAccessKeys(context.Context, string) ([]AccessKey, error) + DeleteNamespaceAccessKey(context.Context, string, string) error } type serverlessService struct { @@ -1474,3 +1495,45 @@ func validateFunctionLevelFields(serverlessAction *ServerlessFunction) ([]string return forbiddenConfigs, nil } + +// CreateNamespaceAccessKey creates a new access key for the specified namespace +func (s *serverlessService) CreateNamespaceAccessKey(ctx context.Context, namespace string, name string) (AccessKey, error) { + path := fmt.Sprintf("v2/functions/namespaces/%s/keys", namespace) + reqBody := map[string]string{"name": name} + req, err := s.client.NewRequest(ctx, http.MethodPost, path, reqBody) + if err != nil { + return AccessKey{}, err + } + decoded := new(AccessKeyResponse) + _, err = s.client.Do(ctx, req, decoded) + if err != nil { + return AccessKey{}, err + } + return decoded.Key, nil +} + +// ListNamespaceAccessKeys lists all access keys for the specified namespace +func (s *serverlessService) ListNamespaceAccessKeys(ctx context.Context, namespace string) ([]AccessKey, error) { + path := fmt.Sprintf("v2/functions/namespaces/%s/keys", namespace) + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return []AccessKey{}, err + } + decoded := new(AccessKeyListResponse) + _, err = s.client.Do(ctx, req, decoded) + if err != nil { + return []AccessKey{}, err + } + return decoded.Keys, nil +} + +// DeleteNamespaceAccessKey deletes an access key from the specified namespace +func (s *serverlessService) DeleteNamespaceAccessKey(ctx context.Context, namespace string, keyID string) error { + path := fmt.Sprintf("v2/functions/namespaces/%s/keys/%s", namespace, keyID) + req, err := s.client.NewRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return err + } + _, err = s.client.Do(ctx, req, nil) + return err +}