Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions commands/serverless.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_<token>:<secret>"

// 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.
Expand Down Expand Up @@ -98,6 +101,7 @@ list your namespaces.`,
// and hence are unknown to the portal.
AddStringFlag(connect, "apihost", "", "", "")
AddStringFlag(connect, "auth", "", "", "")
AddStringFlag(connect, "access-key", "", "", "Access key for direct serverless connection")
connect.Flags().MarkHidden("apihost")
connect.Flags().MarkHidden("auth")

Expand Down Expand Up @@ -230,6 +234,8 @@ func RunServerlessConnect(c *CmdConfig) error {
// The presence of 'auth' and 'apihost' flags trumps other parts of the syntax, but both must be present.
apihost, _ := c.Doit.GetString(c.NS, "apihost")
auth, _ := c.Doit.GetString(c.NS, "auth")
accessKey, _ := c.Doit.GetString(c.NS, "access-key")

if len(apihost) > 0 && len(auth) > 0 {
namespace, err := sls.GetNamespaceFromCluster(apihost, auth)
if err != nil {
Expand Down Expand Up @@ -260,9 +266,56 @@ func RunServerlessConnect(c *CmdConfig) error {

ctx := context.TODO()

if len(accessKey) > 0 {
// Validate access-key format - support new "dof_v1_" formats
if err := validateAccessKeyFormat(accessKey); err != nil {
return err
}

// If namespace argument provided, use it directly
if len(c.Args) > 0 {
// Get the specific namespace the user requested
list, err := getMatchingNamespaces(ctx, sls, c.Args[0])
if err != nil {
return err
}
if len(list) == 0 {
return fmt.Errorf("no namespace found matching '%s'", c.Args[0])
}
if len(list) > 1 {
return fmt.Errorf("multiple namespaces match '%s', please be more specific", c.Args[0])
}

// Use the found namespace with the provided access-key
ns := list[0]
return connectWithAccessKey(sls, ns, accessKey, c.Out)
} else {
// No namespace specified, show menu
list, err := getMatchingNamespaces(ctx, sls, "")
if err != nil {
return err
}
if len(list) == 0 {
return errors.New("you must create a namespace first")
}

// Let user choose, then connect with access-key
ns := chooseFromList(list, c.Out)
if ns.Namespace == "" {
return nil // User chose to exit
}
return connectWithAccessKey(sls, ns, accessKey, c.Out)
}
}

// If an arg is specified, retrieve the namespaces that match and proceed according to whether there
// are 0, 1, or >1 matches.
if len(c.Args) > 0 {
// Show deprecation warning for the legacy connection method
fmt.Fprintf(c.Out, "Warning: Connecting to serverless namespaces via DigitalOcean API is deprecated.\n")
fmt.Fprintf(c.Out, "Please use 'doctl serverless connect %s --access-key <%s>' instead.\n", c.Args[0], accessKeyFormat)
fmt.Fprintf(c.Out, "This method will be removed in a future version.\n\n")

list, err := getMatchingNamespaces(ctx, sls, c.Args[0])
if err != nil {
return err
Expand All @@ -272,6 +325,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 <namespace> --access-key <%s>' instead.\n", accessKeyFormat)
fmt.Fprintf(c.Out, "This method will be removed in a future version.\n\n")

list, err := getMatchingNamespaces(ctx, sls, "")
if err != nil {
return err
Expand All @@ -282,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.
Expand Down Expand Up @@ -513,3 +606,27 @@ func RunServerlessUndeploy(c *CmdConfig) error {
template.Print(`{{success checkmark}} The requested resources have been undeployed.{{nl}}`, nil)
return nil
}

func connectWithAccessKey(sls do.ServerlessService, ns do.OutputNamespace, accessKey string, out io.Writer) error {
// Test if the access key works with this namespace's API host
namespace, err := sls.GetNamespaceFromCluster(ns.APIHost, accessKey)
if err != nil {
return fmt.Errorf("failed to connect with provided access-key: %w", err)
}

// Verify it matches the expected namespace
if namespace != ns.Namespace {
return fmt.Errorf("access-key does not match namespace '%s'", ns.Namespace)
}

// Save credentials using the provided access-key and namespace's API host
credential := do.ServerlessCredential{Auth: accessKey}
creds := do.ServerlessCredentials{
APIHost: ns.APIHost,
Namespace: ns.Namespace,
Label: ns.Label,
Credentials: map[string]map[string]do.ServerlessCredential{ns.APIHost: {ns.Namespace: credential}},
}

return finishConnecting(sls, creds, out)
}
205 changes: 202 additions & 3 deletions commands/serverless_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <namespace> --access-key <your-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",
Expand All @@ -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 <namespace> --access-key <your-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",
Expand All @@ -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 <your-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 {
Expand Down Expand Up @@ -122,6 +122,205 @@ func TestServerlessConnect(t *testing.T) {
}
}

func TestServerlessConnectWithAccessKey(t *testing.T) {
withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
buf := &bytes.Buffer{}
config.Out = buf
config.Args = []string{"ns1"}

config.Doit.Set(config.NS, "access-key", "dof_v1_abc123:xyz789")

// Follow existing pattern: OutputNamespace has APIHost for access-key functionality
nsResponse := do.NamespaceListResponse{
Namespaces: []do.OutputNamespace{
{
Namespace: "ns1",
Region: "nyc1",
Label: "test-label",
APIHost: "https://api.example.com",
},
},
}

tm.serverless.EXPECT().CheckServerlessStatus().Return(do.ErrServerlessNotConnected)
ctx := context.TODO()
tm.serverless.EXPECT().ListNamespaces(ctx).Return(nsResponse, nil)
tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", "dof_v1_abc123:xyz789").Return("ns1", nil)

// Note: WriteCredentials expects the credentials object that will be created
creds := do.ServerlessCredentials{
APIHost: "https://api.example.com",
Namespace: "ns1",
Label: "test-label",
Credentials: map[string]map[string]do.ServerlessCredential{
"https://api.example.com": {
"ns1": do.ServerlessCredential{Auth: "dof_v1_abc123:xyz789"},
},
},
}
tm.serverless.EXPECT().WriteCredentials(creds).Return(nil)

err := RunServerlessConnect(config)
require.NoError(t, err)
assert.Contains(t, buf.String(), "Connected to functions namespace 'ns1' on API host 'https://api.example.com' (label=test-label)")
})
}

func TestServerlessConnectWithInvalidAccessKey(t *testing.T) {
tests := []struct {
name string
accessKey string
args []string
wantError string
setupMocks bool
}{
{
name: "no colon separator",
accessKey: "invalid-key-no-colon",
args: []string{"ns1"},
wantError: "access-key must 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,
},
{
name: "wrong prefix",
accessKey: "wrong_prefix_token:secret",
args: []string{"ns1"},
wantError: "access-key must start with 'dof_v1_' prefix",
setupMocks: true,
},
{
name: "correct prefix but empty token",
accessKey: "dof_v1_:secret",
args: []string{"ns1"},
wantError: "access-key token part cannot be empty after 'dof_v1_' prefix",
setupMocks: true,
},
{
name: "correct prefix but empty secret",
accessKey: "dof_v1_token:",
args: []string{"ns1"},
wantError: "access-key secret part cannot be empty",
setupMocks: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
buf := &bytes.Buffer{}
config.Out = buf

if len(tt.args) > 0 {
config.Args = tt.args
}

if tt.accessKey != "" {
config.Doit.Set(config.NS, "access-key", tt.accessKey)
}

if tt.setupMocks {
tm.serverless.EXPECT().CheckServerlessStatus().Return(do.ErrServerlessNotConnected)

// Only expect ListNamespaces if we get past validation
if tt.wantError == "" {
ctx := context.TODO()
nsResponse := do.NamespaceListResponse{
Namespaces: []do.OutputNamespace{
{
Namespace: "ns1",
Region: "nyc1",
Label: "test-label",
APIHost: "https://api.example.com",
},
},
}
tm.serverless.EXPECT().ListNamespaces(ctx).Return(nsResponse, nil)

var creds do.ServerlessCredentials

if tt.accessKey != "" {
// Access-key path: validate with cluster and create credentials
tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", tt.accessKey).Return("ns1", nil)

creds = do.ServerlessCredentials{
APIHost: "https://api.example.com",
Namespace: "ns1",
Label: "test-label",
Credentials: map[string]map[string]do.ServerlessCredential{
"https://api.example.com": {
"ns1": do.ServerlessCredential{Auth: tt.accessKey},
},
},
}
} else {
// Legacy path: use DigitalOcean API to get namespace credentials
creds = do.ServerlessCredentials{
APIHost: "https://api.example.com",
Namespace: "ns1",
Label: "test-label",
}
tm.serverless.EXPECT().GetNamespace(ctx, "ns1").Return(creds, nil)
}

tm.serverless.EXPECT().WriteCredentials(creds).Return(nil)
}
}

err := RunServerlessConnect(config)

if tt.wantError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.wantError)
} else {
require.NoError(t, err)
}
})
})
}
}

func TestServerlessConnectWithFailingAccessKey(t *testing.T) {
withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
buf := &bytes.Buffer{}
config.Out = buf
config.Args = []string{"ns1"}
config.Doit.Set(config.NS, "access-key", "dof_v1_bad:key")

nsResponse := do.NamespaceListResponse{
Namespaces: []do.OutputNamespace{{
Namespace: "ns1",
Region: "nyc1",
Label: "test-label",
APIHost: "https://api.example.com",
}},
}
tm.serverless.EXPECT().CheckServerlessStatus().Return(do.ErrServerlessNotConnected)
ctx := context.TODO()
tm.serverless.EXPECT().ListNamespaces(ctx).Return(nsResponse, nil)
// This is where the access-key fails - GetNamespaceFromCluster returns an error
tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", "dof_v1_bad:key").Return("", errors.New("invalid credentials"))

err := RunServerlessConnect(config)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to connect with provided access-key")
assert.Contains(t, err.Error(), "invalid credentials")
})
}

func TestServerlessStatusWhenConnected(t *testing.T) {
withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
buf := &bytes.Buffer{}
Expand Down
Loading