Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
79 changes: 79 additions & 0 deletions commands/serverless.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,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 +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")
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 +263,56 @@ func RunServerlessConnect(c *CmdConfig) error {

ctx := context.TODO()

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
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 <your-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
Expand All @@ -272,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 <namespace> --access-key <your-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
Expand Down Expand Up @@ -513,3 +568,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)
}

// 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,
Label: ns.Label,
Credentials: map[string]map[string]do.ServerlessCredential{ns.APIHost: {ns.Namespace: credential}},
}

return finishConnecting(sls, creds, out)
}
184 changes: 181 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,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{}
Expand Down
Loading