From f1ee0d466b36ed28d56ac3e985590f1d4e3c22a5 Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Thu, 23 Oct 2025 13:38:34 +0530 Subject: [PATCH 01/21] Adding Auth Key --- commands/serverless.go | 69 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/commands/serverless.go b/commands/serverless.go index 1290f015b..0ddf7dcea 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -98,6 +98,7 @@ list your namespaces.`, // and hence are unknown to the portal. AddStringFlag(connect, "apihost", "", "", "") AddStringFlag(connect, "auth", "", "", "") + AddStringFlag(connect, "auth-key", "", "", "Authentication key for direct serverless connection") connect.Flags().MarkHidden("apihost") connect.Flags().MarkHidden("auth") @@ -230,6 +231,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") + authKey, _ := c.Doit.GetString(c.NS, "auth-key") + if len(apihost) > 0 && len(auth) > 0 { namespace, err := sls.GetNamespaceFromCluster(apihost, auth) if err != nil { @@ -260,6 +263,48 @@ func RunServerlessConnect(c *CmdConfig) error { ctx := context.TODO() + if len(authKey) > 0 { + // Validate auth-key format + if !strings.Contains(authKey, ":") { + return fmt.Errorf("auth-key must be in format 'uuid:key'") + } + + // 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 auth-key + ns := list[0] + return connectWithAuthKey(sls, ns, authKey, 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 auth-key + ns := chooseFromList(list, c.Out) + if ns.Namespace == "" { + return nil // User chose to exit + } + return connectWithAuthKey(sls, ns, authKey, 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 { @@ -513,3 +558,27 @@ func RunServerlessUndeploy(c *CmdConfig) error { template.Print(`{{success checkmark}} The requested resources have been undeployed.{{nl}}`, nil) return nil } + +func connectWithAuthKey(sls do.ServerlessService, ns do.OutputNamespace, authKey string, out io.Writer) error { + // Test if the auth key works with this namespace's API host + namespace, err := sls.GetNamespaceFromCluster(ns.APIHost, authKey) + if err != nil { + return fmt.Errorf("failed to connect with provided auth-key: %w", err) + } + + // Verify it matches the expected namespace + if namespace != ns.Namespace { + return fmt.Errorf("auth-key does not match namespace '%s'", ns.Namespace) + } + + // Create credentials using the provided auth-key and namespace's API host + credential := do.ServerlessCredential{Auth: authKey} + 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) +} From 6a0db3dfa19db6c17f973ec78653bb6c8c96858b Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Wed, 12 Nov 2025 18:16:36 +0530 Subject: [PATCH 02/21] Adding test cases --- commands/serverless.go | 44 +++++---- commands/serverless_test.go | 184 +++++++++++++++++++++++++++++++++++- 2 files changed, 208 insertions(+), 20 deletions(-) diff --git a/commands/serverless.go b/commands/serverless.go index 0ddf7dcea..6de597d21 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -98,7 +98,7 @@ list your namespaces.`, // and hence are unknown to the portal. AddStringFlag(connect, "apihost", "", "", "") AddStringFlag(connect, "auth", "", "", "") - AddStringFlag(connect, "auth-key", "", "", "Authentication key for direct serverless connection") + AddStringFlag(connect, "access-key", "", "", "Access key for direct serverless connection") connect.Flags().MarkHidden("apihost") connect.Flags().MarkHidden("auth") @@ -231,7 +231,7 @@ 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") - authKey, _ := c.Doit.GetString(c.NS, "auth-key") + accessKey, _ := c.Doit.GetString(c.NS, "access-key") if len(apihost) > 0 && len(auth) > 0 { namespace, err := sls.GetNamespaceFromCluster(apihost, auth) @@ -263,10 +263,10 @@ func RunServerlessConnect(c *CmdConfig) error { ctx := context.TODO() - if len(authKey) > 0 { - // Validate auth-key format - if !strings.Contains(authKey, ":") { - return fmt.Errorf("auth-key must be in format 'uuid:key'") + if len(accessKey) > 0 { + // Validate access-key format - support new "dof_v1_" formats + if !strings.Contains(accessKey, ":") { + return fmt.Errorf("access-key must contain ':' separator (formats:'dof_v1_...:...')") } // If namespace argument provided, use it directly @@ -283,9 +283,9 @@ func RunServerlessConnect(c *CmdConfig) error { return fmt.Errorf("multiple namespaces match '%s', please be more specific", c.Args[0]) } - // Use the found namespace with the provided auth-key + // Use the found namespace with the provided access-key ns := list[0] - return connectWithAuthKey(sls, ns, authKey, c.Out) + return connectWithAccessKey(sls, ns, accessKey, c.Out) } else { // No namespace specified, show menu list, err := getMatchingNamespaces(ctx, sls, "") @@ -296,18 +296,23 @@ func RunServerlessConnect(c *CmdConfig) error { return errors.New("you must create a namespace first") } - // Let user choose, then connect with auth-key + // Let user choose, then connect with access-key ns := chooseFromList(list, c.Out) if ns.Namespace == "" { return nil // User chose to exit } - return connectWithAuthKey(sls, ns, authKey, c.Out) + 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 ' instead.\n", c.Args[0]) + 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 @@ -317,6 +322,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 ' instead.\n") + 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 @@ -559,20 +569,20 @@ func RunServerlessUndeploy(c *CmdConfig) error { return nil } -func connectWithAuthKey(sls do.ServerlessService, ns do.OutputNamespace, authKey string, out io.Writer) error { - // Test if the auth key works with this namespace's API host - namespace, err := sls.GetNamespaceFromCluster(ns.APIHost, authKey) +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 auth-key: %w", err) + 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("auth-key does not match namespace '%s'", ns.Namespace) + return fmt.Errorf("access-key does not match namespace '%s'", ns.Namespace) } - // Create credentials using the provided auth-key and namespace's API host - credential := do.ServerlessCredential{Auth: authKey} + // Create 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, diff --git a/commands/serverless_test.go b/commands/serverless_test.go index 5d6a3aba0..e82a4b8b8 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,184 @@ 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 contain ':' separator", + 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: "", // Valid format, but will fail auth in later test + 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{} From 468787625f6e25fd7cadeabf91dc67c6418d6e38 Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Wed, 19 Nov 2025 10:22:54 +0530 Subject: [PATCH 03/21] updating the key validation --- commands/serverless.go | 48 +++++++++++++++++++++++++++++++++---- commands/serverless_test.go | 21 ++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/commands/serverless.go b/commands/serverless.go index 6de597d21..1492bec64 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. @@ -265,8 +268,8 @@ func RunServerlessConnect(c *CmdConfig) error { if len(accessKey) > 0 { // Validate access-key format - support new "dof_v1_" formats - if !strings.Contains(accessKey, ":") { - return fmt.Errorf("access-key must contain ':' separator (formats:'dof_v1_...:...')") + if err := validateAccessKeyFormat(accessKey); err != nil { + return err } // If namespace argument provided, use it directly @@ -310,7 +313,7 @@ func RunServerlessConnect(c *CmdConfig) error { 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 ' instead.\n", c.Args[0]) + 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]) @@ -324,7 +327,7 @@ func RunServerlessConnect(c *CmdConfig) error { } // 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 ' instead.\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, "") @@ -337,6 +340,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. @@ -581,7 +619,7 @@ func connectWithAccessKey(sls do.ServerlessService, ns do.OutputNamespace, acces return fmt.Errorf("access-key does not match namespace '%s'", ns.Namespace) } - // Create credentials using the provided access-key and namespace's API host + // Save credentials using the provided access-key and namespace's API host credential := do.ServerlessCredential{Auth: accessKey} creds := do.ServerlessCredentials{ APIHost: ns.APIHost, diff --git a/commands/serverless_test.go b/commands/serverless_test.go index e82a4b8b8..987f8d1b8 100644 --- a/commands/serverless_test.go +++ b/commands/serverless_test.go @@ -195,6 +195,27 @@ func TestServerlessConnectWithInvalidAccessKey(t *testing.T) { wantError: "", // Valid format, but will fail auth in later test 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 { From 0e58a322828fc6ae1912699a78d268f31bddeff2 Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Wed, 19 Nov 2025 21:08:51 +0530 Subject: [PATCH 04/21] Added serverless key commands --- commands/displayers/access_keys.go | 100 +++++++ commands/keys.go | 185 ++++++++++++ commands/keys_test.go | 437 +++++++++++++++++++++++++++++ commands/serverless.go | 1 + do/mocks/ServerlessService.go | 48 +++- do/serverless.go | 63 +++++ 6 files changed, 832 insertions(+), 2 deletions(-) create mode 100644 commands/displayers/access_keys.go create mode 100644 commands/keys.go create mode 100644 commands/keys_test.go diff --git a/commands/displayers/access_keys.go b/commands/displayers/access_keys.go new file mode 100644 index 000000000..e779bfed0 --- /dev/null +++ b/commands/displayers/access_keys.go @@ -0,0 +1,100 @@ +/* +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 +} + +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 partial secret (first 8 chars + ...) if present, or "" if not + secret := "" + if key.Secret != "" { + if len(key.Secret) > 8 { + secret = key.Secret[:8] + "..." + } else { + secret = key.Secret + } + } + + // 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} +} diff --git a/commands/keys.go b/commands/keys.go new file mode 100644 index 000000000..6d7e94403 --- /dev/null +++ b/commands/keys.go @@ -0,0 +1,185 @@ +/* +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 revoke 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)") + + revoke := CmdBuilder(cmd, RunAccessKeyRevoke, "revoke ", "Revokes an access key", + `Permanently revokes an existing access key. This action cannot be undone. + +Examples: + doctl serverless key revoke dof_v1_a1b2c3d4e5f67890 + doctl serverless key revoke dof_v1_a1b2c3d4e5f67890 --force`, + Writer, aliasOpt("rm")) + AddStringFlag(revoke, "namespace", "", "", "target namespace (uses connected namespace if not specified)") + AddBoolFlag(revoke, "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 secret + return c.Display(&displayers.AccessKeys{AccessKeys: []do.AccessKey{accessKey}}) +} + +// 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}) +} + +// RunAccessKeyRevoke handles the access key revoke command +func RunAccessKeyRevoke(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: Revoking this key is a permanent action.\n") + if err := AskForConfirm(fmt.Sprintf("revoke key %s", keyID)); err != nil { + return err + } + } + + // Revoke 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 revoked.\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) { + if explicitNamespace != "" { + return explicitNamespace, nil + } + + // Use connected namespace + ss := c.Serverless() + 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..7a2a17566 --- /dev/null +++ b/commands/keys_test.go @@ -0,0 +1,437 @@ +/* +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", "revoke"} + + 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]interface{} + expectedCalls func(*tcMocks) + expectedError string + }{ + { + name: "create with connected namespace", + flags: map[string]interface{}{ + "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]interface{}{ + "name": "my-key", + "namespace": "fn-explicit-namespace", + }, + expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-explicit-namespace", "my-key").Return(testAccessKey, nil) + }, + }, + { + name: "create without name flag", + flags: map[string]interface{}{ + // 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]interface{}{ + "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]interface{} + 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]interface{}{ + "namespace": "fn-explicit-namespace", + }, + expectedCalls: func(tm *tcMocks) { + 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 TestAccessKeyRevoke(t *testing.T) { + tests := []struct { + name string + args []string + flags map[string]interface{} + expectedCalls func(*tcMocks) + expectedError string + }{ + { + name: "revoke with connected namespace and force", + args: []string{"dof_v1_abc123def456"}, + flags: map[string]interface{}{ + "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: "revoke with explicit namespace", + args: []string{"dof_v1_abc123def456"}, + flags: map[string]interface{}{ + "namespace": "fn-explicit-namespace", + "force": true, + }, + expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().DeleteNamespaceAccessKey(context.TODO(), "fn-explicit-namespace", "dof_v1_abc123def456").Return(nil) + }, + }, + { + name: "revoke without key ID", + args: []string{}, + expectedError: "command is missing required arguments", + }, + { + name: "revoke with too many args", + args: []string{"key1", "key2"}, + expectedError: "command contains unsupported arguments", + }, + { + name: "revoke with disconnected namespace", + args: []string{"dof_v1_abc123def456"}, + flags: map[string]interface{}{ + "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 := RunAccessKeyRevoke(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 + credentialsReturn do.ServerlessCredentials + credentialsError error + statusError error + expectedNamespace string + expectedError string + }{ + { + name: "explicit namespace provided", + explicitNamespace: "fn-explicit", + expectedNamespace: "fn-explicit", + }, + { + 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) + } + } + } + + 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 TestAccessKeyRevokeOutput(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 revoked.\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 := RunAccessKeyRevoke(config) + + require.NoError(t, err) + assert.Equal(t, expectedOutput, buf.String()) + }) +} diff --git a/commands/serverless.go b/commands/serverless.go index 1492bec64..e39ac60d5 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -142,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 } 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 +} From ea332ba662d0cb9487361504dc386dc63329af39 Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Thu, 20 Nov 2025 11:13:45 +0530 Subject: [PATCH 05/21] Added serverless key commands tests --- commands/keys.go | 8 +++++++- commands/keys_test.go | 10 ++++++++++ commands/serverless_test.go | 10 +++++----- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/commands/keys.go b/commands/keys.go index 6d7e94403..c6d19e12b 100644 --- a/commands/keys.go +++ b/commands/keys.go @@ -163,12 +163,18 @@ func RunAccessKeyRevoke(c *CmdConfig) error { // 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 != "" { + // VALIDATE NAMESPACE EXISTS + _, err := ss.GetNamespace(context.TODO(), explicitNamespace) + if err != nil { + return "", fmt.Errorf("namespace '%s' not found or not accessible", explicitNamespace) + } return explicitNamespace, nil } // Use connected namespace - ss := c.Serverless() if err := ss.CheckServerlessStatus(); err != nil { return "", err } diff --git a/commands/keys_test.go b/commands/keys_test.go index 7a2a17566..4abe5b95a 100644 --- a/commands/keys_test.go +++ b/commands/keys_test.go @@ -94,6 +94,7 @@ func TestAccessKeyCreate(t *testing.T) { "namespace": "fn-explicit-namespace", }, expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().GetNamespace(context.TODO(), "fn-explicit-namespace").Return(do.ServerlessCredentials{Namespace: "fn-explicit-namespace", APIHost: "https://test.api.host"}, nil) tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-explicit-namespace", "my-key").Return(testAccessKey, nil) }, }, @@ -173,6 +174,7 @@ func TestAccessKeyList(t *testing.T) { "namespace": "fn-explicit-namespace", }, expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().GetNamespace(context.TODO(), "fn-explicit-namespace").Return(do.ServerlessCredentials{Namespace: "fn-explicit-namespace", APIHost: "https://test.api.host"}, nil) tm.serverless.EXPECT().ListNamespaceAccessKeys(context.TODO(), "fn-explicit-namespace").Return(testAccessKeyList, nil) }, }, @@ -246,6 +248,7 @@ func TestAccessKeyRevoke(t *testing.T) { "force": true, }, expectedCalls: func(tm *tcMocks) { + tm.serverless.EXPECT().GetNamespace(context.TODO(), "fn-explicit-namespace").Return(do.ServerlessCredentials{Namespace: "fn-explicit-namespace", APIHost: "https://test.api.host"}, nil) tm.serverless.EXPECT().DeleteNamespaceAccessKey(context.TODO(), "fn-explicit-namespace", "dof_v1_abc123def456").Return(nil) }, }, @@ -355,6 +358,13 @@ func TestResolveTargetNamespace(t *testing.T) { tm.serverless.EXPECT().ReadCredentials().Return(tt.credentialsReturn, nil) } } + } else { + // For explicit namespace, we now need to mock GetNamespace validation call + mockCredentials := do.ServerlessCredentials{ + Namespace: tt.explicitNamespace, + APIHost: "https://test.api.host", + } + tm.serverless.EXPECT().GetNamespace(context.TODO(), tt.explicitNamespace).Return(mockCredentials, nil) } namespace, err := resolveTargetNamespace(config, tt.explicitNamespace) diff --git a/commands/serverless_test.go b/commands/serverless_test.go index 987f8d1b8..431665db2 100644 --- a/commands/serverless_test.go +++ b/commands/serverless_test.go @@ -51,7 +51,7 @@ func TestServerlessConnect(t *testing.T) { Label: "something", }, }, - 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", + 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: "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", + 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: "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", + 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 { @@ -178,7 +178,7 @@ func TestServerlessConnectWithInvalidAccessKey(t *testing.T) { name: "no colon separator", accessKey: "invalid-key-no-colon", args: []string{"ns1"}, - wantError: "access-key must contain ':' separator", + wantError: "access-key must start with 'dof_v1_' prefix", setupMocks: true, }, { @@ -192,7 +192,7 @@ func TestServerlessConnectWithInvalidAccessKey(t *testing.T) { name: "only colon", accessKey: ":", args: []string{"ns1"}, - wantError: "", // Valid format, but will fail auth in later test + wantError: "access-key must start with 'dof_v1_' prefix", setupMocks: true, }, { From e238809d4e694b2dc40c107d04bd17efd99eb82a Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Thu, 20 Nov 2025 14:45:01 +0530 Subject: [PATCH 06/21] Fix gofmt formatting issues - Ran gofmt on all source files - Ensured all tests pass - Code is now properly formatted From 0edafeb1a181e4cf6f2ed3bf4f0323b50a778d3e Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Thu, 20 Nov 2025 14:48:49 +0530 Subject: [PATCH 07/21] Apply gofmt interface{} -> any rule to keys_test.go - Replaced map[string]interface{} with map[string]any - Applied Go 1.18+ preferred syntax for interface types - All tests continue to pass - Resolves gofmt_check CI failure --- commands/keys_test.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/commands/keys_test.go b/commands/keys_test.go index 4abe5b95a..95ff6062f 100644 --- a/commands/keys_test.go +++ b/commands/keys_test.go @@ -72,13 +72,13 @@ func TestAccessKeyCreate(t *testing.T) { tests := []struct { name string args []string - flags map[string]interface{} + flags map[string]any expectedCalls func(*tcMocks) expectedError string }{ { name: "create with connected namespace", - flags: map[string]interface{}{ + flags: map[string]any{ "name": "my-key", }, expectedCalls: func(tm *tcMocks) { @@ -89,7 +89,7 @@ func TestAccessKeyCreate(t *testing.T) { }, { name: "create with explicit namespace", - flags: map[string]interface{}{ + flags: map[string]any{ "name": "my-key", "namespace": "fn-explicit-namespace", }, @@ -100,7 +100,7 @@ func TestAccessKeyCreate(t *testing.T) { }, { name: "create without name flag", - flags: map[string]interface{}{ + flags: map[string]any{ // name is required, but we'll pass empty string "name": "", }, @@ -114,7 +114,7 @@ func TestAccessKeyCreate(t *testing.T) { }, { name: "create with disconnected namespace", - flags: map[string]interface{}{ + flags: map[string]any{ "name": "my-key", }, expectedCalls: func(tm *tcMocks) { @@ -156,7 +156,7 @@ func TestAccessKeyList(t *testing.T) { tests := []struct { name string args []string - flags map[string]interface{} + flags map[string]any expectedCalls func(*tcMocks) expectedError string }{ @@ -170,7 +170,7 @@ func TestAccessKeyList(t *testing.T) { }, { name: "list with explicit namespace", - flags: map[string]interface{}{ + flags: map[string]any{ "namespace": "fn-explicit-namespace", }, expectedCalls: func(tm *tcMocks) { @@ -224,14 +224,14 @@ func TestAccessKeyRevoke(t *testing.T) { tests := []struct { name string args []string - flags map[string]interface{} + flags map[string]any expectedCalls func(*tcMocks) expectedError string }{ { name: "revoke with connected namespace and force", args: []string{"dof_v1_abc123def456"}, - flags: map[string]interface{}{ + flags: map[string]any{ "force": true, }, expectedCalls: func(tm *tcMocks) { @@ -243,7 +243,7 @@ func TestAccessKeyRevoke(t *testing.T) { { name: "revoke with explicit namespace", args: []string{"dof_v1_abc123def456"}, - flags: map[string]interface{}{ + flags: map[string]any{ "namespace": "fn-explicit-namespace", "force": true, }, @@ -265,7 +265,7 @@ func TestAccessKeyRevoke(t *testing.T) { { name: "revoke with disconnected namespace", args: []string{"dof_v1_abc123def456"}, - flags: map[string]interface{}{ + flags: map[string]any{ "force": true, }, expectedCalls: func(tm *tcMocks) { From d1c9d30752963861eae462dffb7ffce8a78c16ed Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Thu, 20 Nov 2025 15:09:17 +0530 Subject: [PATCH 08/21] handling secrets --- commands/displayers/access_keys.go | 17 ++++++++--------- commands/keys.go | 5 +++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/commands/displayers/access_keys.go b/commands/displayers/access_keys.go index e779bfed0..d9ad31f37 100644 --- a/commands/displayers/access_keys.go +++ b/commands/displayers/access_keys.go @@ -20,7 +20,8 @@ import ( ) type AccessKeys struct { - AccessKeys []do.AccessKey + AccessKeys []do.AccessKey + ShowFullSecret bool // When true, shows full secret (for creation), otherwise truncates/hides } var _ Displayable = &AccessKeys{} @@ -57,15 +58,13 @@ func (ak *AccessKeys) KV() []map[string]any { out := make([]map[string]any, 0, len(ak.AccessKeys)) for _, key := range ak.AccessKeys { - // Show partial secret (first 8 chars + ...) if present, or "" if not + // Show full secret during creation, hidden otherwise secret := "" - if key.Secret != "" { - if len(key.Secret) > 8 { - secret = key.Secret[:8] + "..." - } else { - secret = key.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 := "" @@ -96,5 +95,5 @@ func (ak *AccessKeys) KV() []map[string]any { // 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} + return &AccessKeys{AccessKeys: ak.AccessKeys, ShowFullSecret: true} } diff --git a/commands/keys.go b/commands/keys.go index c6d19e12b..b6a1ab0b4 100644 --- a/commands/keys.go +++ b/commands/keys.go @@ -93,8 +93,9 @@ func RunAccessKeyCreate(c *CmdConfig) error { 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 secret - return c.Display(&displayers.AccessKeys{AccessKeys: []do.AccessKey{accessKey}}) + // 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 From babd0ac7b5d17328215b44e380795f58d788e1e7 Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Fri, 21 Nov 2025 15:04:53 +0530 Subject: [PATCH 09/21] Changed revoke to delete --- commands/keys.go | 26 +++++++++++++------------- commands/keys_test.go | 23 +++++++++++------------ 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/commands/keys.go b/commands/keys.go index b6a1ab0b4..c18d07739 100644 --- a/commands/keys.go +++ b/commands/keys.go @@ -31,7 +31,7 @@ func Keys() *Command { 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 revoke namespace-specific access keys. +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"}, }, @@ -56,15 +56,15 @@ Examples: Writer, aliasOpt("ls"), displayerType(&displayers.AccessKeys{})) AddStringFlag(list, "namespace", "", "", "target namespace (uses connected namespace if not specified)") - revoke := CmdBuilder(cmd, RunAccessKeyRevoke, "revoke ", "Revokes an access key", - `Permanently revokes an existing access key. This action cannot be undone. + delete := CmdBuilder(cmd, RunAccessKeyDelete, "delete ", "Deletes an access key", + `Permanently deletes an existing access key. This action cannot be undone. Examples: - doctl serverless key revoke dof_v1_a1b2c3d4e5f67890 - doctl serverless key revoke dof_v1_a1b2c3d4e5f67890 --force`, + doctl serverless key delete dof_v1_a1b2c3d4e5f67890 + doctl serverless key delete dof_v1_a1b2c3d4e5f67890 --force`, Writer, aliasOpt("rm")) - AddStringFlag(revoke, "namespace", "", "", "target namespace (uses connected namespace if not specified)") - AddBoolFlag(revoke, "force", "f", false, "skip confirmation prompt") + AddStringFlag(delete, "namespace", "", "", "target namespace (uses connected namespace if not specified)") + AddBoolFlag(delete, "force", "f", false, "skip confirmation prompt") return cmd } @@ -123,8 +123,8 @@ func RunAccessKeyList(c *CmdConfig) error { return c.Display(&displayers.AccessKeys{AccessKeys: keys}) } -// RunAccessKeyRevoke handles the access key revoke command -func RunAccessKeyRevoke(c *CmdConfig) error { +// RunAccessKeyDelete handles the access key delete command +func RunAccessKeyDelete(c *CmdConfig) error { err := ensureOneArg(c) if err != nil { return err @@ -142,13 +142,13 @@ func RunAccessKeyRevoke(c *CmdConfig) error { // Confirmation prompt unless --force if !force { - fmt.Fprintf(c.Out, "Warning: Revoking this key is a permanent action.\n") - if err := AskForConfirm(fmt.Sprintf("revoke key %s", keyID)); err != nil { + 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 } } - // Revoke the key + // Delete the key ss := c.Serverless() ctx := context.TODO() @@ -157,7 +157,7 @@ func RunAccessKeyRevoke(c *CmdConfig) error { return err } - fmt.Fprintf(c.Out, "Key %s has been revoked.\n", keyID) + fmt.Fprintf(c.Out, "Key %s has been deleted.\n", keyID) return nil } diff --git a/commands/keys_test.go b/commands/keys_test.go index 95ff6062f..63edd534f 100644 --- a/commands/keys_test.go +++ b/commands/keys_test.go @@ -52,7 +52,7 @@ var ( func TestKeysCommand(t *testing.T) { cmd := Keys() assert.NotNil(t, cmd) - expected := []string{"create", "list", "revoke"} + expected := []string{"create", "list", "delete"} names := []string{} for _, c := range cmd.Commands() { @@ -220,7 +220,7 @@ func TestAccessKeyList(t *testing.T) { } } -func TestAccessKeyRevoke(t *testing.T) { +func TestAccessKeyDelete(t *testing.T) { tests := []struct { name string args []string @@ -229,7 +229,7 @@ func TestAccessKeyRevoke(t *testing.T) { expectedError string }{ { - name: "revoke with connected namespace and force", + name: "delete with connected namespace and force", args: []string{"dof_v1_abc123def456"}, flags: map[string]any{ "force": true, @@ -241,7 +241,7 @@ func TestAccessKeyRevoke(t *testing.T) { }, }, { - name: "revoke with explicit namespace", + name: "delete with explicit namespace", args: []string{"dof_v1_abc123def456"}, flags: map[string]any{ "namespace": "fn-explicit-namespace", @@ -253,17 +253,17 @@ func TestAccessKeyRevoke(t *testing.T) { }, }, { - name: "revoke without key ID", + name: "delete without key ID", args: []string{}, expectedError: "command is missing required arguments", }, { - name: "revoke with too many args", + name: "delete with too many args", args: []string{"key1", "key2"}, expectedError: "command contains unsupported arguments", }, { - name: "revoke with disconnected namespace", + name: "delete with disconnected namespace", args: []string{"dof_v1_abc123def456"}, flags: map[string]any{ "force": true, @@ -290,7 +290,7 @@ func TestAccessKeyRevoke(t *testing.T) { // Set args config.Args = tt.args - err := RunAccessKeyRevoke(config) + err := RunAccessKeyDelete(config) if tt.expectedError != "" { assert.Error(t, err) @@ -425,7 +425,7 @@ func TestAccessKeyListOutput(t *testing.T) { }) } -func TestAccessKeyRevokeOutput(t *testing.T) { +func TestAccessKeyDeleteOutput(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { buf := &bytes.Buffer{} config.Out = buf @@ -433,14 +433,13 @@ func TestAccessKeyRevokeOutput(t *testing.T) { config.Args = []string{"dof_v1_abc123def456"} config.Doit.Set(config.NS, "force", true) - expectedOutput := "Key dof_v1_abc123def456 has been revoked.\n" + 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 := RunAccessKeyRevoke(config) - + err := RunAccessKeyDelete(config) require.NoError(t, err) assert.Equal(t, expectedOutput, buf.String()) }) From ab1329aef469dd369b9a1977147fa2766f2086e0 Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Tue, 25 Nov 2025 17:27:02 +0530 Subject: [PATCH 10/21] Resolving comments --- commands/keys.go | 6 +++--- commands/serverless.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/commands/keys.go b/commands/keys.go index c18d07739..463ffa202 100644 --- a/commands/keys.go +++ b/commands/keys.go @@ -56,12 +56,12 @@ Examples: 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", + 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 dof_v1_a1b2c3d4e5f67890 - doctl serverless key delete dof_v1_a1b2c3d4e5f67890 --force`, + 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") diff --git a/commands/serverless.go b/commands/serverless.go index e39ac60d5..6ed0e7947 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -41,7 +41,7 @@ var ( errUndeployTrigPkg = errors.New("the `--packages` and `--triggers` flags are mutually exclusive") // accessKeyFormat defines the expected format for serverless access keys - accessKeyFormat = "dof_v1_:" + 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 From 869e03ee64943aafe1846cc23ecb6b349bfba722 Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Thu, 27 Nov 2025 13:39:56 +0530 Subject: [PATCH 11/21] Fix test expectations for access key format in deprecation warning --- commands/serverless_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commands/serverless_test.go b/commands/serverless_test.go index 431665db2..a1c24efd8 100644 --- a/commands/serverless_test.go +++ b/commands/serverless_test.go @@ -51,7 +51,7 @@ func TestServerlessConnect(t *testing.T) { Label: "something", }, }, - 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", + 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: "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", + 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: "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", + 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 { From e85b7c6b2ab1f5fea0cae761a1b12d1382680f14 Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Thu, 18 Dec 2025 15:31:39 +0530 Subject: [PATCH 12/21] Update accepting namespace label --- commands/keys.go | 17 +++++++++++++---- commands/keys_test.go | 38 ++++++++++++++++++++++++++++---------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/commands/keys.go b/commands/keys.go index 463ffa202..955f50ced 100644 --- a/commands/keys.go +++ b/commands/keys.go @@ -167,12 +167,21 @@ func resolveTargetNamespace(c *CmdConfig, explicitNamespace string) (string, err ss := c.Serverless() if explicitNamespace != "" { - // VALIDATE NAMESPACE EXISTS - _, err := ss.GetNamespace(context.TODO(), explicitNamespace) + // Match namespace by exact ID or exact label match + ctx := context.TODO() + allNamespaces, err := ss.ListNamespaces(ctx) if err != nil { - return "", fmt.Errorf("namespace '%s' not found or not accessible", explicitNamespace) + return "", err } - return explicitNamespace, nil + + // 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 diff --git a/commands/keys_test.go b/commands/keys_test.go index 63edd534f..5146d0a79 100644 --- a/commands/keys_test.go +++ b/commands/keys_test.go @@ -94,7 +94,9 @@ func TestAccessKeyCreate(t *testing.T) { "namespace": "fn-explicit-namespace", }, expectedCalls: func(tm *tcMocks) { - tm.serverless.EXPECT().GetNamespace(context.TODO(), "fn-explicit-namespace").Return(do.ServerlessCredentials{Namespace: "fn-explicit-namespace", APIHost: "https://test.api.host"}, nil) + 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) }, }, @@ -174,7 +176,9 @@ func TestAccessKeyList(t *testing.T) { "namespace": "fn-explicit-namespace", }, expectedCalls: func(tm *tcMocks) { - tm.serverless.EXPECT().GetNamespace(context.TODO(), "fn-explicit-namespace").Return(do.ServerlessCredentials{Namespace: "fn-explicit-namespace", APIHost: "https://test.api.host"}, nil) + 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) }, }, @@ -248,7 +252,9 @@ func TestAccessKeyDelete(t *testing.T) { "force": true, }, expectedCalls: func(tm *tcMocks) { - tm.serverless.EXPECT().GetNamespace(context.TODO(), "fn-explicit-namespace").Return(do.ServerlessCredentials{Namespace: "fn-explicit-namespace", APIHost: "https://test.api.host"}, nil) + 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) }, }, @@ -307,6 +313,7 @@ func TestResolveTargetNamespace(t *testing.T) { tests := []struct { name string explicitNamespace string + namespaceList []do.OutputNamespace credentialsReturn do.ServerlessCredentials credentialsError error statusError error @@ -314,10 +321,23 @@ func TestResolveTargetNamespace(t *testing.T) { expectedError string }{ { - name: "explicit namespace provided", + 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: "", @@ -359,12 +379,10 @@ func TestResolveTargetNamespace(t *testing.T) { } } } else { - // For explicit namespace, we now need to mock GetNamespace validation call - mockCredentials := do.ServerlessCredentials{ - Namespace: tt.explicitNamespace, - APIHost: "https://test.api.host", - } - tm.serverless.EXPECT().GetNamespace(context.TODO(), tt.explicitNamespace).Return(mockCredentials, nil) + // 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) From c9921cf57498008767b9e4e6d514be0672cfc640 Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Mon, 29 Dec 2025 17:06:55 +0530 Subject: [PATCH 13/21] Expiration added --- commands/keys.go | 29 ++++++++++-- commands/keys_test.go | 88 +++++++++++++++++++++++++++++++---- do/mocks/ServerlessService.go | 8 ++-- do/serverless.go | 9 ++-- 4 files changed, 116 insertions(+), 18 deletions(-) diff --git a/commands/keys.go b/commands/keys.go index 955f50ced..c7224811f 100644 --- a/commands/keys.go +++ b/commands/keys.go @@ -41,10 +41,11 @@ Keys operate on the currently connected namespace by default, but can target any `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`, + doctl serverless key create --name "my-laptop-key" --expiration 30d + doctl serverless key create --name "ci-cd-key" --namespace fn-abc123 --expiration never`, Writer) AddStringFlag(create, "name", "n", "", "name for the access key", requiredOpt()) + AddStringFlag(create, "expiration", "e", "", "expiration period: 30d, 60d, 90d, 1y, or never", requiredOpt()) AddStringFlag(create, "namespace", "", "", "target namespace (uses connected namespace if not specified)") list := CmdBuilder(cmd, RunAccessKeyList, "list", "Lists access keys", @@ -73,6 +74,28 @@ Examples: func RunAccessKeyCreate(c *CmdConfig) error { name, _ := c.Doit.GetString(c.NS, "name") namespace, _ := c.Doit.GetString(c.NS, "namespace") + expirationStr, _ := c.Doit.GetString(c.NS, "expiration") + + // Validate expiration + var expiresInSeconds *int64 + switch expirationStr { + case "30d": + seconds := int64(30 * 24 * 60 * 60) + expiresInSeconds = &seconds + case "60d": + seconds := int64(60 * 24 * 60 * 60) + expiresInSeconds = &seconds + case "90d": + seconds := int64(90 * 24 * 60 * 60) + expiresInSeconds = &seconds + case "1y": + seconds := int64(365 * 24 * 60 * 60) + expiresInSeconds = &seconds + case "never": + expiresInSeconds = nil + default: + return fmt.Errorf("invalid expiration value '%s'. Must be one of: 30d, 60d, 90d, 1y, or never", expirationStr) + } // Resolve target namespace targetNamespace, err := resolveTargetNamespace(c, namespace) @@ -84,7 +107,7 @@ func RunAccessKeyCreate(c *CmdConfig) error { ss := c.Serverless() ctx := context.TODO() - accessKey, err := ss.CreateNamespaceAccessKey(ctx, targetNamespace, name) + accessKey, err := ss.CreateNamespaceAccessKey(ctx, targetNamespace, name, expiresInSeconds) if err != nil { return err } diff --git a/commands/keys_test.go b/commands/keys_test.go index 5146d0a79..21cc01072 100644 --- a/commands/keys_test.go +++ b/commands/keys_test.go @@ -79,51 +79,123 @@ func TestAccessKeyCreate(t *testing.T) { { name: "create with connected namespace", flags: map[string]any{ - "name": "my-key", + "name": "my-key", + "expiration": "never", }, 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) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", (*int64)(nil)).Return(testAccessKey, nil) }, }, { name: "create with explicit namespace", flags: map[string]any{ - "name": "my-key", - "namespace": "fn-explicit-namespace", + "name": "my-key", + "namespace": "fn-explicit-namespace", + "expiration": "never", }, 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) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-explicit-namespace", "my-key", (*int64)(nil)).Return(testAccessKey, nil) }, }, { name: "create without name flag", flags: map[string]any{ // name is required, but we'll pass empty string - "name": "", + "name": "", + "expiration": "never", }, 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) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "", (*int64)(nil)).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", + "name": "my-key", + "expiration": "never", }, expectedCalls: func(tm *tcMocks) { tm.serverless.EXPECT().CheckServerlessStatus().Return(do.ErrServerlessNotConnected) }, expectedError: "serverless support is installed but not connected to a functions namespace", }, + { + name: "create with 30 days expiration", + flags: map[string]any{ + "name": "my-key", + "expiration": "30d", + }, + expectedCalls: func(tm *tcMocks) { + expires := int64(30 * 24 * 60 * 60) + tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) + tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) + }, + }, + { + name: "create with 60 days expiration", + flags: map[string]any{ + "name": "my-key", + "expiration": "60d", + }, + expectedCalls: func(tm *tcMocks) { + expires := int64(60 * 24 * 60 * 60) + tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) + tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) + }, + }, + { + name: "create with 90 days expiration", + flags: map[string]any{ + "name": "my-key", + "expiration": "90d", + }, + expectedCalls: func(tm *tcMocks) { + expires := int64(90 * 24 * 60 * 60) + tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) + tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) + }, + }, + { + name: "create with 1 year expiration", + flags: map[string]any{ + "name": "my-key", + "expiration": "1y", + }, + expectedCalls: func(tm *tcMocks) { + expires := int64(365 * 24 * 60 * 60) + tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) + tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) + }, + }, + { + name: "create with invalid expiration", + flags: map[string]any{ + "name": "my-key", + "expiration": "invalid", + }, + expectedError: "invalid expiration value 'invalid'. Must be one of: 30d, 60d, 90d, 1y, or never", + }, + { + name: "create with empty expiration", + flags: map[string]any{ + "name": "my-key", + "expiration": "", + }, + expectedError: "invalid expiration value ''. Must be one of: 30d, 60d, 90d, 1y, or never", + }, } for _, tt := range tests { diff --git a/do/mocks/ServerlessService.go b/do/mocks/ServerlessService.go index 007170dc5..aa8eddd60 100644 --- a/do/mocks/ServerlessService.go +++ b/do/mocks/ServerlessService.go @@ -102,18 +102,18 @@ func (mr *MockServerlessServiceMockRecorder) CreateNamespace(arg0, arg1, arg2 an } // CreateNamespaceAccessKey mocks base method. -func (m *MockServerlessService) CreateNamespaceAccessKey(arg0 context.Context, arg1, arg2 string) (do.AccessKey, error) { +func (m *MockServerlessService) CreateNamespaceAccessKey(arg0 context.Context, arg1, arg2 string, arg3 *int64) (do.AccessKey, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateNamespaceAccessKey", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "CreateNamespaceAccessKey", arg0, arg1, arg2, arg3) 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 { +func (mr *MockServerlessServiceMockRecorder) CreateNamespaceAccessKey(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNamespaceAccessKey", reflect.TypeOf((*MockServerlessService)(nil).CreateNamespaceAccessKey), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNamespaceAccessKey", reflect.TypeOf((*MockServerlessService)(nil).CreateNamespaceAccessKey), arg0, arg1, arg2, arg3) } // CredentialsPath mocks base method. diff --git a/do/serverless.go b/do/serverless.go index fd97ff50b..07b9cf8b8 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -261,7 +261,7 @@ type ServerlessService interface { WriteProject(ServerlessProject) (string, error) SetEffectiveCredentials(auth string, apihost string) CredentialsPath() string - CreateNamespaceAccessKey(context.Context, string, string) (AccessKey, error) + CreateNamespaceAccessKey(context.Context, string, string, *int64) (AccessKey, error) ListNamespaceAccessKeys(context.Context, string) ([]AccessKey, error) DeleteNamespaceAccessKey(context.Context, string, string) error } @@ -1497,9 +1497,12 @@ func validateFunctionLevelFields(serverlessAction *ServerlessFunction) ([]string } // CreateNamespaceAccessKey creates a new access key for the specified namespace -func (s *serverlessService) CreateNamespaceAccessKey(ctx context.Context, namespace string, name string) (AccessKey, error) { +func (s *serverlessService) CreateNamespaceAccessKey(ctx context.Context, namespace string, name string, expiresInSeconds *int64) (AccessKey, error) { path := fmt.Sprintf("v2/functions/namespaces/%s/keys", namespace) - reqBody := map[string]string{"name": name} + reqBody := map[string]interface{}{"name": name} + if expiresInSeconds != nil { + reqBody["expires_in_seconds"] = *expiresInSeconds + } req, err := s.client.NewRequest(ctx, http.MethodPost, path, reqBody) if err != nil { return AccessKey{}, err From cf4de465573015c41727be0e54d7999fe429c037 Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Tue, 30 Dec 2025 10:24:15 +0530 Subject: [PATCH 14/21] Fix gofmt: use any instead of interface{} --- do/serverless.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/do/serverless.go b/do/serverless.go index 07b9cf8b8..b99151904 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -1499,7 +1499,7 @@ func validateFunctionLevelFields(serverlessAction *ServerlessFunction) ([]string // CreateNamespaceAccessKey creates a new access key for the specified namespace func (s *serverlessService) CreateNamespaceAccessKey(ctx context.Context, namespace string, name string, expiresInSeconds *int64) (AccessKey, error) { path := fmt.Sprintf("v2/functions/namespaces/%s/keys", namespace) - reqBody := map[string]interface{}{"name": name} + reqBody := map[string]any{"name": name} if expiresInSeconds != nil { reqBody["expires_in_seconds"] = *expiresInSeconds } From 34b8aec0cb10c8dcb43d09135e5ea0be30242962 Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Tue, 6 Jan 2026 11:29:36 +0530 Subject: [PATCH 15/21] adding custom expiration --- commands/keys.go | 78 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/commands/keys.go b/commands/keys.go index c7224811f..48c2c3940 100644 --- a/commands/keys.go +++ b/commands/keys.go @@ -16,6 +16,8 @@ package commands import ( "context" "fmt" + "strconv" + "strings" "github.com/digitalocean/doctl" "github.com/digitalocean/doctl/commands/displayers" @@ -41,11 +43,12 @@ Keys operate on the currently connected namespace by default, but can target any `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" --expiration 30d - doctl serverless key create --name "ci-cd-key" --namespace fn-abc123 --expiration never`, + doctl serverless key create --name "my-laptop-key" --expiration 7d + doctl serverless key create --name "ci-cd-key" --namespace fn-abc123 --expiration 24h + doctl serverless key create --name "permanent-key" --expiration never`, Writer) AddStringFlag(create, "name", "n", "", "name for the access key", requiredOpt()) - AddStringFlag(create, "expiration", "e", "", "expiration period: 30d, 60d, 90d, 1y, or never", requiredOpt()) + AddStringFlag(create, "expiration", "e", "", "expiration period: h, d (min 1h), or never (e.g., 1h, 7d, 30d)", requiredOpt()) AddStringFlag(create, "namespace", "", "", "target namespace (uses connected namespace if not specified)") list := CmdBuilder(cmd, RunAccessKeyList, "list", "Lists access keys", @@ -76,25 +79,16 @@ func RunAccessKeyCreate(c *CmdConfig) error { namespace, _ := c.Doit.GetString(c.NS, "namespace") expirationStr, _ := c.Doit.GetString(c.NS, "expiration") - // Validate expiration + // Validate and parse expiration var expiresInSeconds *int64 - switch expirationStr { - case "30d": - seconds := int64(30 * 24 * 60 * 60) - expiresInSeconds = &seconds - case "60d": - seconds := int64(60 * 24 * 60 * 60) - expiresInSeconds = &seconds - case "90d": - seconds := int64(90 * 24 * 60 * 60) - expiresInSeconds = &seconds - case "1y": - seconds := int64(365 * 24 * 60 * 60) - expiresInSeconds = &seconds - case "never": + if expirationStr == "never" { expiresInSeconds = nil - default: - return fmt.Errorf("invalid expiration value '%s'. Must be one of: 30d, 60d, 90d, 1y, or never", expirationStr) + } else { + seconds, err := parseExpirationDuration(expirationStr) + if err != nil { + return err + } + expiresInSeconds = &seconds } // Resolve target namespace @@ -222,3 +216,47 @@ func resolveTargetNamespace(c *CmdConfig, explicitNamespace string) (string, err return creds.Namespace, nil } + +// parseExpirationDuration parses a duration string in format h or d +// Returns the duration in seconds and validates minimum TTL of 1h +func parseExpirationDuration(duration string) (int64, error) { + duration = strings.TrimSpace(duration) + if duration == "" { + return 0, fmt.Errorf("expiration duration cannot be empty") + } + + // Check if it ends with 'h' (hour) or 'd' (day) + var unit string + var multiplier int64 + + if strings.HasSuffix(duration, "h") { + unit = "h" + multiplier = 3600 // seconds in an hour + } else if strings.HasSuffix(duration, "d") { + unit = "d" + multiplier = 86400 // seconds in a day + } else { + return 0, fmt.Errorf("invalid expiration format '%s'. Must be in format h or d (e.g., 1h, 7d)", duration) + } + + // Extract the numeric part + numericPart := strings.TrimSuffix(duration, unit) + value, err := strconv.ParseInt(numericPart, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid expiration format '%s'. Must be in format h or d (e.g., 1h, 7d)", duration) + } + + if value <= 0 { + return 0, fmt.Errorf("expiration duration must be a positive number") + } + + // Calculate total seconds + seconds := value * multiplier + + // Validate minimum TTL of 1 hour (3600 seconds) + if seconds < 3600 { + return 0, fmt.Errorf("minimum expiration duration is 1h (1 hour)") + } + + return seconds, nil +} From 4e42675c32caa347b9ccbf797e2b343119041c9e Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Tue, 6 Jan 2026 11:34:47 +0530 Subject: [PATCH 16/21] remove secret --- commands/displayers/access_keys.go | 33 ++++++++++++++++-------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/commands/displayers/access_keys.go b/commands/displayers/access_keys.go index d9ad31f37..5c480580d 100644 --- a/commands/displayers/access_keys.go +++ b/commands/displayers/access_keys.go @@ -33,24 +33,31 @@ func (ak *AccessKeys) JSON(out io.Writer) error { // Cols implements Displayable. func (ak *AccessKeys) Cols() []string { - return []string{ + cols := []string{ "ID", "Name", - "Secret", - "CreatedAt", - "ExpiresAt", } + // Only show Secret during creation (when ShowFullSecret is true) + if ak.ShowFullSecret { + cols = append(cols, "Secret") + } + cols = append(cols, "CreatedAt", "ExpiresAt") + return cols } // ColMap implements Displayable. func (ak *AccessKeys) ColMap() map[string]string { - return map[string]string{ + colMap := map[string]string{ "ID": "ID", "Name": "Name", - "Secret": "Secret", "CreatedAt": "Created At", "ExpiresAt": "Expires At", } + // Only include Secret column during creation + if ak.ShowFullSecret { + colMap["Secret"] = "Secret" + } + return colMap } // KV implements Displayable. @@ -58,14 +65,6 @@ 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 { @@ -81,11 +80,15 @@ func (ak *AccessKeys) KV() []map[string]any { m := map[string]any{ "ID": displayID, "Name": key.Name, - "Secret": secret, "CreatedAt": key.CreatedAt.Format("2006-01-02 15:04:05 UTC"), "ExpiresAt": expiresAt, } + // Only include Secret field during creation (when API returns it) + if ak.ShowFullSecret && key.Secret != "" { + m["Secret"] = key.Secret + } + out = append(out, m) } From 1615f5eaf404a9d19c477bdf25187ab97c28ad2e Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Thu, 29 Jan 2026 12:35:54 +0530 Subject: [PATCH 17/21] Update tests --- commands/keys_test.go | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/commands/keys_test.go b/commands/keys_test.go index 21cc01072..ddd7525d1 100644 --- a/commands/keys_test.go +++ b/commands/keys_test.go @@ -135,7 +135,7 @@ func TestAccessKeyCreate(t *testing.T) { "expiration": "30d", }, expectedCalls: func(tm *tcMocks) { - expires := int64(30 * 24 * 60 * 60) + expires := int64(30 * 24 * 60 * 60) // 30 days in seconds tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) @@ -148,7 +148,7 @@ func TestAccessKeyCreate(t *testing.T) { "expiration": "60d", }, expectedCalls: func(tm *tcMocks) { - expires := int64(60 * 24 * 60 * 60) + expires := int64(60 * 24 * 60 * 60) // 60 days in seconds tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) @@ -161,7 +161,7 @@ func TestAccessKeyCreate(t *testing.T) { "expiration": "90d", }, expectedCalls: func(tm *tcMocks) { - expires := int64(90 * 24 * 60 * 60) + expires := int64(90 * 24 * 60 * 60) // 90 days in seconds tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) @@ -171,10 +171,10 @@ func TestAccessKeyCreate(t *testing.T) { name: "create with 1 year expiration", flags: map[string]any{ "name": "my-key", - "expiration": "1y", + "expiration": "365d", }, expectedCalls: func(tm *tcMocks) { - expires := int64(365 * 24 * 60 * 60) + expires := int64(365 * 24 * 60 * 60) // 365 days in seconds tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) @@ -186,7 +186,7 @@ func TestAccessKeyCreate(t *testing.T) { "name": "my-key", "expiration": "invalid", }, - expectedError: "invalid expiration value 'invalid'. Must be one of: 30d, 60d, 90d, 1y, or never", + expectedError: "invalid expiration format 'invalid'. Must be in format h or d (e.g., 1h, 7d)", }, { name: "create with empty expiration", @@ -194,7 +194,23 @@ func TestAccessKeyCreate(t *testing.T) { "name": "my-key", "expiration": "", }, - expectedError: "invalid expiration value ''. Must be one of: 30d, 60d, 90d, 1y, or never", + expectedError: "expiration duration cannot be empty", + }, + { + name: "create with sub-hour expiration", + flags: map[string]any{ + "name": "my-key", + "expiration": "30m", + }, + expectedError: "invalid expiration format '30m'. Must be in format h or d (e.g., 1h, 7d)", + }, + { + name: "create with zero hour expiration", + flags: map[string]any{ + "name": "my-key", + "expiration": "0h", + }, + expectedError: "expiration duration must be a positive number", }, } @@ -508,7 +524,6 @@ func TestAccessKeyListOutput(t *testing.T) { 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") From cd6b26ba0dccce719e09f72b6fa3009d84ef819d Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Fri, 30 Jan 2026 13:21:16 +0530 Subject: [PATCH 18/21] Post testing --- commands/displayers/access_keys.go | 8 +------- commands/keys.go | 12 +++++------- commands/keys_test.go | 18 +++++++----------- do/mocks/ServerlessService.go | 2 +- do/serverless.go | 17 +++++++++-------- 5 files changed, 23 insertions(+), 34 deletions(-) diff --git a/commands/displayers/access_keys.go b/commands/displayers/access_keys.go index 5c480580d..fc8169727 100644 --- a/commands/displayers/access_keys.go +++ b/commands/displayers/access_keys.go @@ -71,14 +71,8 @@ func (ak *AccessKeys) KV() []map[string]any { 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, + "ID": key.ID, "Name": key.Name, "CreatedAt": key.CreatedAt.Format("2006-01-02 15:04:05 UTC"), "ExpiresAt": expiresAt, diff --git a/commands/keys.go b/commands/keys.go index 48c2c3940..42cb8288d 100644 --- a/commands/keys.go +++ b/commands/keys.go @@ -80,15 +80,13 @@ func RunAccessKeyCreate(c *CmdConfig) error { expirationStr, _ := c.Doit.GetString(c.NS, "expiration") // Validate and parse expiration - var expiresInSeconds *int64 - if expirationStr == "never" { - expiresInSeconds = nil - } else { - seconds, err := parseExpirationDuration(expirationStr) + expirationToSend := "" + if expirationStr != "never" { + _, err := parseExpirationDuration(expirationStr) if err != nil { return err } - expiresInSeconds = &seconds + expirationToSend = expirationStr } // Resolve target namespace @@ -101,7 +99,7 @@ func RunAccessKeyCreate(c *CmdConfig) error { ss := c.Serverless() ctx := context.TODO() - accessKey, err := ss.CreateNamespaceAccessKey(ctx, targetNamespace, name, expiresInSeconds) + accessKey, err := ss.CreateNamespaceAccessKey(ctx, targetNamespace, name, expirationToSend) if err != nil { return err } diff --git a/commands/keys_test.go b/commands/keys_test.go index ddd7525d1..f3bb6f70c 100644 --- a/commands/keys_test.go +++ b/commands/keys_test.go @@ -85,7 +85,7 @@ func TestAccessKeyCreate(t *testing.T) { 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", (*int64)(nil)).Return(testAccessKey, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", "").Return(testAccessKey, nil) }, }, { @@ -99,7 +99,7 @@ func TestAccessKeyCreate(t *testing.T) { 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", (*int64)(nil)).Return(testAccessKey, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-explicit-namespace", "my-key", "").Return(testAccessKey, nil) }, }, { @@ -113,7 +113,7 @@ func TestAccessKeyCreate(t *testing.T) { // 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", "", (*int64)(nil)).Return(do.AccessKey{}, assert.AnError) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "", "").Return(do.AccessKey{}, assert.AnError) }, expectedError: "assert.AnError", // API will reject empty name }, @@ -135,10 +135,9 @@ func TestAccessKeyCreate(t *testing.T) { "expiration": "30d", }, expectedCalls: func(tm *tcMocks) { - expires := int64(30 * 24 * 60 * 60) // 30 days in seconds tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) - tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", "30d").Return(testAccessKey, nil) }, }, { @@ -148,10 +147,9 @@ func TestAccessKeyCreate(t *testing.T) { "expiration": "60d", }, expectedCalls: func(tm *tcMocks) { - expires := int64(60 * 24 * 60 * 60) // 60 days in seconds tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) - tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", "60d").Return(testAccessKey, nil) }, }, { @@ -161,10 +159,9 @@ func TestAccessKeyCreate(t *testing.T) { "expiration": "90d", }, expectedCalls: func(tm *tcMocks) { - expires := int64(90 * 24 * 60 * 60) // 90 days in seconds tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) - tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", "90d").Return(testAccessKey, nil) }, }, { @@ -174,10 +171,9 @@ func TestAccessKeyCreate(t *testing.T) { "expiration": "365d", }, expectedCalls: func(tm *tcMocks) { - expires := int64(365 * 24 * 60 * 60) // 365 days in seconds tm.serverless.EXPECT().CheckServerlessStatus().Return(nil) tm.serverless.EXPECT().ReadCredentials().Return(testServerlessCredentials, nil) - tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", &expires).Return(testAccessKey, nil) + tm.serverless.EXPECT().CreateNamespaceAccessKey(context.TODO(), "fn-test-namespace", "my-key", "365d").Return(testAccessKey, nil) }, }, { diff --git a/do/mocks/ServerlessService.go b/do/mocks/ServerlessService.go index aa8eddd60..4bdf81d74 100644 --- a/do/mocks/ServerlessService.go +++ b/do/mocks/ServerlessService.go @@ -102,7 +102,7 @@ func (mr *MockServerlessServiceMockRecorder) CreateNamespace(arg0, arg1, arg2 an } // CreateNamespaceAccessKey mocks base method. -func (m *MockServerlessService) CreateNamespaceAccessKey(arg0 context.Context, arg1, arg2 string, arg3 *int64) (do.AccessKey, error) { +func (m *MockServerlessService) CreateNamespaceAccessKey(arg0 context.Context, arg1, arg2, arg3 string) (do.AccessKey, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateNamespaceAccessKey", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(do.AccessKey) diff --git a/do/serverless.go b/do/serverless.go index b99151904..d28cfc819 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -200,16 +200,17 @@ type AccessKey struct { 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"` + LastUsedAt *time.Time `json:"updated_at,omitempty"` Secret string `json:"secret,omitempty"` // Only populated when creating/regenerating } type AccessKeyListResponse struct { - Keys []AccessKey `json:"keys"` + AccessKeys []AccessKey `json:"access_keys"` + Count int `json:"count"` } type AccessKeyResponse struct { - Key AccessKey `json:"key"` + Key AccessKey `json:"access_key"` } type TriggerScheduledDetails struct { @@ -261,7 +262,7 @@ type ServerlessService interface { WriteProject(ServerlessProject) (string, error) SetEffectiveCredentials(auth string, apihost string) CredentialsPath() string - CreateNamespaceAccessKey(context.Context, string, string, *int64) (AccessKey, error) + CreateNamespaceAccessKey(context.Context, string, string, string) (AccessKey, error) ListNamespaceAccessKeys(context.Context, string) ([]AccessKey, error) DeleteNamespaceAccessKey(context.Context, string, string) error } @@ -1497,11 +1498,11 @@ func validateFunctionLevelFields(serverlessAction *ServerlessFunction) ([]string } // CreateNamespaceAccessKey creates a new access key for the specified namespace -func (s *serverlessService) CreateNamespaceAccessKey(ctx context.Context, namespace string, name string, expiresInSeconds *int64) (AccessKey, error) { +func (s *serverlessService) CreateNamespaceAccessKey(ctx context.Context, namespace string, name string, expiration string) (AccessKey, error) { path := fmt.Sprintf("v2/functions/namespaces/%s/keys", namespace) reqBody := map[string]any{"name": name} - if expiresInSeconds != nil { - reqBody["expires_in_seconds"] = *expiresInSeconds + if expiration != "" && expiration != "never" { + reqBody["expires_in"] = expiration } req, err := s.client.NewRequest(ctx, http.MethodPost, path, reqBody) if err != nil { @@ -1527,7 +1528,7 @@ func (s *serverlessService) ListNamespaceAccessKeys(ctx context.Context, namespa if err != nil { return []AccessKey{}, err } - return decoded.Keys, nil + return decoded.AccessKeys, nil } // DeleteNamespaceAccessKey deletes an access key from the specified namespace From 1498c2d58964f423289876076264475f0bc0df3d Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Fri, 30 Jan 2026 15:48:10 +0530 Subject: [PATCH 19/21] Test fix --- commands/keys_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commands/keys_test.go b/commands/keys_test.go index f3bb6f70c..fa191325e 100644 --- a/commands/keys_test.go +++ b/commands/keys_test.go @@ -516,9 +516,9 @@ func TestAccessKeyListOutput(t *testing.T) { // Test output contains expected elements output := buf.String() - assert.Contains(t, output, "dof_v1_abc12...") // ID truncated to 12 chars + ... + assert.Contains(t, output, "dof_v1_abc123def456ghi789") assert.Contains(t, output, "laptop-key") - assert.Contains(t, output, "dof_v1_xyz78...") // ID truncated to 12 chars + ... + assert.Contains(t, output, "dof_v1_xyz789abc123def456") assert.Contains(t, output, "ci-cd-key") assert.Contains(t, output, "2023-01-01 12:00:00 UTC") assert.Contains(t, output, "2023-02-15 09:30:00 UTC") From f9f61f12d12171a7f8eb8bbd8affc3dd2484a61a Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Sun, 1 Feb 2026 13:55:39 +0530 Subject: [PATCH 20/21] Styling the warning --- commands/keys.go | 6 +++--- commands/serverless.go | 25 +++++++++++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/commands/keys.go b/commands/keys.go index 42cb8288d..0b7e6e51c 100644 --- a/commands/keys.go +++ b/commands/keys.go @@ -60,12 +60,12 @@ Examples: 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", + 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`, + 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") diff --git a/commands/serverless.go b/commands/serverless.go index 6ed0e7947..55629901c 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -26,6 +26,7 @@ import ( "github.com/digitalocean/doctl" "github.com/digitalocean/doctl/commands/charm/template" + text "github.com/digitalocean/doctl/commands/charm/text" "github.com/digitalocean/doctl/do" "github.com/spf13/cobra" ) @@ -313,9 +314,15 @@ func RunServerlessConnect(c *CmdConfig) error { // 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") + warn := fmt.Sprintf("Warning: Connecting to serverless namespaces via DigitalOcean API is deprecated.\nPlease use 'doctl serverless connect %s --access-key <%s>' instead.\nThis method will be removed in a future version.\n\n", c.Args[0], accessKeyFormat) + s := text.Warning.Sprintf(warn) + // Trim trailing spaces from each line to avoid lipgloss padding causing wrap artifacts + parts := strings.Split(s, "\n") + for i := range parts { + parts[i] = strings.TrimRight(parts[i], " ") + } + s = strings.Join(parts, "\n") + fmt.Fprint(c.Out, s) list, err := getMatchingNamespaces(ctx, sls, c.Args[0]) if err != nil { @@ -327,9 +334,15 @@ 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") + warn := fmt.Sprintf("Warning: Connecting to serverless namespaces via DigitalOcean API is deprecated.\nPlease use 'doctl serverless connect --access-key <%s>' instead.\nThis method will be removed in a future version.\n\n", accessKeyFormat) + s := text.Warning.Sprintf(warn) + // Trim trailing spaces from each line to avoid lipgloss padding causing wrap artifacts + parts := strings.Split(s, "\n") + for i := range parts { + parts[i] = strings.TrimRight(parts[i], " ") + } + s = strings.Join(parts, "\n") + fmt.Fprint(c.Out, s) list, err := getMatchingNamespaces(ctx, sls, "") if err != nil { From b0c744a4bb7714f1e41ae95117e65a0f79fbcd6d Mon Sep 17 00:00:00 2001 From: Vrinda Vinod Date: Sun, 1 Feb 2026 14:00:00 +0530 Subject: [PATCH 21/21] Test fix --- commands/serverless.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commands/serverless.go b/commands/serverless.go index 55629901c..b9e5a7b3a 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -315,7 +315,7 @@ func RunServerlessConnect(c *CmdConfig) error { if len(c.Args) > 0 { // Show deprecation warning for the legacy connection method warn := fmt.Sprintf("Warning: Connecting to serverless namespaces via DigitalOcean API is deprecated.\nPlease use 'doctl serverless connect %s --access-key <%s>' instead.\nThis method will be removed in a future version.\n\n", c.Args[0], accessKeyFormat) - s := text.Warning.Sprintf(warn) + s := text.Warning.Sprint(warn) // Trim trailing spaces from each line to avoid lipgloss padding causing wrap artifacts parts := strings.Split(s, "\n") for i := range parts { @@ -335,7 +335,7 @@ func RunServerlessConnect(c *CmdConfig) error { } // Show deprecation warning for the legacy connection method warn := fmt.Sprintf("Warning: Connecting to serverless namespaces via DigitalOcean API is deprecated.\nPlease use 'doctl serverless connect --access-key <%s>' instead.\nThis method will be removed in a future version.\n\n", accessKeyFormat) - s := text.Warning.Sprintf(warn) + s := text.Warning.Sprint(warn) // Trim trailing spaces from each line to avoid lipgloss padding causing wrap artifacts parts := strings.Split(s, "\n") for i := range parts {