From 0a5c8ae166ab091c8de65047727d2597b01de559 Mon Sep 17 00:00:00 2001 From: "manderjaska@launchdarkly.com" Date: Mon, 5 Oct 2026 13:53:18 +0000 Subject: [PATCH 1/9] Add ldcli aws-devops-agent setup, status and teardown --- README.md | 30 + cmd/aws_devops_agent/aws_devops_agent.go | 66 +++ cmd/aws_devops_agent/manual_steps.go | 219 +++++++ cmd/aws_devops_agent/manual_steps_test.go | 32 ++ cmd/aws_devops_agent/setup.go | 235 ++++++++ cmd/aws_devops_agent/status.go | 75 +++ cmd/aws_devops_agent/teardown.go | 96 ++++ cmd/root.go | 17 +- cmd/templates.go | 1 + go.mod | 17 + go.sum | 34 ++ internal/awsdevops/awsdevops.go | 129 +++++ internal/awsdevops/awsdevops_test.go | 576 +++++++++++++++++++ internal/awsdevops/cliversion.go | 71 +++ internal/awsdevops/cliversion_test.go | 55 ++ internal/awsdevops/setup.go | 663 ++++++++++++++++++++++ internal/awsdevops/status.go | 151 +++++ internal/awsdevops/teardown.go | 211 +++++++ internal/awsdevops/wait.go | 107 ++++ internal/awsdevops/wait_test.go | 82 +++ 20 files changed, 2860 insertions(+), 7 deletions(-) create mode 100644 cmd/aws_devops_agent/aws_devops_agent.go create mode 100644 cmd/aws_devops_agent/manual_steps.go create mode 100644 cmd/aws_devops_agent/manual_steps_test.go create mode 100644 cmd/aws_devops_agent/setup.go create mode 100644 cmd/aws_devops_agent/status.go create mode 100644 cmd/aws_devops_agent/teardown.go create mode 100644 internal/awsdevops/awsdevops.go create mode 100644 internal/awsdevops/awsdevops_test.go create mode 100644 internal/awsdevops/cliversion.go create mode 100644 internal/awsdevops/cliversion_test.go create mode 100644 internal/awsdevops/setup.go create mode 100644 internal/awsdevops/status.go create mode 100644 internal/awsdevops/teardown.go create mode 100644 internal/awsdevops/wait.go create mode 100644 internal/awsdevops/wait_test.go diff --git a/README.md b/README.md index 621da7a12..6ce65333b 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,36 @@ LaunchDarkly CLI commands: - `setup` guides you through creating your first flag, connecting an SDK, and evaluating your flag in your Test environment - `dev-server` lets you start a local server and retrieve flag values from a LaunchDarkly source environment so you can test your code locally. For assistance starting with or running dev-server, refer to the [reference docs](https://launchdarkly.com/docs/guides/flags/ldcli-dev-server). +- `aws-devops-agent` provisions the AWS DevOps Agent integration in your own AWS account + +### AWS DevOps Agent + +`ldcli aws-devops-agent` creates the AWS resources the [AWS DevOps Agent](https://aws.amazon.com/devops-agent/) needs to manage your LaunchDarkly flags: the IAM roles it assumes, an agent space, the association with your AWS account, the operator web app, and the LaunchDarkly MCP server connection. + +The commands use your existing AWS session rather than any cross-account LaunchDarkly role, so authenticate first. They fail before creating anything if no credentials are available: + +```sh-session +aws sso login --profile my-profile +export AWS_PROFILE=my-profile AWS_REGION=us-east-1 + +ldcli aws-devops-agent setup +``` + +The AWS DevOps Agent is available in `us-east-1`, `us-west-2`, `ap-southeast-2`, `ap-northeast-1`, `eu-central-1` and `eu-west-1`, and requires AWS CLI 2.36 or later if you also use the AWS CLI directly. `setup` warns when the `aws` binary on your PATH is missing or older than that; the command itself uses the AWS SDK, so it still runs. + +`setup` is safe to re-run: it reuses the IAM roles, the agent space matching `--agent-space-name`, the account and MCP associations on it, and any LaunchDarkly MCP server already registered on the account. Pass `--new-agent-space` to create an additional agent space instead. + +`--access-token` is optional. The first available token — the flag, then `LD_ACCESS_TOKEN`, then the one already in your ldcli configuration — is registered with AWS as the bearer token the agent uses to call the LaunchDarkly MCP server, so it should be a [service token](https://launchdarkly.com/docs/home/account/api-create) whose permissions match what you want the agent to do. AWS keeps it until the MCP server is re-registered, so a session token written by `ldcli login` eventually expires and the agent then fails with `unauthorized` errors. Re-run with `--access-token --replace-mcp-token` to re-register an MCP server with a different token. By default the agent may call `list-projects`, `list-flags` and `get-flag` without asking, and must ask for approval before calling `toggle-flag`. Use `--mcp-read-only-tools` and `--mcp-mutative-tools` to change that. + +With no token configured at all, `setup` pauses at the LaunchDarkly page where you create a service token and connects the MCP server with the token you paste there, so you do not need one ready beforehand. Pressing Enter without a token skips the step, which `--skip-mcp-server` also does up front. + +`setup` ends by printing the operator app URL, `https://.aidevops.global.app.aws`, which is where you use the agent, together with the AWS console page that manages it. Pass `--no-wait` to skip every pause and list the remaining manual steps instead, for example in CI: + +```sh-session +ldcli aws-devops-agent setup --no-wait --access-token +``` + +`ldcli aws-devops-agent status` shows what exists. `ldcli aws-devops-agent teardown` removes everything in the account and region — every agent space, every registered service including the MCP server, and the IAM roles — after asking you to confirm (`--force` skips the prompt, and is required when there is no terminal). `--agent-space-id ` and `--service-id ` narrow it to those resources, and `--delete-roles` adds the IAM roles back to a narrowed teardown. ### Resource Commands diff --git a/cmd/aws_devops_agent/aws_devops_agent.go b/cmd/aws_devops_agent/aws_devops_agent.go new file mode 100644 index 000000000..956ff2ecb --- /dev/null +++ b/cmd/aws_devops_agent/aws_devops_agent.go @@ -0,0 +1,66 @@ +// Package awsdevopsagent provisions the AWS DevOps Agent so it can manage +// LaunchDarkly flags. Commands run against the caller's own AWS session. +package awsdevopsagent + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/analytics" +) + +const ( + regionFlag = "region" + agentSpaceIDFlag = "agent-space-id" +) + +func NewAWSDevOpsAgentCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { + cmd := &cobra.Command{ + Use: "aws-devops-agent", + Short: "Set up the AWS DevOps Agent", + Long: `Provision the AWS DevOps Agent integration in your own AWS account. + +These commands use your existing AWS session: authenticate first (for example +with "aws sso login" or by exporting AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY +and AWS_SESSION_TOKEN) and they fail before changing anything if no credentials +are available.`, + Args: cobra.MinimumNArgs(1), + } + + cmd.AddCommand(NewSetupCmd(analyticsTrackerFn)) + cmd.AddCommand(NewStatusCmd(analyticsTrackerFn)) + cmd.AddCommand(NewTeardownCmd(analyticsTrackerFn)) + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} + +func trackRun(analyticsTrackerFn analytics.TrackerFn) func(*cobra.Command, []string) { + return func(cmd *cobra.Command, args []string) { + analyticsTrackerFn( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + viper.GetBool(cliflags.AnalyticsOptOut), + ).SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties( + cmd, + "aws-devops-agent", + map[string]interface{}{"action": cmd.Name()}, + )) + } +} + +func printJSON(cmd *cobra.Command, value any) error { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) + + return nil +} diff --git a/cmd/aws_devops_agent/manual_steps.go b/cmd/aws_devops_agent/manual_steps.go new file mode 100644 index 000000000..843cca935 --- /dev/null +++ b/cmd/aws_devops_agent/manual_steps.go @@ -0,0 +1,219 @@ +package awsdevopsagent + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/launchdarkly/ldcli/internal/awsdevops" +) + +const ( + skipKey = 's' + etx = 3 + backspace = 8 + del = 127 +) + +func manualStepPrompt(step awsdevops.ManualStep) string { + switch step.Kind { + case awsdevops.ManualStepOAuthConsent: + return "Approve the LaunchDarkly MCP server consent screen at:" + default: + return step.Description + } +} + +// walkManualSteps pauses on each browser-only step so the operator can finish +// it, then picks up whatever they created. Steps they skip are returned so the +// caller can still list them. +func walkManualSteps( + cmd *cobra.Command, + clients awsdevops.Clients, + opts awsdevops.SetupOptions, + result *awsdevops.SetupResult, +) []awsdevops.ManualStep { + keys := newKeyReader(cmd.InOrStdin()) + defer keys.close() + out := keys.writer(cmd.OutOrStdout()) + errOut := keys.writer(cmd.ErrOrStderr()) + + var skipped []awsdevops.ManualStep + for _, step := range result.RemainingManualSteps { + if step.Kind == awsdevops.ManualStepMCPServer { + if !connectMCPServer(cmd, clients, opts, result, keys, out, errOut) { + skipped = append(skipped, step) + } + + continue + } + + _, _ = fmt.Fprintf(out, "\n%s\n %s\n\n", manualStepPrompt(step), step.URL) + + _, _ = fmt.Fprint(out, "Press Enter when you're done, or s to skip: ") + key := keys.next() + _, _ = fmt.Fprintln(out) + if key == skipKey { + skipped = append(skipped, step) + } + } + + return skipped +} + +// connectMCPServer registers the LaunchDarkly MCP server with a service token +// pasted at the prompt, and reports whether the step is done. +func connectMCPServer( + cmd *cobra.Command, + clients awsdevops.Clients, + opts awsdevops.SetupOptions, + result *awsdevops.SetupResult, + keys *keyReader, + out io.Writer, + errOut io.Writer, +) bool { + _, _ = fmt.Fprintf( + out, + "\nCreate a LaunchDarkly service token at:\n %s\n\n", + awsdevops.AccessTokenURL(opts.LDBaseURI), + ) + _, _ = fmt.Fprint(out, "Paste the token, or press Enter to skip: ") + token := keys.line() + _, _ = fmt.Fprintln(out) + if token == "" { + return false + } + + opts.LDAccessToken = token + if err := awsdevops.RegisterMCPServer(cmd.Context(), clients, opts, result); err != nil { + _, _ = fmt.Fprintf(errOut, "%s\n", err) + + return false + } + + return true +} + +// keyReader delivers single keypresses from one long-lived goroutine, so a +// pause that gives up on stdin cannot leave a reader behind to swallow the +// keys meant for the next one. On a terminal it switches to raw mode, where a +// key registers without Enter. +type keyReader struct { + keys chan byte + restore func() + raw bool +} + +func newKeyReader(in io.Reader) *keyReader { + reader := &keyReader{keys: make(chan byte, 1), restore: func() {}} + + if file, ok := in.(*os.File); ok && term.IsTerminal(int(file.Fd())) { + if state, err := term.MakeRaw(int(file.Fd())); err == nil { + reader.restore = func() { _ = term.Restore(int(file.Fd()), state) } + reader.raw = true + } + } + + go reader.read(in) + + return reader +} + +func (k *keyReader) read(in io.Reader) { + buffered := bufio.NewReader(in) + for { + key, err := buffered.ReadByte() + if err != nil { + close(k.keys) + + return + } + if key == etx { + k.restore() + if process, err := os.FindProcess(os.Getpid()); err == nil { + _ = process.Signal(os.Interrupt) + } + + return + } + k.keys <- key + } +} + +func (k *keyReader) next() byte { + key, ok := <-k.keys + if !ok { + return 0 + } + if key == 'S' { + return skipKey + } + + return key +} + +// line collects keys until Enter without echoing, for prompts that take a +// credential. +func (k *keyReader) line() string { + return k.readLine(nil) +} + +func (k *keyReader) readLine(echo io.Writer) string { + var typed []byte + for { + key, ok := <-k.keys + if !ok || key == '\r' || key == '\n' { + return strings.TrimSpace(string(typed)) + } + if key == backspace || key == del { + if len(typed) > 0 { + typed = typed[:len(typed)-1] + if echo != nil { + _, _ = fmt.Fprint(echo, "\b \b") + } + } + + continue + } + typed = append(typed, key) + if echo != nil { + _, _ = fmt.Fprintf(echo, "%c", key) + } + } +} + +func (k *keyReader) close() { + k.restore() +} + +// writer keeps output readable while the terminal is raw, where a bare newline +// no longer returns the cursor to the first column. +func (k *keyReader) writer(w io.Writer) io.Writer { + if !k.raw { + return w + } + + return crlfWriter{w: w} +} + +type crlfWriter struct { + w io.Writer +} + +func (c crlfWriter) Write(p []byte) (int, error) { + if _, err := c.w.Write(bytes.ReplaceAll(p, []byte("\n"), []byte("\r\n"))); err != nil { + return 0, err + } + + return len(p), nil +} + +func canPrompt() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} diff --git a/cmd/aws_devops_agent/manual_steps_test.go b/cmd/aws_devops_agent/manual_steps_test.go new file mode 100644 index 000000000..403e170e2 --- /dev/null +++ b/cmd/aws_devops_agent/manual_steps_test.go @@ -0,0 +1,32 @@ +package awsdevopsagent + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKeyReaderReadsOneKeyAtATime(t *testing.T) { + keys := newKeyReader(strings.NewReader("S\rx")) + defer keys.close() + + assert.Equal(t, byte(skipKey), keys.next()) + assert.Equal(t, byte('\r'), keys.next()) + assert.Equal(t, byte('x'), keys.next()) + assert.Equal(t, byte(0), keys.next()) +} + +func TestKeyReaderReadsALineWithoutEchoing(t *testing.T) { + keys := newKeyReader(strings.NewReader("api-xx\x7f1 \r")) + defer keys.close() + + assert.Equal(t, "api-x1", keys.line()) +} + +func TestKeyReaderReadsAnEmptyLine(t *testing.T) { + keys := newKeyReader(strings.NewReader("\r")) + defer keys.close() + + assert.Empty(t, keys.line()) +} diff --git a/cmd/aws_devops_agent/setup.go b/cmd/aws_devops_agent/setup.go new file mode 100644 index 000000000..dc4ae597c --- /dev/null +++ b/cmd/aws_devops_agent/setup.go @@ -0,0 +1,235 @@ +package awsdevopsagent + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/awsdevops" +) + +const ( + agentSpaceNameFlag = "agent-space-name" + agentSpaceDescriptionFlag = "agent-space-description" + newAgentSpaceFlag = "new-agent-space" + authFlowFlag = "auth-flow" + idcInstanceARNFlag = "idc-instance-arn" + issuerURLFlag = "issuer-url" + idpClientIDFlag = "idp-client-id" + idpClientSecretFlag = "idp-client-secret" + skipOperatorAppFlag = "skip-operator-app" + skipMCPServerFlag = "skip-mcp-server" + replaceMCPTokenFlag = "replace-mcp-token" + mcpServiceIDFlag = "mcp-service-id" + mcpReadOnlyToolsFlag = "mcp-read-only-tools" + mcpMutativeToolsFlag = "mcp-mutative-tools" + skillNameFlag = "skill-name" + skillFileFlag = "skill-file" + customAgentNameFlag = "custom-agent-name" + customAgentToolsFlag = "custom-agent-tools" + scheduleFlag = "schedule" + noWaitFlag = "no-wait" +) + +func NewSetupCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { + cmd := &cobra.Command{ + Use: "setup", + Short: "Provision the AWS DevOps Agent for LaunchDarkly", + Long: `Create the IAM roles, agent space, AWS account association, operator app and +LaunchDarkly MCP server connection the AWS DevOps Agent needs. + +Re-running is safe: setup reuses the agent space matching --agent-space-name +along with anything already attached to it. The run ends with the operator app +URL, where the agent is used. + +The MCP server is connected with --access-token, or with the token in your +ldcli configuration when you do not pass one. With neither, setup pauses at the +LaunchDarkly page where you create a service token and uses the token you +paste. AWS keeps that token and the agent acts as its account and role, so a +session token from 'ldcli login' stops working once it expires. + +In a terminal, setup pauses on each browser step with the page to open and +resumes once you are done. Pass --no-wait to only list them.`, + Args: cobra.NoArgs, + PreRun: trackRun(analyticsTrackerFn), + RunE: runSetup, + } + + cmd.Flags().String(regionFlag, "", "AWS region to provision in. Defaults to the region of the current AWS session") + cmd.Flags().String(agentSpaceIDFlag, "", "Existing agent space to add to instead of creating one") + cmd.Flags().String(agentSpaceNameFlag, "launchdarkly", "Name of the agent space to create") + cmd.Flags().String(agentSpaceDescriptionFlag, "Managed by the LaunchDarkly CLI", "Description of the agent space to create") + cmd.Flags().Bool(newAgentSpaceFlag, false, "Create another agent space instead of reusing the one matching --agent-space-name") + cmd.Flags().String(authFlowFlag, "iam", "Operator app sign-in method: iam, idc or idp") + cmd.Flags().String(idcInstanceARNFlag, "", "IAM Identity Center instance ARN, required when --auth-flow=idc") + cmd.Flags().String(issuerURLFlag, "", "OIDC issuer URL, required when --auth-flow=idp") + cmd.Flags().String(idpClientIDFlag, "", "OIDC client ID, required when --auth-flow=idp") + cmd.Flags().String(idpClientSecretFlag, "", "OIDC client secret, required when --auth-flow=idp") + cmd.Flags().Bool(skipOperatorAppFlag, false, "Skip the operator app role and web app") + cmd.Flags().Bool(skipMCPServerFlag, false, "Skip registering the LaunchDarkly MCP server, without listing it as a manual step") + cmd.Flags().Bool(replaceMCPTokenFlag, false, "Re-register the LaunchDarkly MCP server so it uses the token passed with --access-token") + cmd.Flags().String(mcpServiceIDFlag, "", "Service ID of an MCP server already registered on the account to associate instead of registering one") + cmd.Flags().StringSlice(mcpReadOnlyToolsFlag, awsdevops.DefaultMCPReadOnlyTools, "LaunchDarkly MCP tools the agent may call without approval") + cmd.Flags().StringSlice(mcpMutativeToolsFlag, awsdevops.DefaultMCPMutativeTools, "LaunchDarkly MCP tools that change flag state and need approval") + cmd.Flags().String(skillNameFlag, "launchdarkly", "Name of the skill asset to create from --skill-file") + cmd.Flags().String(skillFileFlag, "", "Path to a SKILL.md to upload as a skill asset") + cmd.Flags().String(customAgentNameFlag, "", "Create a custom agent with this name") + cmd.Flags().StringSlice(customAgentToolsFlag, nil, "Tools the custom agent may use") + cmd.Flags().String(scheduleFlag, "", "Cron or rate expression to run the custom agent on, for example 'rate(1 day)'") + cmd.Flags().Bool(noWaitFlag, false, "List the browser-only steps instead of pausing on each one") + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} + +func runSetup(cmd *cobra.Command, args []string) error { + opts, err := setupOptions(cmd) + if err != nil { + return err + } + + clients, err := awsdevops.NewClients(cmd.Context(), mustString(cmd, regionFlag)) + if err != nil { + return err + } + + plaintext := cliflags.GetOutputKind(cmd) != "json" + if warning := awsdevops.CheckAWSCLIVersion(cmd.Context()); warning != "" { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", warning) + } + if opts.LDAccessToken != "" && accessTokenFromConfig(cmd) { + _, _ = fmt.Fprintln( + cmd.ErrOrStderr(), + "Using the access token from your ldcli configuration for the MCP server. "+ + "Pass --access-token with a service token if that one expires", + ) + } + if plaintext { + opts.Logf = func(format string, args ...any) { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), format+"\n", args...) + } + } + + result, err := awsdevops.Setup(cmd.Context(), clients, opts) + if err != nil { + return err + } + + if !plaintext { + return printJSON(cmd, result) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nAgent space %s is ready in %s (account %s).\n", result.AgentSpaceID, result.Region, result.AccountID) + remaining := result.RemainingManualSteps + if !mustBool(cmd, noWaitFlag) && canPrompt() { + remaining = walkManualSteps(cmd, clients, opts, &result) + } + if len(remaining) > 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "\nSteps AWS cannot automate:") + for _, step := range remaining { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " - %s\n %s\n", step.Description, step.URL) + } + } + + if result.OperatorAppURL != "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nOpen the DevOps Agent at:\n %s\n", result.OperatorAppURL) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Manage it in the AWS console at:\n %s\n", awsdevops.ConsoleURL(result.Region)) + + return nil +} + +func setupOptions(cmd *cobra.Command) (awsdevops.SetupOptions, error) { + opts := awsdevops.SetupOptions{ + AgentSpaceID: mustString(cmd, agentSpaceIDFlag), + AgentSpaceName: mustString(cmd, agentSpaceNameFlag), + AgentSpaceDescription: mustString(cmd, agentSpaceDescriptionFlag), + AuthFlow: strings.ToLower(mustString(cmd, authFlowFlag)), + IdcInstanceARN: mustString(cmd, idcInstanceARNFlag), + IssuerURL: mustString(cmd, issuerURLFlag), + IdpClientID: mustString(cmd, idpClientIDFlag), + IdpClientSecret: mustString(cmd, idpClientSecretFlag), + NewAgentSpace: mustBool(cmd, newAgentSpaceFlag), + SkipOperatorApp: mustBool(cmd, skipOperatorAppFlag), + LDBaseURI: viper.GetString(cliflags.BaseURIFlag), + LDAccessToken: viper.GetString(cliflags.AccessTokenFlag), + SkipMCPServer: mustBool(cmd, skipMCPServerFlag), + ReplaceMCPToken: mustBool(cmd, replaceMCPTokenFlag), + MCPServiceID: mustString(cmd, mcpServiceIDFlag), + MCPReadOnlyTools: mustStringSlice(cmd, mcpReadOnlyToolsFlag), + MCPMutativeTools: mustStringSlice(cmd, mcpMutativeToolsFlag), + SkillName: mustString(cmd, skillNameFlag), + CustomAgentName: mustString(cmd, customAgentNameFlag), + CustomAgentTools: mustStringSlice(cmd, customAgentToolsFlag), + Schedule: mustString(cmd, scheduleFlag), + } + + switch opts.AuthFlow { + case "iam": + case "idc": + if opts.IdcInstanceARN == "" { + return opts, fmt.Errorf("--%s is required when --%s=idc", idcInstanceARNFlag, authFlowFlag) + } + case "idp": + if opts.IssuerURL == "" || opts.IdpClientID == "" || opts.IdpClientSecret == "" { + return opts, fmt.Errorf("--%s, --%s and --%s are required when --%s=idp", issuerURLFlag, idpClientIDFlag, idpClientSecretFlag, authFlowFlag) + } + default: + return opts, fmt.Errorf("--%s must be iam, idc or idp", authFlowFlag) + } + + if path := mustString(cmd, skillFileFlag); path != "" { + body, err := os.ReadFile(path) + if err != nil { + return opts, fmt.Errorf("unable to read %s: %w", path, err) + } + opts.SkillBody = string(body) + } + + return opts, nil +} + +// accessTokenFromConfig reports whether the token came from the ldcli +// configuration rather than this run, where `ldcli login` may have written a +// session token that AWS would keep past its expiry. +func accessTokenFromConfig(cmd *cobra.Command) bool { + return !cmd.Flags().Changed(cliflags.AccessTokenFlag) && + os.Getenv("LD_ACCESS_TOKEN") == "" && + viper.GetString(cliflags.AccessTokenFlag) != "" +} + +// mustString and friends read flags this package declares itself, so a lookup +// error is a programming error rather than user input. +func mustString(cmd *cobra.Command, name string) string { + value, err := cmd.Flags().GetString(name) + if err != nil { + panic(err) + } + + return value +} + +func mustBool(cmd *cobra.Command, name string) bool { + value, err := cmd.Flags().GetBool(name) + if err != nil { + panic(err) + } + + return value +} + +func mustStringSlice(cmd *cobra.Command, name string) []string { + value, err := cmd.Flags().GetStringSlice(name) + if err != nil { + panic(err) + } + + return value +} diff --git a/cmd/aws_devops_agent/status.go b/cmd/aws_devops_agent/status.go new file mode 100644 index 000000000..7ac2ed3a5 --- /dev/null +++ b/cmd/aws_devops_agent/status.go @@ -0,0 +1,75 @@ +package awsdevopsagent + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/awsdevops" +) + +func NewStatusCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show what the AWS DevOps Agent setup has provisioned", + Args: cobra.NoArgs, + PreRun: trackRun(analyticsTrackerFn), + RunE: runStatus, + } + + cmd.Flags().String(regionFlag, "", "AWS region to inspect. Defaults to the region of the current AWS session") + cmd.Flags().String(agentSpaceIDFlag, "", "Inspect a single agent space instead of every agent space") + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} + +func runStatus(cmd *cobra.Command, args []string) error { + clients, err := awsdevops.NewClients(cmd.Context(), mustString(cmd, regionFlag)) + if err != nil { + return err + } + + status, err := awsdevops.GetStatus(cmd.Context(), clients, mustString(cmd, agentSpaceIDFlag)) + if err != nil { + return err + } + + if cliflags.GetOutputKind(cmd) == "json" { + return printJSON(cmd, status) + } + + out := cmd.OutOrStdout() + _, _ = fmt.Fprintf(out, "Account %s in %s\n", status.AccountID, status.Region) + for _, role := range status.Roles { + if role.Exists { + _, _ = fmt.Fprintf(out, " role %s: %s\n", role.Name, role.ARN) + + continue + } + _, _ = fmt.Fprintf(out, " role %s: missing\n", role.Name) + } + for _, service := range status.Services { + _, _ = fmt.Fprintln(out, strings.TrimRight(fmt.Sprintf( + " service %s: %s %s", service.ServiceID, service.ServiceType, service.Name), " ")) + } + for _, space := range status.AgentSpaces { + _, _ = fmt.Fprintf(out, " agent space %s (%s)\n", space.Name, space.AgentSpaceID) + _, _ = fmt.Fprintf(out, " operator app: %s\n", space.OperatorAppURL) + for _, association := range space.Associations { + _, _ = fmt.Fprintln(out, strings.TrimRight(fmt.Sprintf( + " association %s: %s %s", association.AssociationID, association.ServiceID, association.Status), " ")) + } + for _, asset := range space.Assets { + _, _ = fmt.Fprintf(out, " asset %s: %s\n", asset.AssetID, asset.AssetType) + } + } + _, _ = fmt.Fprintf(out, "\nAWS DevOps Agent console: %s\n", awsdevops.ConsoleURL(status.Region)) + + return nil +} diff --git a/cmd/aws_devops_agent/teardown.go b/cmd/aws_devops_agent/teardown.go new file mode 100644 index 000000000..e15a42138 --- /dev/null +++ b/cmd/aws_devops_agent/teardown.go @@ -0,0 +1,96 @@ +package awsdevopsagent + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/awsdevops" +) + +const ( + serviceIDFlag = "service-id" + deleteRolesFlag = "delete-roles" + forceFlag = "force" +) + +func NewTeardownCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { + cmd := &cobra.Command{ + Use: "teardown", + Short: "Remove the AWS DevOps Agent resources created by setup", + Long: `Disassociate services, delete assets and agent spaces, deregister services and +delete the IAM roles that setup created. + +Without flags this removes everything in the account and region, after asking +for confirmation. The flags narrow it to part of that.`, + Args: cobra.NoArgs, + PreRun: trackRun(analyticsTrackerFn), + RunE: runTeardown, + } + + cmd.Flags().String(regionFlag, "", "AWS region to tear down in. Defaults to the region of the current AWS session") + cmd.Flags().String(agentSpaceIDFlag, "", "Agent space to empty and delete") + cmd.Flags().StringSlice(serviceIDFlag, nil, "Registered services to deregister, such as the LaunchDarkly MCP server") + cmd.Flags().Bool(deleteRolesFlag, false, "Also delete the IAM roles setup created") + cmd.Flags().Bool(forceFlag, false, "Remove everything without asking for confirmation") + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} + +func runTeardown(cmd *cobra.Command, args []string) error { + clients, err := awsdevops.NewClients(cmd.Context(), mustString(cmd, regionFlag)) + if err != nil { + return err + } + + opts := awsdevops.TeardownOptions{ + AgentSpaceID: mustString(cmd, agentSpaceIDFlag), + ServiceIDs: mustStringSlice(cmd, serviceIDFlag), + DeleteRoles: mustBool(cmd, deleteRolesFlag), + } + if opts.RemovesEverything() && !mustBool(cmd, forceFlag) { + accountID, err := clients.AccountID(cmd.Context()) + if err != nil { + return err + } + if err := confirmTeardown(cmd, accountID, clients.Region); err != nil { + return err + } + } + + opts.Logf = func(format string, args ...any) { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), format+"\n", args...) + } + + return awsdevops.Teardown(cmd.Context(), clients, opts) +} + +func confirmTeardown(cmd *cobra.Command, accountID, region string) error { + if !canPrompt() { + return errors.New( + "teardown without flags removes every agent space, registered service and IAM role: pass --force to confirm", + ) + } + + keys := newKeyReader(cmd.InOrStdin()) + defer keys.close() + out := keys.writer(cmd.OutOrStdout()) + _, _ = fmt.Fprintf( + out, + "This removes every agent space, registered service and IAM role in account %s (%s).\nPress y to continue: ", + accountID, + region, + ) + key := keys.next() + _, _ = fmt.Fprintln(out) + if key != 'y' && key != 'Y' { + return errors.New("teardown cancelled") + } + + return nil +} diff --git a/cmd/root.go b/cmd/root.go index 0503d70b5..dd9266bd3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( "golang.org/x/term" cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" + awsdevopsagentcmd "github.com/launchdarkly/ldcli/cmd/aws_devops_agent" "github.com/launchdarkly/ldcli/cmd/cliflags" configcmd "github.com/launchdarkly/ldcli/cmd/config" devcmd "github.com/launchdarkly/ldcli/cmd/dev_server" @@ -110,13 +111,14 @@ func forceTTYDefaultOutput(getenv func(string) string) bool { // authExemptCommands are commands (and their subcommands) that don't call the // LaunchDarkly API and so don't require --access-token. var authExemptCommands = map[string]bool{ - "completion": true, - "config": true, - "help": true, - "login": true, - "setup": true, - "signup": true, - "whoami": true, + "aws-devops-agent": true, + "completion": true, + "config": true, + "help": true, + "login": true, + "setup": true, + "signup": true, + "whoami": true, } // clearAccessTokenRequirement drops the "required" annotation on --access-token @@ -300,6 +302,7 @@ func NewRootCommand( cmd.AddCommand(signupcmd.NewSignupCmd(analyticsTrackerFn)) cmd.AddCommand(resourcecmd.NewResourcesCmd()) cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient)) + cmd.AddCommand(awsdevopsagentcmd.NewAWSDevOpsAgentCmd(analyticsTrackerFn)) cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(symbolscmd.NewSymbolsCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(synccmd.NewSyncCmd(clients.ResourcesClient, analyticsTrackerFn)) diff --git a/cmd/templates.go b/cmd/templates.go index d21a31865..4f9083f5d 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -20,6 +20,7 @@ Commands: {{rpad "signup" 29}} Create a new LaunchDarkly account {{rpad "dev-server" 29}} Run a development server to serve flags locally {{rpad "sync" 29}} Synchronize local resources with LaunchDarkly + {{rpad "aws-devops-agent" 29}} Set up the AWS DevOps Agent in your AWS account Common resource commands: {{rpad "flags" 29}} List, create, and modify feature flags and their targeting diff --git a/go.mod b/go.mod index cbf9b2456..2eb76c7fb 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,11 @@ require ( github.com/adrg/frontmatter v0.2.0 github.com/adrg/xdg v0.5.3 github.com/atotto/clipboard v0.1.4 + github.com/aws/aws-sdk-go-v2 v1.47.0 + github.com/aws/aws-sdk-go-v2/config v1.33.5 + github.com/aws/aws-sdk-go-v2/service/devopsagent v1.17.0 + github.com/aws/aws-sdk-go-v2/service/iam v1.64.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.51.0 github.com/blacktop/go-dwarf v1.0.14 github.com/blacktop/go-macho v1.1.282 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 @@ -49,6 +54,18 @@ require ( github.com/BurntSushi/toml v1.3.2 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect + github.com/aws/smithy-go v1.28.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect diff --git a/go.sum b/go.sum index 54f855e1c..25e93628d 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,40 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7D github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ= +github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= +github.com/aws/aws-sdk-go-v2/config v1.33.5 h1:UA1dmokBFOLFoOyVBhO6HjM6edy0MIk5AZSkJVcksQw= +github.com/aws/aws-sdk-go-v2/config v1.33.5/go.mod h1:Dop8axzz0xx38GExIYWXdeyc8QQ7Cr+nPsxpD/LYy4U= +github.com/aws/aws-sdk-go-v2/credentials v1.20.5 h1:wklUVvHMc9xTQ3rcp49/ISpiMnhbCicJcA6n6S8m7J8= +github.com/aws/aws-sdk-go-v2/credentials v1.20.5/go.mod h1:fyEdrn6ccLFOkoK84j5bQyGTxp9zPt5l2XMhxf4DVZs= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 h1:AM4hHjww+PSFtt6E+UrBrPlZkWsePCLEt9AjkfQX+yM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0/go.mod h1:3x/yXezeQjpOvBb4jEMxrS8SXvpdvJ5abv6l5c1gWM8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A= +github.com/aws/aws-sdk-go-v2/service/devopsagent v1.17.0 h1:nVr3wL92fkSof1T+blrzUEKJzWa1Kpp9zBhTKG9Wa5c= +github.com/aws/aws-sdk-go-v2/service/devopsagent v1.17.0/go.mod h1:gT39kJ1GWdG7BOdHmzqOTe3lIaX70nJPQUNxd+nWnXU= +github.com/aws/aws-sdk-go-v2/service/iam v1.64.0 h1:CjgiPuyW26WQeQ934DxMNuwkDg6+ziqgPN9lHQ5rwYA= +github.com/aws/aws-sdk-go-v2/service/iam v1.64.0/go.mod h1:TI7OT5XlpXfs4BM4Y5NXcHTiyBwkqv6NpGiM4/usn5A= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.0 h1:Zpnqa6XtrNzXZnwbdCqHOXpXhMsa01ql/pcRQ1sb4hk= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= diff --git a/internal/awsdevops/awsdevops.go b/internal/awsdevops/awsdevops.go new file mode 100644 index 000000000..2a467463c --- /dev/null +++ b/internal/awsdevops/awsdevops.go @@ -0,0 +1,129 @@ +// Package awsdevops provisions the AWS DevOps Agent resources that let the agent +// operate against a LaunchDarkly account. Every call is made with the caller's own +// AWS credentials, so the customer must authenticate before invoking the CLI. +package awsdevops + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/devopsagent" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +// SupportedRegions are the regions where the AWS DevOps Agent control plane is available. +var SupportedRegions = []string{ + "us-east-1", + "us-west-2", + "ap-southeast-2", + "ap-northeast-1", + "eu-central-1", + "eu-west-1", +} + +var ( + ErrNoCredentials = errors.New("no AWS credentials found. Authenticate first (for example `aws sso login --profile my-profile` or exporting AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN), then re-run this command") + ErrNoRegion = errors.New("no AWS region found. Set AWS_REGION or pass --region") +) + +// AgentAPI is the subset of the AWS DevOps Agent API this package uses. +type AgentAPI interface { + AssociateService(context.Context, *devopsagent.AssociateServiceInput, ...func(*devopsagent.Options)) (*devopsagent.AssociateServiceOutput, error) + CreateAgentSpace(context.Context, *devopsagent.CreateAgentSpaceInput, ...func(*devopsagent.Options)) (*devopsagent.CreateAgentSpaceOutput, error) + CreateAsset(context.Context, *devopsagent.CreateAssetInput, ...func(*devopsagent.Options)) (*devopsagent.CreateAssetOutput, error) + CreateTrigger(context.Context, *devopsagent.CreateTriggerInput, ...func(*devopsagent.Options)) (*devopsagent.CreateTriggerOutput, error) + DeleteAgentSpace(context.Context, *devopsagent.DeleteAgentSpaceInput, ...func(*devopsagent.Options)) (*devopsagent.DeleteAgentSpaceOutput, error) + DeleteAsset(context.Context, *devopsagent.DeleteAssetInput, ...func(*devopsagent.Options)) (*devopsagent.DeleteAssetOutput, error) + DeregisterService(context.Context, *devopsagent.DeregisterServiceInput, ...func(*devopsagent.Options)) (*devopsagent.DeregisterServiceOutput, error) + DisassociateService(context.Context, *devopsagent.DisassociateServiceInput, ...func(*devopsagent.Options)) (*devopsagent.DisassociateServiceOutput, error) + EnableOperatorApp(context.Context, *devopsagent.EnableOperatorAppInput, ...func(*devopsagent.Options)) (*devopsagent.EnableOperatorAppOutput, error) + GetAgentSpace(context.Context, *devopsagent.GetAgentSpaceInput, ...func(*devopsagent.Options)) (*devopsagent.GetAgentSpaceOutput, error) + ListAgentSpaces(context.Context, *devopsagent.ListAgentSpacesInput, ...func(*devopsagent.Options)) (*devopsagent.ListAgentSpacesOutput, error) + ListAssets(context.Context, *devopsagent.ListAssetsInput, ...func(*devopsagent.Options)) (*devopsagent.ListAssetsOutput, error) + ListAssociations(context.Context, *devopsagent.ListAssociationsInput, ...func(*devopsagent.Options)) (*devopsagent.ListAssociationsOutput, error) + ListServices(context.Context, *devopsagent.ListServicesInput, ...func(*devopsagent.Options)) (*devopsagent.ListServicesOutput, error) + RegisterService(context.Context, *devopsagent.RegisterServiceInput, ...func(*devopsagent.Options)) (*devopsagent.RegisterServiceOutput, error) +} + +// IAMAPI is the subset of the IAM API this package uses. +type IAMAPI interface { + AttachRolePolicy(context.Context, *iam.AttachRolePolicyInput, ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) + CreateRole(context.Context, *iam.CreateRoleInput, ...func(*iam.Options)) (*iam.CreateRoleOutput, error) + DeleteRole(context.Context, *iam.DeleteRoleInput, ...func(*iam.Options)) (*iam.DeleteRoleOutput, error) + DeleteRolePolicy(context.Context, *iam.DeleteRolePolicyInput, ...func(*iam.Options)) (*iam.DeleteRolePolicyOutput, error) + DetachRolePolicy(context.Context, *iam.DetachRolePolicyInput, ...func(*iam.Options)) (*iam.DetachRolePolicyOutput, error) + GetRole(context.Context, *iam.GetRoleInput, ...func(*iam.Options)) (*iam.GetRoleOutput, error) + PutRolePolicy(context.Context, *iam.PutRolePolicyInput, ...func(*iam.Options)) (*iam.PutRolePolicyOutput, error) +} + +// STSAPI is the subset of the STS API this package uses. +type STSAPI interface { + GetCallerIdentity(context.Context, *sts.GetCallerIdentityInput, ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) +} + +// Clients bundles the AWS clients and the resolved session details the +// provisioning steps run against. +type Clients struct { + Agent AgentAPI + IAM IAMAPI + STS STSAPI + Region string +} + +// NewClients builds AWS clients from the ambient credential chain: environment +// variables, shared config/credentials files, SSO caches, or instance metadata. +// It fails before any API call when the session has no credentials or no region. +func NewClients(ctx context.Context, region string) (Clients, error) { + opts := []func(*awsconfig.LoadOptions) error{} + if region != "" { + opts = append(opts, awsconfig.WithRegion(region)) + } + + cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) + if err != nil { + return Clients{}, fmt.Errorf("unable to load AWS configuration: %w", err) + } + if cfg.Region == "" { + return Clients{}, ErrNoRegion + } + if !slices.Contains(SupportedRegions, cfg.Region) { + return Clients{}, fmt.Errorf( + "AWS DevOps Agent is not available in %s. Supported regions: %s", + cfg.Region, + strings.Join(SupportedRegions, ", "), + ) + } + + creds, err := cfg.Credentials.Retrieve(ctx) + if err != nil { + return Clients{}, fmt.Errorf("%w: %s", ErrNoCredentials, err) + } + if !creds.HasKeys() { + return Clients{}, ErrNoCredentials + } + + return Clients{ + Agent: devopsagent.NewFromConfig(cfg), + IAM: iam.NewFromConfig(cfg), + STS: sts.NewFromConfig(cfg), + Region: cfg.Region, + }, nil +} + +// AccountID returns the account the current session authenticates to. +func (c Clients) AccountID(ctx context.Context) (string, error) { + out, err := c.STS.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return "", fmt.Errorf("unable to identify the AWS account for the current session: %w", err) + } + if out.Account == nil || *out.Account == "" { + return "", ErrNoCredentials + } + + return *out.Account, nil +} diff --git a/internal/awsdevops/awsdevops_test.go b/internal/awsdevops/awsdevops_test.go new file mode 100644 index 000000000..a51665958 --- /dev/null +++ b/internal/awsdevops/awsdevops_test.go @@ -0,0 +1,576 @@ +package awsdevops_test + +import ( + "context" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/devopsagent" + agenttypes "github.com/aws/aws-sdk-go-v2/service/devopsagent/types" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/awsdevops" +) + +const testAccountID = "123456789012" + +type fakeAgent struct { + calls []string + services []agenttypes.RegisteredService + associationInputs []*devopsagent.AssociateServiceInput + createdAssetTypes []string + registerOutput *devopsagent.RegisterServiceOutput + associations []agenttypes.Association + agentSpaces []agenttypes.AgentSpace + assets []agenttypes.Asset + disassociatedIDs []string + deletedAssetIDs []string + deregisteredIDs []string + deletedAgentSpaces []string + associateFailures int +} + +func (f *fakeAgent) AssociateService(_ context.Context, in *devopsagent.AssociateServiceInput, _ ...func(*devopsagent.Options)) (*devopsagent.AssociateServiceOutput, error) { + f.calls = append(f.calls, "AssociateService") + f.associationInputs = append(f.associationInputs, in) + + if f.associateFailures > 0 { + f.associateFailures-- + + return nil, errors.New( + "ValidationException: Invalid STS role configuration for session monitorVerificationAssociationRoleSession", + ) + } + + return &devopsagent.AssociateServiceOutput{ + Association: &agenttypes.Association{AssociationId: aws.String("assoc-" + aws.ToString(in.ServiceId))}, + }, nil +} + +func (f *fakeAgent) CreateAgentSpace(_ context.Context, _ *devopsagent.CreateAgentSpaceInput, _ ...func(*devopsagent.Options)) (*devopsagent.CreateAgentSpaceOutput, error) { + f.calls = append(f.calls, "CreateAgentSpace") + + return &devopsagent.CreateAgentSpaceOutput{ + AgentSpace: &agenttypes.AgentSpace{AgentSpaceId: aws.String("space-1")}, + }, nil +} + +func (f *fakeAgent) CreateAsset(_ context.Context, in *devopsagent.CreateAssetInput, _ ...func(*devopsagent.Options)) (*devopsagent.CreateAssetOutput, error) { + f.calls = append(f.calls, "CreateAsset") + f.createdAssetTypes = append(f.createdAssetTypes, aws.ToString(in.AssetType)) + + return &devopsagent.CreateAssetOutput{ + Asset: &agenttypes.Asset{AssetId: aws.String("asset-" + aws.ToString(in.AssetType))}, + }, nil +} + +func (f *fakeAgent) CreateTrigger(_ context.Context, _ *devopsagent.CreateTriggerInput, _ ...func(*devopsagent.Options)) (*devopsagent.CreateTriggerOutput, error) { + f.calls = append(f.calls, "CreateTrigger") + + return &devopsagent.CreateTriggerOutput{ + Trigger: &agenttypes.Trigger{TriggerId: aws.String("trigger-1")}, + }, nil +} + +func (f *fakeAgent) DeleteAgentSpace(_ context.Context, in *devopsagent.DeleteAgentSpaceInput, _ ...func(*devopsagent.Options)) (*devopsagent.DeleteAgentSpaceOutput, error) { + f.calls = append(f.calls, "DeleteAgentSpace") + f.deletedAgentSpaces = append(f.deletedAgentSpaces, aws.ToString(in.AgentSpaceId)) + + return &devopsagent.DeleteAgentSpaceOutput{}, nil +} + +func (f *fakeAgent) DeleteAsset(_ context.Context, in *devopsagent.DeleteAssetInput, _ ...func(*devopsagent.Options)) (*devopsagent.DeleteAssetOutput, error) { + f.calls = append(f.calls, "DeleteAsset") + f.deletedAssetIDs = append(f.deletedAssetIDs, aws.ToString(in.AssetId)) + + return &devopsagent.DeleteAssetOutput{}, nil +} + +func (f *fakeAgent) DeregisterService(_ context.Context, in *devopsagent.DeregisterServiceInput, _ ...func(*devopsagent.Options)) (*devopsagent.DeregisterServiceOutput, error) { + f.calls = append(f.calls, "DeregisterService") + f.deregisteredIDs = append(f.deregisteredIDs, aws.ToString(in.ServiceId)) + + return &devopsagent.DeregisterServiceOutput{}, nil +} + +func (f *fakeAgent) DisassociateService(_ context.Context, in *devopsagent.DisassociateServiceInput, _ ...func(*devopsagent.Options)) (*devopsagent.DisassociateServiceOutput, error) { + f.calls = append(f.calls, "DisassociateService") + f.disassociatedIDs = append(f.disassociatedIDs, aws.ToString(in.AssociationId)) + + return &devopsagent.DisassociateServiceOutput{}, nil +} + +func (f *fakeAgent) EnableOperatorApp(_ context.Context, _ *devopsagent.EnableOperatorAppInput, _ ...func(*devopsagent.Options)) (*devopsagent.EnableOperatorAppOutput, error) { + f.calls = append(f.calls, "EnableOperatorApp") + + return &devopsagent.EnableOperatorAppOutput{ + OperatorAppUrl: aws.String("https://space-1.aidevops.global.app.aws"), + }, nil +} + +func (f *fakeAgent) GetAgentSpace(_ context.Context, in *devopsagent.GetAgentSpaceInput, _ ...func(*devopsagent.Options)) (*devopsagent.GetAgentSpaceOutput, error) { + return &devopsagent.GetAgentSpaceOutput{ + AgentSpace: &agenttypes.AgentSpace{ + AgentSpaceId: in.AgentSpaceId, + Name: aws.String("launchdarkly"), + }, + }, nil +} + +func (f *fakeAgent) ListAgentSpaces(_ context.Context, _ *devopsagent.ListAgentSpacesInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListAgentSpacesOutput, error) { + if f.agentSpaces != nil { + return &devopsagent.ListAgentSpacesOutput{AgentSpaces: f.agentSpaces}, nil + } + + return &devopsagent.ListAgentSpacesOutput{ + AgentSpaces: []agenttypes.AgentSpace{{AgentSpaceId: aws.String("space-1")}}, + }, nil +} + +func (f *fakeAgent) ListServices(_ context.Context, _ *devopsagent.ListServicesInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListServicesOutput, error) { + f.calls = append(f.calls, "ListServices") + + return &devopsagent.ListServicesOutput{Services: f.services}, nil +} + +func (f *fakeAgent) ListAssets(_ context.Context, _ *devopsagent.ListAssetsInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListAssetsOutput, error) { + return &devopsagent.ListAssetsOutput{Items: f.assets}, nil +} + +func (f *fakeAgent) ListAssociations(_ context.Context, _ *devopsagent.ListAssociationsInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListAssociationsOutput, error) { + return &devopsagent.ListAssociationsOutput{Associations: f.associations}, nil +} + +func (f *fakeAgent) RegisterService(_ context.Context, _ *devopsagent.RegisterServiceInput, _ ...func(*devopsagent.Options)) (*devopsagent.RegisterServiceOutput, error) { + f.calls = append(f.calls, "RegisterService") + if f.registerOutput != nil { + return f.registerOutput, nil + } + + return &devopsagent.RegisterServiceOutput{ServiceId: aws.String("mcp-1")}, nil +} + +type fakeIAM struct { + existingRoles map[string]bool + createdRoles []string + attachedPolicy map[string]string + inlinePolicies map[string]string + deletedRoles []string + detachedPolicy []string + deletedInlinePs []string +} + +func newFakeIAM() *fakeIAM { + return &fakeIAM{ + existingRoles: map[string]bool{}, + attachedPolicy: map[string]string{}, + inlinePolicies: map[string]string{}, + } +} + +func (f *fakeIAM) AttachRolePolicy(_ context.Context, in *iam.AttachRolePolicyInput, _ ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) { + f.attachedPolicy[aws.ToString(in.RoleName)] = aws.ToString(in.PolicyArn) + + return &iam.AttachRolePolicyOutput{}, nil +} + +func (f *fakeIAM) CreateRole(_ context.Context, in *iam.CreateRoleInput, _ ...func(*iam.Options)) (*iam.CreateRoleOutput, error) { + name := aws.ToString(in.RoleName) + if f.existingRoles[name] { + return nil, &iamtypes.EntityAlreadyExistsException{} + } + f.existingRoles[name] = true + f.createdRoles = append(f.createdRoles, name) + + return &iam.CreateRoleOutput{ + Role: &iamtypes.Role{Arn: aws.String("arn:aws:iam::" + testAccountID + ":role/" + name)}, + }, nil +} + +func (f *fakeIAM) DeleteRole(_ context.Context, in *iam.DeleteRoleInput, _ ...func(*iam.Options)) (*iam.DeleteRoleOutput, error) { + f.deletedRoles = append(f.deletedRoles, aws.ToString(in.RoleName)) + + return &iam.DeleteRoleOutput{}, nil +} + +func (f *fakeIAM) DeleteRolePolicy(_ context.Context, in *iam.DeleteRolePolicyInput, _ ...func(*iam.Options)) (*iam.DeleteRolePolicyOutput, error) { + f.deletedInlinePs = append(f.deletedInlinePs, aws.ToString(in.PolicyName)) + + return &iam.DeleteRolePolicyOutput{}, nil +} + +func (f *fakeIAM) DetachRolePolicy(_ context.Context, in *iam.DetachRolePolicyInput, _ ...func(*iam.Options)) (*iam.DetachRolePolicyOutput, error) { + f.detachedPolicy = append(f.detachedPolicy, aws.ToString(in.PolicyArn)) + + return &iam.DetachRolePolicyOutput{}, nil +} + +func (f *fakeIAM) GetRole(_ context.Context, in *iam.GetRoleInput, _ ...func(*iam.Options)) (*iam.GetRoleOutput, error) { + name := aws.ToString(in.RoleName) + if !f.existingRoles[name] { + return nil, &iamtypes.NoSuchEntityException{} + } + + return &iam.GetRoleOutput{ + Role: &iamtypes.Role{Arn: aws.String("arn:aws:iam::" + testAccountID + ":role/" + name)}, + }, nil +} + +func (f *fakeIAM) PutRolePolicy(_ context.Context, in *iam.PutRolePolicyInput, _ ...func(*iam.Options)) (*iam.PutRolePolicyOutput, error) { + f.inlinePolicies[aws.ToString(in.RoleName)] = aws.ToString(in.PolicyDocument) + + return &iam.PutRolePolicyOutput{}, nil +} + +type fakeSTS struct { + account string +} + +func (f fakeSTS) GetCallerIdentity(_ context.Context, _ *sts.GetCallerIdentityInput, _ ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return &sts.GetCallerIdentityOutput{Account: aws.String(f.account)}, nil +} + +func newTestClients(agent *fakeAgent, iamClient *fakeIAM) awsdevops.Clients { + return awsdevops.Clients{ + Agent: agent, + IAM: iamClient, + STS: fakeSTS{account: testAccountID}, + Region: "us-east-1", + } +} + +func TestSetupCreatesRolesAgentSpaceAndAssociations(t *testing.T) { + agent := &fakeAgent{} + iamClient := newFakeIAM() + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, iamClient), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + LDAccessToken: "api-token", + }) + require.NoError(t, err) + + assert.Equal(t, testAccountID, result.AccountID) + assert.Equal(t, "space-1", result.AgentSpaceID) + assert.Equal(t, "mcp-1", result.MCPServiceID) + assert.Equal(t, "assoc-mcp-1", result.MCPAssociationID) + assert.Equal(t, "https://space-1.aidevops.global.app.aws", result.OperatorAppURL) + assert.Equal(t, []string{awsdevops.AgentSpaceRoleName, awsdevops.OperatorAppRoleName}, iamClient.createdRoles) + assert.Equal(t, awsdevops.AgentSpacePolicyARN, iamClient.attachedPolicy[awsdevops.AgentSpaceRoleName]) + assert.Equal(t, awsdevops.OperatorAppPolicyARN, iamClient.attachedPolicy[awsdevops.OperatorAppRoleName]) + assert.Contains(t, iamClient.inlinePolicies[awsdevops.AgentSpaceRoleName], "iam:CreateServiceLinkedRole") + assert.Equal( + t, + []string{"CreateAgentSpace", "AssociateService", "EnableOperatorApp", "ListServices", "RegisterService", "AssociateService"}, + agent.calls, + ) +} + +func TestSetupReusesExistingRolesAndAgentSpace(t *testing.T) { + agent := &fakeAgent{} + iamClient := newFakeIAM() + iamClient.existingRoles[awsdevops.AgentSpaceRoleName] = true + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, iamClient), awsdevops.SetupOptions{ + AgentSpaceID: "space-existing", + AuthFlow: "iam", + SkipOperatorApp: true, + SkipMCPServer: true, + }) + require.NoError(t, err) + + assert.Equal(t, "space-existing", result.AgentSpaceID) + assert.NotContains(t, agent.calls, "CreateAgentSpace") + assert.NotContains(t, agent.calls, "EnableOperatorApp") + assert.Empty(t, iamClient.createdRoles) + assert.Equal(t, "arn:aws:iam::"+testAccountID+":role/"+awsdevops.AgentSpaceRoleName, result.AgentSpaceRoleARN) +} + +func TestSetupRetriesTheAccountAssociationUntilTheRoleIsAssumable(t *testing.T) { + agent := &fakeAgent{associateFailures: 1} + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + SkipOperatorApp: true, + SkipMCPServer: true, + }) + require.NoError(t, err) + + assert.Equal(t, "assoc-aws", result.AWSAssociationID) + assert.Len(t, agent.associationInputs, 2) +} + +func TestSetupWithoutAccessTokenReportsMCPServerAsManualStep(t *testing.T) { + agent := &fakeAgent{} + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + }) + require.NoError(t, err) + + assert.NotContains(t, agent.calls, "RegisterService") + assert.Empty(t, result.MCPServiceID) + assert.Contains(t, result.RemainingManualSteps[0].Description, awsdevops.MCPServerEndpoint) + assert.Equal(t, awsdevops.AccessTokenURL(""), result.RemainingManualSteps[0].URL) +} + +func TestSetupReusesAgentSpaceAndAssociations(t *testing.T) { + agent := &fakeAgent{ + agentSpaces: []agenttypes.AgentSpace{{ + AgentSpaceId: aws.String("space-existing"), + Name: aws.String("launchdarkly"), + }}, + associations: []agenttypes.Association{{ + AssociationId: aws.String("assoc-aws"), + ServiceId: aws.String("aws"), + }}, + } + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + SkipMCPServer: true, + SkipOperatorApp: true, + }) + require.NoError(t, err) + + assert.Equal(t, "space-existing", result.AgentSpaceID) + assert.Equal(t, "assoc-aws", result.AWSAssociationID) + assert.NotContains(t, agent.calls, "CreateAgentSpace") + assert.Empty(t, agent.associationInputs) +} + +func TestSetupClassifiesMCPTools(t *testing.T) { + agent := &fakeAgent{} + + _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + LDAccessToken: "api-token", + SkipOperatorApp: true, + MCPReadOnlyTools: []string{"list-flags"}, + MCPMutativeTools: []string{"toggle-flag"}, + }) + require.NoError(t, err) + + config, ok := agent.associationInputs[1].Configuration.(*agenttypes.ServiceConfigurationMemberMcpserver) + require.True(t, ok) + assert.Equal(t, []string{"list-flags", "toggle-flag"}, config.Value.Tools) + assert.Equal(t, agenttypes.ToolClassificationReadOnly, config.Value.ToolDetails[0].ToolClassification) + assert.Equal(t, agenttypes.ToolClassificationMutative, config.Value.ToolDetails[1].ToolClassification) +} + +func TestSetupReportsOAuthConsentAsManualStep(t *testing.T) { + agent := &fakeAgent{ + registerOutput: &devopsagent.RegisterServiceOutput{ + AdditionalStep: &agenttypes.AdditionalServiceRegistrationStepMemberOauth{ + Value: agenttypes.OAuthAdditionalStepDetails{ + AuthorizationUrl: aws.String("https://example.com/consent"), + }, + }, + }, + } + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + LDAccessToken: "api-token", + SkipOperatorApp: true, + }) + require.NoError(t, err) + + assert.Empty(t, result.MCPServiceID) + assert.Equal(t, "https://example.com/consent", result.RemainingManualSteps[0].URL) +} + +func TestSetupCreatesSkillAndScheduledCustomAgent(t *testing.T) { + agent := &fakeAgent{} + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + SkipOperatorApp: true, + SkipMCPServer: true, + SkillName: "launchdarkly", + SkillBody: "# LaunchDarkly\n", + CustomAgentName: "flag-cleanup", + Schedule: "rate(1 day)", + }) + require.NoError(t, err) + + assert.Equal(t, []string{"skill", "custom_agent"}, agent.createdAssetTypes) + assert.Equal(t, "asset-skill", result.SkillAssetID) + assert.Equal(t, "asset-custom_agent", result.CustomAgentAssetID) + assert.Equal(t, "trigger-1", result.TriggerID) +} + +func TestSetupRejectsScheduleWithoutCustomAgent(t *testing.T) { + _, err := awsdevops.Setup(context.Background(), newTestClients(&fakeAgent{}, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + SkipOperatorApp: true, + SkipMCPServer: true, + Schedule: "rate(1 day)", + }) + + assert.ErrorContains(t, err, "--schedule requires --custom-agent-name") +} + +func TestTeardownEmptiesAgentSpaceBeforeDeletingIt(t *testing.T) { + agent := &fakeAgent{ + associations: []agenttypes.Association{{AssociationId: aws.String("assoc-1"), ServiceId: aws.String("aws")}}, + assets: []agenttypes.Asset{{AssetId: aws.String("asset-1")}}, + } + iamClient := newFakeIAM() + + require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, iamClient), awsdevops.TeardownOptions{ + AgentSpaceID: "space-1", + ServiceIDs: []string{"mcp-1"}, + DeleteRoles: true, + })) + + assert.Equal( + t, + []string{"DisassociateService", "DeleteAsset", "DeleteAgentSpace", "DeregisterService"}, + agent.calls, + ) + assert.Equal(t, []string{"mcp-1"}, agent.deregisteredIDs) + assert.Equal(t, []string{awsdevops.AgentSpaceRoleName, awsdevops.OperatorAppRoleName}, iamClient.deletedRoles) + assert.Equal(t, []string{awsdevops.ServiceLinkedRolePolicyName}, iamClient.deletedInlinePs) +} + +func TestTeardownDeletesMemoryStoresLast(t *testing.T) { + agent := &fakeAgent{ + assets: []agenttypes.Asset{ + {AssetId: aws.String("store-1"), AssetType: aws.String("memory_store")}, + {AssetId: aws.String("memory-1"), AssetType: aws.String("memory")}, + {AssetId: aws.String("skill-1"), AssetType: aws.String("skill")}, + }, + } + + require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.TeardownOptions{ + AgentSpaceID: "space-1", + })) + + assert.Equal(t, []string{"memory-1", "skill-1", "store-1"}, agent.deletedAssetIDs) +} + +func TestTeardownWithoutOptionsRemovesEverything(t *testing.T) { + agent := &fakeAgent{ + agentSpaces: []agenttypes.AgentSpace{ + {AgentSpaceId: aws.String("space-1")}, + {AgentSpaceId: aws.String("space-2")}, + }, + services: []agenttypes.RegisteredService{ + {ServiceId: aws.String("svc-1"), ServiceType: agenttypes.ServiceGitlab}, + {ServiceId: aws.String("mcp-1"), ServiceType: agenttypes.ServiceMcpServer}, + }, + } + iamClient := newFakeIAM() + + require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, iamClient), awsdevops.TeardownOptions{})) + + assert.Equal(t, []string{"space-1", "space-2"}, agent.deletedAgentSpaces) + assert.Equal(t, []string{"svc-1", "mcp-1"}, agent.deregisteredIDs) + assert.Equal(t, []string{awsdevops.AgentSpaceRoleName, awsdevops.OperatorAppRoleName}, iamClient.deletedRoles) +} + +func TestTeardownStillRemovesEverythingWithDeleteRoles(t *testing.T) { + agent := &fakeAgent{ + agentSpaces: []agenttypes.AgentSpace{{AgentSpaceId: aws.String("space-1")}}, + services: []agenttypes.RegisteredService{{ServiceId: aws.String("mcp-1")}}, + } + + require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.TeardownOptions{ + DeleteRoles: true, + })) + + assert.Equal(t, []string{"space-1"}, agent.deletedAgentSpaces) + assert.Equal(t, []string{"mcp-1"}, agent.deregisteredIDs) +} + +func TestStatusReportsMissingRoles(t *testing.T) { + agent := &fakeAgent{ + associations: []agenttypes.Association{{AssociationId: aws.String("assoc-1"), ServiceId: aws.String("aws")}}, + assets: []agenttypes.Asset{{AssetId: aws.String("asset-1"), AssetType: aws.String("skill")}}, + } + iamClient := newFakeIAM() + iamClient.existingRoles[awsdevops.AgentSpaceRoleName] = true + + status, err := awsdevops.GetStatus(context.Background(), newTestClients(agent, iamClient), "") + require.NoError(t, err) + + assert.True(t, status.Roles[0].Exists) + assert.False(t, status.Roles[1].Exists) + assert.Equal(t, []string{"IAM role " + awsdevops.OperatorAppRoleName + " does not exist"}, status.MissingSetup) + require.Len(t, status.AgentSpaces, 1) + assert.Equal(t, "space-1", status.AgentSpaces[0].AgentSpaceID) + assert.Equal(t, "aws", status.AgentSpaces[0].Associations[0].ServiceID) + assert.Equal(t, "skill", status.AgentSpaces[0].Assets[0].AssetType) +} + +func TestStatusOmitsAgentManagedAssetsAndNamesMCPServers(t *testing.T) { + agent := &fakeAgent{ + services: []agenttypes.RegisteredService{{ + ServiceId: aws.String("mcp-1"), + ServiceType: agenttypes.ServiceMcpServer, + AdditionalServiceDetails: &agenttypes.AdditionalServiceDetailsMemberMcpserver{ + Value: agenttypes.RegisteredMCPServerDetails{ + Name: aws.String(awsdevops.MCPServerName), + Endpoint: aws.String(awsdevops.MCPServerEndpoint), + }, + }, + }}, + assets: []agenttypes.Asset{ + {AssetId: aws.String("asset-1"), AssetType: aws.String("skill")}, + {AssetId: aws.String("asset-2"), AssetType: aws.String("memory")}, + }, + } + + status, err := awsdevops.GetStatus(context.Background(), newTestClients(agent, newFakeIAM()), "") + require.NoError(t, err) + + assert.Equal(t, awsdevops.MCPServerName, status.Services[0].Name) + require.Len(t, status.AgentSpaces[0].Assets, 1) + assert.Equal(t, "asset-1", status.AgentSpaces[0].Assets[0].AssetID) +} + +func TestManualStepURLsPointAtRegistrationPages(t *testing.T) { + assert.Equal( + t, + "https://eu-west-1.console.aws.amazon.com/aidevops/home?region=eu-west-1", + awsdevops.ConsoleURL("eu-west-1"), + ) + assert.Equal( + t, + "https://app.launchdarkly.com/settings/authorization", + awsdevops.AccessTokenURL(""), + ) +} + +func TestNewClientsRejectsUnsupportedRegion(t *testing.T) { + _, err := awsdevops.NewClients(context.Background(), "us-east-2") + + assert.ErrorContains(t, err, "AWS DevOps Agent is not available in us-east-2") +} + +func TestNewClientsRequiresCredentials(t *testing.T) { + t.Setenv("AWS_ACCESS_KEY_ID", "") + t.Setenv("AWS_SECRET_ACCESS_KEY", "") + t.Setenv("AWS_SESSION_TOKEN", "") + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_CONFIG_FILE", "/dev/null") + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null") + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + + _, err := awsdevops.NewClients(context.Background(), "us-east-1") + + assert.ErrorIs(t, err, awsdevops.ErrNoCredentials) +} diff --git a/internal/awsdevops/cliversion.go b/internal/awsdevops/cliversion.go new file mode 100644 index 000000000..9789cf5e1 --- /dev/null +++ b/internal/awsdevops/cliversion.go @@ -0,0 +1,71 @@ +package awsdevops + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "slices" + "strconv" + "strings" +) + +// MinAWSCLIVersion is the first AWS CLI release with `aws devops-agent`. +var MinAWSCLIVersion = [3]int{2, 36, 0} + +var awsCLIVersionPattern = regexp.MustCompile(`aws-cli/(\d+)\.(\d+)\.(\d+)`) + +// CheckAWSCLIVersion reports why the installed AWS CLI cannot run +// `aws devops-agent` commands, and returns an empty string when it can. The +// CLI itself is not required by this package, so callers should warn rather +// than fail. +func CheckAWSCLIVersion(ctx context.Context) string { + path, err := exec.LookPath("aws") + if err != nil { + return fmt.Sprintf( + "The AWS CLI is not installed. This command does not need it, but `aws devops-agent` needs %s or later.", + formatVersion(MinAWSCLIVersion), + ) + } + + out, err := exec.CommandContext(ctx, path, "--version").CombinedOutput() + if err != nil { + return fmt.Sprintf("Unable to determine the AWS CLI version: %s", err) + } + + version, ok := parseAWSCLIVersion(string(out)) + if !ok { + return fmt.Sprintf("Unable to determine the AWS CLI version from %q", strings.TrimSpace(string(out))) + } + if slices.Compare(version[:], MinAWSCLIVersion[:]) < 0 { + return fmt.Sprintf( + "AWS CLI %s does not support `aws devops-agent`. This command does not need it, but upgrade to %s or later to run those commands yourself (https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html).", + formatVersion(version), + formatVersion(MinAWSCLIVersion), + ) + } + + return "" +} + +func parseAWSCLIVersion(output string) ([3]int, bool) { + match := awsCLIVersionPattern.FindStringSubmatch(output) + if match == nil { + return [3]int{}, false + } + + var version [3]int + for i := range version { + part, err := strconv.Atoi(match[i+1]) + if err != nil { + return [3]int{}, false + } + version[i] = part + } + + return version, true +} + +func formatVersion(version [3]int) string { + return fmt.Sprintf("%d.%d.%d", version[0], version[1], version[2]) +} diff --git a/internal/awsdevops/cliversion_test.go b/internal/awsdevops/cliversion_test.go new file mode 100644 index 000000000..622ac5840 --- /dev/null +++ b/internal/awsdevops/cliversion_test.go @@ -0,0 +1,55 @@ +package awsdevops_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/awsdevops" +) + +func TestCheckAWSCLIVersion(t *testing.T) { + tests := map[string]struct { + reported string + wantWarning bool + }{ + "supported": {reported: "aws-cli/2.36.0 Python/3.13.0 linux/6.8 exe/x86_64", wantWarning: false}, + "newer": {reported: "aws-cli/2.41.2 Python/3.13.0 linux/6.8 exe/x86_64", wantWarning: false}, + "too old": {reported: "aws-cli/2.35.9 Python/3.13.0 linux/6.8 exe/x86_64", wantWarning: true}, + "v1": {reported: "aws-cli/1.42.0 Python/3.13.0 linux/6.8", wantWarning: true}, + "unparseable": {reported: "something else", wantWarning: true}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + withFakeAWSCLI(t, test.reported) + + warning := awsdevops.CheckAWSCLIVersion(context.Background()) + if test.wantWarning { + assert.NotEmpty(t, warning) + + return + } + assert.Empty(t, warning) + }) + } +} + +func TestCheckAWSCLIVersionWithoutAWSCLI(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + assert.Contains(t, awsdevops.CheckAWSCLIVersion(context.Background()), "not installed") +} + +func withFakeAWSCLI(t *testing.T, reported string) { + t.Helper() + + dir := t.TempDir() + script := "#!/bin/sh\necho \"" + reported + "\"\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "aws"), []byte(script), 0o700)) + t.Setenv("PATH", dir) +} diff --git a/internal/awsdevops/setup.go b/internal/awsdevops/setup.go new file mode 100644 index 000000000..e6b33c21a --- /dev/null +++ b/internal/awsdevops/setup.go @@ -0,0 +1,663 @@ +package awsdevops + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/devopsagent" + "github.com/aws/aws-sdk-go-v2/service/devopsagent/document" + agenttypes "github.com/aws/aws-sdk-go-v2/service/devopsagent/types" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" +) + +const ( + AgentSpaceRoleName = "DevOpsAgentRole-AgentSpace" + OperatorAppRoleName = "DevOpsAgentRole-WebappAdmin" + + AgentSpacePolicyARN = "arn:aws:iam::aws:policy/AIDevOpsAgentAccessPolicy" + OperatorAppPolicyARN = "arn:aws:iam::aws:policy/AIDevOpsOperatorAppAccessPolicy" + + ServiceLinkedRolePolicyName = "AllowCreateServiceLinkedRoles" + + // awsServiceID is the well-known service identifier for the agent's own + // AWS account association. + awsServiceID = "aws" + + DefaultLDBaseURI = "https://app.launchdarkly.com" + + roleAssumableTimeout = 2 * time.Minute + roleAssumableFirstWait = time.Second + roleAssumableMaxInterval = 10 * time.Second + + MCPServerName = "LaunchDarkly" + MCPServerEndpoint = "https://mcp.launchdarkly.com/mcp/launchdarkly" + + skillAssetType = "skill" + customAgentAssetType = "custom_agent" + memoryStoreAssetType = "memory_store" +) + +// DefaultMCPReadOnlyTools and DefaultMCPMutativeTools are the LaunchDarkly MCP +// tools enabled when the caller does not choose their own. The agent invokes +// read-only tools without asking for approval, so anything that changes flag +// state has to be classified explicitly. +var ( + DefaultMCPReadOnlyTools = []string{"list-projects", "list-flags", "get-flag"} + DefaultMCPMutativeTools = []string{"toggle-flag"} +) + +// SetupOptions describes what to provision. Zero values mean "skip that step". +type SetupOptions struct { + AgentSpaceID string + AgentSpaceName string + AgentSpaceDescription string + NewAgentSpace bool + + AuthFlow string + IdcInstanceARN string + IssuerURL string + IdpClientID string + IdpClientSecret string + SkipOperatorApp bool + + LDBaseURI string + LDAccessToken string + SkipMCPServer bool + ReplaceMCPToken bool + MCPServiceID string + MCPReadOnlyTools []string + MCPMutativeTools []string + + SkillName string + SkillBody string + CustomAgentName string + CustomAgentTools []string + Schedule string + + // Logf, when set, reports progress as each step completes. + Logf func(format string, args ...any) +} + +// SetupResult holds the identifiers of everything the setup touched. Callers +// need these to tear the environment back down. +type SetupResult struct { + AccountID string `json:"accountId"` + Region string `json:"region"` + AgentSpaceID string `json:"agentSpaceId,omitempty"` + AgentSpaceRoleARN string `json:"agentSpaceRoleArn,omitempty"` + OperatorAppRoleARN string `json:"operatorAppRoleArn,omitempty"` + AWSAssociationID string `json:"awsAssociationId,omitempty"` + OperatorAppURL string `json:"operatorAppUrl,omitempty"` + MCPServiceID string `json:"mcpServiceId,omitempty"` + MCPAssociationID string `json:"mcpAssociationId,omitempty"` + MCPAuthorizationURL string `json:"mcpAuthorizationUrl,omitempty"` + SkillAssetID string `json:"skillAssetId,omitempty"` + CustomAgentAssetID string `json:"customAgentAssetId,omitempty"` + TriggerID string `json:"triggerId,omitempty"` + RemainingManualSteps []ManualStep `json:"remainingManualSteps,omitempty"` +} + +// ManualStep pairs a step AWS only exposes in a browser with the page to open. +type ManualStep struct { + Kind ManualStepKind `json:"kind"` + Description string `json:"description"` + URL string `json:"url"` +} + +type ManualStepKind string + +const ( + ManualStepOAuthConsent ManualStepKind = "oauthConsent" + ManualStepMCPServer ManualStepKind = "mcpServer" +) + +// Setup provisions the IAM roles, agent space, service associations and assets +// the AWS DevOps Agent needs to manage LaunchDarkly flags. +func Setup(ctx context.Context, clients Clients, opts SetupOptions) (SetupResult, error) { + logf := opts.Logf + if logf == nil { + logf = func(string, ...any) {} + } + + accountID, err := clients.AccountID(ctx) + if err != nil { + return SetupResult{}, err + } + + result := SetupResult{AccountID: accountID, Region: clients.Region} + + result.AgentSpaceRoleARN, err = ensureAgentSpaceRole(ctx, clients.IAM, accountID, clients.Region) + if err != nil { + return result, err + } + logf("Agent space role ready: %s", result.AgentSpaceRoleARN) + + if !opts.SkipOperatorApp { + result.OperatorAppRoleARN, err = ensureOperatorAppRole(ctx, clients.IAM, accountID, clients.Region) + if err != nil { + return result, err + } + logf("Operator app role ready: %s", result.OperatorAppRoleARN) + } + + result.AgentSpaceID = opts.AgentSpaceID + if result.AgentSpaceID == "" && !opts.NewAgentSpace { + existing, err := FindAgentSpace(ctx, clients, opts.AgentSpaceName) + if err != nil { + return result, err + } + if existing != "" { + result.AgentSpaceID = existing + logf("Reusing agent space %s (%s); pass --new-agent-space to create another", opts.AgentSpaceName, existing) + } + } + if result.AgentSpaceID == "" { + space, err := clients.Agent.CreateAgentSpace(ctx, &devopsagent.CreateAgentSpaceInput{ + Name: aws.String(opts.AgentSpaceName), + Description: aws.String(opts.AgentSpaceDescription), + }) + if err != nil { + return result, fmt.Errorf("unable to create the agent space: %w", err) + } + result.AgentSpaceID = aws.ToString(space.AgentSpace.AgentSpaceId) + logf("Created agent space %s (%s)", opts.AgentSpaceName, result.AgentSpaceID) + } + + result.AWSAssociationID, err = FindAssociation(ctx, clients, result.AgentSpaceID, awsServiceID) + if err != nil { + return result, err + } + if result.AWSAssociationID != "" { + logf("Account %s is already associated with the agent space", accountID) + } else { + awsAssociation, err := associateAWSAccount(ctx, clients, accountID, result, logf) + if err != nil { + return result, fmt.Errorf("unable to associate the AWS account with the agent space: %w", err) + } + result.AWSAssociationID = aws.ToString(awsAssociation.Association.AssociationId) + logf("Associated account %s with the agent space", accountID) + } + + if !opts.SkipOperatorApp { + operatorApp, err := clients.Agent.EnableOperatorApp(ctx, operatorAppInput(result.AgentSpaceID, result.OperatorAppRoleARN, opts)) + if err != nil { + return result, fmt.Errorf("unable to enable the operator app: %w", err) + } + result.OperatorAppURL = aws.ToString(operatorApp.OperatorAppUrl) + logf("Operator app available at %s", result.OperatorAppURL) + } + + if !opts.SkipMCPServer && (opts.LDAccessToken != "" || opts.MCPServiceID != "") { + if err := registerMCPServer(ctx, clients, opts, &result, logf); err != nil { + return result, err + } + } + + if opts.SkillBody != "" { + skill, err := clients.Agent.CreateAsset(ctx, &devopsagent.CreateAssetInput{ + AgentSpaceId: aws.String(result.AgentSpaceID), + AssetType: aws.String(skillAssetType), + Metadata: document.NewLazyDocument(map[string]any{ + "name": opts.SkillName, + "agent_types": []string{"GENERIC"}, + }), + Content: &agenttypes.AssetContentMemberFile{ + Value: agenttypes.AssetFileContent{ + Path: aws.String("SKILL.md"), + Body: &agenttypes.AssetFileBodyMemberText{Value: opts.SkillBody}, + }, + }, + }) + if err != nil { + return result, fmt.Errorf("unable to create the skill asset: %w", err) + } + result.SkillAssetID = aws.ToString(skill.Asset.AssetId) + logf("Created skill %s (%s)", opts.SkillName, result.SkillAssetID) + } + + if opts.CustomAgentName != "" { + metadata := map[string]any{"name": opts.CustomAgentName} + if result.SkillAssetID != "" { + metadata["skills"] = []string{result.SkillAssetID} + } + if len(opts.CustomAgentTools) > 0 { + metadata["tools"] = opts.CustomAgentTools + } + agent, err := clients.Agent.CreateAsset(ctx, &devopsagent.CreateAssetInput{ + AgentSpaceId: aws.String(result.AgentSpaceID), + AssetType: aws.String(customAgentAssetType), + Metadata: document.NewLazyDocument(metadata), + Content: &agenttypes.AssetContentMemberFile{ + Value: agenttypes.AssetFileContent{ + Path: aws.String("AGENT.md"), + Body: &agenttypes.AssetFileBodyMemberText{ + Value: fmt.Sprintf("# %s\n", opts.CustomAgentName), + }, + }, + }, + }) + if err != nil { + return result, fmt.Errorf("unable to create the custom agent asset: %w", err) + } + result.CustomAgentAssetID = aws.ToString(agent.Asset.AssetId) + logf("Created custom agent %s (%s)", opts.CustomAgentName, result.CustomAgentAssetID) + } + + if opts.Schedule != "" { + if result.CustomAgentAssetID == "" { + return result, errors.New("--schedule requires --custom-agent-name so the trigger has an agent to run") + } + trigger, err := clients.Agent.CreateTrigger(ctx, &devopsagent.CreateTriggerInput{ + AgentSpaceId: aws.String(result.AgentSpaceID), + Type: aws.String("TIME_BASED"), + Status: aws.String("Active"), + Condition: &agenttypes.TriggerConditionMemberSchedule{ + Value: agenttypes.ScheduleCondition{Expression: aws.String(opts.Schedule)}, + }, + Action: document.NewLazyDocument(map[string]any{ + "actionType": "create:task", + "task": map[string]any{"agent": "custom:" + result.CustomAgentAssetID}, + }), + }) + if err != nil { + return result, fmt.Errorf("unable to schedule the custom agent: %w", err) + } + result.TriggerID = aws.ToString(trigger.Trigger.TriggerId) + logf("Scheduled %s on %s", opts.CustomAgentName, opts.Schedule) + } + + result.RemainingManualSteps = remainingManualSteps(opts, result) + + return result, nil +} + +// associateAWSAccount retries while AWS still rejects the agent space role: +// a freshly created role takes a while to become assumable. +func associateAWSAccount( + ctx context.Context, + clients Clients, + accountID string, + result SetupResult, + logf func(string, ...any), +) (*devopsagent.AssociateServiceOutput, error) { + input := &devopsagent.AssociateServiceInput{ + AgentSpaceId: aws.String(result.AgentSpaceID), + ServiceId: aws.String(awsServiceID), + Configuration: &agenttypes.ServiceConfigurationMemberAws{ + Value: agenttypes.AWSConfiguration{ + AccountId: aws.String(accountID), + AccountType: agenttypes.MonitorAccountTypeMonitor, + AssumableRoleArn: aws.String(result.AgentSpaceRoleARN), + }, + }, + } + + deadline := time.Now().Add(roleAssumableTimeout) + wait := roleAssumableFirstWait + for first := true; ; first = false { + association, err := clients.Agent.AssociateService(ctx, input) + if err == nil || !isRoleNotAssumableYet(err) || time.Now().After(deadline) { + return association, err + } + if first { + logf("Waiting for %s to become assumable", result.AgentSpaceRoleARN) + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + if wait < roleAssumableMaxInterval { + wait *= 2 + } + } +} + +// registerWithRetry retries while AWS reports the MCP endpoint as unreachable, +// which it does intermittently for an endpoint that is in fact serving. +func registerWithRetry( + ctx context.Context, + clients Clients, + input *devopsagent.RegisterServiceInput, + logf func(string, ...any), +) (*devopsagent.RegisterServiceOutput, error) { + deadline := time.Now().Add(roleAssumableTimeout) + wait := roleAssumableFirstWait + for first := true; ; first = false { + registration, err := clients.Agent.RegisterService(ctx, input) + if err == nil || !isEndpointUnreachable(err) || time.Now().After(deadline) { + return registration, err + } + if first { + logf("AWS could not reach %s, retrying", MCPServerEndpoint) + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + if wait < roleAssumableMaxInterval { + wait *= 2 + } + } +} + +func isEndpointUnreachable(err error) bool { + return strings.Contains(err.Error(), "is not reachable") +} + +// isRoleNotAssumableYet matches how AWS reports a role IAM has not finished +// propagating, which reads as a trust policy problem. +func isRoleNotAssumableYet(err error) bool { + return strings.Contains(err.Error(), "Invalid STS role configuration") +} + +// RegisterMCPServer registers and associates the LaunchDarkly MCP server on +// its own, for a token collected after Setup has run. +func RegisterMCPServer(ctx context.Context, clients Clients, opts SetupOptions, result *SetupResult) error { + logf := opts.Logf + if logf == nil { + logf = func(string, ...any) {} + } + + return registerMCPServer(ctx, clients, opts, result, logf) +} + +func registerMCPServer( + ctx context.Context, + clients Clients, + opts SetupOptions, + result *SetupResult, + logf func(string, ...any), +) error { + existing := opts.MCPServiceID + if existing == "" { + var err error + existing, err = FindMCPServer(ctx, clients) + if err != nil { + return err + } + } + replacingToken := opts.ReplaceMCPToken && opts.LDAccessToken != "" + if existing != "" && !replacingToken { + result.MCPServiceID = existing + logf( + "Reusing the %s MCP server already registered on this account (%s); it keeps the access "+ + "token it was registered with, so pass --access-token --replace-mcp-token to change it", + MCPServerName, + existing, + ) + + return associateMCPServer(ctx, clients, opts, result, logf) + } + if existing != "" { + if _, err := clients.Agent.DeregisterService(ctx, &devopsagent.DeregisterServiceInput{ + ServiceId: aws.String(existing), + }); err != nil { + return fmt.Errorf("unable to deregister the existing LaunchDarkly MCP server %s: %w", existing, err) + } + logf("Deregistered the %s MCP server (%s) so it can be re-registered with the new access token", + MCPServerName, existing) + } + + input := &devopsagent.RegisterServiceInput{ + Service: agenttypes.PostRegisterServiceSupportedServiceMcpServer, + ServiceDetails: &agenttypes.ServiceDetailsMemberMcpserver{ + Value: agenttypes.MCPServerDetails{ + Name: aws.String(MCPServerName), + Endpoint: aws.String(MCPServerEndpoint), + Description: aws.String("LaunchDarkly feature flag management MCP server"), + AuthorizationConfig: &agenttypes.MCPServerAuthorizationConfigMemberBearerToken{ + Value: agenttypes.MCPServerBearerTokenConfig{ + TokenName: aws.String("launchdarkly-api-token"), + TokenValue: aws.String(opts.LDAccessToken), + }, + }, + }, + }, + } + + registration, err := registerWithRetry(ctx, clients, input, logf) + if err != nil { + if !strings.Contains(err.Error(), "already exists") { + return fmt.Errorf("unable to register the LaunchDarkly MCP server: %w", err) + } + + return fmt.Errorf( + "an MCP server named %s already exists on this account but is not visible to ListServices, "+ + "so it cannot be associated automatically: associate it with agent space %s in the console, "+ + "or re-run with --skip-mcp-server", + MCPServerName, + result.AgentSpaceID, + ) + } + + // RegisterService returns a service ID or an additional step, never both. + if oauth, ok := registration.AdditionalStep.(*agenttypes.AdditionalServiceRegistrationStepMemberOauth); ok { + result.MCPAuthorizationURL = aws.ToString(oauth.Value.AuthorizationUrl) + return nil + } + result.MCPServiceID = aws.ToString(registration.ServiceId) + logf( + "Registered the %s MCP server (%s) against the LaunchDarkly account the access token belongs to", + MCPServerName, + result.MCPServiceID, + ) + + return associateMCPServer(ctx, clients, opts, result, logf) +} + +func associateMCPServer( + ctx context.Context, + clients Clients, + opts SetupOptions, + result *SetupResult, + logf func(string, ...any), +) error { + existing, err := FindAssociation(ctx, clients, result.AgentSpaceID, result.MCPServiceID) + if err != nil { + return err + } + if existing != "" { + result.MCPAssociationID = existing + logf("The %s MCP server is already associated with the agent space", MCPServerName) + + return nil + } + + association, err := clients.Agent.AssociateService(ctx, &devopsagent.AssociateServiceInput{ + AgentSpaceId: aws.String(result.AgentSpaceID), + ServiceId: aws.String(result.MCPServiceID), + Configuration: &agenttypes.ServiceConfigurationMemberMcpserver{ + Value: mcpServerConfiguration(opts), + }, + }) + if err != nil { + return fmt.Errorf("unable to associate the LaunchDarkly MCP server with the agent space: %w", err) + } + result.MCPAssociationID = aws.ToString(association.Association.AssociationId) + logf("Associated the %s MCP server with the agent space", MCPServerName) + + return nil +} + +// mcpServerConfiguration builds the tool allowlist. AWS has no "allow all +// tools" option, and an unclassified tool is treated as read-only, which the +// agent may invoke without approval. +func mcpServerConfiguration(opts SetupOptions) agenttypes.MCPServerConfiguration { + readOnly := opts.MCPReadOnlyTools + if readOnly == nil { + readOnly = DefaultMCPReadOnlyTools + } + mutative := opts.MCPMutativeTools + if mutative == nil { + mutative = DefaultMCPMutativeTools + } + + config := agenttypes.MCPServerConfiguration{ + Tools: make([]string, 0, len(readOnly)+len(mutative)), + ToolDetails: make([]agenttypes.MCPToolDetail, 0, len(readOnly)+len(mutative)), + } + for _, tool := range readOnly { + config.Tools = append(config.Tools, tool) + config.ToolDetails = append(config.ToolDetails, agenttypes.MCPToolDetail{ + Name: aws.String(tool), + ToolClassification: agenttypes.ToolClassificationReadOnly, + }) + } + for _, tool := range mutative { + config.Tools = append(config.Tools, tool) + config.ToolDetails = append(config.ToolDetails, agenttypes.MCPToolDetail{ + Name: aws.String(tool), + ToolClassification: agenttypes.ToolClassificationMutative, + }) + } + + return config +} + +func operatorAppInput(agentSpaceID, roleARN string, opts SetupOptions) *devopsagent.EnableOperatorAppInput { + input := &devopsagent.EnableOperatorAppInput{ + AgentSpaceId: aws.String(agentSpaceID), + AuthFlow: agenttypes.AuthFlow(opts.AuthFlow), + OperatorAppRoleArn: aws.String(roleARN), + } + switch agenttypes.AuthFlow(opts.AuthFlow) { + case agenttypes.AuthFlowIdc: + input.IdcInstanceArn = aws.String(opts.IdcInstanceARN) + case agenttypes.AuthFlowIdp: + input.IssuerUrl = aws.String(opts.IssuerURL) + input.IdpClientId = aws.String(opts.IdpClientID) + input.IdpClientSecret = aws.String(opts.IdpClientSecret) + } + + return input +} + +func ensureAgentSpaceRole(ctx context.Context, client IAMAPI, accountID, region string) (string, error) { + trustPolicy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow",`+ + `"Principal":{"Service":"aidevops.amazonaws.com"},"Action":"sts:AssumeRole",`+ + `"Condition":{"StringEquals":{"aws:SourceAccount":"%[1]s"},`+ + `"ArnLike":{"aws:SourceArn":"arn:aws:aidevops:%[2]s:%[1]s:agentspace/*"}}}]}`, accountID, region) + + arn, err := ensureRole(ctx, client, AgentSpaceRoleName, trustPolicy, AgentSpacePolicyARN) + if err != nil { + return "", err + } + + // Topology discovery creates the Resource Explorer service-linked role on + // first use, which the managed policy does not allow. + inlinePolicy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Sid":"AllowCreateServiceLinkedRoles",`+ + `"Effect":"Allow","Action":["iam:CreateServiceLinkedRole"],`+ + `"Resource":["arn:aws:iam::%s:role/aws-service-role/resource-explorer-2.amazonaws.com/AWSServiceRoleForResourceExplorer"]}]}`, accountID) + if _, err := client.PutRolePolicy(ctx, &iam.PutRolePolicyInput{ + RoleName: aws.String(AgentSpaceRoleName), + PolicyName: aws.String(ServiceLinkedRolePolicyName), + PolicyDocument: aws.String(inlinePolicy), + }); err != nil { + return "", fmt.Errorf("unable to add the %s policy to %s: %w", ServiceLinkedRolePolicyName, AgentSpaceRoleName, err) + } + + return arn, nil +} + +func ensureOperatorAppRole(ctx context.Context, client IAMAPI, accountID, region string) (string, error) { + // sts:TagSession is required because the managed policy scopes web app + // access per space with an aws:PrincipalTag/AgentSpaceId condition. + trustPolicy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow",`+ + `"Principal":{"Service":"aidevops.amazonaws.com"},"Action":["sts:AssumeRole","sts:TagSession"],`+ + `"Condition":{"StringEquals":{"aws:SourceAccount":"%[1]s"},`+ + `"ArnLike":{"aws:SourceArn":"arn:aws:aidevops:%[2]s:%[1]s:agentspace/*"}}}]}`, accountID, region) + + return ensureRole(ctx, client, OperatorAppRoleName, trustPolicy, OperatorAppPolicyARN) +} + +func ensureRole(ctx context.Context, client IAMAPI, name, trustPolicy, policyARN string) (string, error) { + var arn string + created, err := client.CreateRole(ctx, &iam.CreateRoleInput{ + RoleName: aws.String(name), + AssumeRolePolicyDocument: aws.String(trustPolicy), + }) + switch { + case err == nil: + arn = aws.ToString(created.Role.Arn) + case isAlreadyExists(err): + existing, getErr := client.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(name)}) + if getErr != nil { + return "", fmt.Errorf("unable to read the existing %s role: %w", name, getErr) + } + arn = aws.ToString(existing.Role.Arn) + default: + return "", fmt.Errorf("unable to create the %s role: %w", name, err) + } + + if _, err := client.AttachRolePolicy(ctx, &iam.AttachRolePolicyInput{ + RoleName: aws.String(name), + PolicyArn: aws.String(policyARN), + }); err != nil { + return "", fmt.Errorf("unable to attach %s to %s: %w", policyARN, name, err) + } + + return arn, nil +} + +func isAlreadyExists(err error) bool { + var alreadyExists *iamtypes.EntityAlreadyExistsException + + return errors.As(err, &alreadyExists) +} + +func isNoSuchEntity(err error) bool { + var noSuchEntity *iamtypes.NoSuchEntityException + + return errors.As(err, &noSuchEntity) +} + +func remainingManualSteps(opts SetupOptions, result SetupResult) []ManualStep { + var steps []ManualStep + if result.MCPAuthorizationURL != "" { + steps = append(steps, ManualStep{ + Kind: ManualStepOAuthConsent, + Description: "Approve the LaunchDarkly MCP server OAuth consent screen, then re-run setup", + URL: result.MCPAuthorizationURL, + }) + } + if !opts.SkipMCPServer && opts.LDAccessToken == "" && result.MCPServiceID == "" { + steps = append(steps, ManualStep{ + Kind: ManualStepMCPServer, + Description: fmt.Sprintf( + "Create a LaunchDarkly service token to connect the MCP server (%s)", + MCPServerEndpoint, + ), + URL: AccessTokenURL(opts.LDBaseURI), + }) + } + + return steps +} + +// ConsoleURL is the AWS DevOps Agent console for a region. +func ConsoleURL(region string) string { + return fmt.Sprintf("https://%s.console.aws.amazon.com/aidevops/home?region=%[1]s", region) +} + +// AccessTokenURL is the LaunchDarkly page where a service token is created. +func AccessTokenURL(baseURI string) string { + if baseURI == "" { + baseURI = DefaultLDBaseURI + } + + return strings.TrimSuffix(baseURI, "/") + "/settings/authorization" +} + +// OperatorAppURL is the browser entry point for an agent space's operator app. +func OperatorAppURL(agentSpaceID string) string { + return fmt.Sprintf("https://%s.aidevops.global.app.aws", agentSpaceID) +} diff --git a/internal/awsdevops/status.go b/internal/awsdevops/status.go new file mode 100644 index 000000000..72187e9e5 --- /dev/null +++ b/internal/awsdevops/status.go @@ -0,0 +1,151 @@ +package awsdevops + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/devopsagent" + "github.com/aws/aws-sdk-go-v2/service/iam" +) + +// Status is a read-only view of what setup has provisioned so far. +type Status struct { + AccountID string `json:"accountId"` + Region string `json:"region"` + Roles []RoleStatus `json:"roles"` + Services []ServiceStatus `json:"services,omitempty"` + AgentSpaces []AgentSpaceStatus `json:"agentSpaces"` + MissingSetup []string `json:"missingSetup,omitempty"` +} + +type RoleStatus struct { + Name string `json:"name"` + ARN string `json:"arn,omitempty"` + Exists bool `json:"exists"` +} + +type ServiceStatus struct { + ServiceID string `json:"serviceId"` + ServiceType string `json:"serviceType"` + Name string `json:"name,omitempty"` +} + +type AgentSpaceStatus struct { + AgentSpaceID string `json:"agentSpaceId"` + Name string `json:"name"` + OperatorAppURL string `json:"operatorAppUrl"` + Associations []AssociationStatus `json:"associations,omitempty"` + Assets []AssetStatus `json:"assets,omitempty"` +} + +type AssociationStatus struct { + AssociationID string `json:"associationId"` + ServiceID string `json:"serviceId"` + Status string `json:"status,omitempty"` +} + +type AssetStatus struct { + AssetID string `json:"assetId"` + AssetType string `json:"assetType"` +} + +// managedAssetTypes are the asset types setup creates; agent spaces also hold +// memory and artifact assets the agent manages itself. +var managedAssetTypes = map[string]bool{ + skillAssetType: true, + customAgentAssetType: true, +} + +// GetStatus reports the IAM roles and agent spaces in the caller's account. It +// inspects one agent space when agentSpaceID is set, and every space otherwise. +func GetStatus(ctx context.Context, clients Clients, agentSpaceID string) (Status, error) { + accountID, err := clients.AccountID(ctx) + if err != nil { + return Status{}, err + } + + status := Status{AccountID: accountID, Region: clients.Region} + for _, name := range []string{AgentSpaceRoleName, OperatorAppRoleName} { + role, err := clients.IAM.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(name)}) + if err != nil { + status.Roles = append(status.Roles, RoleStatus{Name: name}) + status.MissingSetup = append(status.MissingSetup, fmt.Sprintf("IAM role %s does not exist", name)) + + continue + } + status.Roles = append(status.Roles, RoleStatus{ + Name: name, + ARN: aws.ToString(role.Role.Arn), + Exists: true, + }) + } + + services, err := clients.Agent.ListServices(ctx, &devopsagent.ListServicesInput{}) + if err != nil { + return status, fmt.Errorf("unable to list registered services: %w", err) + } + for _, service := range services.Services { + status.Services = append(status.Services, ServiceStatus{ + ServiceID: aws.ToString(service.ServiceId), + ServiceType: string(service.ServiceType), + Name: serviceName(service), + }) + } + + spaceIDs := []string{agentSpaceID} + if agentSpaceID == "" { + spaces, err := clients.Agent.ListAgentSpaces(ctx, &devopsagent.ListAgentSpacesInput{}) + if err != nil { + return status, fmt.Errorf("unable to list agent spaces: %w", err) + } + spaceIDs = nil + for _, space := range spaces.AgentSpaces { + spaceIDs = append(spaceIDs, aws.ToString(space.AgentSpaceId)) + } + } + + for _, id := range spaceIDs { + space, err := clients.Agent.GetAgentSpace(ctx, &devopsagent.GetAgentSpaceInput{AgentSpaceId: aws.String(id)}) + if err != nil { + return status, fmt.Errorf("unable to read agent space %s: %w", id, err) + } + spaceStatus := AgentSpaceStatus{ + AgentSpaceID: id, + Name: aws.ToString(space.AgentSpace.Name), + OperatorAppURL: OperatorAppURL(id), + } + + associations, err := clients.Agent.ListAssociations(ctx, &devopsagent.ListAssociationsInput{ + AgentSpaceId: aws.String(id), + }) + if err != nil { + return status, fmt.Errorf("unable to list associations for agent space %s: %w", id, err) + } + for _, association := range associations.Associations { + spaceStatus.Associations = append(spaceStatus.Associations, AssociationStatus{ + AssociationID: aws.ToString(association.AssociationId), + ServiceID: aws.ToString(association.ServiceId), + Status: string(association.Status), + }) + } + + assets, err := clients.Agent.ListAssets(ctx, &devopsagent.ListAssetsInput{AgentSpaceId: aws.String(id)}) + if err != nil { + return status, fmt.Errorf("unable to list assets for agent space %s: %w", id, err) + } + for _, asset := range assets.Items { + if !managedAssetTypes[aws.ToString(asset.AssetType)] { + continue + } + spaceStatus.Assets = append(spaceStatus.Assets, AssetStatus{ + AssetID: aws.ToString(asset.AssetId), + AssetType: aws.ToString(asset.AssetType), + }) + } + + status.AgentSpaces = append(status.AgentSpaces, spaceStatus) + } + + return status, nil +} diff --git a/internal/awsdevops/teardown.go b/internal/awsdevops/teardown.go new file mode 100644 index 000000000..aeb84aa11 --- /dev/null +++ b/internal/awsdevops/teardown.go @@ -0,0 +1,211 @@ +package awsdevops + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/devopsagent" + agenttypes "github.com/aws/aws-sdk-go-v2/service/devopsagent/types" + "github.com/aws/aws-sdk-go-v2/service/iam" +) + +// TeardownOptions narrows what to remove: only AgentSpaceID and ServiceIDs do +// that, while DeleteRoles adds the IAM roles to a narrowed teardown. +// With no selector set, Teardown removes everything setup can create. The +// agent space has to be emptied before it can be deleted, so assets and +// associations always go first. +type TeardownOptions struct { + AgentSpaceID string + ServiceIDs []string + DeleteRoles bool + + Logf func(format string, args ...any) +} + +// RemovesEverything reports whether nothing narrows the teardown, in which +// case it takes every agent space, registered service and role in the account. +func (o TeardownOptions) RemovesEverything() bool { + return o.AgentSpaceID == "" && len(o.ServiceIDs) == 0 +} + +// Teardown removes the resources Setup created: every association and asset in +// an agent space, the agent space itself, the registered services, and the IAM +// roles. Options narrow that; with none it covers the whole account. +func Teardown(ctx context.Context, clients Clients, opts TeardownOptions) error { + logf := opts.Logf + if logf == nil { + logf = func(string, ...any) {} + } + all := opts.RemovesEverything() + + agentSpaceIDs := []string{} + if opts.AgentSpaceID != "" { + agentSpaceIDs = append(agentSpaceIDs, opts.AgentSpaceID) + } + if all { + var err error + if agentSpaceIDs, err = listAgentSpaceIDs(ctx, clients); err != nil { + return err + } + } + + for _, agentSpaceID := range agentSpaceIDs { + if err := teardownAgentSpace(ctx, clients, agentSpaceID, logf); err != nil { + return err + } + } + + serviceIDs := opts.ServiceIDs + if all { + registered, err := listServiceIDs(ctx, clients) + if err != nil { + return err + } + serviceIDs = registered + } + + for _, serviceID := range serviceIDs { + if _, err := clients.Agent.DeregisterService(ctx, &devopsagent.DeregisterServiceInput{ + ServiceId: aws.String(serviceID), + }); err != nil { + return fmt.Errorf("unable to deregister service %s: %w", serviceID, err) + } + logf("Deregistered service %s", serviceID) + } + + if opts.DeleteRoles || all { + if err := deleteRole(ctx, clients.IAM, AgentSpaceRoleName, AgentSpacePolicyARN, ServiceLinkedRolePolicyName); err != nil { + return err + } + logf("Deleted role %s", AgentSpaceRoleName) + if err := deleteRole(ctx, clients.IAM, OperatorAppRoleName, OperatorAppPolicyARN, ""); err != nil { + return err + } + logf("Deleted role %s", OperatorAppRoleName) + } + + return nil +} + +func teardownAgentSpace(ctx context.Context, clients Clients, agentSpaceID string, logf func(string, ...any)) error { + associations, err := clients.Agent.ListAssociations(ctx, &devopsagent.ListAssociationsInput{ + AgentSpaceId: aws.String(agentSpaceID), + }) + if err != nil { + return fmt.Errorf("unable to list associations for agent space %s: %w", agentSpaceID, err) + } + for _, association := range associations.Associations { + if _, err := clients.Agent.DisassociateService(ctx, &devopsagent.DisassociateServiceInput{ + AgentSpaceId: aws.String(agentSpaceID), + AssociationId: association.AssociationId, + }); err != nil { + return fmt.Errorf("unable to disassociate %s: %w", aws.ToString(association.ServiceId), err) + } + logf("Disassociated %s", aws.ToString(association.ServiceId)) + } + + assets, err := clients.Agent.ListAssets(ctx, &devopsagent.ListAssetsInput{ + AgentSpaceId: aws.String(agentSpaceID), + }) + if err != nil { + return fmt.Errorf("unable to list assets for agent space %s: %w", agentSpaceID, err) + } + for _, asset := range sortAssetsForDeletion(assets.Items) { + if _, err := clients.Agent.DeleteAsset(ctx, &devopsagent.DeleteAssetInput{ + AgentSpaceId: aws.String(agentSpaceID), + AssetId: asset.AssetId, + }); err != nil { + return fmt.Errorf("unable to delete asset %s: %w", aws.ToString(asset.AssetId), err) + } + logf("Deleted asset %s", aws.ToString(asset.AssetId)) + } + + if _, err := clients.Agent.DeleteAgentSpace(ctx, &devopsagent.DeleteAgentSpaceInput{ + AgentSpaceId: aws.String(agentSpaceID), + }); err != nil { + return fmt.Errorf("unable to delete agent space %s: %w", agentSpaceID, err) + } + logf("Deleted agent space %s", agentSpaceID) + + return nil +} + +func listAgentSpaceIDs(ctx context.Context, clients Clients) ([]string, error) { + var ( + ids []string + nextToken *string + ) + for { + spaces, err := clients.Agent.ListAgentSpaces(ctx, &devopsagent.ListAgentSpacesInput{NextToken: nextToken}) + if err != nil { + return nil, fmt.Errorf("unable to list agent spaces: %w", err) + } + for _, space := range spaces.AgentSpaces { + ids = append(ids, aws.ToString(space.AgentSpaceId)) + } + if spaces.NextToken == nil { + return ids, nil + } + nextToken = spaces.NextToken + } +} + +func listServiceIDs(ctx context.Context, clients Clients) ([]string, error) { + var ( + ids []string + nextToken *string + ) + for { + services, err := clients.Agent.ListServices(ctx, &devopsagent.ListServicesInput{NextToken: nextToken}) + if err != nil { + return nil, fmt.Errorf("unable to list registered services: %w", err) + } + for _, service := range services.Services { + ids = append(ids, aws.ToString(service.ServiceId)) + } + if services.NextToken == nil { + return ids, nil + } + nextToken = services.NextToken + } +} + +// sortAssetsForDeletion puts memory stores last, since AWS refuses to delete a +// store that still holds memory files. +func sortAssetsForDeletion(assets []agenttypes.Asset) []agenttypes.Asset { + ordered := make([]agenttypes.Asset, 0, len(assets)) + var stores []agenttypes.Asset + for _, asset := range assets { + if aws.ToString(asset.AssetType) == memoryStoreAssetType { + stores = append(stores, asset) + + continue + } + ordered = append(ordered, asset) + } + + return append(ordered, stores...) +} + +func deleteRole(ctx context.Context, client IAMAPI, name, policyARN, inlinePolicyName string) error { + if _, err := client.DetachRolePolicy(ctx, &iam.DetachRolePolicyInput{ + RoleName: aws.String(name), + PolicyArn: aws.String(policyARN), + }); err != nil && !isNoSuchEntity(err) { + return fmt.Errorf("unable to detach %s from %s: %w", policyARN, name, err) + } + if inlinePolicyName != "" { + if _, err := client.DeleteRolePolicy(ctx, &iam.DeleteRolePolicyInput{ + RoleName: aws.String(name), + PolicyName: aws.String(inlinePolicyName), + }); err != nil && !isNoSuchEntity(err) { + return fmt.Errorf("unable to delete the %s policy on %s: %w", inlinePolicyName, name, err) + } + } + if _, err := client.DeleteRole(ctx, &iam.DeleteRoleInput{RoleName: aws.String(name)}); err != nil && !isNoSuchEntity(err) { + return fmt.Errorf("unable to delete the %s role: %w", name, err) + } + + return nil +} diff --git a/internal/awsdevops/wait.go b/internal/awsdevops/wait.go new file mode 100644 index 000000000..3d5e987bf --- /dev/null +++ b/internal/awsdevops/wait.go @@ -0,0 +1,107 @@ +package awsdevops + +import ( + "context" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/devopsagent" + agenttypes "github.com/aws/aws-sdk-go-v2/service/devopsagent/types" +) + +// FindMCPServer returns the ID of the LaunchDarkly MCP server if one is +// already registered on the account. AWS keeps MCP servers at the account +// level, so a second agent space reuses the existing registration. +func FindMCPServer(ctx context.Context, clients Clients) (string, error) { + var nextToken *string + for { + services, err := clients.Agent.ListServices(ctx, &devopsagent.ListServicesInput{NextToken: nextToken}) + if err != nil { + return "", fmt.Errorf("unable to list registered services: %w", err) + } + for _, service := range services.Services { + if isLaunchDarklyMCPServer(service) { + return aws.ToString(service.ServiceId), nil + } + } + if services.NextToken == nil { + return "", nil + } + nextToken = services.NextToken + } +} + +// FindAgentSpace returns the ID of the agent space with the given name, or an +// empty string when the account has none. +func FindAgentSpace(ctx context.Context, clients Clients, name string) (string, error) { + var nextToken *string + for { + spaces, err := clients.Agent.ListAgentSpaces(ctx, &devopsagent.ListAgentSpacesInput{NextToken: nextToken}) + if err != nil { + return "", fmt.Errorf("unable to list agent spaces: %w", err) + } + for _, space := range spaces.AgentSpaces { + if strings.EqualFold(aws.ToString(space.Name), name) { + return aws.ToString(space.AgentSpaceId), nil + } + } + if spaces.NextToken == nil { + return "", nil + } + nextToken = spaces.NextToken + } +} + +// FindAssociation returns the ID of an existing association between an agent +// space and a service, so setup can be re-run without AWS rejecting duplicates. +func FindAssociation(ctx context.Context, clients Clients, agentSpaceID, serviceID string) (string, error) { + var nextToken *string + for { + associations, err := clients.Agent.ListAssociations(ctx, &devopsagent.ListAssociationsInput{ + AgentSpaceId: aws.String(agentSpaceID), + NextToken: nextToken, + }) + if err != nil { + return "", fmt.Errorf("unable to list associations for agent space %s: %w", agentSpaceID, err) + } + for _, association := range associations.Associations { + if aws.ToString(association.ServiceId) == serviceID { + return aws.ToString(association.AssociationId), nil + } + } + if associations.NextToken == nil { + return "", nil + } + nextToken = associations.NextToken + } +} + +// isLaunchDarklyMCPServer matches on the name AWS reports at either level, and +// on the endpoint, since MCP servers carry their name in the type-specific +// details rather than the top-level field. +func isLaunchDarklyMCPServer(service agenttypes.RegisteredService) bool { + if strings.EqualFold(aws.ToString(service.Name), MCPServerName) { + return true + } + + details, ok := service.AdditionalServiceDetails.(*agenttypes.AdditionalServiceDetailsMemberMcpserver) + if !ok { + return false + } + + return strings.EqualFold(aws.ToString(details.Value.Name), MCPServerName) || + aws.ToString(details.Value.Endpoint) == MCPServerEndpoint +} + +// serviceName prefers the name AWS keeps in the type-specific details, which is +// where MCP servers carry theirs. +func serviceName(service agenttypes.RegisteredService) string { + if details, ok := service.AdditionalServiceDetails.(*agenttypes.AdditionalServiceDetailsMemberMcpserver); ok { + if name := aws.ToString(details.Value.Name); name != "" { + return name + } + } + + return aws.ToString(service.Name) +} diff --git a/internal/awsdevops/wait_test.go b/internal/awsdevops/wait_test.go new file mode 100644 index 000000000..be1c3eb0e --- /dev/null +++ b/internal/awsdevops/wait_test.go @@ -0,0 +1,82 @@ +package awsdevops_test + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + agenttypes "github.com/aws/aws-sdk-go-v2/service/devopsagent/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/awsdevops" +) + +func TestSetupReusesAnAlreadyRegisteredMCPServer(t *testing.T) { + agent := &fakeAgent{ + services: []agenttypes.RegisteredService{ + { + ServiceId: aws.String("mcp-1"), + ServiceType: agenttypes.ServiceMcpServer, + Name: aws.String(awsdevops.MCPServerName), + }, + }, + } + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + LDAccessToken: "api-token", + }) + require.NoError(t, err) + + assert.NotContains(t, agent.calls, "RegisterService") + assert.Equal(t, "mcp-1", result.MCPServiceID) + assert.Equal(t, "assoc-mcp-1", result.MCPAssociationID) +} + +func TestSetupReplacesTheMCPServerTokenWhenAsked(t *testing.T) { + agent := &fakeAgent{ + services: []agenttypes.RegisteredService{ + { + ServiceId: aws.String("mcp-1"), + ServiceType: agenttypes.ServiceMcpServer, + Name: aws.String(awsdevops.MCPServerName), + }, + }, + } + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + SkipOperatorApp: true, + LDAccessToken: "api-token", + ReplaceMCPToken: true, + }) + require.NoError(t, err) + + assert.Equal(t, []string{"mcp-1"}, agent.deregisteredIDs) + assert.Contains(t, agent.calls, "RegisterService") + assert.Equal(t, "mcp-1", result.MCPServiceID) +} + +func TestFindMCPServerMatchesOnTheEndpointInTheServiceDetails(t *testing.T) { + agent := &fakeAgent{ + services: []agenttypes.RegisteredService{ + { + ServiceId: aws.String("mcp-2"), + ServiceType: agenttypes.ServiceMcpServer, + AdditionalServiceDetails: &agenttypes.AdditionalServiceDetailsMemberMcpserver{ + Value: agenttypes.RegisteredMCPServerDetails{ + Endpoint: aws.String(awsdevops.MCPServerEndpoint), + }, + }, + }, + }, + } + + serviceID, err := awsdevops.FindMCPServer(context.Background(), newTestClients(agent, newFakeIAM())) + require.NoError(t, err) + + assert.Equal(t, "mcp-2", serviceID) +} From 6f0629ae7b875d2de6b853283cef83ac9bf8cc5f Mon Sep 17 00:00:00 2001 From: "manderjaska@launchdarkly.com" Date: Mon, 5 Oct 2026 14:45:50 +0000 Subject: [PATCH 2/9] Trim the aws-devops-agent commands to the DevOps Agent setup --- README.md | 10 +- cmd/aws_devops_agent/manual_steps.go | 219 ---------------- cmd/aws_devops_agent/manual_steps_test.go | 32 --- cmd/aws_devops_agent/prompt.go | 37 +++ cmd/aws_devops_agent/prompt_test.go | 16 ++ cmd/aws_devops_agent/setup.go | 140 +++++----- cmd/aws_devops_agent/status.go | 3 - cmd/aws_devops_agent/teardown.go | 12 +- internal/awsdevops/awsdevops.go | 2 - internal/awsdevops/awsdevops_test.go | 156 +---------- internal/awsdevops/{wait.go => lookup.go} | 0 .../{wait_test.go => lookup_test.go} | 1 - internal/awsdevops/setup.go | 247 ++++-------------- internal/awsdevops/status.go | 27 -- 14 files changed, 172 insertions(+), 730 deletions(-) delete mode 100644 cmd/aws_devops_agent/manual_steps.go delete mode 100644 cmd/aws_devops_agent/manual_steps_test.go create mode 100644 cmd/aws_devops_agent/prompt.go create mode 100644 cmd/aws_devops_agent/prompt_test.go rename internal/awsdevops/{wait.go => lookup.go} (100%) rename internal/awsdevops/{wait_test.go => lookup_test.go} (98%) diff --git a/README.md b/README.md index 6ce65333b..3ca009e78 100644 --- a/README.md +++ b/README.md @@ -125,15 +125,11 @@ The AWS DevOps Agent is available in `us-east-1`, `us-west-2`, `ap-southeast-2`, `setup` is safe to re-run: it reuses the IAM roles, the agent space matching `--agent-space-name`, the account and MCP associations on it, and any LaunchDarkly MCP server already registered on the account. Pass `--new-agent-space` to create an additional agent space instead. -`--access-token` is optional. The first available token — the flag, then `LD_ACCESS_TOKEN`, then the one already in your ldcli configuration — is registered with AWS as the bearer token the agent uses to call the LaunchDarkly MCP server, so it should be a [service token](https://launchdarkly.com/docs/home/account/api-create) whose permissions match what you want the agent to do. AWS keeps it until the MCP server is re-registered, so a session token written by `ldcli login` eventually expires and the agent then fails with `unauthorized` errors. Re-run with `--access-token --replace-mcp-token` to re-register an MCP server with a different token. By default the agent may call `list-projects`, `list-flags` and `get-flag` without asking, and must ask for approval before calling `toggle-flag`. Use `--mcp-read-only-tools` and `--mcp-mutative-tools` to change that. +`--access-token` is optional. The first available token — the flag, then `LD_ACCESS_TOKEN`, then the one already in your ldcli configuration — is registered with AWS as the bearer token the agent uses to call the LaunchDarkly MCP server, so it should be a [service token](https://launchdarkly.com/docs/home/account/api-create) whose permissions match what you want the agent to do. AWS keeps it until the MCP server is re-registered, so a session token written by `ldcli login` eventually expires and the agent then fails with `unauthorized` errors. Re-run with `--access-token --replace-mcp-token` to re-register an MCP server with a different token. The agent may call `list-projects`, `list-flags` and `get-flag` without asking, and must ask for approval before calling `toggle-flag`. -With no token configured at all, `setup` pauses at the LaunchDarkly page where you create a service token and connects the MCP server with the token you paste there, so you do not need one ready beforehand. Pressing Enter without a token skips the step, which `--skip-mcp-server` also does up front. +With no token configured at all, `setup` prints the LaunchDarkly page where you create a service token and connects the MCP server with the token you paste there, so you do not need one ready beforehand. Pressing Enter without a token skips the step, as does running without a terminal. -`setup` ends by printing the operator app URL, `https://.aidevops.global.app.aws`, which is where you use the agent, together with the AWS console page that manages it. Pass `--no-wait` to skip every pause and list the remaining manual steps instead, for example in CI: - -```sh-session -ldcli aws-devops-agent setup --no-wait --access-token -``` +`setup` ends by printing the operator app URL, `https://.aidevops.global.app.aws`, which is where you use the agent, together with the AWS console page that manages it. `ldcli aws-devops-agent status` shows what exists. `ldcli aws-devops-agent teardown` removes everything in the account and region — every agent space, every registered service including the MCP server, and the IAM roles — after asking you to confirm (`--force` skips the prompt, and is required when there is no terminal). `--agent-space-id ` and `--service-id ` narrow it to those resources, and `--delete-roles` adds the IAM roles back to a narrowed teardown. diff --git a/cmd/aws_devops_agent/manual_steps.go b/cmd/aws_devops_agent/manual_steps.go deleted file mode 100644 index 843cca935..000000000 --- a/cmd/aws_devops_agent/manual_steps.go +++ /dev/null @@ -1,219 +0,0 @@ -package awsdevopsagent - -import ( - "bufio" - "bytes" - "fmt" - "io" - "os" - "strings" - - "github.com/spf13/cobra" - "golang.org/x/term" - - "github.com/launchdarkly/ldcli/internal/awsdevops" -) - -const ( - skipKey = 's' - etx = 3 - backspace = 8 - del = 127 -) - -func manualStepPrompt(step awsdevops.ManualStep) string { - switch step.Kind { - case awsdevops.ManualStepOAuthConsent: - return "Approve the LaunchDarkly MCP server consent screen at:" - default: - return step.Description - } -} - -// walkManualSteps pauses on each browser-only step so the operator can finish -// it, then picks up whatever they created. Steps they skip are returned so the -// caller can still list them. -func walkManualSteps( - cmd *cobra.Command, - clients awsdevops.Clients, - opts awsdevops.SetupOptions, - result *awsdevops.SetupResult, -) []awsdevops.ManualStep { - keys := newKeyReader(cmd.InOrStdin()) - defer keys.close() - out := keys.writer(cmd.OutOrStdout()) - errOut := keys.writer(cmd.ErrOrStderr()) - - var skipped []awsdevops.ManualStep - for _, step := range result.RemainingManualSteps { - if step.Kind == awsdevops.ManualStepMCPServer { - if !connectMCPServer(cmd, clients, opts, result, keys, out, errOut) { - skipped = append(skipped, step) - } - - continue - } - - _, _ = fmt.Fprintf(out, "\n%s\n %s\n\n", manualStepPrompt(step), step.URL) - - _, _ = fmt.Fprint(out, "Press Enter when you're done, or s to skip: ") - key := keys.next() - _, _ = fmt.Fprintln(out) - if key == skipKey { - skipped = append(skipped, step) - } - } - - return skipped -} - -// connectMCPServer registers the LaunchDarkly MCP server with a service token -// pasted at the prompt, and reports whether the step is done. -func connectMCPServer( - cmd *cobra.Command, - clients awsdevops.Clients, - opts awsdevops.SetupOptions, - result *awsdevops.SetupResult, - keys *keyReader, - out io.Writer, - errOut io.Writer, -) bool { - _, _ = fmt.Fprintf( - out, - "\nCreate a LaunchDarkly service token at:\n %s\n\n", - awsdevops.AccessTokenURL(opts.LDBaseURI), - ) - _, _ = fmt.Fprint(out, "Paste the token, or press Enter to skip: ") - token := keys.line() - _, _ = fmt.Fprintln(out) - if token == "" { - return false - } - - opts.LDAccessToken = token - if err := awsdevops.RegisterMCPServer(cmd.Context(), clients, opts, result); err != nil { - _, _ = fmt.Fprintf(errOut, "%s\n", err) - - return false - } - - return true -} - -// keyReader delivers single keypresses from one long-lived goroutine, so a -// pause that gives up on stdin cannot leave a reader behind to swallow the -// keys meant for the next one. On a terminal it switches to raw mode, where a -// key registers without Enter. -type keyReader struct { - keys chan byte - restore func() - raw bool -} - -func newKeyReader(in io.Reader) *keyReader { - reader := &keyReader{keys: make(chan byte, 1), restore: func() {}} - - if file, ok := in.(*os.File); ok && term.IsTerminal(int(file.Fd())) { - if state, err := term.MakeRaw(int(file.Fd())); err == nil { - reader.restore = func() { _ = term.Restore(int(file.Fd()), state) } - reader.raw = true - } - } - - go reader.read(in) - - return reader -} - -func (k *keyReader) read(in io.Reader) { - buffered := bufio.NewReader(in) - for { - key, err := buffered.ReadByte() - if err != nil { - close(k.keys) - - return - } - if key == etx { - k.restore() - if process, err := os.FindProcess(os.Getpid()); err == nil { - _ = process.Signal(os.Interrupt) - } - - return - } - k.keys <- key - } -} - -func (k *keyReader) next() byte { - key, ok := <-k.keys - if !ok { - return 0 - } - if key == 'S' { - return skipKey - } - - return key -} - -// line collects keys until Enter without echoing, for prompts that take a -// credential. -func (k *keyReader) line() string { - return k.readLine(nil) -} - -func (k *keyReader) readLine(echo io.Writer) string { - var typed []byte - for { - key, ok := <-k.keys - if !ok || key == '\r' || key == '\n' { - return strings.TrimSpace(string(typed)) - } - if key == backspace || key == del { - if len(typed) > 0 { - typed = typed[:len(typed)-1] - if echo != nil { - _, _ = fmt.Fprint(echo, "\b \b") - } - } - - continue - } - typed = append(typed, key) - if echo != nil { - _, _ = fmt.Fprintf(echo, "%c", key) - } - } -} - -func (k *keyReader) close() { - k.restore() -} - -// writer keeps output readable while the terminal is raw, where a bare newline -// no longer returns the cursor to the first column. -func (k *keyReader) writer(w io.Writer) io.Writer { - if !k.raw { - return w - } - - return crlfWriter{w: w} -} - -type crlfWriter struct { - w io.Writer -} - -func (c crlfWriter) Write(p []byte) (int, error) { - if _, err := c.w.Write(bytes.ReplaceAll(p, []byte("\n"), []byte("\r\n"))); err != nil { - return 0, err - } - - return len(p), nil -} - -func canPrompt() bool { - return term.IsTerminal(int(os.Stdin.Fd())) -} diff --git a/cmd/aws_devops_agent/manual_steps_test.go b/cmd/aws_devops_agent/manual_steps_test.go deleted file mode 100644 index 403e170e2..000000000 --- a/cmd/aws_devops_agent/manual_steps_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package awsdevopsagent - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestKeyReaderReadsOneKeyAtATime(t *testing.T) { - keys := newKeyReader(strings.NewReader("S\rx")) - defer keys.close() - - assert.Equal(t, byte(skipKey), keys.next()) - assert.Equal(t, byte('\r'), keys.next()) - assert.Equal(t, byte('x'), keys.next()) - assert.Equal(t, byte(0), keys.next()) -} - -func TestKeyReaderReadsALineWithoutEchoing(t *testing.T) { - keys := newKeyReader(strings.NewReader("api-xx\x7f1 \r")) - defer keys.close() - - assert.Equal(t, "api-x1", keys.line()) -} - -func TestKeyReaderReadsAnEmptyLine(t *testing.T) { - keys := newKeyReader(strings.NewReader("\r")) - defer keys.close() - - assert.Empty(t, keys.line()) -} diff --git a/cmd/aws_devops_agent/prompt.go b/cmd/aws_devops_agent/prompt.go new file mode 100644 index 000000000..d9d314237 --- /dev/null +++ b/cmd/aws_devops_agent/prompt.go @@ -0,0 +1,37 @@ +package awsdevopsagent + +import ( + "bufio" + "io" + "os" + "strings" + + "golang.org/x/term" +) + +// readLine reads one line, which the terminal echoes as it is typed. +func readLine(in io.Reader) string { + line, _ := bufio.NewReader(in).ReadString('\n') + + return strings.TrimSpace(line) +} + +// readSecret reads one line without echoing it, so a pasted credential stays +// off the screen. +func readSecret(in io.Reader) string { + file, ok := in.(*os.File) + if !ok || !term.IsTerminal(int(file.Fd())) { + return readLine(in) + } + + typed, err := term.ReadPassword(int(file.Fd())) + if err != nil { + return "" + } + + return strings.TrimSpace(string(typed)) +} + +func canPrompt() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} diff --git a/cmd/aws_devops_agent/prompt_test.go b/cmd/aws_devops_agent/prompt_test.go new file mode 100644 index 000000000..46b33b087 --- /dev/null +++ b/cmd/aws_devops_agent/prompt_test.go @@ -0,0 +1,16 @@ +package awsdevopsagent + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReadSecretTrimsThePastedToken(t *testing.T) { + assert.Equal(t, "api-token", readSecret(strings.NewReader("api-token\n"))) +} + +func TestReadSecretReadsAnEmptyLine(t *testing.T) { + assert.Empty(t, readSecret(strings.NewReader("\n"))) +} diff --git a/cmd/aws_devops_agent/setup.go b/cmd/aws_devops_agent/setup.go index dc4ae597c..564ff3b0b 100644 --- a/cmd/aws_devops_agent/setup.go +++ b/cmd/aws_devops_agent/setup.go @@ -15,26 +15,14 @@ import ( ) const ( - agentSpaceNameFlag = "agent-space-name" - agentSpaceDescriptionFlag = "agent-space-description" - newAgentSpaceFlag = "new-agent-space" - authFlowFlag = "auth-flow" - idcInstanceARNFlag = "idc-instance-arn" - issuerURLFlag = "issuer-url" - idpClientIDFlag = "idp-client-id" - idpClientSecretFlag = "idp-client-secret" - skipOperatorAppFlag = "skip-operator-app" - skipMCPServerFlag = "skip-mcp-server" - replaceMCPTokenFlag = "replace-mcp-token" - mcpServiceIDFlag = "mcp-service-id" - mcpReadOnlyToolsFlag = "mcp-read-only-tools" - mcpMutativeToolsFlag = "mcp-mutative-tools" - skillNameFlag = "skill-name" - skillFileFlag = "skill-file" - customAgentNameFlag = "custom-agent-name" - customAgentToolsFlag = "custom-agent-tools" - scheduleFlag = "schedule" - noWaitFlag = "no-wait" + agentSpaceNameFlag = "agent-space-name" + newAgentSpaceFlag = "new-agent-space" + authFlowFlag = "auth-flow" + idcInstanceARNFlag = "idc-instance-arn" + issuerURLFlag = "issuer-url" + idpClientIDFlag = "idp-client-id" + idpClientSecretFlag = "idp-client-secret" + replaceMCPTokenFlag = "replace-mcp-token" ) func NewSetupCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { @@ -49,13 +37,10 @@ along with anything already attached to it. The run ends with the operator app URL, where the agent is used. The MCP server is connected with --access-token, or with the token in your -ldcli configuration when you do not pass one. With neither, setup pauses at the -LaunchDarkly page where you create a service token and uses the token you -paste. AWS keeps that token and the agent acts as its account and role, so a -session token from 'ldcli login' stops working once it expires. - -In a terminal, setup pauses on each browser step with the page to open and -resumes once you are done. Pass --no-wait to only list them.`, +ldcli configuration when you do not pass one. With neither, setup asks for a +LaunchDarkly service token. AWS keeps that token and the agent acts as its +account and role, so a session token from 'ldcli login' stops working once it +expires.`, Args: cobra.NoArgs, PreRun: trackRun(analyticsTrackerFn), RunE: runSetup, @@ -64,25 +49,13 @@ resumes once you are done. Pass --no-wait to only list them.`, cmd.Flags().String(regionFlag, "", "AWS region to provision in. Defaults to the region of the current AWS session") cmd.Flags().String(agentSpaceIDFlag, "", "Existing agent space to add to instead of creating one") cmd.Flags().String(agentSpaceNameFlag, "launchdarkly", "Name of the agent space to create") - cmd.Flags().String(agentSpaceDescriptionFlag, "Managed by the LaunchDarkly CLI", "Description of the agent space to create") cmd.Flags().Bool(newAgentSpaceFlag, false, "Create another agent space instead of reusing the one matching --agent-space-name") cmd.Flags().String(authFlowFlag, "iam", "Operator app sign-in method: iam, idc or idp") cmd.Flags().String(idcInstanceARNFlag, "", "IAM Identity Center instance ARN, required when --auth-flow=idc") cmd.Flags().String(issuerURLFlag, "", "OIDC issuer URL, required when --auth-flow=idp") cmd.Flags().String(idpClientIDFlag, "", "OIDC client ID, required when --auth-flow=idp") cmd.Flags().String(idpClientSecretFlag, "", "OIDC client secret, required when --auth-flow=idp") - cmd.Flags().Bool(skipOperatorAppFlag, false, "Skip the operator app role and web app") - cmd.Flags().Bool(skipMCPServerFlag, false, "Skip registering the LaunchDarkly MCP server, without listing it as a manual step") cmd.Flags().Bool(replaceMCPTokenFlag, false, "Re-register the LaunchDarkly MCP server so it uses the token passed with --access-token") - cmd.Flags().String(mcpServiceIDFlag, "", "Service ID of an MCP server already registered on the account to associate instead of registering one") - cmd.Flags().StringSlice(mcpReadOnlyToolsFlag, awsdevops.DefaultMCPReadOnlyTools, "LaunchDarkly MCP tools the agent may call without approval") - cmd.Flags().StringSlice(mcpMutativeToolsFlag, awsdevops.DefaultMCPMutativeTools, "LaunchDarkly MCP tools that change flag state and need approval") - cmd.Flags().String(skillNameFlag, "launchdarkly", "Name of the skill asset to create from --skill-file") - cmd.Flags().String(skillFileFlag, "", "Path to a SKILL.md to upload as a skill asset") - cmd.Flags().String(customAgentNameFlag, "", "Create a custom agent with this name") - cmd.Flags().StringSlice(customAgentToolsFlag, nil, "Tools the custom agent may use") - cmd.Flags().String(scheduleFlag, "", "Cron or rate expression to run the custom agent on, for example 'rate(1 day)'") - cmd.Flags().Bool(noWaitFlag, false, "List the browser-only steps instead of pausing on each one") cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) @@ -126,49 +99,62 @@ func runSetup(cmd *cobra.Command, args []string) error { return printJSON(cmd, result) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nAgent space %s is ready in %s (account %s).\n", result.AgentSpaceID, result.Region, result.AccountID) - remaining := result.RemainingManualSteps - if !mustBool(cmd, noWaitFlag) && canPrompt() { - remaining = walkManualSteps(cmd, clients, opts, &result) - } - if len(remaining) > 0 { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "\nSteps AWS cannot automate:") - for _, step := range remaining { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), " - %s\n %s\n", step.Description, step.URL) - } + if result.MCPServiceID == "" { + connectMCPServer(cmd, clients, opts, &result) } - if result.OperatorAppURL != "" { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nOpen the DevOps Agent at:\n %s\n", result.OperatorAppURL) - } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nOpen the DevOps Agent at:\n %s\n", result.OperatorAppURL) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Manage it in the AWS console at:\n %s\n", awsdevops.ConsoleURL(result.Region)) return nil } +// connectMCPServer collects a LaunchDarkly service token and registers the MCP +// server with it, for a run that started without a token to register. +func connectMCPServer( + cmd *cobra.Command, + clients awsdevops.Clients, + opts awsdevops.SetupOptions, + result *awsdevops.SetupResult, +) { + tokenURL := awsdevops.AccessTokenURL(opts.LDBaseURI) + if !canPrompt() { + _, _ = fmt.Fprintf( + cmd.ErrOrStderr(), + "The LaunchDarkly MCP server is not connected. Create a service token at %s, "+ + "then re-run setup with --access-token\n", + tokenURL, + ) + + return + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nCreate a LaunchDarkly service token at:\n %s\n\n", tokenURL) + _, _ = fmt.Fprint(cmd.OutOrStdout(), "Paste the token, or press Enter to skip: ") + opts.LDAccessToken = readSecret(cmd.InOrStdin()) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + if opts.LDAccessToken == "" { + return + } + + if err := awsdevops.RegisterMCPServer(cmd.Context(), clients, opts, result); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", err) + } +} + func setupOptions(cmd *cobra.Command) (awsdevops.SetupOptions, error) { opts := awsdevops.SetupOptions{ - AgentSpaceID: mustString(cmd, agentSpaceIDFlag), - AgentSpaceName: mustString(cmd, agentSpaceNameFlag), - AgentSpaceDescription: mustString(cmd, agentSpaceDescriptionFlag), - AuthFlow: strings.ToLower(mustString(cmd, authFlowFlag)), - IdcInstanceARN: mustString(cmd, idcInstanceARNFlag), - IssuerURL: mustString(cmd, issuerURLFlag), - IdpClientID: mustString(cmd, idpClientIDFlag), - IdpClientSecret: mustString(cmd, idpClientSecretFlag), - NewAgentSpace: mustBool(cmd, newAgentSpaceFlag), - SkipOperatorApp: mustBool(cmd, skipOperatorAppFlag), - LDBaseURI: viper.GetString(cliflags.BaseURIFlag), - LDAccessToken: viper.GetString(cliflags.AccessTokenFlag), - SkipMCPServer: mustBool(cmd, skipMCPServerFlag), - ReplaceMCPToken: mustBool(cmd, replaceMCPTokenFlag), - MCPServiceID: mustString(cmd, mcpServiceIDFlag), - MCPReadOnlyTools: mustStringSlice(cmd, mcpReadOnlyToolsFlag), - MCPMutativeTools: mustStringSlice(cmd, mcpMutativeToolsFlag), - SkillName: mustString(cmd, skillNameFlag), - CustomAgentName: mustString(cmd, customAgentNameFlag), - CustomAgentTools: mustStringSlice(cmd, customAgentToolsFlag), - Schedule: mustString(cmd, scheduleFlag), + AgentSpaceID: mustString(cmd, agentSpaceIDFlag), + AgentSpaceName: mustString(cmd, agentSpaceNameFlag), + AuthFlow: strings.ToLower(mustString(cmd, authFlowFlag)), + IdcInstanceARN: mustString(cmd, idcInstanceARNFlag), + IssuerURL: mustString(cmd, issuerURLFlag), + IdpClientID: mustString(cmd, idpClientIDFlag), + IdpClientSecret: mustString(cmd, idpClientSecretFlag), + NewAgentSpace: mustBool(cmd, newAgentSpaceFlag), + LDBaseURI: viper.GetString(cliflags.BaseURIFlag), + LDAccessToken: viper.GetString(cliflags.AccessTokenFlag), + ReplaceMCPToken: mustBool(cmd, replaceMCPTokenFlag), } switch opts.AuthFlow { @@ -185,14 +171,6 @@ func setupOptions(cmd *cobra.Command) (awsdevops.SetupOptions, error) { return opts, fmt.Errorf("--%s must be iam, idc or idp", authFlowFlag) } - if path := mustString(cmd, skillFileFlag); path != "" { - body, err := os.ReadFile(path) - if err != nil { - return opts, fmt.Errorf("unable to read %s: %w", path, err) - } - opts.SkillBody = string(body) - } - return opts, nil } diff --git a/cmd/aws_devops_agent/status.go b/cmd/aws_devops_agent/status.go index 7ac2ed3a5..223514162 100644 --- a/cmd/aws_devops_agent/status.go +++ b/cmd/aws_devops_agent/status.go @@ -65,9 +65,6 @@ func runStatus(cmd *cobra.Command, args []string) error { _, _ = fmt.Fprintln(out, strings.TrimRight(fmt.Sprintf( " association %s: %s %s", association.AssociationID, association.ServiceID, association.Status), " ")) } - for _, asset := range space.Assets { - _, _ = fmt.Fprintf(out, " asset %s: %s\n", asset.AssetID, asset.AssetType) - } } _, _ = fmt.Fprintf(out, "\nAWS DevOps Agent console: %s\n", awsdevops.ConsoleURL(status.Region)) diff --git a/cmd/aws_devops_agent/teardown.go b/cmd/aws_devops_agent/teardown.go index e15a42138..fc52155d1 100644 --- a/cmd/aws_devops_agent/teardown.go +++ b/cmd/aws_devops_agent/teardown.go @@ -3,6 +3,7 @@ package awsdevopsagent import ( "errors" "fmt" + "strings" "github.com/spf13/cobra" @@ -77,18 +78,13 @@ func confirmTeardown(cmd *cobra.Command, accountID, region string) error { ) } - keys := newKeyReader(cmd.InOrStdin()) - defer keys.close() - out := keys.writer(cmd.OutOrStdout()) _, _ = fmt.Fprintf( - out, - "This removes every agent space, registered service and IAM role in account %s (%s).\nPress y to continue: ", + cmd.OutOrStdout(), + "This removes every agent space, registered service and IAM role in account %s (%s).\nType y to continue: ", accountID, region, ) - key := keys.next() - _, _ = fmt.Fprintln(out) - if key != 'y' && key != 'Y' { + if !strings.EqualFold(readLine(cmd.InOrStdin()), "y") { return errors.New("teardown cancelled") } diff --git a/internal/awsdevops/awsdevops.go b/internal/awsdevops/awsdevops.go index 2a467463c..1b0d078e5 100644 --- a/internal/awsdevops/awsdevops.go +++ b/internal/awsdevops/awsdevops.go @@ -35,8 +35,6 @@ var ( type AgentAPI interface { AssociateService(context.Context, *devopsagent.AssociateServiceInput, ...func(*devopsagent.Options)) (*devopsagent.AssociateServiceOutput, error) CreateAgentSpace(context.Context, *devopsagent.CreateAgentSpaceInput, ...func(*devopsagent.Options)) (*devopsagent.CreateAgentSpaceOutput, error) - CreateAsset(context.Context, *devopsagent.CreateAssetInput, ...func(*devopsagent.Options)) (*devopsagent.CreateAssetOutput, error) - CreateTrigger(context.Context, *devopsagent.CreateTriggerInput, ...func(*devopsagent.Options)) (*devopsagent.CreateTriggerOutput, error) DeleteAgentSpace(context.Context, *devopsagent.DeleteAgentSpaceInput, ...func(*devopsagent.Options)) (*devopsagent.DeleteAgentSpaceOutput, error) DeleteAsset(context.Context, *devopsagent.DeleteAssetInput, ...func(*devopsagent.Options)) (*devopsagent.DeleteAssetOutput, error) DeregisterService(context.Context, *devopsagent.DeregisterServiceInput, ...func(*devopsagent.Options)) (*devopsagent.DeregisterServiceOutput, error) diff --git a/internal/awsdevops/awsdevops_test.go b/internal/awsdevops/awsdevops_test.go index a51665958..414d5b4aa 100644 --- a/internal/awsdevops/awsdevops_test.go +++ b/internal/awsdevops/awsdevops_test.go @@ -23,7 +23,6 @@ type fakeAgent struct { calls []string services []agenttypes.RegisteredService associationInputs []*devopsagent.AssociateServiceInput - createdAssetTypes []string registerOutput *devopsagent.RegisterServiceOutput associations []agenttypes.Association agentSpaces []agenttypes.AgentSpace @@ -60,23 +59,6 @@ func (f *fakeAgent) CreateAgentSpace(_ context.Context, _ *devopsagent.CreateAge }, nil } -func (f *fakeAgent) CreateAsset(_ context.Context, in *devopsagent.CreateAssetInput, _ ...func(*devopsagent.Options)) (*devopsagent.CreateAssetOutput, error) { - f.calls = append(f.calls, "CreateAsset") - f.createdAssetTypes = append(f.createdAssetTypes, aws.ToString(in.AssetType)) - - return &devopsagent.CreateAssetOutput{ - Asset: &agenttypes.Asset{AssetId: aws.String("asset-" + aws.ToString(in.AssetType))}, - }, nil -} - -func (f *fakeAgent) CreateTrigger(_ context.Context, _ *devopsagent.CreateTriggerInput, _ ...func(*devopsagent.Options)) (*devopsagent.CreateTriggerOutput, error) { - f.calls = append(f.calls, "CreateTrigger") - - return &devopsagent.CreateTriggerOutput{ - Trigger: &agenttypes.Trigger{TriggerId: aws.String("trigger-1")}, - }, nil -} - func (f *fakeAgent) DeleteAgentSpace(_ context.Context, in *devopsagent.DeleteAgentSpaceInput, _ ...func(*devopsagent.Options)) (*devopsagent.DeleteAgentSpaceOutput, error) { f.calls = append(f.calls, "DeleteAgentSpace") f.deletedAgentSpaces = append(f.deletedAgentSpaces, aws.ToString(in.AgentSpaceId)) @@ -275,18 +257,16 @@ func TestSetupReusesExistingRolesAndAgentSpace(t *testing.T) { agent := &fakeAgent{} iamClient := newFakeIAM() iamClient.existingRoles[awsdevops.AgentSpaceRoleName] = true + iamClient.existingRoles[awsdevops.OperatorAppRoleName] = true result, err := awsdevops.Setup(context.Background(), newTestClients(agent, iamClient), awsdevops.SetupOptions{ - AgentSpaceID: "space-existing", - AuthFlow: "iam", - SkipOperatorApp: true, - SkipMCPServer: true, + AgentSpaceID: "space-existing", + AuthFlow: "iam", }) require.NoError(t, err) assert.Equal(t, "space-existing", result.AgentSpaceID) assert.NotContains(t, agent.calls, "CreateAgentSpace") - assert.NotContains(t, agent.calls, "EnableOperatorApp") assert.Empty(t, iamClient.createdRoles) assert.Equal(t, "arn:aws:iam::"+testAccountID+":role/"+awsdevops.AgentSpaceRoleName, result.AgentSpaceRoleARN) } @@ -294,31 +274,14 @@ func TestSetupReusesExistingRolesAndAgentSpace(t *testing.T) { func TestSetupRetriesTheAccountAssociationUntilTheRoleIsAssumable(t *testing.T) { agent := &fakeAgent{associateFailures: 1} - result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ - AgentSpaceName: "launchdarkly", - AuthFlow: "iam", - SkipOperatorApp: true, - SkipMCPServer: true, - }) - require.NoError(t, err) - - assert.Equal(t, "assoc-aws", result.AWSAssociationID) - assert.Len(t, agent.associationInputs, 2) -} - -func TestSetupWithoutAccessTokenReportsMCPServerAsManualStep(t *testing.T) { - agent := &fakeAgent{} - result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ AgentSpaceName: "launchdarkly", AuthFlow: "iam", }) require.NoError(t, err) - assert.NotContains(t, agent.calls, "RegisterService") - assert.Empty(t, result.MCPServiceID) - assert.Contains(t, result.RemainingManualSteps[0].Description, awsdevops.MCPServerEndpoint) - assert.Equal(t, awsdevops.AccessTokenURL(""), result.RemainingManualSteps[0].URL) + assert.Equal(t, "assoc-aws", result.AWSAssociationID) + assert.Len(t, agent.associationInputs, 2) } func TestSetupReusesAgentSpaceAndAssociations(t *testing.T) { @@ -334,10 +297,8 @@ func TestSetupReusesAgentSpaceAndAssociations(t *testing.T) { } result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ - AgentSpaceName: "launchdarkly", - AuthFlow: "iam", - SkipMCPServer: true, - SkipOperatorApp: true, + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", }) require.NoError(t, err) @@ -347,82 +308,6 @@ func TestSetupReusesAgentSpaceAndAssociations(t *testing.T) { assert.Empty(t, agent.associationInputs) } -func TestSetupClassifiesMCPTools(t *testing.T) { - agent := &fakeAgent{} - - _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ - AgentSpaceName: "launchdarkly", - AuthFlow: "iam", - LDAccessToken: "api-token", - SkipOperatorApp: true, - MCPReadOnlyTools: []string{"list-flags"}, - MCPMutativeTools: []string{"toggle-flag"}, - }) - require.NoError(t, err) - - config, ok := agent.associationInputs[1].Configuration.(*agenttypes.ServiceConfigurationMemberMcpserver) - require.True(t, ok) - assert.Equal(t, []string{"list-flags", "toggle-flag"}, config.Value.Tools) - assert.Equal(t, agenttypes.ToolClassificationReadOnly, config.Value.ToolDetails[0].ToolClassification) - assert.Equal(t, agenttypes.ToolClassificationMutative, config.Value.ToolDetails[1].ToolClassification) -} - -func TestSetupReportsOAuthConsentAsManualStep(t *testing.T) { - agent := &fakeAgent{ - registerOutput: &devopsagent.RegisterServiceOutput{ - AdditionalStep: &agenttypes.AdditionalServiceRegistrationStepMemberOauth{ - Value: agenttypes.OAuthAdditionalStepDetails{ - AuthorizationUrl: aws.String("https://example.com/consent"), - }, - }, - }, - } - - result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ - AgentSpaceName: "launchdarkly", - AuthFlow: "iam", - LDAccessToken: "api-token", - SkipOperatorApp: true, - }) - require.NoError(t, err) - - assert.Empty(t, result.MCPServiceID) - assert.Equal(t, "https://example.com/consent", result.RemainingManualSteps[0].URL) -} - -func TestSetupCreatesSkillAndScheduledCustomAgent(t *testing.T) { - agent := &fakeAgent{} - - result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ - AgentSpaceName: "launchdarkly", - AuthFlow: "iam", - SkipOperatorApp: true, - SkipMCPServer: true, - SkillName: "launchdarkly", - SkillBody: "# LaunchDarkly\n", - CustomAgentName: "flag-cleanup", - Schedule: "rate(1 day)", - }) - require.NoError(t, err) - - assert.Equal(t, []string{"skill", "custom_agent"}, agent.createdAssetTypes) - assert.Equal(t, "asset-skill", result.SkillAssetID) - assert.Equal(t, "asset-custom_agent", result.CustomAgentAssetID) - assert.Equal(t, "trigger-1", result.TriggerID) -} - -func TestSetupRejectsScheduleWithoutCustomAgent(t *testing.T) { - _, err := awsdevops.Setup(context.Background(), newTestClients(&fakeAgent{}, newFakeIAM()), awsdevops.SetupOptions{ - AgentSpaceName: "launchdarkly", - AuthFlow: "iam", - SkipOperatorApp: true, - SkipMCPServer: true, - Schedule: "rate(1 day)", - }) - - assert.ErrorContains(t, err, "--schedule requires --custom-agent-name") -} - func TestTeardownEmptiesAgentSpaceBeforeDeletingIt(t *testing.T) { agent := &fakeAgent{ associations: []agenttypes.Association{{AssociationId: aws.String("assoc-1"), ServiceId: aws.String("aws")}}, @@ -513,33 +398,6 @@ func TestStatusReportsMissingRoles(t *testing.T) { require.Len(t, status.AgentSpaces, 1) assert.Equal(t, "space-1", status.AgentSpaces[0].AgentSpaceID) assert.Equal(t, "aws", status.AgentSpaces[0].Associations[0].ServiceID) - assert.Equal(t, "skill", status.AgentSpaces[0].Assets[0].AssetType) -} - -func TestStatusOmitsAgentManagedAssetsAndNamesMCPServers(t *testing.T) { - agent := &fakeAgent{ - services: []agenttypes.RegisteredService{{ - ServiceId: aws.String("mcp-1"), - ServiceType: agenttypes.ServiceMcpServer, - AdditionalServiceDetails: &agenttypes.AdditionalServiceDetailsMemberMcpserver{ - Value: agenttypes.RegisteredMCPServerDetails{ - Name: aws.String(awsdevops.MCPServerName), - Endpoint: aws.String(awsdevops.MCPServerEndpoint), - }, - }, - }}, - assets: []agenttypes.Asset{ - {AssetId: aws.String("asset-1"), AssetType: aws.String("skill")}, - {AssetId: aws.String("asset-2"), AssetType: aws.String("memory")}, - }, - } - - status, err := awsdevops.GetStatus(context.Background(), newTestClients(agent, newFakeIAM()), "") - require.NoError(t, err) - - assert.Equal(t, awsdevops.MCPServerName, status.Services[0].Name) - require.Len(t, status.AgentSpaces[0].Assets, 1) - assert.Equal(t, "asset-1", status.AgentSpaces[0].Assets[0].AssetID) } func TestManualStepURLsPointAtRegistrationPages(t *testing.T) { diff --git a/internal/awsdevops/wait.go b/internal/awsdevops/lookup.go similarity index 100% rename from internal/awsdevops/wait.go rename to internal/awsdevops/lookup.go diff --git a/internal/awsdevops/wait_test.go b/internal/awsdevops/lookup_test.go similarity index 98% rename from internal/awsdevops/wait_test.go rename to internal/awsdevops/lookup_test.go index be1c3eb0e..bf6703dd6 100644 --- a/internal/awsdevops/wait_test.go +++ b/internal/awsdevops/lookup_test.go @@ -49,7 +49,6 @@ func TestSetupReplacesTheMCPServerTokenWhenAsked(t *testing.T) { result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ AgentSpaceName: "launchdarkly", AuthFlow: "iam", - SkipOperatorApp: true, LDAccessToken: "api-token", ReplaceMCPToken: true, }) diff --git a/internal/awsdevops/setup.go b/internal/awsdevops/setup.go index e6b33c21a..69ea27974 100644 --- a/internal/awsdevops/setup.go +++ b/internal/awsdevops/setup.go @@ -9,7 +9,6 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/devopsagent" - "github.com/aws/aws-sdk-go-v2/service/devopsagent/document" agenttypes "github.com/aws/aws-sdk-go-v2/service/devopsagent/types" "github.com/aws/aws-sdk-go-v2/service/iam" iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" @@ -37,47 +36,33 @@ const ( MCPServerName = "LaunchDarkly" MCPServerEndpoint = "https://mcp.launchdarkly.com/mcp/launchdarkly" - skillAssetType = "skill" - customAgentAssetType = "custom_agent" memoryStoreAssetType = "memory_store" + + agentSpaceDescription = "Managed by the LaunchDarkly CLI" ) -// DefaultMCPReadOnlyTools and DefaultMCPMutativeTools are the LaunchDarkly MCP -// tools enabled when the caller does not choose their own. The agent invokes -// read-only tools without asking for approval, so anything that changes flag -// state has to be classified explicitly. +// The agent invokes read-only tools without asking for approval, so anything +// that changes flag state has to be classified explicitly. var ( - DefaultMCPReadOnlyTools = []string{"list-projects", "list-flags", "get-flag"} - DefaultMCPMutativeTools = []string{"toggle-flag"} + mcpReadOnlyTools = []string{"list-projects", "list-flags", "get-flag"} + mcpMutativeTools = []string{"toggle-flag"} ) -// SetupOptions describes what to provision. Zero values mean "skip that step". +// SetupOptions describes what to provision. type SetupOptions struct { - AgentSpaceID string - AgentSpaceName string - AgentSpaceDescription string - NewAgentSpace bool + AgentSpaceID string + AgentSpaceName string + NewAgentSpace bool AuthFlow string IdcInstanceARN string IssuerURL string IdpClientID string IdpClientSecret string - SkipOperatorApp bool - - LDBaseURI string - LDAccessToken string - SkipMCPServer bool - ReplaceMCPToken bool - MCPServiceID string - MCPReadOnlyTools []string - MCPMutativeTools []string - - SkillName string - SkillBody string - CustomAgentName string - CustomAgentTools []string - Schedule string + + LDBaseURI string + LDAccessToken string + ReplaceMCPToken bool // Logf, when set, reports progress as each step completes. Logf func(format string, args ...any) @@ -86,38 +71,19 @@ type SetupOptions struct { // SetupResult holds the identifiers of everything the setup touched. Callers // need these to tear the environment back down. type SetupResult struct { - AccountID string `json:"accountId"` - Region string `json:"region"` - AgentSpaceID string `json:"agentSpaceId,omitempty"` - AgentSpaceRoleARN string `json:"agentSpaceRoleArn,omitempty"` - OperatorAppRoleARN string `json:"operatorAppRoleArn,omitempty"` - AWSAssociationID string `json:"awsAssociationId,omitempty"` - OperatorAppURL string `json:"operatorAppUrl,omitempty"` - MCPServiceID string `json:"mcpServiceId,omitempty"` - MCPAssociationID string `json:"mcpAssociationId,omitempty"` - MCPAuthorizationURL string `json:"mcpAuthorizationUrl,omitempty"` - SkillAssetID string `json:"skillAssetId,omitempty"` - CustomAgentAssetID string `json:"customAgentAssetId,omitempty"` - TriggerID string `json:"triggerId,omitempty"` - RemainingManualSteps []ManualStep `json:"remainingManualSteps,omitempty"` -} - -// ManualStep pairs a step AWS only exposes in a browser with the page to open. -type ManualStep struct { - Kind ManualStepKind `json:"kind"` - Description string `json:"description"` - URL string `json:"url"` + AccountID string `json:"accountId"` + Region string `json:"region"` + AgentSpaceID string `json:"agentSpaceId,omitempty"` + AgentSpaceRoleARN string `json:"agentSpaceRoleArn,omitempty"` + OperatorAppRoleARN string `json:"operatorAppRoleArn,omitempty"` + AWSAssociationID string `json:"awsAssociationId,omitempty"` + OperatorAppURL string `json:"operatorAppUrl,omitempty"` + MCPServiceID string `json:"mcpServiceId,omitempty"` + MCPAssociationID string `json:"mcpAssociationId,omitempty"` } -type ManualStepKind string - -const ( - ManualStepOAuthConsent ManualStepKind = "oauthConsent" - ManualStepMCPServer ManualStepKind = "mcpServer" -) - -// Setup provisions the IAM roles, agent space, service associations and assets -// the AWS DevOps Agent needs to manage LaunchDarkly flags. +// Setup provisions the IAM roles, agent space, service associations and the +// MCP server the AWS DevOps Agent needs to manage LaunchDarkly flags. func Setup(ctx context.Context, clients Clients, opts SetupOptions) (SetupResult, error) { logf := opts.Logf if logf == nil { @@ -137,13 +103,11 @@ func Setup(ctx context.Context, clients Clients, opts SetupOptions) (SetupResult } logf("Agent space role ready: %s", result.AgentSpaceRoleARN) - if !opts.SkipOperatorApp { - result.OperatorAppRoleARN, err = ensureOperatorAppRole(ctx, clients.IAM, accountID, clients.Region) - if err != nil { - return result, err - } - logf("Operator app role ready: %s", result.OperatorAppRoleARN) + result.OperatorAppRoleARN, err = ensureOperatorAppRole(ctx, clients.IAM, accountID, clients.Region) + if err != nil { + return result, err } + logf("Operator app role ready: %s", result.OperatorAppRoleARN) result.AgentSpaceID = opts.AgentSpaceID if result.AgentSpaceID == "" && !opts.NewAgentSpace { @@ -159,7 +123,7 @@ func Setup(ctx context.Context, clients Clients, opts SetupOptions) (SetupResult if result.AgentSpaceID == "" { space, err := clients.Agent.CreateAgentSpace(ctx, &devopsagent.CreateAgentSpaceInput{ Name: aws.String(opts.AgentSpaceName), - Description: aws.String(opts.AgentSpaceDescription), + Description: aws.String(agentSpaceDescription), }) if err != nil { return result, fmt.Errorf("unable to create the agent space: %w", err) @@ -183,96 +147,19 @@ func Setup(ctx context.Context, clients Clients, opts SetupOptions) (SetupResult logf("Associated account %s with the agent space", accountID) } - if !opts.SkipOperatorApp { - operatorApp, err := clients.Agent.EnableOperatorApp(ctx, operatorAppInput(result.AgentSpaceID, result.OperatorAppRoleARN, opts)) - if err != nil { - return result, fmt.Errorf("unable to enable the operator app: %w", err) - } - result.OperatorAppURL = aws.ToString(operatorApp.OperatorAppUrl) - logf("Operator app available at %s", result.OperatorAppURL) + operatorApp, err := clients.Agent.EnableOperatorApp(ctx, operatorAppInput(result.AgentSpaceID, result.OperatorAppRoleARN, opts)) + if err != nil { + return result, fmt.Errorf("unable to enable the operator app: %w", err) } + result.OperatorAppURL = aws.ToString(operatorApp.OperatorAppUrl) + logf("Operator app available at %s", result.OperatorAppURL) - if !opts.SkipMCPServer && (opts.LDAccessToken != "" || opts.MCPServiceID != "") { + if opts.LDAccessToken != "" { if err := registerMCPServer(ctx, clients, opts, &result, logf); err != nil { return result, err } } - if opts.SkillBody != "" { - skill, err := clients.Agent.CreateAsset(ctx, &devopsagent.CreateAssetInput{ - AgentSpaceId: aws.String(result.AgentSpaceID), - AssetType: aws.String(skillAssetType), - Metadata: document.NewLazyDocument(map[string]any{ - "name": opts.SkillName, - "agent_types": []string{"GENERIC"}, - }), - Content: &agenttypes.AssetContentMemberFile{ - Value: agenttypes.AssetFileContent{ - Path: aws.String("SKILL.md"), - Body: &agenttypes.AssetFileBodyMemberText{Value: opts.SkillBody}, - }, - }, - }) - if err != nil { - return result, fmt.Errorf("unable to create the skill asset: %w", err) - } - result.SkillAssetID = aws.ToString(skill.Asset.AssetId) - logf("Created skill %s (%s)", opts.SkillName, result.SkillAssetID) - } - - if opts.CustomAgentName != "" { - metadata := map[string]any{"name": opts.CustomAgentName} - if result.SkillAssetID != "" { - metadata["skills"] = []string{result.SkillAssetID} - } - if len(opts.CustomAgentTools) > 0 { - metadata["tools"] = opts.CustomAgentTools - } - agent, err := clients.Agent.CreateAsset(ctx, &devopsagent.CreateAssetInput{ - AgentSpaceId: aws.String(result.AgentSpaceID), - AssetType: aws.String(customAgentAssetType), - Metadata: document.NewLazyDocument(metadata), - Content: &agenttypes.AssetContentMemberFile{ - Value: agenttypes.AssetFileContent{ - Path: aws.String("AGENT.md"), - Body: &agenttypes.AssetFileBodyMemberText{ - Value: fmt.Sprintf("# %s\n", opts.CustomAgentName), - }, - }, - }, - }) - if err != nil { - return result, fmt.Errorf("unable to create the custom agent asset: %w", err) - } - result.CustomAgentAssetID = aws.ToString(agent.Asset.AssetId) - logf("Created custom agent %s (%s)", opts.CustomAgentName, result.CustomAgentAssetID) - } - - if opts.Schedule != "" { - if result.CustomAgentAssetID == "" { - return result, errors.New("--schedule requires --custom-agent-name so the trigger has an agent to run") - } - trigger, err := clients.Agent.CreateTrigger(ctx, &devopsagent.CreateTriggerInput{ - AgentSpaceId: aws.String(result.AgentSpaceID), - Type: aws.String("TIME_BASED"), - Status: aws.String("Active"), - Condition: &agenttypes.TriggerConditionMemberSchedule{ - Value: agenttypes.ScheduleCondition{Expression: aws.String(opts.Schedule)}, - }, - Action: document.NewLazyDocument(map[string]any{ - "actionType": "create:task", - "task": map[string]any{"agent": "custom:" + result.CustomAgentAssetID}, - }), - }) - if err != nil { - return result, fmt.Errorf("unable to schedule the custom agent: %w", err) - } - result.TriggerID = aws.ToString(trigger.Trigger.TriggerId) - logf("Scheduled %s on %s", opts.CustomAgentName, opts.Schedule) - } - - result.RemainingManualSteps = remainingManualSteps(opts, result) - return result, nil } @@ -377,16 +264,11 @@ func registerMCPServer( result *SetupResult, logf func(string, ...any), ) error { - existing := opts.MCPServiceID - if existing == "" { - var err error - existing, err = FindMCPServer(ctx, clients) - if err != nil { - return err - } + existing, err := FindMCPServer(ctx, clients) + if err != nil { + return err } - replacingToken := opts.ReplaceMCPToken && opts.LDAccessToken != "" - if existing != "" && !replacingToken { + if existing != "" && !opts.ReplaceMCPToken { result.MCPServiceID = existing logf( "Reusing the %s MCP server already registered on this account (%s); it keeps the access "+ @@ -395,7 +277,7 @@ func registerMCPServer( existing, ) - return associateMCPServer(ctx, clients, opts, result, logf) + return associateMCPServer(ctx, clients, result, logf) } if existing != "" { if _, err := clients.Agent.DeregisterService(ctx, &devopsagent.DeregisterServiceInput{ @@ -432,18 +314,12 @@ func registerMCPServer( return fmt.Errorf( "an MCP server named %s already exists on this account but is not visible to ListServices, "+ - "so it cannot be associated automatically: associate it with agent space %s in the console, "+ - "or re-run with --skip-mcp-server", + "so it cannot be associated automatically: associate it with agent space %s in the console", MCPServerName, result.AgentSpaceID, ) } - // RegisterService returns a service ID or an additional step, never both. - if oauth, ok := registration.AdditionalStep.(*agenttypes.AdditionalServiceRegistrationStepMemberOauth); ok { - result.MCPAuthorizationURL = aws.ToString(oauth.Value.AuthorizationUrl) - return nil - } result.MCPServiceID = aws.ToString(registration.ServiceId) logf( "Registered the %s MCP server (%s) against the LaunchDarkly account the access token belongs to", @@ -451,13 +327,12 @@ func registerMCPServer( result.MCPServiceID, ) - return associateMCPServer(ctx, clients, opts, result, logf) + return associateMCPServer(ctx, clients, result, logf) } func associateMCPServer( ctx context.Context, clients Clients, - opts SetupOptions, result *SetupResult, logf func(string, ...any), ) error { @@ -476,7 +351,7 @@ func associateMCPServer( AgentSpaceId: aws.String(result.AgentSpaceID), ServiceId: aws.String(result.MCPServiceID), Configuration: &agenttypes.ServiceConfigurationMemberMcpserver{ - Value: mcpServerConfiguration(opts), + Value: mcpServerConfiguration(), }, }) if err != nil { @@ -491,15 +366,8 @@ func associateMCPServer( // mcpServerConfiguration builds the tool allowlist. AWS has no "allow all // tools" option, and an unclassified tool is treated as read-only, which the // agent may invoke without approval. -func mcpServerConfiguration(opts SetupOptions) agenttypes.MCPServerConfiguration { - readOnly := opts.MCPReadOnlyTools - if readOnly == nil { - readOnly = DefaultMCPReadOnlyTools - } - mutative := opts.MCPMutativeTools - if mutative == nil { - mutative = DefaultMCPMutativeTools - } +func mcpServerConfiguration() agenttypes.MCPServerConfiguration { + readOnly, mutative := mcpReadOnlyTools, mcpMutativeTools config := agenttypes.MCPServerConfiguration{ Tools: make([]string, 0, len(readOnly)+len(mutative)), @@ -620,29 +488,6 @@ func isNoSuchEntity(err error) bool { return errors.As(err, &noSuchEntity) } -func remainingManualSteps(opts SetupOptions, result SetupResult) []ManualStep { - var steps []ManualStep - if result.MCPAuthorizationURL != "" { - steps = append(steps, ManualStep{ - Kind: ManualStepOAuthConsent, - Description: "Approve the LaunchDarkly MCP server OAuth consent screen, then re-run setup", - URL: result.MCPAuthorizationURL, - }) - } - if !opts.SkipMCPServer && opts.LDAccessToken == "" && result.MCPServiceID == "" { - steps = append(steps, ManualStep{ - Kind: ManualStepMCPServer, - Description: fmt.Sprintf( - "Create a LaunchDarkly service token to connect the MCP server (%s)", - MCPServerEndpoint, - ), - URL: AccessTokenURL(opts.LDBaseURI), - }) - } - - return steps -} - // ConsoleURL is the AWS DevOps Agent console for a region. func ConsoleURL(region string) string { return fmt.Sprintf("https://%s.console.aws.amazon.com/aidevops/home?region=%[1]s", region) diff --git a/internal/awsdevops/status.go b/internal/awsdevops/status.go index 72187e9e5..28e1950e8 100644 --- a/internal/awsdevops/status.go +++ b/internal/awsdevops/status.go @@ -36,7 +36,6 @@ type AgentSpaceStatus struct { Name string `json:"name"` OperatorAppURL string `json:"operatorAppUrl"` Associations []AssociationStatus `json:"associations,omitempty"` - Assets []AssetStatus `json:"assets,omitempty"` } type AssociationStatus struct { @@ -45,18 +44,6 @@ type AssociationStatus struct { Status string `json:"status,omitempty"` } -type AssetStatus struct { - AssetID string `json:"assetId"` - AssetType string `json:"assetType"` -} - -// managedAssetTypes are the asset types setup creates; agent spaces also hold -// memory and artifact assets the agent manages itself. -var managedAssetTypes = map[string]bool{ - skillAssetType: true, - customAgentAssetType: true, -} - // GetStatus reports the IAM roles and agent spaces in the caller's account. It // inspects one agent space when agentSpaceID is set, and every space otherwise. func GetStatus(ctx context.Context, clients Clients, agentSpaceID string) (Status, error) { @@ -130,20 +117,6 @@ func GetStatus(ctx context.Context, clients Clients, agentSpaceID string) (Statu }) } - assets, err := clients.Agent.ListAssets(ctx, &devopsagent.ListAssetsInput{AgentSpaceId: aws.String(id)}) - if err != nil { - return status, fmt.Errorf("unable to list assets for agent space %s: %w", id, err) - } - for _, asset := range assets.Items { - if !managedAssetTypes[aws.ToString(asset.AssetType)] { - continue - } - spaceStatus.Assets = append(spaceStatus.Assets, AssetStatus{ - AssetID: aws.ToString(asset.AssetId), - AssetType: aws.ToString(asset.AssetType), - }) - } - status.AgentSpaces = append(status.AgentSpaces, spaceStatus) } From 9c0b263c2b001b9764e825197ac554d2e693e792 Mon Sep 17 00:00:00 2001 From: Michael Anderjaska Date: Mon, 5 Oct 2026 11:25:15 -0400 Subject: [PATCH 3/9] profile flag --- cmd/aws_devops_agent/aws_devops_agent.go | 1 + cmd/aws_devops_agent/setup.go | 3 +- cmd/aws_devops_agent/status.go | 3 +- cmd/aws_devops_agent/teardown.go | 3 +- internal/awsdevops/awsdevops.go | 137 ++++++++++++++++++++++- internal/awsdevops/awsdevops_test.go | 56 ++++++++- 6 files changed, 194 insertions(+), 9 deletions(-) diff --git a/cmd/aws_devops_agent/aws_devops_agent.go b/cmd/aws_devops_agent/aws_devops_agent.go index 956ff2ecb..fd07edea1 100644 --- a/cmd/aws_devops_agent/aws_devops_agent.go +++ b/cmd/aws_devops_agent/aws_devops_agent.go @@ -17,6 +17,7 @@ import ( const ( regionFlag = "region" + profileFlag = "profile" agentSpaceIDFlag = "agent-space-id" ) diff --git a/cmd/aws_devops_agent/setup.go b/cmd/aws_devops_agent/setup.go index dc4ae597c..a93a99fd8 100644 --- a/cmd/aws_devops_agent/setup.go +++ b/cmd/aws_devops_agent/setup.go @@ -62,6 +62,7 @@ resumes once you are done. Pass --no-wait to only list them.`, } cmd.Flags().String(regionFlag, "", "AWS region to provision in. Defaults to the region of the current AWS session") + cmd.Flags().String(profileFlag, "", "Named AWS profile to use. Overrides AWS_PROFILE for this command") cmd.Flags().String(agentSpaceIDFlag, "", "Existing agent space to add to instead of creating one") cmd.Flags().String(agentSpaceNameFlag, "launchdarkly", "Name of the agent space to create") cmd.Flags().String(agentSpaceDescriptionFlag, "Managed by the LaunchDarkly CLI", "Description of the agent space to create") @@ -95,7 +96,7 @@ func runSetup(cmd *cobra.Command, args []string) error { return err } - clients, err := awsdevops.NewClients(cmd.Context(), mustString(cmd, regionFlag)) + clients, err := awsdevops.NewClients(cmd.Context(), mustString(cmd, regionFlag), mustString(cmd, profileFlag)) if err != nil { return err } diff --git a/cmd/aws_devops_agent/status.go b/cmd/aws_devops_agent/status.go index 7ac2ed3a5..f71397b39 100644 --- a/cmd/aws_devops_agent/status.go +++ b/cmd/aws_devops_agent/status.go @@ -22,6 +22,7 @@ func NewStatusCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { } cmd.Flags().String(regionFlag, "", "AWS region to inspect. Defaults to the region of the current AWS session") + cmd.Flags().String(profileFlag, "", "Named AWS profile to use. Overrides AWS_PROFILE for this command") cmd.Flags().String(agentSpaceIDFlag, "", "Inspect a single agent space instead of every agent space") cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) @@ -30,7 +31,7 @@ func NewStatusCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { } func runStatus(cmd *cobra.Command, args []string) error { - clients, err := awsdevops.NewClients(cmd.Context(), mustString(cmd, regionFlag)) + clients, err := awsdevops.NewClients(cmd.Context(), mustString(cmd, regionFlag), mustString(cmd, profileFlag)) if err != nil { return err } diff --git a/cmd/aws_devops_agent/teardown.go b/cmd/aws_devops_agent/teardown.go index e15a42138..cc37d0fe9 100644 --- a/cmd/aws_devops_agent/teardown.go +++ b/cmd/aws_devops_agent/teardown.go @@ -32,6 +32,7 @@ for confirmation. The flags narrow it to part of that.`, } cmd.Flags().String(regionFlag, "", "AWS region to tear down in. Defaults to the region of the current AWS session") + cmd.Flags().String(profileFlag, "", "Named AWS profile to use. Overrides AWS_PROFILE for this command") cmd.Flags().String(agentSpaceIDFlag, "", "Agent space to empty and delete") cmd.Flags().StringSlice(serviceIDFlag, nil, "Registered services to deregister, such as the LaunchDarkly MCP server") cmd.Flags().Bool(deleteRolesFlag, false, "Also delete the IAM roles setup created") @@ -43,7 +44,7 @@ for confirmation. The flags narrow it to part of that.`, } func runTeardown(cmd *cobra.Command, args []string) error { - clients, err := awsdevops.NewClients(cmd.Context(), mustString(cmd, regionFlag)) + clients, err := awsdevops.NewClients(cmd.Context(), mustString(cmd, regionFlag), mustString(cmd, profileFlag)) if err != nil { return err } diff --git a/internal/awsdevops/awsdevops.go b/internal/awsdevops/awsdevops.go index 2a467463c..f63fcb2d1 100644 --- a/internal/awsdevops/awsdevops.go +++ b/internal/awsdevops/awsdevops.go @@ -4,9 +4,12 @@ package awsdevops import ( + "bufio" "context" "errors" "fmt" + "os" + "path/filepath" "slices" "strings" @@ -27,7 +30,11 @@ var SupportedRegions = []string{ } var ( - ErrNoCredentials = errors.New("no AWS credentials found. Authenticate first (for example `aws sso login --profile my-profile` or exporting AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN), then re-run this command") + // ErrNoCredentials is the sentinel for any missing-credentials failure. + // Use errors.Is to detect it; the surfaced error message is built by + // noCredentialsError with the detail that fits the situation (named + // profile not usable, no profile selected, nothing configured at all). + ErrNoCredentials = errors.New("no AWS credentials found") ErrNoRegion = errors.New("no AWS region found. Set AWS_REGION or pass --region") ) @@ -77,12 +84,18 @@ type Clients struct { // NewClients builds AWS clients from the ambient credential chain: environment // variables, shared config/credentials files, SSO caches, or instance metadata. +// A non-empty profile overrides AWS_PROFILE for this session, so callers that +// just ran `aws sso login --profile foo` can select that profile without +// exporting an env var. // It fails before any API call when the session has no credentials or no region. -func NewClients(ctx context.Context, region string) (Clients, error) { +func NewClients(ctx context.Context, region, profile string) (Clients, error) { opts := []func(*awsconfig.LoadOptions) error{} if region != "" { opts = append(opts, awsconfig.WithRegion(region)) } + if profile != "" { + opts = append(opts, awsconfig.WithSharedConfigProfile(profile)) + } cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) if err != nil { @@ -101,10 +114,10 @@ func NewClients(ctx context.Context, region string) (Clients, error) { creds, err := cfg.Credentials.Retrieve(ctx) if err != nil { - return Clients{}, fmt.Errorf("%w: %s", ErrNoCredentials, err) + return Clients{}, noCredentialsError(profile, err) } if !creds.HasKeys() { - return Clients{}, ErrNoCredentials + return Clients{}, noCredentialsError(profile, nil) } return Clients{ @@ -127,3 +140,119 @@ func (c Clients) AccountID(ctx context.Context) (string, error) { return *out.Account, nil } + +// noCredsErr wraps a formatted message while still satisfying +// errors.Is(err, ErrNoCredentials). Callers get an actionable message +// without losing the ability to branch on the sentinel. +type noCredsErr struct { + msg string + cause error +} + +func (e *noCredsErr) Error() string { + if e.cause == nil { + return e.msg + } + + return fmt.Sprintf("%s: %s", e.msg, e.cause) +} + +func (e *noCredsErr) Unwrap() error { return e.cause } +func (e *noCredsErr) Is(target error) bool { return target == ErrNoCredentials } + +// noCredentialsError builds the surfaced message for a credential-resolution +// failure. profile is the value passed via --profile; cause is the underlying +// SDK error, which may be nil when credentials simply had no keys. +func noCredentialsError(profile string, cause error) error { + active := profile + if active == "" { + active = os.Getenv("AWS_PROFILE") + } + + switch { + case active != "": + return &noCredsErr{ + msg: fmt.Sprintf( + "no AWS credentials found for profile %q. Run `aws sso login --profile %s` (or refresh the profile's credentials), then re-run this command", + active, active, + ), + cause: cause, + } + case len(knownProfiles()) > 0: + profiles := knownProfiles() + return &noCredsErr{ + msg: fmt.Sprintf( + "no AWS credentials found and no AWS profile selected. Pass --profile or set AWS_PROFILE, then re-run. Profiles configured on this machine: %s", + strings.Join(profiles, ", "), + ), + cause: cause, + } + default: + return &noCredsErr{ + msg: "no AWS credentials found. Authenticate first (for example `aws sso login --profile my-profile` or exporting AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN), then re-run this command", + cause: cause, + } + } +} + +// knownProfiles returns the profile names configured in the shared AWS +// config and credentials files. It is best-effort: unreadable files are +// silently skipped so a diagnostic path never masks the original error. +func knownProfiles() []string { + var profiles []string + seen := map[string]bool{} + add := func(name string) { + if name == "" || seen[name] { + return + } + seen[name] = true + profiles = append(profiles, name) + } + + for _, path := range sharedConfigPaths() { + file, err := os.Open(path) + if err != nil { + continue + } + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !strings.HasPrefix(line, "[") || !strings.HasSuffix(line, "]") { + continue + } + name := strings.TrimSpace(line[1 : len(line)-1]) + switch { + case name == "default": + add("default") + case strings.HasPrefix(name, "profile "): + add(strings.TrimSpace(strings.TrimPrefix(name, "profile "))) + case strings.ContainsRune(name, ' '): + // sso-session, services, etc. — skip + default: + // credentials file lists bare names + add(name) + } + } + _ = file.Close() + } + + return profiles +} + +// sharedConfigPaths returns the paths the AWS SDK reads profiles from, +// honoring AWS_CONFIG_FILE and AWS_SHARED_CREDENTIALS_FILE overrides. +func sharedConfigPaths() []string { + var paths []string + if v := os.Getenv("AWS_SHARED_CREDENTIALS_FILE"); v != "" { + paths = append(paths, v) + } else if home, err := os.UserHomeDir(); err == nil { + paths = append(paths, filepath.Join(home, ".aws", "credentials")) + } + if v := os.Getenv("AWS_CONFIG_FILE"); v != "" { + paths = append(paths, v) + } else if home, err := os.UserHomeDir(); err == nil { + paths = append(paths, filepath.Join(home, ".aws", "config")) + } + + return paths +} diff --git a/internal/awsdevops/awsdevops_test.go b/internal/awsdevops/awsdevops_test.go index a51665958..c582113d2 100644 --- a/internal/awsdevops/awsdevops_test.go +++ b/internal/awsdevops/awsdevops_test.go @@ -3,6 +3,8 @@ package awsdevops_test import ( "context" "errors" + "os" + "path/filepath" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -556,7 +558,7 @@ func TestManualStepURLsPointAtRegistrationPages(t *testing.T) { } func TestNewClientsRejectsUnsupportedRegion(t *testing.T) { - _, err := awsdevops.NewClients(context.Background(), "us-east-2") + _, err := awsdevops.NewClients(context.Background(), "us-east-2", "") assert.ErrorContains(t, err, "AWS DevOps Agent is not available in us-east-2") } @@ -570,7 +572,57 @@ func TestNewClientsRequiresCredentials(t *testing.T) { t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null") t.Setenv("AWS_EC2_METADATA_DISABLED", "true") - _, err := awsdevops.NewClients(context.Background(), "us-east-1") + _, err := awsdevops.NewClients(context.Background(), "us-east-1", "") assert.ErrorIs(t, err, awsdevops.ErrNoCredentials) + assert.ErrorContains(t, err, "Authenticate first") +} + +func TestNewClientsSuggestsSelectingAProfile(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config") + require.NoError(t, os.WriteFile(configPath, []byte( + "[profile dev]\nregion = us-east-1\n\n[profile prod]\nregion = us-west-2\n\n[sso-session corp]\nsso_region = us-east-1\n", + ), 0o600)) + + t.Setenv("AWS_ACCESS_KEY_ID", "") + t.Setenv("AWS_SECRET_ACCESS_KEY", "") + t.Setenv("AWS_SESSION_TOKEN", "") + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_CONFIG_FILE", configPath) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null") + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + + _, err := awsdevops.NewClients(context.Background(), "us-east-1", "") + + assert.ErrorIs(t, err, awsdevops.ErrNoCredentials) + assert.ErrorContains(t, err, "no AWS profile selected") + assert.ErrorContains(t, err, "--profile") + assert.ErrorContains(t, err, "dev") + assert.ErrorContains(t, err, "prod") + assert.NotContains(t, err.Error(), "corp") +} + +func TestNewClientsNamesTheSelectedProfile(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config") + // The profile exists but has no usable credentials (no static keys, no + // SSO config), so the SDK loads it and then fails to resolve credentials. + require.NoError(t, os.WriteFile(configPath, []byte( + "[profile my-profile]\nregion = us-east-1\n", + ), 0o600)) + + t.Setenv("AWS_ACCESS_KEY_ID", "") + t.Setenv("AWS_SECRET_ACCESS_KEY", "") + t.Setenv("AWS_SESSION_TOKEN", "") + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_CONFIG_FILE", configPath) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/dev/null") + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + + _, err := awsdevops.NewClients(context.Background(), "us-east-1", "my-profile") + + assert.ErrorIs(t, err, awsdevops.ErrNoCredentials) + assert.ErrorContains(t, err, `profile "my-profile"`) + assert.ErrorContains(t, err, "aws sso login --profile my-profile") } From 0e987dcc51eb2f8e8568b6c622339296e6861463 Mon Sep 17 00:00:00 2001 From: "manderjaska@launchdarkly.com" Date: Mon, 5 Oct 2026 16:51:38 +0000 Subject: [PATCH 4/9] Address review feedback on the AWS DevOps Agent commands - disassociate a registered service everywhere before deregistering it - look up an existing LaunchDarkly MCP server even with no access token - refresh the trust policy on reused IAM roles - follow NextToken on every list API - fail setup when a prompted MCP registration errors --- cmd/aws_devops_agent/setup.go | 15 +- internal/awsdevops/awsdevops.go | 5 +- internal/awsdevops/awsdevops_test.go | 218 +++++++++++++++++++++++++-- internal/awsdevops/lookup.go | 145 +++++++++++++----- internal/awsdevops/setup.go | 25 ++- internal/awsdevops/status.go | 20 ++- internal/awsdevops/teardown.go | 99 ++++++------ 7 files changed, 406 insertions(+), 121 deletions(-) diff --git a/cmd/aws_devops_agent/setup.go b/cmd/aws_devops_agent/setup.go index cbd865fdd..e308c7868 100644 --- a/cmd/aws_devops_agent/setup.go +++ b/cmd/aws_devops_agent/setup.go @@ -100,14 +100,15 @@ func runSetup(cmd *cobra.Command, args []string) error { return printJSON(cmd, result) } + var mcpErr error if result.MCPServiceID == "" { - connectMCPServer(cmd, clients, opts, &result) + mcpErr = connectMCPServer(cmd, clients, opts, &result) } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nOpen the DevOps Agent at:\n %s\n", result.OperatorAppURL) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Manage it in the AWS console at:\n %s\n", awsdevops.ConsoleURL(result.Region)) - return nil + return mcpErr } // connectMCPServer collects a LaunchDarkly service token and registers the MCP @@ -117,7 +118,7 @@ func connectMCPServer( clients awsdevops.Clients, opts awsdevops.SetupOptions, result *awsdevops.SetupResult, -) { +) error { tokenURL := awsdevops.AccessTokenURL(opts.LDBaseURI) if !canPrompt() { _, _ = fmt.Fprintf( @@ -127,7 +128,7 @@ func connectMCPServer( tokenURL, ) - return + return nil } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nCreate a LaunchDarkly service token at:\n %s\n\n", tokenURL) @@ -135,12 +136,10 @@ func connectMCPServer( opts.LDAccessToken = readSecret(cmd.InOrStdin()) _, _ = fmt.Fprintln(cmd.OutOrStdout()) if opts.LDAccessToken == "" { - return + return nil } - if err := awsdevops.RegisterMCPServer(cmd.Context(), clients, opts, result); err != nil { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", err) - } + return awsdevops.RegisterMCPServer(cmd.Context(), clients, opts, result) } func setupOptions(cmd *cobra.Command) (awsdevops.SetupOptions, error) { diff --git a/internal/awsdevops/awsdevops.go b/internal/awsdevops/awsdevops.go index ff9cced84..1905bcf72 100644 --- a/internal/awsdevops/awsdevops.go +++ b/internal/awsdevops/awsdevops.go @@ -64,6 +64,7 @@ type IAMAPI interface { DetachRolePolicy(context.Context, *iam.DetachRolePolicyInput, ...func(*iam.Options)) (*iam.DetachRolePolicyOutput, error) GetRole(context.Context, *iam.GetRoleInput, ...func(*iam.Options)) (*iam.GetRoleOutput, error) PutRolePolicy(context.Context, *iam.PutRolePolicyInput, ...func(*iam.Options)) (*iam.PutRolePolicyOutput, error) + UpdateAssumeRolePolicy(context.Context, *iam.UpdateAssumeRolePolicyInput, ...func(*iam.Options)) (*iam.UpdateAssumeRolePolicyOutput, error) } // STSAPI is the subset of the STS API this package uses. @@ -155,7 +156,7 @@ func (e *noCredsErr) Error() string { return fmt.Sprintf("%s: %s", e.msg, e.cause) } -func (e *noCredsErr) Unwrap() error { return e.cause } +func (e *noCredsErr) Unwrap() error { return e.cause } func (e *noCredsErr) Is(target error) bool { return target == ErrNoCredentials } // noCredentialsError builds the surfaced message for a credential-resolution @@ -187,7 +188,7 @@ func noCredentialsError(profile string, cause error) error { } default: return &noCredsErr{ - msg: "no AWS credentials found. Authenticate first (for example `aws sso login --profile my-profile` or exporting AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN), then re-run this command", + msg: "no AWS credentials found. Authenticate first (for example `aws sso login --profile my-profile` or exporting AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN), then re-run this command", cause: cause, } } diff --git a/internal/awsdevops/awsdevops_test.go b/internal/awsdevops/awsdevops_test.go index 74b109a5c..ce930a751 100644 --- a/internal/awsdevops/awsdevops_test.go +++ b/internal/awsdevops/awsdevops_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strconv" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -34,6 +35,28 @@ type fakeAgent struct { deregisteredIDs []string deletedAgentSpaces []string associateFailures int + // paginate makes every list API return one item per page, so callers are + // exercised against the NextToken AWS sends for a long list. + paginate bool +} + +func page[T any](items []T, token *string, paginate bool) ([]T, *string) { + if !paginate { + return items, nil + } + + first := 0 + if token != nil { + first, _ = strconv.Atoi(*token) + } + if first >= len(items) { + return nil, nil + } + if next := first + 1; next < len(items) { + return items[first:next], aws.String(strconv.Itoa(next)) + } + + return items[first:], nil } func (f *fakeAgent) AssociateService(_ context.Context, in *devopsagent.AssociateServiceInput, _ ...func(*devopsagent.Options)) (*devopsagent.AssociateServiceOutput, error) { @@ -106,28 +129,33 @@ func (f *fakeAgent) GetAgentSpace(_ context.Context, in *devopsagent.GetAgentSpa }, nil } -func (f *fakeAgent) ListAgentSpaces(_ context.Context, _ *devopsagent.ListAgentSpacesInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListAgentSpacesOutput, error) { - if f.agentSpaces != nil { - return &devopsagent.ListAgentSpacesOutput{AgentSpaces: f.agentSpaces}, nil +func (f *fakeAgent) ListAgentSpaces(_ context.Context, in *devopsagent.ListAgentSpacesInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListAgentSpacesOutput, error) { + spaces := f.agentSpaces + if spaces == nil { + spaces = []agenttypes.AgentSpace{{AgentSpaceId: aws.String("space-1")}} } + items, next := page(spaces, in.NextToken, f.paginate) - return &devopsagent.ListAgentSpacesOutput{ - AgentSpaces: []agenttypes.AgentSpace{{AgentSpaceId: aws.String("space-1")}}, - }, nil + return &devopsagent.ListAgentSpacesOutput{AgentSpaces: items, NextToken: next}, nil } -func (f *fakeAgent) ListServices(_ context.Context, _ *devopsagent.ListServicesInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListServicesOutput, error) { +func (f *fakeAgent) ListServices(_ context.Context, in *devopsagent.ListServicesInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListServicesOutput, error) { f.calls = append(f.calls, "ListServices") + items, next := page(f.services, in.NextToken, f.paginate) - return &devopsagent.ListServicesOutput{Services: f.services}, nil + return &devopsagent.ListServicesOutput{Services: items, NextToken: next}, nil } -func (f *fakeAgent) ListAssets(_ context.Context, _ *devopsagent.ListAssetsInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListAssetsOutput, error) { - return &devopsagent.ListAssetsOutput{Items: f.assets}, nil +func (f *fakeAgent) ListAssets(_ context.Context, in *devopsagent.ListAssetsInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListAssetsOutput, error) { + items, next := page(f.assets, in.NextToken, f.paginate) + + return &devopsagent.ListAssetsOutput{Items: items, NextToken: next}, nil } -func (f *fakeAgent) ListAssociations(_ context.Context, _ *devopsagent.ListAssociationsInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListAssociationsOutput, error) { - return &devopsagent.ListAssociationsOutput{Associations: f.associations}, nil +func (f *fakeAgent) ListAssociations(_ context.Context, in *devopsagent.ListAssociationsInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListAssociationsOutput, error) { + items, next := page(f.associations, in.NextToken, f.paginate) + + return &devopsagent.ListAssociationsOutput{Associations: items, NextToken: next}, nil } func (f *fakeAgent) RegisterService(_ context.Context, _ *devopsagent.RegisterServiceInput, _ ...func(*devopsagent.Options)) (*devopsagent.RegisterServiceOutput, error) { @@ -144,6 +172,7 @@ type fakeIAM struct { createdRoles []string attachedPolicy map[string]string inlinePolicies map[string]string + trustPolicies map[string]string deletedRoles []string detachedPolicy []string deletedInlinePs []string @@ -154,6 +183,7 @@ func newFakeIAM() *fakeIAM { existingRoles: map[string]bool{}, attachedPolicy: map[string]string{}, inlinePolicies: map[string]string{}, + trustPolicies: map[string]string{}, } } @@ -170,6 +200,7 @@ func (f *fakeIAM) CreateRole(_ context.Context, in *iam.CreateRoleInput, _ ...fu } f.existingRoles[name] = true f.createdRoles = append(f.createdRoles, name) + f.trustPolicies[name] = aws.ToString(in.AssumeRolePolicyDocument) return &iam.CreateRoleOutput{ Role: &iamtypes.Role{Arn: aws.String("arn:aws:iam::" + testAccountID + ":role/" + name)}, @@ -205,6 +236,12 @@ func (f *fakeIAM) GetRole(_ context.Context, in *iam.GetRoleInput, _ ...func(*ia }, nil } +func (f *fakeIAM) UpdateAssumeRolePolicy(_ context.Context, in *iam.UpdateAssumeRolePolicyInput, _ ...func(*iam.Options)) (*iam.UpdateAssumeRolePolicyOutput, error) { + f.trustPolicies[aws.ToString(in.RoleName)] = aws.ToString(in.PolicyDocument) + + return &iam.UpdateAssumeRolePolicyOutput{}, nil +} + func (f *fakeIAM) PutRolePolicy(_ context.Context, in *iam.PutRolePolicyInput, _ ...func(*iam.Options)) (*iam.PutRolePolicyOutput, error) { f.inlinePolicies[aws.ToString(in.RoleName)] = aws.ToString(in.PolicyDocument) @@ -310,6 +347,163 @@ func TestSetupReusesAgentSpaceAndAssociations(t *testing.T) { assert.Empty(t, agent.associationInputs) } +func TestSetupRefreshesTheTrustPolicyOnAReusedRole(t *testing.T) { + agent := &fakeAgent{} + iamClient := newFakeIAM() + iamClient.existingRoles[awsdevops.AgentSpaceRoleName] = true + iamClient.existingRoles[awsdevops.OperatorAppRoleName] = true + + _, err := awsdevops.Setup(context.Background(), newTestClients(agent, iamClient), awsdevops.SetupOptions{ + AgentSpaceID: "space-existing", + AuthFlow: "iam", + }) + require.NoError(t, err) + + for _, name := range []string{awsdevops.AgentSpaceRoleName, awsdevops.OperatorAppRoleName} { + assert.Contains(t, iamClient.trustPolicies[name], "arn:aws:aidevops:us-east-1:"+testAccountID+":agentspace/*") + } +} + +func TestSetupReusesAnExistingMCPServerWithoutAToken(t *testing.T) { + agent := &fakeAgent{ + services: []agenttypes.RegisteredService{{ + ServiceId: aws.String("mcp-existing"), + Name: aws.String("LaunchDarkly"), + }}, + } + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + }) + require.NoError(t, err) + + assert.Equal(t, "mcp-existing", result.MCPServiceID) + assert.Equal(t, "assoc-mcp-existing", result.MCPAssociationID) + assert.NotContains(t, agent.calls, "RegisterService") +} + +func TestSetupWithoutATokenLeavesTheMCPServerUnconnected(t *testing.T) { + agent := &fakeAgent{} + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + }) + require.NoError(t, err) + + assert.Empty(t, result.MCPServiceID) + assert.NotContains(t, agent.calls, "RegisterService") +} + +func TestSetupDisassociatesTheMCPServerBeforeReplacingItsToken(t *testing.T) { + agent := &fakeAgent{ + services: []agenttypes.RegisteredService{{ + ServiceId: aws.String("mcp-existing"), + Name: aws.String("LaunchDarkly"), + }}, + associations: []agenttypes.Association{{ + AssociationId: aws.String("assoc-mcp-existing"), + ServiceId: aws.String("mcp-existing"), + }}, + } + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceName: "launchdarkly", + AuthFlow: "iam", + LDAccessToken: "api-token", + ReplaceMCPToken: true, + }) + require.NoError(t, err) + + assert.Equal(t, []string{"assoc-mcp-existing"}, agent.disassociatedIDs) + assert.Equal(t, []string{"mcp-existing"}, agent.deregisteredIDs) + assert.Less(t, indexOf(agent.calls, "DisassociateService"), indexOf(agent.calls, "DeregisterService")) + assert.Equal(t, "mcp-1", result.MCPServiceID) + assert.Equal(t, "assoc-mcp-1", result.MCPAssociationID) +} + +func TestTeardownDisassociatesAServiceBeforeDeregisteringIt(t *testing.T) { + agent := &fakeAgent{ + agentSpaces: []agenttypes.AgentSpace{{AgentSpaceId: aws.String("space-1")}}, + associations: []agenttypes.Association{{ + AssociationId: aws.String("assoc-mcp-1"), + ServiceId: aws.String("mcp-1"), + }}, + } + + require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.TeardownOptions{ + ServiceIDs: []string{"mcp-1"}, + })) + + assert.Equal(t, []string{"assoc-mcp-1"}, agent.disassociatedIDs) + assert.Equal(t, []string{"mcp-1"}, agent.deregisteredIDs) + assert.Less(t, indexOf(agent.calls, "DisassociateService"), indexOf(agent.calls, "DeregisterService")) +} + +func TestTeardownFollowsEveryListPage(t *testing.T) { + agent := &fakeAgent{ + paginate: true, + agentSpaces: []agenttypes.AgentSpace{ + {AgentSpaceId: aws.String("space-1")}, + {AgentSpaceId: aws.String("space-2")}, + }, + services: []agenttypes.RegisteredService{ + {ServiceId: aws.String("svc-1")}, + {ServiceId: aws.String("mcp-1")}, + }, + associations: []agenttypes.Association{ + {AssociationId: aws.String("assoc-1"), ServiceId: aws.String("aws")}, + {AssociationId: aws.String("assoc-2"), ServiceId: aws.String("mcp-1")}, + }, + assets: []agenttypes.Asset{ + {AssetId: aws.String("asset-1"), AssetType: aws.String("skill")}, + {AssetId: aws.String("asset-2"), AssetType: aws.String("skill")}, + }, + } + + require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.TeardownOptions{})) + + assert.Equal(t, []string{"space-1", "space-2"}, agent.deletedAgentSpaces) + assert.Equal(t, []string{"svc-1", "mcp-1"}, agent.deregisteredIDs) + assert.Equal(t, []string{"asset-1", "asset-2", "asset-1", "asset-2"}, agent.deletedAssetIDs) +} + +func TestStatusFollowsEveryListPage(t *testing.T) { + agent := &fakeAgent{ + paginate: true, + agentSpaces: []agenttypes.AgentSpace{ + {AgentSpaceId: aws.String("space-1")}, + {AgentSpaceId: aws.String("space-2")}, + }, + services: []agenttypes.RegisteredService{ + {ServiceId: aws.String("svc-1")}, + {ServiceId: aws.String("mcp-1")}, + }, + associations: []agenttypes.Association{ + {AssociationId: aws.String("assoc-1"), ServiceId: aws.String("aws")}, + {AssociationId: aws.String("assoc-2"), ServiceId: aws.String("mcp-1")}, + }, + } + + status, err := awsdevops.GetStatus(context.Background(), newTestClients(agent, newFakeIAM()), "") + require.NoError(t, err) + + assert.Len(t, status.Services, 2) + require.Len(t, status.AgentSpaces, 2) + assert.Len(t, status.AgentSpaces[0].Associations, 2) +} + +func indexOf(calls []string, name string) int { + for i, call := range calls { + if call == name { + return i + } + } + + return -1 +} + func TestTeardownEmptiesAgentSpaceBeforeDeletingIt(t *testing.T) { agent := &fakeAgent{ associations: []agenttypes.Association{{AssociationId: aws.String("assoc-1"), ServiceId: aws.String("aws")}}, diff --git a/internal/awsdevops/lookup.go b/internal/awsdevops/lookup.go index 3d5e987bf..cad89f32a 100644 --- a/internal/awsdevops/lookup.go +++ b/internal/awsdevops/lookup.go @@ -10,71 +10,134 @@ import ( agenttypes "github.com/aws/aws-sdk-go-v2/service/devopsagent/types" ) -// FindMCPServer returns the ID of the LaunchDarkly MCP server if one is -// already registered on the account. AWS keeps MCP servers at the account -// level, so a second agent space reuses the existing registration. -func FindMCPServer(ctx context.Context, clients Clients) (string, error) { - var nextToken *string +// The DevOps Agent list APIs page at 100 items, so every caller has to follow +// NextToken: a partial list would leave resources behind on teardown. + +func listServices(ctx context.Context, clients Clients) ([]agenttypes.RegisteredService, error) { + var ( + services []agenttypes.RegisteredService + nextToken *string + ) for { - services, err := clients.Agent.ListServices(ctx, &devopsagent.ListServicesInput{NextToken: nextToken}) + page, err := clients.Agent.ListServices(ctx, &devopsagent.ListServicesInput{NextToken: nextToken}) if err != nil { - return "", fmt.Errorf("unable to list registered services: %w", err) - } - for _, service := range services.Services { - if isLaunchDarklyMCPServer(service) { - return aws.ToString(service.ServiceId), nil - } + return nil, fmt.Errorf("unable to list registered services: %w", err) } - if services.NextToken == nil { - return "", nil + services = append(services, page.Services...) + if page.NextToken == nil { + return services, nil } - nextToken = services.NextToken + nextToken = page.NextToken } } -// FindAgentSpace returns the ID of the agent space with the given name, or an -// empty string when the account has none. -func FindAgentSpace(ctx context.Context, clients Clients, name string) (string, error) { - var nextToken *string +func listAgentSpaces(ctx context.Context, clients Clients) ([]agenttypes.AgentSpace, error) { + var ( + spaces []agenttypes.AgentSpace + nextToken *string + ) for { - spaces, err := clients.Agent.ListAgentSpaces(ctx, &devopsagent.ListAgentSpacesInput{NextToken: nextToken}) + page, err := clients.Agent.ListAgentSpaces(ctx, &devopsagent.ListAgentSpacesInput{NextToken: nextToken}) if err != nil { - return "", fmt.Errorf("unable to list agent spaces: %w", err) + return nil, fmt.Errorf("unable to list agent spaces: %w", err) } - for _, space := range spaces.AgentSpaces { - if strings.EqualFold(aws.ToString(space.Name), name) { - return aws.ToString(space.AgentSpaceId), nil - } + spaces = append(spaces, page.AgentSpaces...) + if page.NextToken == nil { + return spaces, nil + } + nextToken = page.NextToken + } +} + +func listAssociations(ctx context.Context, clients Clients, agentSpaceID string) ([]agenttypes.Association, error) { + var ( + associations []agenttypes.Association + nextToken *string + ) + for { + page, err := clients.Agent.ListAssociations(ctx, &devopsagent.ListAssociationsInput{ + AgentSpaceId: aws.String(agentSpaceID), + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("unable to list associations for agent space %s: %w", agentSpaceID, err) } - if spaces.NextToken == nil { - return "", nil + associations = append(associations, page.Associations...) + if page.NextToken == nil { + return associations, nil } - nextToken = spaces.NextToken + nextToken = page.NextToken } } -// FindAssociation returns the ID of an existing association between an agent -// space and a service, so setup can be re-run without AWS rejecting duplicates. -func FindAssociation(ctx context.Context, clients Clients, agentSpaceID, serviceID string) (string, error) { - var nextToken *string +func listAssets(ctx context.Context, clients Clients, agentSpaceID string) ([]agenttypes.Asset, error) { + var ( + assets []agenttypes.Asset + nextToken *string + ) for { - associations, err := clients.Agent.ListAssociations(ctx, &devopsagent.ListAssociationsInput{ + page, err := clients.Agent.ListAssets(ctx, &devopsagent.ListAssetsInput{ AgentSpaceId: aws.String(agentSpaceID), NextToken: nextToken, }) if err != nil { - return "", fmt.Errorf("unable to list associations for agent space %s: %w", agentSpaceID, err) + return nil, fmt.Errorf("unable to list assets for agent space %s: %w", agentSpaceID, err) + } + assets = append(assets, page.Items...) + if page.NextToken == nil { + return assets, nil + } + nextToken = page.NextToken + } +} + +// FindMCPServer returns the ID of the LaunchDarkly MCP server if one is +// already registered on the account. AWS keeps MCP servers at the account +// level, so a second agent space reuses the existing registration. +func FindMCPServer(ctx context.Context, clients Clients) (string, error) { + services, err := listServices(ctx, clients) + if err != nil { + return "", err + } + for _, service := range services { + if isLaunchDarklyMCPServer(service) { + return aws.ToString(service.ServiceId), nil } - for _, association := range associations.Associations { - if aws.ToString(association.ServiceId) == serviceID { - return aws.ToString(association.AssociationId), nil - } + } + + return "", nil +} + +// FindAgentSpace returns the ID of the agent space with the given name, or an +// empty string when the account has none. +func FindAgentSpace(ctx context.Context, clients Clients, name string) (string, error) { + spaces, err := listAgentSpaces(ctx, clients) + if err != nil { + return "", err + } + for _, space := range spaces { + if strings.EqualFold(aws.ToString(space.Name), name) { + return aws.ToString(space.AgentSpaceId), nil } - if associations.NextToken == nil { - return "", nil + } + + return "", nil +} + +// FindAssociation returns the ID of an existing association between an agent +// space and a service, so setup can be re-run without AWS rejecting duplicates. +func FindAssociation(ctx context.Context, clients Clients, agentSpaceID, serviceID string) (string, error) { + associations, err := listAssociations(ctx, clients, agentSpaceID) + if err != nil { + return "", err + } + for _, association := range associations { + if aws.ToString(association.ServiceId) == serviceID { + return aws.ToString(association.AssociationId), nil } - nextToken = associations.NextToken } + + return "", nil } // isLaunchDarklyMCPServer matches on the name AWS reports at either level, and diff --git a/internal/awsdevops/setup.go b/internal/awsdevops/setup.go index 69ea27974..e1da913dd 100644 --- a/internal/awsdevops/setup.go +++ b/internal/awsdevops/setup.go @@ -154,10 +154,8 @@ func Setup(ctx context.Context, clients Clients, opts SetupOptions) (SetupResult result.OperatorAppURL = aws.ToString(operatorApp.OperatorAppUrl) logf("Operator app available at %s", result.OperatorAppURL) - if opts.LDAccessToken != "" { - if err := registerMCPServer(ctx, clients, opts, &result, logf); err != nil { - return result, err - } + if err := registerMCPServer(ctx, clients, opts, &result, logf); err != nil { + return result, err } return result, nil @@ -268,7 +266,12 @@ func registerMCPServer( if err != nil { return err } - if existing != "" && !opts.ReplaceMCPToken { + if existing == "" && opts.LDAccessToken == "" { + // Nothing to reuse and no token to register with; the caller collects + // one and calls RegisterMCPServer afterwards. + return nil + } + if existing != "" && (!opts.ReplaceMCPToken || opts.LDAccessToken == "") { result.MCPServiceID = existing logf( "Reusing the %s MCP server already registered on this account (%s); it keeps the access "+ @@ -280,6 +283,9 @@ func registerMCPServer( return associateMCPServer(ctx, clients, result, logf) } if existing != "" { + if err := DisassociateEverywhere(ctx, clients, existing, logf); err != nil { + return err + } if _, err := clients.Agent.DeregisterService(ctx, &devopsagent.DeregisterServiceInput{ ServiceId: aws.String(existing), }); err != nil { @@ -462,6 +468,15 @@ func ensureRole(ctx context.Context, client IAMAPI, name, trustPolicy, policyARN return "", fmt.Errorf("unable to read the existing %s role: %w", name, getErr) } arn = aws.ToString(existing.Role.Arn) + + // A role left over from a setup in another region trusts only that + // region's agent spaces, so the trust policy has to be refreshed. + if _, err := client.UpdateAssumeRolePolicy(ctx, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String(name), + PolicyDocument: aws.String(trustPolicy), + }); err != nil { + return "", fmt.Errorf("unable to update the trust policy on the %s role: %w", name, err) + } default: return "", fmt.Errorf("unable to create the %s role: %w", name, err) } diff --git a/internal/awsdevops/status.go b/internal/awsdevops/status.go index 28e1950e8..413d29bc5 100644 --- a/internal/awsdevops/status.go +++ b/internal/awsdevops/status.go @@ -68,11 +68,11 @@ func GetStatus(ctx context.Context, clients Clients, agentSpaceID string) (Statu }) } - services, err := clients.Agent.ListServices(ctx, &devopsagent.ListServicesInput{}) + services, err := listServices(ctx, clients) if err != nil { - return status, fmt.Errorf("unable to list registered services: %w", err) + return status, err } - for _, service := range services.Services { + for _, service := range services { status.Services = append(status.Services, ServiceStatus{ ServiceID: aws.ToString(service.ServiceId), ServiceType: string(service.ServiceType), @@ -82,12 +82,12 @@ func GetStatus(ctx context.Context, clients Clients, agentSpaceID string) (Statu spaceIDs := []string{agentSpaceID} if agentSpaceID == "" { - spaces, err := clients.Agent.ListAgentSpaces(ctx, &devopsagent.ListAgentSpacesInput{}) + spaces, err := listAgentSpaces(ctx, clients) if err != nil { - return status, fmt.Errorf("unable to list agent spaces: %w", err) + return status, err } spaceIDs = nil - for _, space := range spaces.AgentSpaces { + for _, space := range spaces { spaceIDs = append(spaceIDs, aws.ToString(space.AgentSpaceId)) } } @@ -103,13 +103,11 @@ func GetStatus(ctx context.Context, clients Clients, agentSpaceID string) (Statu OperatorAppURL: OperatorAppURL(id), } - associations, err := clients.Agent.ListAssociations(ctx, &devopsagent.ListAssociationsInput{ - AgentSpaceId: aws.String(id), - }) + associations, err := listAssociations(ctx, clients, id) if err != nil { - return status, fmt.Errorf("unable to list associations for agent space %s: %w", id, err) + return status, err } - for _, association := range associations.Associations { + for _, association := range associations { spaceStatus.Associations = append(spaceStatus.Associations, AssociationStatus{ AssociationID: aws.ToString(association.AssociationId), ServiceID: aws.ToString(association.ServiceId), diff --git a/internal/awsdevops/teardown.go b/internal/awsdevops/teardown.go index aeb84aa11..3486f43e9 100644 --- a/internal/awsdevops/teardown.go +++ b/internal/awsdevops/teardown.go @@ -66,6 +66,9 @@ func Teardown(ctx context.Context, clients Clients, opts TeardownOptions) error } for _, serviceID := range serviceIDs { + if err := DisassociateEverywhere(ctx, clients, serviceID, logf); err != nil { + return err + } if _, err := clients.Agent.DeregisterService(ctx, &devopsagent.DeregisterServiceInput{ ServiceId: aws.String(serviceID), }); err != nil { @@ -88,14 +91,40 @@ func Teardown(ctx context.Context, clients Clients, opts TeardownOptions) error return nil } +// DisassociateEverywhere removes a service from every agent space that uses +// it, which AWS requires before the service can be deregistered. +func DisassociateEverywhere(ctx context.Context, clients Clients, serviceID string, logf func(string, ...any)) error { + spaces, err := listAgentSpaces(ctx, clients) + if err != nil { + return err + } + for _, space := range spaces { + agentSpaceID := aws.ToString(space.AgentSpaceId) + associationID, err := FindAssociation(ctx, clients, agentSpaceID, serviceID) + if err != nil { + return err + } + if associationID == "" { + continue + } + if _, err := clients.Agent.DisassociateService(ctx, &devopsagent.DisassociateServiceInput{ + AgentSpaceId: aws.String(agentSpaceID), + AssociationId: aws.String(associationID), + }); err != nil { + return fmt.Errorf("unable to disassociate service %s from agent space %s: %w", serviceID, agentSpaceID, err) + } + logf("Disassociated service %s from agent space %s", serviceID, agentSpaceID) + } + + return nil +} + func teardownAgentSpace(ctx context.Context, clients Clients, agentSpaceID string, logf func(string, ...any)) error { - associations, err := clients.Agent.ListAssociations(ctx, &devopsagent.ListAssociationsInput{ - AgentSpaceId: aws.String(agentSpaceID), - }) + associations, err := listAssociations(ctx, clients, agentSpaceID) if err != nil { - return fmt.Errorf("unable to list associations for agent space %s: %w", agentSpaceID, err) + return err } - for _, association := range associations.Associations { + for _, association := range associations { if _, err := clients.Agent.DisassociateService(ctx, &devopsagent.DisassociateServiceInput{ AgentSpaceId: aws.String(agentSpaceID), AssociationId: association.AssociationId, @@ -105,13 +134,11 @@ func teardownAgentSpace(ctx context.Context, clients Clients, agentSpaceID strin logf("Disassociated %s", aws.ToString(association.ServiceId)) } - assets, err := clients.Agent.ListAssets(ctx, &devopsagent.ListAssetsInput{ - AgentSpaceId: aws.String(agentSpaceID), - }) + assets, err := listAssets(ctx, clients, agentSpaceID) if err != nil { - return fmt.Errorf("unable to list assets for agent space %s: %w", agentSpaceID, err) + return err } - for _, asset := range sortAssetsForDeletion(assets.Items) { + for _, asset := range sortAssetsForDeletion(assets) { if _, err := clients.Agent.DeleteAsset(ctx, &devopsagent.DeleteAssetInput{ AgentSpaceId: aws.String(agentSpaceID), AssetId: asset.AssetId, @@ -132,43 +159,31 @@ func teardownAgentSpace(ctx context.Context, clients Clients, agentSpaceID strin } func listAgentSpaceIDs(ctx context.Context, clients Clients) ([]string, error) { - var ( - ids []string - nextToken *string - ) - for { - spaces, err := clients.Agent.ListAgentSpaces(ctx, &devopsagent.ListAgentSpacesInput{NextToken: nextToken}) - if err != nil { - return nil, fmt.Errorf("unable to list agent spaces: %w", err) - } - for _, space := range spaces.AgentSpaces { - ids = append(ids, aws.ToString(space.AgentSpaceId)) - } - if spaces.NextToken == nil { - return ids, nil - } - nextToken = spaces.NextToken + spaces, err := listAgentSpaces(ctx, clients) + if err != nil { + return nil, err + } + + ids := make([]string, 0, len(spaces)) + for _, space := range spaces { + ids = append(ids, aws.ToString(space.AgentSpaceId)) } + + return ids, nil } func listServiceIDs(ctx context.Context, clients Clients) ([]string, error) { - var ( - ids []string - nextToken *string - ) - for { - services, err := clients.Agent.ListServices(ctx, &devopsagent.ListServicesInput{NextToken: nextToken}) - if err != nil { - return nil, fmt.Errorf("unable to list registered services: %w", err) - } - for _, service := range services.Services { - ids = append(ids, aws.ToString(service.ServiceId)) - } - if services.NextToken == nil { - return ids, nil - } - nextToken = services.NextToken + services, err := listServices(ctx, clients) + if err != nil { + return nil, err + } + + ids := make([]string, 0, len(services)) + for _, service := range services { + ids = append(ids, aws.ToString(service.ServiceId)) } + + return ids, nil } // sortAssetsForDeletion puts memory stores last, since AWS refuses to delete a From aeec9dbbc4db82697be01cc913b163de7f895f49 Mon Sep 17 00:00:00 2001 From: "manderjaska@launchdarkly.com" Date: Mon, 5 Oct 2026 17:29:40 +0000 Subject: [PATCH 5/9] Reconnect every agent space after replacing the MCP token --- internal/awsdevops/awsdevops_test.go | 33 +++++++++++++++++++ internal/awsdevops/setup.go | 47 +++++++++++++++++++++++----- internal/awsdevops/teardown.go | 24 +++++++++----- 3 files changed, 89 insertions(+), 15 deletions(-) diff --git a/internal/awsdevops/awsdevops_test.go b/internal/awsdevops/awsdevops_test.go index ce930a751..931708c2f 100644 --- a/internal/awsdevops/awsdevops_test.go +++ b/internal/awsdevops/awsdevops_test.go @@ -423,6 +423,39 @@ func TestSetupDisassociatesTheMCPServerBeforeReplacingItsToken(t *testing.T) { assert.Equal(t, "assoc-mcp-1", result.MCPAssociationID) } +func TestSetupReconnectsOtherSpacesAfterReplacingTheMCPToken(t *testing.T) { + agent := &fakeAgent{ + agentSpaces: []agenttypes.AgentSpace{ + {AgentSpaceId: aws.String("space-1")}, + {AgentSpaceId: aws.String("space-2")}, + }, + services: []agenttypes.RegisteredService{{ + ServiceId: aws.String("mcp-existing"), + Name: aws.String("LaunchDarkly"), + }}, + associations: []agenttypes.Association{{ + AssociationId: aws.String("assoc-mcp-existing"), + ServiceId: aws.String("mcp-existing"), + }}, + } + + _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceID: "space-1", + AuthFlow: "iam", + LDAccessToken: "api-token", + ReplaceMCPToken: true, + }) + require.NoError(t, err) + + associated := []string{} + for _, in := range agent.associationInputs { + if aws.ToString(in.ServiceId) == "mcp-1" { + associated = append(associated, aws.ToString(in.AgentSpaceId)) + } + } + assert.Equal(t, []string{"space-1", "space-2"}, associated) +} + func TestTeardownDisassociatesAServiceBeforeDeregisteringIt(t *testing.T) { agent := &fakeAgent{ agentSpaces: []agenttypes.AgentSpace{{AgentSpaceId: aws.String("space-1")}}, diff --git a/internal/awsdevops/setup.go b/internal/awsdevops/setup.go index e1da913dd..228ffc90e 100644 --- a/internal/awsdevops/setup.go +++ b/internal/awsdevops/setup.go @@ -282,8 +282,10 @@ func registerMCPServer( return associateMCPServer(ctx, clients, result, logf) } + var detached []string if existing != "" { - if err := DisassociateEverywhere(ctx, clients, existing, logf); err != nil { + detached, err = DisassociateEverywhere(ctx, clients, existing, logf) + if err != nil { return err } if _, err := clients.Agent.DeregisterService(ctx, &devopsagent.DeregisterServiceInput{ @@ -333,7 +335,23 @@ func registerMCPServer( result.MCPServiceID, ) - return associateMCPServer(ctx, clients, result, logf) + if err := associateMCPServer(ctx, clients, result, logf); err != nil { + return err + } + + // The deregistered service was account-level, so reconnect the other agent + // spaces that were using it before the token was replaced. + for _, agentSpaceID := range detached { + if agentSpaceID == result.AgentSpaceID { + continue + } + if _, err := associateMCPServerWith(ctx, clients, agentSpaceID, result.MCPServiceID); err != nil { + return err + } + logf("Re-associated the %s MCP server with agent space %s", MCPServerName, agentSpaceID) + } + + return nil } func associateMCPServer( @@ -353,20 +371,33 @@ func associateMCPServer( return nil } + associationID, err := associateMCPServerWith(ctx, clients, result.AgentSpaceID, result.MCPServiceID) + if err != nil { + return err + } + result.MCPAssociationID = associationID + logf("Associated the %s MCP server with the agent space", MCPServerName) + + return nil +} + +func associateMCPServerWith(ctx context.Context, clients Clients, agentSpaceID, serviceID string) (string, error) { association, err := clients.Agent.AssociateService(ctx, &devopsagent.AssociateServiceInput{ - AgentSpaceId: aws.String(result.AgentSpaceID), - ServiceId: aws.String(result.MCPServiceID), + AgentSpaceId: aws.String(agentSpaceID), + ServiceId: aws.String(serviceID), Configuration: &agenttypes.ServiceConfigurationMemberMcpserver{ Value: mcpServerConfiguration(), }, }) if err != nil { - return fmt.Errorf("unable to associate the LaunchDarkly MCP server with the agent space: %w", err) + return "", fmt.Errorf( + "unable to associate the LaunchDarkly MCP server with agent space %s: %w", + agentSpaceID, + err, + ) } - result.MCPAssociationID = aws.ToString(association.Association.AssociationId) - logf("Associated the %s MCP server with the agent space", MCPServerName) - return nil + return aws.ToString(association.Association.AssociationId), nil } // mcpServerConfiguration builds the tool allowlist. AWS has no "allow all diff --git a/internal/awsdevops/teardown.go b/internal/awsdevops/teardown.go index 3486f43e9..a41aee811 100644 --- a/internal/awsdevops/teardown.go +++ b/internal/awsdevops/teardown.go @@ -66,7 +66,7 @@ func Teardown(ctx context.Context, clients Clients, opts TeardownOptions) error } for _, serviceID := range serviceIDs { - if err := DisassociateEverywhere(ctx, clients, serviceID, logf); err != nil { + if _, err := DisassociateEverywhere(ctx, clients, serviceID, logf); err != nil { return err } if _, err := clients.Agent.DeregisterService(ctx, &devopsagent.DeregisterServiceInput{ @@ -92,17 +92,26 @@ func Teardown(ctx context.Context, clients Clients, opts TeardownOptions) error } // DisassociateEverywhere removes a service from every agent space that uses -// it, which AWS requires before the service can be deregistered. -func DisassociateEverywhere(ctx context.Context, clients Clients, serviceID string, logf func(string, ...any)) error { +// it, which AWS requires before the service can be deregistered. It returns +// the agent spaces it was removed from, so a caller re-registering the service +// can reconnect them. +func DisassociateEverywhere( + ctx context.Context, + clients Clients, + serviceID string, + logf func(string, ...any), +) ([]string, error) { spaces, err := listAgentSpaces(ctx, clients) if err != nil { - return err + return nil, err } + + var detached []string for _, space := range spaces { agentSpaceID := aws.ToString(space.AgentSpaceId) associationID, err := FindAssociation(ctx, clients, agentSpaceID, serviceID) if err != nil { - return err + return nil, err } if associationID == "" { continue @@ -111,12 +120,13 @@ func DisassociateEverywhere(ctx context.Context, clients Clients, serviceID stri AgentSpaceId: aws.String(agentSpaceID), AssociationId: aws.String(associationID), }); err != nil { - return fmt.Errorf("unable to disassociate service %s from agent space %s: %w", serviceID, agentSpaceID, err) + return nil, fmt.Errorf("unable to disassociate service %s from agent space %s: %w", serviceID, agentSpaceID, err) } logf("Disassociated service %s from agent space %s", serviceID, agentSpaceID) + detached = append(detached, agentSpaceID) } - return nil + return detached, nil } func teardownAgentSpace(ctx context.Context, clients Clients, agentSpaceID string, logf func(string, ...any)) error { From b36a8fde219b5e737f1b8ce77fad323d7b6c9f96 Mon Sep 17 00:00:00 2001 From: "manderjaska@launchdarkly.com" Date: Tue, 6 Oct 2026 14:47:04 +0000 Subject: [PATCH 6/9] Drop the teardown command and list every supported region --- README.md | 4 +- cmd/aws_devops_agent/aws_devops_agent.go | 1 - cmd/aws_devops_agent/setup.go | 9 - cmd/aws_devops_agent/teardown.go | 93 --------- internal/awsdevops/awsdevops.go | 11 +- internal/awsdevops/awsdevops_test.go | 192 ++---------------- internal/awsdevops/lookup.go | 23 +-- internal/awsdevops/setup.go | 51 +++-- internal/awsdevops/teardown.go | 236 ----------------------- 9 files changed, 62 insertions(+), 558 deletions(-) delete mode 100644 cmd/aws_devops_agent/teardown.go delete mode 100644 internal/awsdevops/teardown.go diff --git a/README.md b/README.md index 3ca009e78..e54b04aa3 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ export AWS_PROFILE=my-profile AWS_REGION=us-east-1 ldcli aws-devops-agent setup ``` -The AWS DevOps Agent is available in `us-east-1`, `us-west-2`, `ap-southeast-2`, `ap-northeast-1`, `eu-central-1` and `eu-west-1`, and requires AWS CLI 2.36 or later if you also use the AWS CLI directly. `setup` warns when the `aws` binary on your PATH is missing or older than that; the command itself uses the AWS SDK, so it still runs. +The AWS DevOps Agent is available in `us-east-1`, `us-west-2`, `ca-central-1`, `sa-east-1`, `ap-south-1`, `ap-southeast-1`, `ap-southeast-2`, `ap-northeast-1`, `eu-central-1`, `eu-west-1` and `eu-west-2`, and requires AWS CLI 2.36 or later if you also use the AWS CLI directly. `setup` warns when the `aws` binary on your PATH is missing or older than that; the command itself uses the AWS SDK, so it still runs. `setup` is safe to re-run: it reuses the IAM roles, the agent space matching `--agent-space-name`, the account and MCP associations on it, and any LaunchDarkly MCP server already registered on the account. Pass `--new-agent-space` to create an additional agent space instead. @@ -131,7 +131,7 @@ With no token configured at all, `setup` prints the LaunchDarkly page where you `setup` ends by printing the operator app URL, `https://.aidevops.global.app.aws`, which is where you use the agent, together with the AWS console page that manages it. -`ldcli aws-devops-agent status` shows what exists. `ldcli aws-devops-agent teardown` removes everything in the account and region — every agent space, every registered service including the MCP server, and the IAM roles — after asking you to confirm (`--force` skips the prompt, and is required when there is no terminal). `--agent-space-id ` and `--service-id ` narrow it to those resources, and `--delete-roles` adds the IAM roles back to a narrowed teardown. +`ldcli aws-devops-agent status` shows what exists in the account and region. ### Resource Commands diff --git a/cmd/aws_devops_agent/aws_devops_agent.go b/cmd/aws_devops_agent/aws_devops_agent.go index fd07edea1..45afc3975 100644 --- a/cmd/aws_devops_agent/aws_devops_agent.go +++ b/cmd/aws_devops_agent/aws_devops_agent.go @@ -36,7 +36,6 @@ are available.`, cmd.AddCommand(NewSetupCmd(analyticsTrackerFn)) cmd.AddCommand(NewStatusCmd(analyticsTrackerFn)) - cmd.AddCommand(NewTeardownCmd(analyticsTrackerFn)) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) return cmd diff --git a/cmd/aws_devops_agent/setup.go b/cmd/aws_devops_agent/setup.go index e308c7868..346a02f81 100644 --- a/cmd/aws_devops_agent/setup.go +++ b/cmd/aws_devops_agent/setup.go @@ -202,12 +202,3 @@ func mustBool(cmd *cobra.Command, name string) bool { return value } - -func mustStringSlice(cmd *cobra.Command, name string) []string { - value, err := cmd.Flags().GetStringSlice(name) - if err != nil { - panic(err) - } - - return value -} diff --git a/cmd/aws_devops_agent/teardown.go b/cmd/aws_devops_agent/teardown.go deleted file mode 100644 index ce86cf358..000000000 --- a/cmd/aws_devops_agent/teardown.go +++ /dev/null @@ -1,93 +0,0 @@ -package awsdevopsagent - -import ( - "errors" - "fmt" - "strings" - - "github.com/spf13/cobra" - - resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" - "github.com/launchdarkly/ldcli/internal/analytics" - "github.com/launchdarkly/ldcli/internal/awsdevops" -) - -const ( - serviceIDFlag = "service-id" - deleteRolesFlag = "delete-roles" - forceFlag = "force" -) - -func NewTeardownCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { - cmd := &cobra.Command{ - Use: "teardown", - Short: "Remove the AWS DevOps Agent resources created by setup", - Long: `Disassociate services, delete assets and agent spaces, deregister services and -delete the IAM roles that setup created. - -Without flags this removes everything in the account and region, after asking -for confirmation. The flags narrow it to part of that.`, - Args: cobra.NoArgs, - PreRun: trackRun(analyticsTrackerFn), - RunE: runTeardown, - } - - cmd.Flags().String(regionFlag, "", "AWS region to tear down in. Defaults to the region of the current AWS session") - cmd.Flags().String(profileFlag, "", "Named AWS profile to use. Overrides AWS_PROFILE for this command") - cmd.Flags().String(agentSpaceIDFlag, "", "Agent space to empty and delete") - cmd.Flags().StringSlice(serviceIDFlag, nil, "Registered services to deregister, such as the LaunchDarkly MCP server") - cmd.Flags().Bool(deleteRolesFlag, false, "Also delete the IAM roles setup created") - cmd.Flags().Bool(forceFlag, false, "Remove everything without asking for confirmation") - - cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) - - return cmd -} - -func runTeardown(cmd *cobra.Command, args []string) error { - clients, err := awsdevops.NewClients(cmd.Context(), mustString(cmd, regionFlag), mustString(cmd, profileFlag)) - if err != nil { - return err - } - - opts := awsdevops.TeardownOptions{ - AgentSpaceID: mustString(cmd, agentSpaceIDFlag), - ServiceIDs: mustStringSlice(cmd, serviceIDFlag), - DeleteRoles: mustBool(cmd, deleteRolesFlag), - } - if opts.RemovesEverything() && !mustBool(cmd, forceFlag) { - accountID, err := clients.AccountID(cmd.Context()) - if err != nil { - return err - } - if err := confirmTeardown(cmd, accountID, clients.Region); err != nil { - return err - } - } - - opts.Logf = func(format string, args ...any) { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), format+"\n", args...) - } - - return awsdevops.Teardown(cmd.Context(), clients, opts) -} - -func confirmTeardown(cmd *cobra.Command, accountID, region string) error { - if !canPrompt() { - return errors.New( - "teardown without flags removes every agent space, registered service and IAM role: pass --force to confirm", - ) - } - - _, _ = fmt.Fprintf( - cmd.OutOrStdout(), - "This removes every agent space, registered service and IAM role in account %s (%s).\nType y to continue: ", - accountID, - region, - ) - if !strings.EqualFold(readLine(cmd.InOrStdin()), "y") { - return errors.New("teardown cancelled") - } - - return nil -} diff --git a/internal/awsdevops/awsdevops.go b/internal/awsdevops/awsdevops.go index 1905bcf72..cef033d52 100644 --- a/internal/awsdevops/awsdevops.go +++ b/internal/awsdevops/awsdevops.go @@ -23,10 +23,15 @@ import ( var SupportedRegions = []string{ "us-east-1", "us-west-2", + "ca-central-1", + "sa-east-1", + "ap-south-1", + "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "eu-central-1", "eu-west-1", + "eu-west-2", } var ( @@ -42,14 +47,11 @@ var ( type AgentAPI interface { AssociateService(context.Context, *devopsagent.AssociateServiceInput, ...func(*devopsagent.Options)) (*devopsagent.AssociateServiceOutput, error) CreateAgentSpace(context.Context, *devopsagent.CreateAgentSpaceInput, ...func(*devopsagent.Options)) (*devopsagent.CreateAgentSpaceOutput, error) - DeleteAgentSpace(context.Context, *devopsagent.DeleteAgentSpaceInput, ...func(*devopsagent.Options)) (*devopsagent.DeleteAgentSpaceOutput, error) - DeleteAsset(context.Context, *devopsagent.DeleteAssetInput, ...func(*devopsagent.Options)) (*devopsagent.DeleteAssetOutput, error) DeregisterService(context.Context, *devopsagent.DeregisterServiceInput, ...func(*devopsagent.Options)) (*devopsagent.DeregisterServiceOutput, error) DisassociateService(context.Context, *devopsagent.DisassociateServiceInput, ...func(*devopsagent.Options)) (*devopsagent.DisassociateServiceOutput, error) EnableOperatorApp(context.Context, *devopsagent.EnableOperatorAppInput, ...func(*devopsagent.Options)) (*devopsagent.EnableOperatorAppOutput, error) GetAgentSpace(context.Context, *devopsagent.GetAgentSpaceInput, ...func(*devopsagent.Options)) (*devopsagent.GetAgentSpaceOutput, error) ListAgentSpaces(context.Context, *devopsagent.ListAgentSpacesInput, ...func(*devopsagent.Options)) (*devopsagent.ListAgentSpacesOutput, error) - ListAssets(context.Context, *devopsagent.ListAssetsInput, ...func(*devopsagent.Options)) (*devopsagent.ListAssetsOutput, error) ListAssociations(context.Context, *devopsagent.ListAssociationsInput, ...func(*devopsagent.Options)) (*devopsagent.ListAssociationsOutput, error) ListServices(context.Context, *devopsagent.ListServicesInput, ...func(*devopsagent.Options)) (*devopsagent.ListServicesOutput, error) RegisterService(context.Context, *devopsagent.RegisterServiceInput, ...func(*devopsagent.Options)) (*devopsagent.RegisterServiceOutput, error) @@ -59,9 +61,6 @@ type AgentAPI interface { type IAMAPI interface { AttachRolePolicy(context.Context, *iam.AttachRolePolicyInput, ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) CreateRole(context.Context, *iam.CreateRoleInput, ...func(*iam.Options)) (*iam.CreateRoleOutput, error) - DeleteRole(context.Context, *iam.DeleteRoleInput, ...func(*iam.Options)) (*iam.DeleteRoleOutput, error) - DeleteRolePolicy(context.Context, *iam.DeleteRolePolicyInput, ...func(*iam.Options)) (*iam.DeleteRolePolicyOutput, error) - DetachRolePolicy(context.Context, *iam.DetachRolePolicyInput, ...func(*iam.Options)) (*iam.DetachRolePolicyOutput, error) GetRole(context.Context, *iam.GetRoleInput, ...func(*iam.Options)) (*iam.GetRoleOutput, error) PutRolePolicy(context.Context, *iam.PutRolePolicyInput, ...func(*iam.Options)) (*iam.PutRolePolicyOutput, error) UpdateAssumeRolePolicy(context.Context, *iam.UpdateAssumeRolePolicyInput, ...func(*iam.Options)) (*iam.UpdateAssumeRolePolicyOutput, error) diff --git a/internal/awsdevops/awsdevops_test.go b/internal/awsdevops/awsdevops_test.go index 931708c2f..328c64bb9 100644 --- a/internal/awsdevops/awsdevops_test.go +++ b/internal/awsdevops/awsdevops_test.go @@ -23,18 +23,15 @@ import ( const testAccountID = "123456789012" type fakeAgent struct { - calls []string - services []agenttypes.RegisteredService - associationInputs []*devopsagent.AssociateServiceInput - registerOutput *devopsagent.RegisterServiceOutput - associations []agenttypes.Association - agentSpaces []agenttypes.AgentSpace - assets []agenttypes.Asset - disassociatedIDs []string - deletedAssetIDs []string - deregisteredIDs []string - deletedAgentSpaces []string - associateFailures int + calls []string + services []agenttypes.RegisteredService + associationInputs []*devopsagent.AssociateServiceInput + registerOutput *devopsagent.RegisterServiceOutput + associations []agenttypes.Association + agentSpaces []agenttypes.AgentSpace + disassociatedIDs []string + deregisteredIDs []string + associateFailures int // paginate makes every list API return one item per page, so callers are // exercised against the NextToken AWS sends for a long list. paginate bool @@ -84,20 +81,6 @@ func (f *fakeAgent) CreateAgentSpace(_ context.Context, _ *devopsagent.CreateAge }, nil } -func (f *fakeAgent) DeleteAgentSpace(_ context.Context, in *devopsagent.DeleteAgentSpaceInput, _ ...func(*devopsagent.Options)) (*devopsagent.DeleteAgentSpaceOutput, error) { - f.calls = append(f.calls, "DeleteAgentSpace") - f.deletedAgentSpaces = append(f.deletedAgentSpaces, aws.ToString(in.AgentSpaceId)) - - return &devopsagent.DeleteAgentSpaceOutput{}, nil -} - -func (f *fakeAgent) DeleteAsset(_ context.Context, in *devopsagent.DeleteAssetInput, _ ...func(*devopsagent.Options)) (*devopsagent.DeleteAssetOutput, error) { - f.calls = append(f.calls, "DeleteAsset") - f.deletedAssetIDs = append(f.deletedAssetIDs, aws.ToString(in.AssetId)) - - return &devopsagent.DeleteAssetOutput{}, nil -} - func (f *fakeAgent) DeregisterService(_ context.Context, in *devopsagent.DeregisterServiceInput, _ ...func(*devopsagent.Options)) (*devopsagent.DeregisterServiceOutput, error) { f.calls = append(f.calls, "DeregisterService") f.deregisteredIDs = append(f.deregisteredIDs, aws.ToString(in.ServiceId)) @@ -146,12 +129,6 @@ func (f *fakeAgent) ListServices(_ context.Context, in *devopsagent.ListServices return &devopsagent.ListServicesOutput{Services: items, NextToken: next}, nil } -func (f *fakeAgent) ListAssets(_ context.Context, in *devopsagent.ListAssetsInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListAssetsOutput, error) { - items, next := page(f.assets, in.NextToken, f.paginate) - - return &devopsagent.ListAssetsOutput{Items: items, NextToken: next}, nil -} - func (f *fakeAgent) ListAssociations(_ context.Context, in *devopsagent.ListAssociationsInput, _ ...func(*devopsagent.Options)) (*devopsagent.ListAssociationsOutput, error) { items, next := page(f.associations, in.NextToken, f.paginate) @@ -168,14 +145,11 @@ func (f *fakeAgent) RegisterService(_ context.Context, _ *devopsagent.RegisterSe } type fakeIAM struct { - existingRoles map[string]bool - createdRoles []string - attachedPolicy map[string]string - inlinePolicies map[string]string - trustPolicies map[string]string - deletedRoles []string - detachedPolicy []string - deletedInlinePs []string + existingRoles map[string]bool + createdRoles []string + attachedPolicy map[string]string + inlinePolicies map[string]string + trustPolicies map[string]string } func newFakeIAM() *fakeIAM { @@ -207,24 +181,6 @@ func (f *fakeIAM) CreateRole(_ context.Context, in *iam.CreateRoleInput, _ ...fu }, nil } -func (f *fakeIAM) DeleteRole(_ context.Context, in *iam.DeleteRoleInput, _ ...func(*iam.Options)) (*iam.DeleteRoleOutput, error) { - f.deletedRoles = append(f.deletedRoles, aws.ToString(in.RoleName)) - - return &iam.DeleteRoleOutput{}, nil -} - -func (f *fakeIAM) DeleteRolePolicy(_ context.Context, in *iam.DeleteRolePolicyInput, _ ...func(*iam.Options)) (*iam.DeleteRolePolicyOutput, error) { - f.deletedInlinePs = append(f.deletedInlinePs, aws.ToString(in.PolicyName)) - - return &iam.DeleteRolePolicyOutput{}, nil -} - -func (f *fakeIAM) DetachRolePolicy(_ context.Context, in *iam.DetachRolePolicyInput, _ ...func(*iam.Options)) (*iam.DetachRolePolicyOutput, error) { - f.detachedPolicy = append(f.detachedPolicy, aws.ToString(in.PolicyArn)) - - return &iam.DetachRolePolicyOutput{}, nil -} - func (f *fakeIAM) GetRole(_ context.Context, in *iam.GetRoleInput, _ ...func(*iam.Options)) (*iam.GetRoleOutput, error) { name := aws.ToString(in.RoleName) if !f.existingRoles[name] { @@ -456,52 +412,6 @@ func TestSetupReconnectsOtherSpacesAfterReplacingTheMCPToken(t *testing.T) { assert.Equal(t, []string{"space-1", "space-2"}, associated) } -func TestTeardownDisassociatesAServiceBeforeDeregisteringIt(t *testing.T) { - agent := &fakeAgent{ - agentSpaces: []agenttypes.AgentSpace{{AgentSpaceId: aws.String("space-1")}}, - associations: []agenttypes.Association{{ - AssociationId: aws.String("assoc-mcp-1"), - ServiceId: aws.String("mcp-1"), - }}, - } - - require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.TeardownOptions{ - ServiceIDs: []string{"mcp-1"}, - })) - - assert.Equal(t, []string{"assoc-mcp-1"}, agent.disassociatedIDs) - assert.Equal(t, []string{"mcp-1"}, agent.deregisteredIDs) - assert.Less(t, indexOf(agent.calls, "DisassociateService"), indexOf(agent.calls, "DeregisterService")) -} - -func TestTeardownFollowsEveryListPage(t *testing.T) { - agent := &fakeAgent{ - paginate: true, - agentSpaces: []agenttypes.AgentSpace{ - {AgentSpaceId: aws.String("space-1")}, - {AgentSpaceId: aws.String("space-2")}, - }, - services: []agenttypes.RegisteredService{ - {ServiceId: aws.String("svc-1")}, - {ServiceId: aws.String("mcp-1")}, - }, - associations: []agenttypes.Association{ - {AssociationId: aws.String("assoc-1"), ServiceId: aws.String("aws")}, - {AssociationId: aws.String("assoc-2"), ServiceId: aws.String("mcp-1")}, - }, - assets: []agenttypes.Asset{ - {AssetId: aws.String("asset-1"), AssetType: aws.String("skill")}, - {AssetId: aws.String("asset-2"), AssetType: aws.String("skill")}, - }, - } - - require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.TeardownOptions{})) - - assert.Equal(t, []string{"space-1", "space-2"}, agent.deletedAgentSpaces) - assert.Equal(t, []string{"svc-1", "mcp-1"}, agent.deregisteredIDs) - assert.Equal(t, []string{"asset-1", "asset-2", "asset-1", "asset-2"}, agent.deletedAssetIDs) -} - func TestStatusFollowsEveryListPage(t *testing.T) { agent := &fakeAgent{ paginate: true, @@ -537,83 +447,9 @@ func indexOf(calls []string, name string) int { return -1 } -func TestTeardownEmptiesAgentSpaceBeforeDeletingIt(t *testing.T) { - agent := &fakeAgent{ - associations: []agenttypes.Association{{AssociationId: aws.String("assoc-1"), ServiceId: aws.String("aws")}}, - assets: []agenttypes.Asset{{AssetId: aws.String("asset-1")}}, - } - iamClient := newFakeIAM() - - require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, iamClient), awsdevops.TeardownOptions{ - AgentSpaceID: "space-1", - ServiceIDs: []string{"mcp-1"}, - DeleteRoles: true, - })) - - assert.Equal( - t, - []string{"DisassociateService", "DeleteAsset", "DeleteAgentSpace", "DeregisterService"}, - agent.calls, - ) - assert.Equal(t, []string{"mcp-1"}, agent.deregisteredIDs) - assert.Equal(t, []string{awsdevops.AgentSpaceRoleName, awsdevops.OperatorAppRoleName}, iamClient.deletedRoles) - assert.Equal(t, []string{awsdevops.ServiceLinkedRolePolicyName}, iamClient.deletedInlinePs) -} - -func TestTeardownDeletesMemoryStoresLast(t *testing.T) { - agent := &fakeAgent{ - assets: []agenttypes.Asset{ - {AssetId: aws.String("store-1"), AssetType: aws.String("memory_store")}, - {AssetId: aws.String("memory-1"), AssetType: aws.String("memory")}, - {AssetId: aws.String("skill-1"), AssetType: aws.String("skill")}, - }, - } - - require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.TeardownOptions{ - AgentSpaceID: "space-1", - })) - - assert.Equal(t, []string{"memory-1", "skill-1", "store-1"}, agent.deletedAssetIDs) -} - -func TestTeardownWithoutOptionsRemovesEverything(t *testing.T) { - agent := &fakeAgent{ - agentSpaces: []agenttypes.AgentSpace{ - {AgentSpaceId: aws.String("space-1")}, - {AgentSpaceId: aws.String("space-2")}, - }, - services: []agenttypes.RegisteredService{ - {ServiceId: aws.String("svc-1"), ServiceType: agenttypes.ServiceGitlab}, - {ServiceId: aws.String("mcp-1"), ServiceType: agenttypes.ServiceMcpServer}, - }, - } - iamClient := newFakeIAM() - - require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, iamClient), awsdevops.TeardownOptions{})) - - assert.Equal(t, []string{"space-1", "space-2"}, agent.deletedAgentSpaces) - assert.Equal(t, []string{"svc-1", "mcp-1"}, agent.deregisteredIDs) - assert.Equal(t, []string{awsdevops.AgentSpaceRoleName, awsdevops.OperatorAppRoleName}, iamClient.deletedRoles) -} - -func TestTeardownStillRemovesEverythingWithDeleteRoles(t *testing.T) { - agent := &fakeAgent{ - agentSpaces: []agenttypes.AgentSpace{{AgentSpaceId: aws.String("space-1")}}, - services: []agenttypes.RegisteredService{{ServiceId: aws.String("mcp-1")}}, - } - - require.NoError(t, awsdevops.Teardown(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.TeardownOptions{ - DeleteRoles: true, - })) - - assert.Equal(t, []string{"space-1"}, agent.deletedAgentSpaces) - assert.Equal(t, []string{"mcp-1"}, agent.deregisteredIDs) -} - func TestStatusReportsMissingRoles(t *testing.T) { agent := &fakeAgent{ associations: []agenttypes.Association{{AssociationId: aws.String("assoc-1"), ServiceId: aws.String("aws")}}, - assets: []agenttypes.Asset{{AssetId: aws.String("asset-1"), AssetType: aws.String("skill")}}, } iamClient := newFakeIAM() iamClient.existingRoles[awsdevops.AgentSpaceRoleName] = true diff --git a/internal/awsdevops/lookup.go b/internal/awsdevops/lookup.go index cad89f32a..6ab0b5408 100644 --- a/internal/awsdevops/lookup.go +++ b/internal/awsdevops/lookup.go @@ -11,7 +11,7 @@ import ( ) // The DevOps Agent list APIs page at 100 items, so every caller has to follow -// NextToken: a partial list would leave resources behind on teardown. +// NextToken: a partial list hides resources that already exist. func listServices(ctx context.Context, clients Clients) ([]agenttypes.RegisteredService, error) { var ( @@ -70,27 +70,6 @@ func listAssociations(ctx context.Context, clients Clients, agentSpaceID string) } } -func listAssets(ctx context.Context, clients Clients, agentSpaceID string) ([]agenttypes.Asset, error) { - var ( - assets []agenttypes.Asset - nextToken *string - ) - for { - page, err := clients.Agent.ListAssets(ctx, &devopsagent.ListAssetsInput{ - AgentSpaceId: aws.String(agentSpaceID), - NextToken: nextToken, - }) - if err != nil { - return nil, fmt.Errorf("unable to list assets for agent space %s: %w", agentSpaceID, err) - } - assets = append(assets, page.Items...) - if page.NextToken == nil { - return assets, nil - } - nextToken = page.NextToken - } -} - // FindMCPServer returns the ID of the LaunchDarkly MCP server if one is // already registered on the account. AWS keeps MCP servers at the account // level, so a second agent space reuses the existing registration. diff --git a/internal/awsdevops/setup.go b/internal/awsdevops/setup.go index 228ffc90e..06b72996c 100644 --- a/internal/awsdevops/setup.go +++ b/internal/awsdevops/setup.go @@ -36,8 +36,6 @@ const ( MCPServerName = "LaunchDarkly" MCPServerEndpoint = "https://mcp.launchdarkly.com/mcp/launchdarkly" - memoryStoreAssetType = "memory_store" - agentSpaceDescription = "Managed by the LaunchDarkly CLI" ) @@ -68,8 +66,7 @@ type SetupOptions struct { Logf func(format string, args ...any) } -// SetupResult holds the identifiers of everything the setup touched. Callers -// need these to tear the environment back down. +// SetupResult holds the identifiers of everything the setup touched. type SetupResult struct { AccountID string `json:"accountId"` Region string `json:"region"` @@ -284,7 +281,7 @@ func registerMCPServer( } var detached []string if existing != "" { - detached, err = DisassociateEverywhere(ctx, clients, existing, logf) + detached, err = disassociateEverywhere(ctx, clients, existing, logf) if err != nil { return err } @@ -354,6 +351,44 @@ func registerMCPServer( return nil } +// disassociateEverywhere removes a service from every agent space that uses +// it, which AWS requires before the service can be deregistered. It returns +// the agent spaces it was removed from, so the re-registered service can be +// reconnected to them. +func disassociateEverywhere( + ctx context.Context, + clients Clients, + serviceID string, + logf func(string, ...any), +) ([]string, error) { + spaces, err := listAgentSpaces(ctx, clients) + if err != nil { + return nil, err + } + + var detached []string + for _, space := range spaces { + agentSpaceID := aws.ToString(space.AgentSpaceId) + associationID, err := FindAssociation(ctx, clients, agentSpaceID, serviceID) + if err != nil { + return nil, err + } + if associationID == "" { + continue + } + if _, err := clients.Agent.DisassociateService(ctx, &devopsagent.DisassociateServiceInput{ + AgentSpaceId: aws.String(agentSpaceID), + AssociationId: aws.String(associationID), + }); err != nil { + return nil, fmt.Errorf("unable to disassociate service %s from agent space %s: %w", serviceID, agentSpaceID, err) + } + logf("Disassociated service %s from agent space %s", serviceID, agentSpaceID) + detached = append(detached, agentSpaceID) + } + + return detached, nil +} + func associateMCPServer( ctx context.Context, clients Clients, @@ -528,12 +563,6 @@ func isAlreadyExists(err error) bool { return errors.As(err, &alreadyExists) } -func isNoSuchEntity(err error) bool { - var noSuchEntity *iamtypes.NoSuchEntityException - - return errors.As(err, &noSuchEntity) -} - // ConsoleURL is the AWS DevOps Agent console for a region. func ConsoleURL(region string) string { return fmt.Sprintf("https://%s.console.aws.amazon.com/aidevops/home?region=%[1]s", region) diff --git a/internal/awsdevops/teardown.go b/internal/awsdevops/teardown.go deleted file mode 100644 index a41aee811..000000000 --- a/internal/awsdevops/teardown.go +++ /dev/null @@ -1,236 +0,0 @@ -package awsdevops - -import ( - "context" - "fmt" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/devopsagent" - agenttypes "github.com/aws/aws-sdk-go-v2/service/devopsagent/types" - "github.com/aws/aws-sdk-go-v2/service/iam" -) - -// TeardownOptions narrows what to remove: only AgentSpaceID and ServiceIDs do -// that, while DeleteRoles adds the IAM roles to a narrowed teardown. -// With no selector set, Teardown removes everything setup can create. The -// agent space has to be emptied before it can be deleted, so assets and -// associations always go first. -type TeardownOptions struct { - AgentSpaceID string - ServiceIDs []string - DeleteRoles bool - - Logf func(format string, args ...any) -} - -// RemovesEverything reports whether nothing narrows the teardown, in which -// case it takes every agent space, registered service and role in the account. -func (o TeardownOptions) RemovesEverything() bool { - return o.AgentSpaceID == "" && len(o.ServiceIDs) == 0 -} - -// Teardown removes the resources Setup created: every association and asset in -// an agent space, the agent space itself, the registered services, and the IAM -// roles. Options narrow that; with none it covers the whole account. -func Teardown(ctx context.Context, clients Clients, opts TeardownOptions) error { - logf := opts.Logf - if logf == nil { - logf = func(string, ...any) {} - } - all := opts.RemovesEverything() - - agentSpaceIDs := []string{} - if opts.AgentSpaceID != "" { - agentSpaceIDs = append(agentSpaceIDs, opts.AgentSpaceID) - } - if all { - var err error - if agentSpaceIDs, err = listAgentSpaceIDs(ctx, clients); err != nil { - return err - } - } - - for _, agentSpaceID := range agentSpaceIDs { - if err := teardownAgentSpace(ctx, clients, agentSpaceID, logf); err != nil { - return err - } - } - - serviceIDs := opts.ServiceIDs - if all { - registered, err := listServiceIDs(ctx, clients) - if err != nil { - return err - } - serviceIDs = registered - } - - for _, serviceID := range serviceIDs { - if _, err := DisassociateEverywhere(ctx, clients, serviceID, logf); err != nil { - return err - } - if _, err := clients.Agent.DeregisterService(ctx, &devopsagent.DeregisterServiceInput{ - ServiceId: aws.String(serviceID), - }); err != nil { - return fmt.Errorf("unable to deregister service %s: %w", serviceID, err) - } - logf("Deregistered service %s", serviceID) - } - - if opts.DeleteRoles || all { - if err := deleteRole(ctx, clients.IAM, AgentSpaceRoleName, AgentSpacePolicyARN, ServiceLinkedRolePolicyName); err != nil { - return err - } - logf("Deleted role %s", AgentSpaceRoleName) - if err := deleteRole(ctx, clients.IAM, OperatorAppRoleName, OperatorAppPolicyARN, ""); err != nil { - return err - } - logf("Deleted role %s", OperatorAppRoleName) - } - - return nil -} - -// DisassociateEverywhere removes a service from every agent space that uses -// it, which AWS requires before the service can be deregistered. It returns -// the agent spaces it was removed from, so a caller re-registering the service -// can reconnect them. -func DisassociateEverywhere( - ctx context.Context, - clients Clients, - serviceID string, - logf func(string, ...any), -) ([]string, error) { - spaces, err := listAgentSpaces(ctx, clients) - if err != nil { - return nil, err - } - - var detached []string - for _, space := range spaces { - agentSpaceID := aws.ToString(space.AgentSpaceId) - associationID, err := FindAssociation(ctx, clients, agentSpaceID, serviceID) - if err != nil { - return nil, err - } - if associationID == "" { - continue - } - if _, err := clients.Agent.DisassociateService(ctx, &devopsagent.DisassociateServiceInput{ - AgentSpaceId: aws.String(agentSpaceID), - AssociationId: aws.String(associationID), - }); err != nil { - return nil, fmt.Errorf("unable to disassociate service %s from agent space %s: %w", serviceID, agentSpaceID, err) - } - logf("Disassociated service %s from agent space %s", serviceID, agentSpaceID) - detached = append(detached, agentSpaceID) - } - - return detached, nil -} - -func teardownAgentSpace(ctx context.Context, clients Clients, agentSpaceID string, logf func(string, ...any)) error { - associations, err := listAssociations(ctx, clients, agentSpaceID) - if err != nil { - return err - } - for _, association := range associations { - if _, err := clients.Agent.DisassociateService(ctx, &devopsagent.DisassociateServiceInput{ - AgentSpaceId: aws.String(agentSpaceID), - AssociationId: association.AssociationId, - }); err != nil { - return fmt.Errorf("unable to disassociate %s: %w", aws.ToString(association.ServiceId), err) - } - logf("Disassociated %s", aws.ToString(association.ServiceId)) - } - - assets, err := listAssets(ctx, clients, agentSpaceID) - if err != nil { - return err - } - for _, asset := range sortAssetsForDeletion(assets) { - if _, err := clients.Agent.DeleteAsset(ctx, &devopsagent.DeleteAssetInput{ - AgentSpaceId: aws.String(agentSpaceID), - AssetId: asset.AssetId, - }); err != nil { - return fmt.Errorf("unable to delete asset %s: %w", aws.ToString(asset.AssetId), err) - } - logf("Deleted asset %s", aws.ToString(asset.AssetId)) - } - - if _, err := clients.Agent.DeleteAgentSpace(ctx, &devopsagent.DeleteAgentSpaceInput{ - AgentSpaceId: aws.String(agentSpaceID), - }); err != nil { - return fmt.Errorf("unable to delete agent space %s: %w", agentSpaceID, err) - } - logf("Deleted agent space %s", agentSpaceID) - - return nil -} - -func listAgentSpaceIDs(ctx context.Context, clients Clients) ([]string, error) { - spaces, err := listAgentSpaces(ctx, clients) - if err != nil { - return nil, err - } - - ids := make([]string, 0, len(spaces)) - for _, space := range spaces { - ids = append(ids, aws.ToString(space.AgentSpaceId)) - } - - return ids, nil -} - -func listServiceIDs(ctx context.Context, clients Clients) ([]string, error) { - services, err := listServices(ctx, clients) - if err != nil { - return nil, err - } - - ids := make([]string, 0, len(services)) - for _, service := range services { - ids = append(ids, aws.ToString(service.ServiceId)) - } - - return ids, nil -} - -// sortAssetsForDeletion puts memory stores last, since AWS refuses to delete a -// store that still holds memory files. -func sortAssetsForDeletion(assets []agenttypes.Asset) []agenttypes.Asset { - ordered := make([]agenttypes.Asset, 0, len(assets)) - var stores []agenttypes.Asset - for _, asset := range assets { - if aws.ToString(asset.AssetType) == memoryStoreAssetType { - stores = append(stores, asset) - - continue - } - ordered = append(ordered, asset) - } - - return append(ordered, stores...) -} - -func deleteRole(ctx context.Context, client IAMAPI, name, policyARN, inlinePolicyName string) error { - if _, err := client.DetachRolePolicy(ctx, &iam.DetachRolePolicyInput{ - RoleName: aws.String(name), - PolicyArn: aws.String(policyARN), - }); err != nil && !isNoSuchEntity(err) { - return fmt.Errorf("unable to detach %s from %s: %w", policyARN, name, err) - } - if inlinePolicyName != "" { - if _, err := client.DeleteRolePolicy(ctx, &iam.DeleteRolePolicyInput{ - RoleName: aws.String(name), - PolicyName: aws.String(inlinePolicyName), - }); err != nil && !isNoSuchEntity(err) { - return fmt.Errorf("unable to delete the %s policy on %s: %w", inlinePolicyName, name, err) - } - } - if _, err := client.DeleteRole(ctx, &iam.DeleteRoleInput{RoleName: aws.String(name)}); err != nil && !isNoSuchEntity(err) { - return fmt.Errorf("unable to delete the %s role: %w", name, err) - } - - return nil -} From eb83d55407cac3e19793191326bf31010852a50c Mon Sep 17 00:00:00 2001 From: "manderjaska@launchdarkly.com" Date: Wed, 7 Oct 2026 16:55:52 +0000 Subject: [PATCH 7/9] Make role reuse and MCP token replacement safe Trust agent spaces in every region, leave roles this CLI did not create alone, check an access token before the old MCP registration is removed, and identify the MCP server by its endpoint rather than its name. --- README.md | 4 +- cmd/aws_devops_agent/setup.go | 6 + internal/awsdevops/awsdevops.go | 1 + internal/awsdevops/awsdevops_test.go | 226 ++++++++++++++++++++++++--- internal/awsdevops/lookup.go | 27 ++-- internal/awsdevops/lookup_test.go | 24 ++- internal/awsdevops/setup.go | 168 ++++++++++++++++---- internal/awsdevops/token.go | 57 +++++++ 8 files changed, 431 insertions(+), 82 deletions(-) create mode 100644 internal/awsdevops/token.go diff --git a/README.md b/README.md index e54b04aa3..c38b3a5b9 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,9 @@ ldcli aws-devops-agent setup The AWS DevOps Agent is available in `us-east-1`, `us-west-2`, `ca-central-1`, `sa-east-1`, `ap-south-1`, `ap-southeast-1`, `ap-southeast-2`, `ap-northeast-1`, `eu-central-1`, `eu-west-1` and `eu-west-2`, and requires AWS CLI 2.36 or later if you also use the AWS CLI directly. `setup` warns when the `aws` binary on your PATH is missing or older than that; the command itself uses the AWS SDK, so it still runs. -`setup` is safe to re-run: it reuses the IAM roles, the agent space matching `--agent-space-name`, the account and MCP associations on it, and any LaunchDarkly MCP server already registered on the account. Pass `--new-agent-space` to create an additional agent space instead. +`setup` is safe to re-run: it reuses the IAM roles, the agent space matching `--agent-space-name`, the account and MCP associations on it, and any LaunchDarkly MCP server already registered on the account. Pass `--new-agent-space` to create an additional agent space instead. The IAM roles are account-wide and trust the agent spaces in every region, so running `setup` in a second region leaves the first one working; a role named `DevOpsAgentRole-AgentSpace` or `DevOpsAgentRole-WebappAdmin` that `setup` did not create is reused untouched, and it has to trust `aidevops.amazonaws.com` itself. -`--access-token` is optional. The first available token — the flag, then `LD_ACCESS_TOKEN`, then the one already in your ldcli configuration — is registered with AWS as the bearer token the agent uses to call the LaunchDarkly MCP server, so it should be a [service token](https://launchdarkly.com/docs/home/account/api-create) whose permissions match what you want the agent to do. AWS keeps it until the MCP server is re-registered, so a session token written by `ldcli login` eventually expires and the agent then fails with `unauthorized` errors. Re-run with `--access-token --replace-mcp-token` to re-register an MCP server with a different token. The agent may call `list-projects`, `list-flags` and `get-flag` without asking, and must ask for approval before calling `toggle-flag`. +`--access-token` is optional. The first available token — the flag, then `LD_ACCESS_TOKEN`, then the one already in your ldcli configuration — is registered with AWS as the bearer token the agent uses to call the LaunchDarkly MCP server, so it should be a [service token](https://launchdarkly.com/docs/home/account/api-create) whose permissions match what you want the agent to do. AWS keeps it until the MCP server is re-registered, so a session token written by `ldcli login` eventually expires and the agent then fails with `unauthorized` errors. Re-run with `--access-token --replace-mcp-token` to re-register an MCP server with a different token; the token is checked against LaunchDarkly first, because AWS stores it write-only and the previous registration cannot be restored. The MCP server already registered on the account is the one serving `https://mcp.launchdarkly.com/mcp/launchdarkly`, whatever it is named: pass `--mcp-endpoint` to connect a LaunchDarkly MCP server you host yourself, or `--mcp-service-id` to point at a specific registration. The agent may call `list-projects`, `list-flags` and `get-flag` without asking, and must ask for approval before calling `toggle-flag`. With no token configured at all, `setup` prints the LaunchDarkly page where you create a service token and connects the MCP server with the token you paste there, so you do not need one ready beforehand. Pressing Enter without a token skips the step, as does running without a terminal. diff --git a/cmd/aws_devops_agent/setup.go b/cmd/aws_devops_agent/setup.go index 346a02f81..bb117ee5f 100644 --- a/cmd/aws_devops_agent/setup.go +++ b/cmd/aws_devops_agent/setup.go @@ -23,6 +23,8 @@ const ( idpClientIDFlag = "idp-client-id" idpClientSecretFlag = "idp-client-secret" replaceMCPTokenFlag = "replace-mcp-token" + mcpServiceIDFlag = "mcp-service-id" + mcpEndpointFlag = "mcp-endpoint" ) func NewSetupCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { @@ -57,6 +59,8 @@ expires.`, cmd.Flags().String(idpClientIDFlag, "", "OIDC client ID, required when --auth-flow=idp") cmd.Flags().String(idpClientSecretFlag, "", "OIDC client secret, required when --auth-flow=idp") cmd.Flags().Bool(replaceMCPTokenFlag, false, "Re-register the LaunchDarkly MCP server so it uses the token passed with --access-token") + cmd.Flags().String(mcpServiceIDFlag, "", "Registered MCP server to use, instead of the one registered at --mcp-endpoint") + cmd.Flags().String(mcpEndpointFlag, "", "MCP endpoint to register and look the server up by. Defaults to "+awsdevops.MCPServerEndpoint) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) @@ -155,6 +159,8 @@ func setupOptions(cmd *cobra.Command) (awsdevops.SetupOptions, error) { LDBaseURI: viper.GetString(cliflags.BaseURIFlag), LDAccessToken: viper.GetString(cliflags.AccessTokenFlag), ReplaceMCPToken: mustBool(cmd, replaceMCPTokenFlag), + MCPServiceID: mustString(cmd, mcpServiceIDFlag), + MCPEndpoint: mustString(cmd, mcpEndpointFlag), } switch opts.AuthFlow { diff --git a/internal/awsdevops/awsdevops.go b/internal/awsdevops/awsdevops.go index cef033d52..acba0a664 100644 --- a/internal/awsdevops/awsdevops.go +++ b/internal/awsdevops/awsdevops.go @@ -62,6 +62,7 @@ type IAMAPI interface { AttachRolePolicy(context.Context, *iam.AttachRolePolicyInput, ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) CreateRole(context.Context, *iam.CreateRoleInput, ...func(*iam.Options)) (*iam.CreateRoleOutput, error) GetRole(context.Context, *iam.GetRoleInput, ...func(*iam.Options)) (*iam.GetRoleOutput, error) + ListRoleTags(context.Context, *iam.ListRoleTagsInput, ...func(*iam.Options)) (*iam.ListRoleTagsOutput, error) PutRolePolicy(context.Context, *iam.PutRolePolicyInput, ...func(*iam.Options)) (*iam.PutRolePolicyOutput, error) UpdateAssumeRolePolicy(context.Context, *iam.UpdateAssumeRolePolicyInput, ...func(*iam.Options)) (*iam.UpdateAssumeRolePolicyOutput, error) } diff --git a/internal/awsdevops/awsdevops_test.go b/internal/awsdevops/awsdevops_test.go index 328c64bb9..1c57390d1 100644 --- a/internal/awsdevops/awsdevops_test.go +++ b/internal/awsdevops/awsdevops_test.go @@ -3,9 +3,13 @@ package awsdevops_test import ( "context" "errors" + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "strconv" + "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -27,6 +31,8 @@ type fakeAgent struct { services []agenttypes.RegisteredService associationInputs []*devopsagent.AssociateServiceInput registerOutput *devopsagent.RegisterServiceOutput + registerErr error + registerInputs []*devopsagent.RegisterServiceInput associations []agenttypes.Association agentSpaces []agenttypes.AgentSpace disassociatedIDs []string @@ -135,8 +141,12 @@ func (f *fakeAgent) ListAssociations(_ context.Context, in *devopsagent.ListAsso return &devopsagent.ListAssociationsOutput{Associations: items, NextToken: next}, nil } -func (f *fakeAgent) RegisterService(_ context.Context, _ *devopsagent.RegisterServiceInput, _ ...func(*devopsagent.Options)) (*devopsagent.RegisterServiceOutput, error) { +func (f *fakeAgent) RegisterService(_ context.Context, in *devopsagent.RegisterServiceInput, _ ...func(*devopsagent.Options)) (*devopsagent.RegisterServiceOutput, error) { f.calls = append(f.calls, "RegisterService") + f.registerInputs = append(f.registerInputs, in) + if f.registerErr != nil { + return nil, f.registerErr + } if f.registerOutput != nil { return f.registerOutput, nil } @@ -146,6 +156,7 @@ func (f *fakeAgent) RegisterService(_ context.Context, _ *devopsagent.RegisterSe type fakeIAM struct { existingRoles map[string]bool + roleTags map[string][]iamtypes.Tag createdRoles []string attachedPolicy map[string]string inlinePolicies map[string]string @@ -155,12 +166,22 @@ type fakeIAM struct { func newFakeIAM() *fakeIAM { return &fakeIAM{ existingRoles: map[string]bool{}, + roleTags: map[string][]iamtypes.Tag{}, attachedPolicy: map[string]string{}, inlinePolicies: map[string]string{}, trustPolicies: map[string]string{}, } } +// addManagedRole registers a role this CLI created in an earlier run. +func (f *fakeIAM) addManagedRole(name string) { + f.existingRoles[name] = true + f.roleTags[name] = []iamtypes.Tag{{ + Key: aws.String(awsdevops.ManagedTagKey), + Value: aws.String(awsdevops.ManagedTagValue), + }} +} + func (f *fakeIAM) AttachRolePolicy(_ context.Context, in *iam.AttachRolePolicyInput, _ ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) { f.attachedPolicy[aws.ToString(in.RoleName)] = aws.ToString(in.PolicyArn) @@ -175,6 +196,7 @@ func (f *fakeIAM) CreateRole(_ context.Context, in *iam.CreateRoleInput, _ ...fu f.existingRoles[name] = true f.createdRoles = append(f.createdRoles, name) f.trustPolicies[name] = aws.ToString(in.AssumeRolePolicyDocument) + f.roleTags[name] = in.Tags return &iam.CreateRoleOutput{ Role: &iamtypes.Role{Arn: aws.String("arn:aws:iam::" + testAccountID + ":role/" + name)}, @@ -192,6 +214,10 @@ func (f *fakeIAM) GetRole(_ context.Context, in *iam.GetRoleInput, _ ...func(*ia }, nil } +func (f *fakeIAM) ListRoleTags(_ context.Context, in *iam.ListRoleTagsInput, _ ...func(*iam.Options)) (*iam.ListRoleTagsOutput, error) { + return &iam.ListRoleTagsOutput{Tags: f.roleTags[aws.ToString(in.RoleName)]}, nil +} + func (f *fakeIAM) UpdateAssumeRolePolicy(_ context.Context, in *iam.UpdateAssumeRolePolicyInput, _ ...func(*iam.Options)) (*iam.UpdateAssumeRolePolicyOutput, error) { f.trustPolicies[aws.ToString(in.RoleName)] = aws.ToString(in.PolicyDocument) @@ -251,8 +277,8 @@ func TestSetupCreatesRolesAgentSpaceAndAssociations(t *testing.T) { func TestSetupReusesExistingRolesAndAgentSpace(t *testing.T) { agent := &fakeAgent{} iamClient := newFakeIAM() - iamClient.existingRoles[awsdevops.AgentSpaceRoleName] = true - iamClient.existingRoles[awsdevops.OperatorAppRoleName] = true + iamClient.addManagedRole(awsdevops.AgentSpaceRoleName) + iamClient.addManagedRole(awsdevops.OperatorAppRoleName) result, err := awsdevops.Setup(context.Background(), newTestClients(agent, iamClient), awsdevops.SetupOptions{ AgentSpaceID: "space-existing", @@ -303,11 +329,11 @@ func TestSetupReusesAgentSpaceAndAssociations(t *testing.T) { assert.Empty(t, agent.associationInputs) } -func TestSetupRefreshesTheTrustPolicyOnAReusedRole(t *testing.T) { +func TestSetupTrustsAgentSpacesInEveryRegion(t *testing.T) { agent := &fakeAgent{} iamClient := newFakeIAM() - iamClient.existingRoles[awsdevops.AgentSpaceRoleName] = true - iamClient.existingRoles[awsdevops.OperatorAppRoleName] = true + iamClient.addManagedRole(awsdevops.AgentSpaceRoleName) + iamClient.addManagedRole(awsdevops.OperatorAppRoleName) _, err := awsdevops.Setup(context.Background(), newTestClients(agent, iamClient), awsdevops.SetupOptions{ AgentSpaceID: "space-existing", @@ -315,17 +341,75 @@ func TestSetupRefreshesTheTrustPolicyOnAReusedRole(t *testing.T) { }) require.NoError(t, err) + // A region-pinned trust policy would lock out the agent spaces in every + // other region, since the role names are shared across the account. for _, name := range []string{awsdevops.AgentSpaceRoleName, awsdevops.OperatorAppRoleName} { - assert.Contains(t, iamClient.trustPolicies[name], "arn:aws:aidevops:us-east-1:"+testAccountID+":agentspace/*") + assert.Contains(t, iamClient.trustPolicies[name], "arn:aws:aidevops:*:"+testAccountID+":agentspace/*") } } +func TestSetupLeavesARoleItDidNotCreateAlone(t *testing.T) { + agent := &fakeAgent{} + iamClient := newFakeIAM() + iamClient.existingRoles[awsdevops.AgentSpaceRoleName] = true + iamClient.trustPolicies[awsdevops.AgentSpaceRoleName] = "customer-owned" + iamClient.addManagedRole(awsdevops.OperatorAppRoleName) + + var logged []string + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, iamClient), awsdevops.SetupOptions{ + AgentSpaceID: "space-existing", + AuthFlow: "iam", + Logf: func(format string, args ...any) { + logged = append(logged, fmt.Sprintf(format, args...)) + }, + }) + require.NoError(t, err) + + assert.Equal(t, "arn:aws:iam::"+testAccountID+":role/"+awsdevops.AgentSpaceRoleName, result.AgentSpaceRoleARN) + assert.Equal(t, "customer-owned", iamClient.trustPolicies[awsdevops.AgentSpaceRoleName]) + assert.Empty(t, iamClient.attachedPolicy[awsdevops.AgentSpaceRoleName]) + assert.Empty(t, iamClient.inlinePolicies[awsdevops.AgentSpaceRoleName]) + assert.Contains(t, strings.Join(logged, "\n"), "was not created by this CLI") +} + +// launchDarklyMCPService is how AWS reports our registration: the endpoint is +// what identifies it, and it lives in the type-specific details. +func launchDarklyMCPService(serviceID string) agenttypes.RegisteredService { + return mcpService(serviceID, awsdevops.MCPServerName, awsdevops.MCPServerEndpoint) +} + +func mcpService(serviceID, name, endpoint string) agenttypes.RegisteredService { + return agenttypes.RegisteredService{ + ServiceId: aws.String(serviceID), + ServiceType: agenttypes.ServiceMcpServer, + Name: aws.String(name), + AdditionalServiceDetails: &agenttypes.AdditionalServiceDetailsMemberMcpserver{ + Value: agenttypes.RegisteredMCPServerDetails{ + Name: aws.String(name), + Endpoint: aws.String(endpoint), + }, + }, + } +} + +// stubLaunchDarkly stands in for the LaunchDarkly API the access token is +// checked against, and returns its base URI. +func stubLaunchDarkly(t *testing.T, status int) string { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/caller-identity", r.URL.Path) + assert.Equal(t, "api-token", r.Header.Get("Authorization")) + w.WriteHeader(status) + })) + t.Cleanup(server.Close) + + return server.URL +} + func TestSetupReusesAnExistingMCPServerWithoutAToken(t *testing.T) { agent := &fakeAgent{ - services: []agenttypes.RegisteredService{{ - ServiceId: aws.String("mcp-existing"), - Name: aws.String("LaunchDarkly"), - }}, + services: []agenttypes.RegisteredService{launchDarklyMCPService("mcp-existing")}, } result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ @@ -354,10 +438,7 @@ func TestSetupWithoutATokenLeavesTheMCPServerUnconnected(t *testing.T) { func TestSetupDisassociatesTheMCPServerBeforeReplacingItsToken(t *testing.T) { agent := &fakeAgent{ - services: []agenttypes.RegisteredService{{ - ServiceId: aws.String("mcp-existing"), - Name: aws.String("LaunchDarkly"), - }}, + services: []agenttypes.RegisteredService{launchDarklyMCPService("mcp-existing")}, associations: []agenttypes.Association{{ AssociationId: aws.String("assoc-mcp-existing"), ServiceId: aws.String("mcp-existing"), @@ -367,6 +448,7 @@ func TestSetupDisassociatesTheMCPServerBeforeReplacingItsToken(t *testing.T) { result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ AgentSpaceName: "launchdarkly", AuthFlow: "iam", + LDBaseURI: stubLaunchDarkly(t, http.StatusOK), LDAccessToken: "api-token", ReplaceMCPToken: true, }) @@ -385,10 +467,7 @@ func TestSetupReconnectsOtherSpacesAfterReplacingTheMCPToken(t *testing.T) { {AgentSpaceId: aws.String("space-1")}, {AgentSpaceId: aws.String("space-2")}, }, - services: []agenttypes.RegisteredService{{ - ServiceId: aws.String("mcp-existing"), - Name: aws.String("LaunchDarkly"), - }}, + services: []agenttypes.RegisteredService{launchDarklyMCPService("mcp-existing")}, associations: []agenttypes.Association{{ AssociationId: aws.String("assoc-mcp-existing"), ServiceId: aws.String("mcp-existing"), @@ -398,6 +477,7 @@ func TestSetupReconnectsOtherSpacesAfterReplacingTheMCPToken(t *testing.T) { _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ AgentSpaceID: "space-1", AuthFlow: "iam", + LDBaseURI: stubLaunchDarkly(t, http.StatusOK), LDAccessToken: "api-token", ReplaceMCPToken: true, }) @@ -412,6 +492,114 @@ func TestSetupReconnectsOtherSpacesAfterReplacingTheMCPToken(t *testing.T) { assert.Equal(t, []string{"space-1", "space-2"}, associated) } +func TestSetupKeepsTheMCPServerWhenLaunchDarklyRejectsTheNewToken(t *testing.T) { + agent := &fakeAgent{ + services: []agenttypes.RegisteredService{launchDarklyMCPService("mcp-existing")}, + associations: []agenttypes.Association{{ + AssociationId: aws.String("assoc-mcp-existing"), + ServiceId: aws.String("mcp-existing"), + }}, + } + + _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceID: "space-1", + AuthFlow: "iam", + LDBaseURI: stubLaunchDarkly(t, http.StatusUnauthorized), + LDAccessToken: "api-token", + ReplaceMCPToken: true, + }) + + assert.ErrorIs(t, err, awsdevops.ErrInvalidAccessToken) + assert.Empty(t, agent.disassociatedIDs) + assert.Empty(t, agent.deregisteredIDs) + assert.NotContains(t, agent.calls, "RegisterService") +} + +func TestSetupNamesTheSpacesLeftWithoutAnMCPServer(t *testing.T) { + agent := &fakeAgent{ + registerErr: errors.New("ValidationException: something broke"), + agentSpaces: []agenttypes.AgentSpace{ + {AgentSpaceId: aws.String("space-1")}, + {AgentSpaceId: aws.String("space-2")}, + }, + services: []agenttypes.RegisteredService{launchDarklyMCPService("mcp-existing")}, + associations: []agenttypes.Association{{ + AssociationId: aws.String("assoc-mcp-existing"), + ServiceId: aws.String("mcp-existing"), + }}, + } + + _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceID: "space-1", + AuthFlow: "iam", + LDBaseURI: stubLaunchDarkly(t, http.StatusOK), + LDAccessToken: "api-token", + ReplaceMCPToken: true, + }) + + assert.ErrorContains(t, err, "agent spaces space-1, space-2 have no LaunchDarkly MCP server") + assert.ErrorContains(t, err, "--access-token") +} + +func TestSetupIgnoresAnMCPServerAtAnotherEndpoint(t *testing.T) { + agent := &fakeAgent{ + services: []agenttypes.RegisteredService{ + mcpService("mcp-self-hosted", awsdevops.MCPServerName, "https://mcp.example.internal/mcp"), + }, + associations: []agenttypes.Association{{ + AssociationId: aws.String("assoc-mcp-self-hosted"), + ServiceId: aws.String("mcp-self-hosted"), + }}, + } + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceID: "space-1", + AuthFlow: "iam", + LDAccessToken: "api-token", + ReplaceMCPToken: true, + }) + require.NoError(t, err) + + assert.Equal(t, "mcp-1", result.MCPServiceID) + assert.Empty(t, agent.deregisteredIDs) + assert.Empty(t, agent.disassociatedIDs) +} + +func TestSetupUsesTheMCPServerItIsPointedAt(t *testing.T) { + agent := &fakeAgent{ + services: []agenttypes.RegisteredService{ + mcpService("mcp-renamed", "LaunchDarkly (prod)", "https://mcp.example.internal/mcp"), + }, + } + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceID: "space-1", + AuthFlow: "iam", + MCPServiceID: "mcp-renamed", + }) + require.NoError(t, err) + + assert.Equal(t, "mcp-renamed", result.MCPServiceID) + assert.NotContains(t, agent.calls, "RegisterService") +} + +func TestSetupRegistersAtTheEndpointItIsGiven(t *testing.T) { + agent := &fakeAgent{} + + _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceID: "space-1", + AuthFlow: "iam", + LDAccessToken: "api-token", + MCPEndpoint: "https://mcp.example.internal/mcp", + }) + require.NoError(t, err) + + require.Len(t, agent.registerInputs, 1) + details, ok := agent.registerInputs[0].ServiceDetails.(*agenttypes.ServiceDetailsMemberMcpserver) + require.True(t, ok) + assert.Equal(t, "https://mcp.example.internal/mcp", aws.ToString(details.Value.Endpoint)) +} + func TestStatusFollowsEveryListPage(t *testing.T) { agent := &fakeAgent{ paginate: true, diff --git a/internal/awsdevops/lookup.go b/internal/awsdevops/lookup.go index 6ab0b5408..7d6baa2d3 100644 --- a/internal/awsdevops/lookup.go +++ b/internal/awsdevops/lookup.go @@ -70,16 +70,16 @@ func listAssociations(ctx context.Context, clients Clients, agentSpaceID string) } } -// FindMCPServer returns the ID of the LaunchDarkly MCP server if one is -// already registered on the account. AWS keeps MCP servers at the account -// level, so a second agent space reuses the existing registration. -func FindMCPServer(ctx context.Context, clients Clients) (string, error) { +// FindMCPServer returns the ID of the MCP server registered at endpoint, if the +// account has one. AWS keeps MCP servers at the account level, so a second +// agent space reuses the existing registration. +func FindMCPServer(ctx context.Context, clients Clients, endpoint string) (string, error) { services, err := listServices(ctx, clients) if err != nil { return "", err } for _, service := range services { - if isLaunchDarklyMCPServer(service) { + if mcpEndpointOf(service) == endpoint { return aws.ToString(service.ServiceId), nil } } @@ -119,21 +119,16 @@ func FindAssociation(ctx context.Context, clients Clients, agentSpaceID, service return "", nil } -// isLaunchDarklyMCPServer matches on the name AWS reports at either level, and -// on the endpoint, since MCP servers carry their name in the type-specific -// details rather than the top-level field. -func isLaunchDarklyMCPServer(service agenttypes.RegisteredService) bool { - if strings.EqualFold(aws.ToString(service.Name), MCPServerName) { - return true - } - +// mcpEndpointOf returns the endpoint a registered MCP server serves, which is +// what identifies a registration: the name is the customer's to choose, so a +// self-hosted LaunchDarkly MCP server can carry ours. +func mcpEndpointOf(service agenttypes.RegisteredService) string { details, ok := service.AdditionalServiceDetails.(*agenttypes.AdditionalServiceDetailsMemberMcpserver) if !ok { - return false + return "" } - return strings.EqualFold(aws.ToString(details.Value.Name), MCPServerName) || - aws.ToString(details.Value.Endpoint) == MCPServerEndpoint + return aws.ToString(details.Value.Endpoint) } // serviceName prefers the name AWS keeps in the type-specific details, which is diff --git a/internal/awsdevops/lookup_test.go b/internal/awsdevops/lookup_test.go index bf6703dd6..8457ef052 100644 --- a/internal/awsdevops/lookup_test.go +++ b/internal/awsdevops/lookup_test.go @@ -2,6 +2,7 @@ package awsdevops_test import ( "context" + "net/http" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -14,13 +15,7 @@ import ( func TestSetupReusesAnAlreadyRegisteredMCPServer(t *testing.T) { agent := &fakeAgent{ - services: []agenttypes.RegisteredService{ - { - ServiceId: aws.String("mcp-1"), - ServiceType: agenttypes.ServiceMcpServer, - Name: aws.String(awsdevops.MCPServerName), - }, - }, + services: []agenttypes.RegisteredService{launchDarklyMCPService("mcp-1")}, } result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ @@ -37,18 +32,13 @@ func TestSetupReusesAnAlreadyRegisteredMCPServer(t *testing.T) { func TestSetupReplacesTheMCPServerTokenWhenAsked(t *testing.T) { agent := &fakeAgent{ - services: []agenttypes.RegisteredService{ - { - ServiceId: aws.String("mcp-1"), - ServiceType: agenttypes.ServiceMcpServer, - Name: aws.String(awsdevops.MCPServerName), - }, - }, + services: []agenttypes.RegisteredService{launchDarklyMCPService("mcp-1")}, } result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ AgentSpaceName: "launchdarkly", AuthFlow: "iam", + LDBaseURI: stubLaunchDarkly(t, http.StatusOK), LDAccessToken: "api-token", ReplaceMCPToken: true, }) @@ -74,7 +64,11 @@ func TestFindMCPServerMatchesOnTheEndpointInTheServiceDetails(t *testing.T) { }, } - serviceID, err := awsdevops.FindMCPServer(context.Background(), newTestClients(agent, newFakeIAM())) + serviceID, err := awsdevops.FindMCPServer( + context.Background(), + newTestClients(agent, newFakeIAM()), + awsdevops.MCPServerEndpoint, + ) require.NoError(t, err) assert.Equal(t, "mcp-2", serviceID) diff --git a/internal/awsdevops/setup.go b/internal/awsdevops/setup.go index 06b72996c..a270a39d9 100644 --- a/internal/awsdevops/setup.go +++ b/internal/awsdevops/setup.go @@ -23,6 +23,11 @@ const ( ServiceLinkedRolePolicyName = "AllowCreateServiceLinkedRoles" + // ManagedTagKey marks the roles this CLI created, so a re-run never + // rewrites a role of the same name that belongs to the customer. + ManagedTagKey = "ldcli:managed" + ManagedTagValue = "aws-devops-agent" + // awsServiceID is the well-known service identifier for the agent's own // AWS account association. awsServiceID = "aws" @@ -62,6 +67,14 @@ type SetupOptions struct { LDAccessToken string ReplaceMCPToken bool + // MCPServiceID, when set, is the registration to reuse or replace instead + // of the one found by endpoint. + MCPServiceID string + // MCPEndpoint overrides the LaunchDarkly MCP endpoint, for an account + // running the open-source MCP server itself. The endpoint identifies the + // registration, so a self-hosted one is never confused with ours. + MCPEndpoint string + // Logf, when set, reports progress as each step completes. Logf func(format string, args ...any) } @@ -94,13 +107,13 @@ func Setup(ctx context.Context, clients Clients, opts SetupOptions) (SetupResult result := SetupResult{AccountID: accountID, Region: clients.Region} - result.AgentSpaceRoleARN, err = ensureAgentSpaceRole(ctx, clients.IAM, accountID, clients.Region) + result.AgentSpaceRoleARN, err = ensureAgentSpaceRole(ctx, clients.IAM, accountID, logf) if err != nil { return result, err } logf("Agent space role ready: %s", result.AgentSpaceRoleARN) - result.OperatorAppRoleARN, err = ensureOperatorAppRole(ctx, clients.IAM, accountID, clients.Region) + result.OperatorAppRoleARN, err = ensureOperatorAppRole(ctx, clients.IAM, accountID, logf) if err != nil { return result, err } @@ -158,6 +171,32 @@ func Setup(ctx context.Context, clients Clients, opts SetupOptions) (SetupResult return result, nil } +// mcpEndpoint is the endpoint the MCP server is registered at and found by. +func (o SetupOptions) mcpEndpoint() string { + if o.MCPEndpoint != "" { + return o.MCPEndpoint + } + + return MCPServerEndpoint +} + +// registrationFailed names the agent spaces a failed token replacement left +// without an MCP server: the old registration is already gone by then, and its +// token cannot be read back, so only a re-run with a working token restores them. +func registrationFailed(err error, detached []string) error { + if len(detached) == 0 { + return fmt.Errorf("unable to register the LaunchDarkly MCP server: %w", err) + } + + return fmt.Errorf( + "unable to register the LaunchDarkly MCP server: %w. The previous registration was already "+ + "removed, so agent spaces %s have no LaunchDarkly MCP server: re-run setup with "+ + "--access-token to restore them", + err, + strings.Join(detached, ", "), + ) +} + // associateAWSAccount retries while AWS still rejects the agent space role: // a freshly created role takes a while to become assumable. func associateAWSAccount( @@ -208,6 +247,7 @@ func registerWithRetry( clients Clients, input *devopsagent.RegisterServiceInput, logf func(string, ...any), + endpoint string, ) (*devopsagent.RegisterServiceOutput, error) { deadline := time.Now().Add(roleAssumableTimeout) wait := roleAssumableFirstWait @@ -217,7 +257,7 @@ func registerWithRetry( return registration, err } if first { - logf("AWS could not reach %s, retrying", MCPServerEndpoint) + logf("AWS could not reach %s, retrying", endpoint) } select { @@ -259,9 +299,14 @@ func registerMCPServer( result *SetupResult, logf func(string, ...any), ) error { - existing, err := FindMCPServer(ctx, clients) - if err != nil { - return err + endpoint := opts.mcpEndpoint() + existing := opts.MCPServiceID + if existing == "" { + found, err := FindMCPServer(ctx, clients, endpoint) + if err != nil { + return err + } + existing = found } if existing == "" && opts.LDAccessToken == "" { // Nothing to reuse and no token to register with; the caller collects @@ -281,6 +326,14 @@ func registerMCPServer( } var detached []string if existing != "" { + // AWS stores the token write-only, so the old registration cannot be + // restored once it is deregistered. Reject a token LaunchDarkly will + // not accept while the working registration is still in place. + if err := ValidateAccessToken(ctx, opts.LDBaseURI, opts.LDAccessToken); err != nil { + return err + } + + var err error detached, err = disassociateEverywhere(ctx, clients, existing, logf) if err != nil { return err @@ -299,7 +352,7 @@ func registerMCPServer( ServiceDetails: &agenttypes.ServiceDetailsMemberMcpserver{ Value: agenttypes.MCPServerDetails{ Name: aws.String(MCPServerName), - Endpoint: aws.String(MCPServerEndpoint), + Endpoint: aws.String(endpoint), Description: aws.String("LaunchDarkly feature flag management MCP server"), AuthorizationConfig: &agenttypes.MCPServerAuthorizationConfigMemberBearerToken{ Value: agenttypes.MCPServerBearerTokenConfig{ @@ -311,18 +364,19 @@ func registerMCPServer( }, } - registration, err := registerWithRetry(ctx, clients, input, logf) + registration, err := registerWithRetry(ctx, clients, input, logf, endpoint) if err != nil { - if !strings.Contains(err.Error(), "already exists") { - return fmt.Errorf("unable to register the LaunchDarkly MCP server: %w", err) + if strings.Contains(err.Error(), "already exists") { + return fmt.Errorf( + "an MCP server named %s is already registered on this account at another endpoint, "+ + "so %s cannot be registered: pass --mcp-service-id to use that registration, "+ + "or rename it in the console", + MCPServerName, + endpoint, + ) } - return fmt.Errorf( - "an MCP server named %s already exists on this account but is not visible to ListServices, "+ - "so it cannot be associated automatically: associate it with agent space %s in the console", - MCPServerName, - result.AgentSpaceID, - ) + return registrationFailed(err, detached) } result.MCPServiceID = aws.ToString(registration.ServiceId) @@ -481,16 +535,19 @@ func operatorAppInput(agentSpaceID, roleARN string, opts SetupOptions) *devopsag return input } -func ensureAgentSpaceRole(ctx context.Context, client IAMAPI, accountID, region string) (string, error) { +func ensureAgentSpaceRole(ctx context.Context, client IAMAPI, accountID string, logf func(string, ...any)) (string, error) { trustPolicy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow",`+ `"Principal":{"Service":"aidevops.amazonaws.com"},"Action":"sts:AssumeRole",`+ `"Condition":{"StringEquals":{"aws:SourceAccount":"%[1]s"},`+ - `"ArnLike":{"aws:SourceArn":"arn:aws:aidevops:%[2]s:%[1]s:agentspace/*"}}}]}`, accountID, region) + `"ArnLike":{"aws:SourceArn":"arn:aws:aidevops:*:%[1]s:agentspace/*"}}}]}`, accountID) - arn, err := ensureRole(ctx, client, AgentSpaceRoleName, trustPolicy, AgentSpacePolicyARN) + arn, ours, err := ensureRole(ctx, client, AgentSpaceRoleName, trustPolicy, AgentSpacePolicyARN, logf) if err != nil { return "", err } + if !ours { + return arn, nil + } // Topology discovery creates the Resource Explorer service-linked role on // first use, which the managed policy does not allow. @@ -508,22 +565,37 @@ func ensureAgentSpaceRole(ctx context.Context, client IAMAPI, accountID, region return arn, nil } -func ensureOperatorAppRole(ctx context.Context, client IAMAPI, accountID, region string) (string, error) { +func ensureOperatorAppRole(ctx context.Context, client IAMAPI, accountID string, logf func(string, ...any)) (string, error) { // sts:TagSession is required because the managed policy scopes web app // access per space with an aws:PrincipalTag/AgentSpaceId condition. trustPolicy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow",`+ `"Principal":{"Service":"aidevops.amazonaws.com"},"Action":["sts:AssumeRole","sts:TagSession"],`+ `"Condition":{"StringEquals":{"aws:SourceAccount":"%[1]s"},`+ - `"ArnLike":{"aws:SourceArn":"arn:aws:aidevops:%[2]s:%[1]s:agentspace/*"}}}]}`, accountID, region) + `"ArnLike":{"aws:SourceArn":"arn:aws:aidevops:*:%[1]s:agentspace/*"}}}]}`, accountID) - return ensureRole(ctx, client, OperatorAppRoleName, trustPolicy, OperatorAppPolicyARN) + arn, _, err := ensureRole(ctx, client, OperatorAppRoleName, trustPolicy, OperatorAppPolicyARN, logf) + + return arn, err } -func ensureRole(ctx context.Context, client IAMAPI, name, trustPolicy, policyARN string) (string, error) { +// ensureRole creates the role, or reuses one that already has its name. The +// second return value reports whether the role is one this CLI manages: a role +// the customer created themselves is reused as it is, since rewriting its trust +// policy or attaching policies to it would change something setup does not own. +func ensureRole( + ctx context.Context, + client IAMAPI, + name, trustPolicy, policyARN string, + logf func(string, ...any), +) (string, bool, error) { var arn string created, err := client.CreateRole(ctx, &iam.CreateRoleInput{ RoleName: aws.String(name), AssumeRolePolicyDocument: aws.String(trustPolicy), + Tags: []iamtypes.Tag{{ + Key: aws.String(ManagedTagKey), + Value: aws.String(ManagedTagValue), + }}, }) switch { case err == nil: @@ -531,30 +603,66 @@ func ensureRole(ctx context.Context, client IAMAPI, name, trustPolicy, policyARN case isAlreadyExists(err): existing, getErr := client.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(name)}) if getErr != nil { - return "", fmt.Errorf("unable to read the existing %s role: %w", name, getErr) + return "", false, fmt.Errorf("unable to read the existing %s role: %w", name, getErr) } arn = aws.ToString(existing.Role.Arn) - // A role left over from a setup in another region trusts only that - // region's agent spaces, so the trust policy has to be refreshed. + managed, err := isManagedRole(ctx, client, name) + if err != nil { + return "", false, err + } + if !managed { + logf( + "Reusing %s as it is: it has no %s=%s tag, so it was not created by this CLI. "+ + "It must trust aidevops.amazonaws.com and allow %s", + arn, ManagedTagKey, ManagedTagValue, policyARN, + ) + + return arn, false, nil + } + + // Roles created before the trust policy covered every region are + // pinned to one region's agent spaces, so refresh it. if _, err := client.UpdateAssumeRolePolicy(ctx, &iam.UpdateAssumeRolePolicyInput{ RoleName: aws.String(name), PolicyDocument: aws.String(trustPolicy), }); err != nil { - return "", fmt.Errorf("unable to update the trust policy on the %s role: %w", name, err) + return "", false, fmt.Errorf("unable to update the trust policy on the %s role: %w", name, err) } default: - return "", fmt.Errorf("unable to create the %s role: %w", name, err) + return "", false, fmt.Errorf("unable to create the %s role: %w", name, err) } if _, err := client.AttachRolePolicy(ctx, &iam.AttachRolePolicyInput{ RoleName: aws.String(name), PolicyArn: aws.String(policyARN), }); err != nil { - return "", fmt.Errorf("unable to attach %s to %s: %w", policyARN, name, err) + return "", false, fmt.Errorf("unable to attach %s to %s: %w", policyARN, name, err) } - return arn, nil + return arn, true, nil +} + +func isManagedRole(ctx context.Context, client IAMAPI, name string) (bool, error) { + var marker *string + for { + tags, err := client.ListRoleTags(ctx, &iam.ListRoleTagsInput{ + RoleName: aws.String(name), + Marker: marker, + }) + if err != nil { + return false, fmt.Errorf("unable to read the tags on the %s role: %w", name, err) + } + for _, tag := range tags.Tags { + if aws.ToString(tag.Key) == ManagedTagKey && aws.ToString(tag.Value) == ManagedTagValue { + return true, nil + } + } + if !tags.IsTruncated { + return false, nil + } + marker = tags.Marker + } } func isAlreadyExists(err error) bool { diff --git a/internal/awsdevops/token.go b/internal/awsdevops/token.go new file mode 100644 index 000000000..b5028bd6d --- /dev/null +++ b/internal/awsdevops/token.go @@ -0,0 +1,57 @@ +package awsdevops + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// ErrInvalidAccessToken is the sentinel for a token LaunchDarkly rejects. +var ErrInvalidAccessToken = errors.New("LaunchDarkly rejected the access token") + +var ldHTTPClient = &http.Client{Timeout: 15 * time.Second} + +// ValidateAccessToken checks a token against the LaunchDarkly API before AWS +// stores it. AWS keeps the token write-only, so replacing a working +// registration with an unusable token cannot be undone. +func ValidateAccessToken(ctx context.Context, baseURI, token string) error { + if baseURI == "" { + baseURI = DefaultLDBaseURI + } + endpoint, err := url.JoinPath(baseURI, "api/v2/caller-identity") + if err != nil { + return fmt.Errorf("invalid LaunchDarkly base URI %q: %w", baseURI, err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", token) + + res, err := ldHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("unable to reach LaunchDarkly at %s to check the access token: %w", baseURI, err) + } + defer func() { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() + + switch { + case res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden: + return fmt.Errorf( + "%w. Create a service token at %s and pass it with --access-token", + ErrInvalidAccessToken, + AccessTokenURL(baseURI), + ) + case res.StatusCode >= http.StatusBadRequest: + return fmt.Errorf("unable to check the access token: LaunchDarkly returned %s", res.Status) + } + + return nil +} From 3654d6e1a162d3ca38aeea77b876cf2beb7d8897 Mon Sep 17 00:00:00 2001 From: "manderjaska@launchdarkly.com" Date: Wed, 7 Oct 2026 19:46:32 +0000 Subject: [PATCH 8/9] Register the MCP server with a service token setup creates --- README.md | 4 +- cmd/aws_devops_agent/setup.go | 25 ++++--- internal/awsdevops/awsdevops_test.go | 100 +++++++++++++++++++++++++-- internal/awsdevops/setup.go | 79 +++++++++++++++++---- internal/awsdevops/token.go | 80 +++++++++++++++++++++ 5 files changed, 258 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index c38b3a5b9..4c0bb83b5 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,9 @@ The AWS DevOps Agent is available in `us-east-1`, `us-west-2`, `ca-central-1`, ` `setup` is safe to re-run: it reuses the IAM roles, the agent space matching `--agent-space-name`, the account and MCP associations on it, and any LaunchDarkly MCP server already registered on the account. Pass `--new-agent-space` to create an additional agent space instead. The IAM roles are account-wide and trust the agent spaces in every region, so running `setup` in a second region leaves the first one working; a role named `DevOpsAgentRole-AgentSpace` or `DevOpsAgentRole-WebappAdmin` that `setup` did not create is reused untouched, and it has to trust `aidevops.amazonaws.com` itself. -`--access-token` is optional. The first available token — the flag, then `LD_ACCESS_TOKEN`, then the one already in your ldcli configuration — is registered with AWS as the bearer token the agent uses to call the LaunchDarkly MCP server, so it should be a [service token](https://launchdarkly.com/docs/home/account/api-create) whose permissions match what you want the agent to do. AWS keeps it until the MCP server is re-registered, so a session token written by `ldcli login` eventually expires and the agent then fails with `unauthorized` errors. Re-run with `--access-token --replace-mcp-token` to re-register an MCP server with a different token; the token is checked against LaunchDarkly first, because AWS stores it write-only and the previous registration cannot be restored. The MCP server already registered on the account is the one serving `https://mcp.launchdarkly.com/mcp/launchdarkly`, whatever it is named: pass `--mcp-endpoint` to connect a LaunchDarkly MCP server you host yourself, or `--mcp-service-id` to point at a specific registration. The agent may call `list-projects`, `list-flags` and `get-flag` without asking, and must ask for approval before calling `toggle-flag`. +The agent never uses your own token. `setup` creates a writer [service token](https://launchdarkly.com/docs/home/account/api-create) named after the agent space and registers that with AWS, so the agent's API calls are identifiable as the agent's and revoking them does not take your token with it. Creating it uses the first available token — `--access-token`, then `LD_ACCESS_TOKEN`, then the one in your ldcli configuration — which therefore has to be allowed to create access tokens. Pass `--mcp-access-token` to hand AWS a token you created yourself instead. Re-run with `--replace-mcp-token` to re-register the MCP server with a fresh token; a token passed with `--mcp-access-token` is checked against LaunchDarkly first, because AWS stores it write-only and the previous registration cannot be restored. The MCP server already registered on the account is the one serving `https://mcp.launchdarkly.com/mcp/launchdarkly`, whatever it is named: pass `--mcp-endpoint` to connect a LaunchDarkly MCP server you host yourself, or `--mcp-service-id` to point at a specific registration. The agent may call `list-projects`, `list-flags` and `get-flag` without asking, and must ask for approval before calling `toggle-flag`. -With no token configured at all, `setup` prints the LaunchDarkly page where you create a service token and connects the MCP server with the token you paste there, so you do not need one ready beforehand. Pressing Enter without a token skips the step, as does running without a terminal. +With no LaunchDarkly token configured at all, `setup` prints the page where you create a service token and connects the MCP server with the token you paste there, so you do not need one ready beforehand. Pressing Enter without a token skips the step, as does running without a terminal. `setup` ends by printing the operator app URL, `https://.aidevops.global.app.aws`, which is where you use the agent, together with the AWS console page that manages it. diff --git a/cmd/aws_devops_agent/setup.go b/cmd/aws_devops_agent/setup.go index bb117ee5f..f9a0d2c7e 100644 --- a/cmd/aws_devops_agent/setup.go +++ b/cmd/aws_devops_agent/setup.go @@ -25,6 +25,7 @@ const ( replaceMCPTokenFlag = "replace-mcp-token" mcpServiceIDFlag = "mcp-service-id" mcpEndpointFlag = "mcp-endpoint" + mcpAccessTokenFlag = "mcp-access-token" ) func NewSetupCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { @@ -38,11 +39,11 @@ Re-running is safe: setup reuses the agent space matching --agent-space-name along with anything already attached to it. The run ends with the operator app URL, where the agent is used. -The MCP server is connected with --access-token, or with the token in your -ldcli configuration when you do not pass one. With neither, setup asks for a -LaunchDarkly service token. AWS keeps that token and the agent acts as its -account and role, so a session token from 'ldcli login' stops working once it -expires.`, +The MCP server is connected with a LaunchDarkly service token setup creates +for it, so the agent's API calls are attributable to the agent rather than to +you. Creating that token uses --access-token or the token in your ldcli +configuration; pass --mcp-access-token to hand AWS a token you made yourself +instead.`, Args: cobra.NoArgs, PreRun: trackRun(analyticsTrackerFn), RunE: runSetup, @@ -58,9 +59,10 @@ expires.`, cmd.Flags().String(issuerURLFlag, "", "OIDC issuer URL, required when --auth-flow=idp") cmd.Flags().String(idpClientIDFlag, "", "OIDC client ID, required when --auth-flow=idp") cmd.Flags().String(idpClientSecretFlag, "", "OIDC client secret, required when --auth-flow=idp") - cmd.Flags().Bool(replaceMCPTokenFlag, false, "Re-register the LaunchDarkly MCP server so it uses the token passed with --access-token") + cmd.Flags().Bool(replaceMCPTokenFlag, false, "Re-register the LaunchDarkly MCP server so it uses a new token") cmd.Flags().String(mcpServiceIDFlag, "", "Registered MCP server to use, instead of the one registered at --mcp-endpoint") cmd.Flags().String(mcpEndpointFlag, "", "MCP endpoint to register and look the server up by. Defaults to "+awsdevops.MCPServerEndpoint) + cmd.Flags().String(mcpAccessTokenFlag, "", "LaunchDarkly token to register the MCP server with, instead of a service token setup creates") cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) @@ -82,11 +84,11 @@ func runSetup(cmd *cobra.Command, args []string) error { if warning := awsdevops.CheckAWSCLIVersion(cmd.Context()); warning != "" { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", warning) } - if opts.LDAccessToken != "" && accessTokenFromConfig(cmd) { + if opts.MCPAccessToken == "" && opts.LDAccessToken != "" && accessTokenFromConfig(cmd) { _, _ = fmt.Fprintln( cmd.ErrOrStderr(), - "Using the access token from your ldcli configuration for the MCP server. "+ - "Pass --access-token with a service token if that one expires", + "Creating the MCP server's service token with the access token from your ldcli "+ + "configuration. Pass --access-token if that one is expired", ) } if plaintext { @@ -137,9 +139,9 @@ func connectMCPServer( _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nCreate a LaunchDarkly service token at:\n %s\n\n", tokenURL) _, _ = fmt.Fprint(cmd.OutOrStdout(), "Paste the token, or press Enter to skip: ") - opts.LDAccessToken = readSecret(cmd.InOrStdin()) + opts.MCPAccessToken = readSecret(cmd.InOrStdin()) _, _ = fmt.Fprintln(cmd.OutOrStdout()) - if opts.LDAccessToken == "" { + if opts.MCPAccessToken == "" { return nil } @@ -161,6 +163,7 @@ func setupOptions(cmd *cobra.Command) (awsdevops.SetupOptions, error) { ReplaceMCPToken: mustBool(cmd, replaceMCPTokenFlag), MCPServiceID: mustString(cmd, mcpServiceIDFlag), MCPEndpoint: mustString(cmd, mcpEndpointFlag), + MCPAccessToken: mustString(cmd, mcpAccessTokenFlag), } switch opts.AuthFlow { diff --git a/internal/awsdevops/awsdevops_test.go b/internal/awsdevops/awsdevops_test.go index 1c57390d1..0faa09075 100644 --- a/internal/awsdevops/awsdevops_test.go +++ b/internal/awsdevops/awsdevops_test.go @@ -2,6 +2,7 @@ package awsdevops_test import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -254,6 +255,7 @@ func TestSetupCreatesRolesAgentSpaceAndAssociations(t *testing.T) { result, err := awsdevops.Setup(context.Background(), newTestClients(agent, iamClient), awsdevops.SetupOptions{ AgentSpaceName: "launchdarkly", AuthFlow: "iam", + LDBaseURI: stubLaunchDarkly(t, http.StatusOK), LDAccessToken: "api-token", }) require.NoError(t, err) @@ -392,15 +394,34 @@ func mcpService(serviceID, name, endpoint string) agenttypes.RegisteredService { } } -// stubLaunchDarkly stands in for the LaunchDarkly API the access token is -// checked against, and returns its base URI. +// mintedToken is the service token stubLaunchDarkly issues. +const mintedToken = "minted-service-token" + +// stubLaunchDarkly stands in for the LaunchDarkly API that checks access +// tokens and issues service tokens, and returns its base URI. status is what +// it answers both with, so a test can make the caller's token unusable. func stubLaunchDarkly(t *testing.T, status int) string { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v2/caller-identity", r.URL.Path) assert.Equal(t, "api-token", r.Header.Get("Authorization")) - w.WriteHeader(status) + + switch { + case r.URL.Path == "/api/v2/caller-identity": + w.WriteHeader(status) + case r.URL.Path == "/api/v2/tokens" && status == http.StatusOK: + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, true, body["serviceToken"]) + assert.Contains(t, body["name"], "AWS DevOps Agent") + + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"_id":"token-1","name":"` + body["name"].(string) + `","token":"` + mintedToken + `"}`)) + case r.URL.Path == "/api/v2/tokens": + w.WriteHeader(status) + default: + t.Errorf("unexpected request to %s", r.URL.Path) + } })) t.Cleanup(server.Close) @@ -555,6 +576,7 @@ func TestSetupIgnoresAnMCPServerAtAnotherEndpoint(t *testing.T) { result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ AgentSpaceID: "space-1", AuthFlow: "iam", + LDBaseURI: stubLaunchDarkly(t, http.StatusOK), LDAccessToken: "api-token", ReplaceMCPToken: true, }) @@ -589,6 +611,7 @@ func TestSetupRegistersAtTheEndpointItIsGiven(t *testing.T) { _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ AgentSpaceID: "space-1", AuthFlow: "iam", + LDBaseURI: stubLaunchDarkly(t, http.StatusOK), LDAccessToken: "api-token", MCPEndpoint: "https://mcp.example.internal/mcp", }) @@ -600,6 +623,75 @@ func TestSetupRegistersAtTheEndpointItIsGiven(t *testing.T) { assert.Equal(t, "https://mcp.example.internal/mcp", aws.ToString(details.Value.Endpoint)) } +// registeredToken is the bearer token AWS was told to store for the MCP +// server registered by the only RegisterService call. +func registeredToken(t *testing.T, agent *fakeAgent) string { + t.Helper() + + require.Len(t, agent.registerInputs, 1) + details, ok := agent.registerInputs[0].ServiceDetails.(*agenttypes.ServiceDetailsMemberMcpserver) + require.True(t, ok) + auth, ok := details.Value.AuthorizationConfig.(*agenttypes.MCPServerAuthorizationConfigMemberBearerToken) + require.True(t, ok) + + return aws.ToString(auth.Value.TokenValue) +} + +func TestSetupRegistersAServiceTokenItCreatesRatherThanTheCallers(t *testing.T) { + agent := &fakeAgent{} + + _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceID: "space-1", + AuthFlow: "iam", + LDBaseURI: stubLaunchDarkly(t, http.StatusOK), + LDAccessToken: "api-token", + }) + require.NoError(t, err) + + assert.Equal(t, mintedToken, registeredToken(t, agent)) +} + +func TestSetupRegistersTheTokenItIsGivenWithoutCreatingOne(t *testing.T) { + agent := &fakeAgent{} + baseURI := stubLaunchDarkly(t, http.StatusInternalServerError) + + _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceID: "space-1", + AuthFlow: "iam", + LDBaseURI: baseURI, + LDAccessToken: "api-token", + MCPAccessToken: "my-own-token", + }) + require.NoError(t, err) + + // The stub fails every request, so reaching AWS at all shows setup never + // called LaunchDarkly. + assert.Equal(t, "my-own-token", registeredToken(t, agent)) +} + +func TestSetupKeepsTheMCPServerWhenItCannotCreateAServiceToken(t *testing.T) { + agent := &fakeAgent{ + services: []agenttypes.RegisteredService{launchDarklyMCPService("mcp-existing")}, + associations: []agenttypes.Association{{ + AssociationId: aws.String("assoc-mcp-existing"), + ServiceId: aws.String("mcp-existing"), + }}, + } + + _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ + AgentSpaceID: "space-1", + AuthFlow: "iam", + LDBaseURI: stubLaunchDarkly(t, http.StatusForbidden), + LDAccessToken: "api-token", + ReplaceMCPToken: true, + }) + + assert.ErrorContains(t, err, "not allowed to create access tokens") + assert.Empty(t, agent.disassociatedIDs) + assert.Empty(t, agent.deregisteredIDs) + assert.NotContains(t, agent.calls, "RegisterService") +} + func TestStatusFollowsEveryListPage(t *testing.T) { agent := &fakeAgent{ paginate: true, diff --git a/internal/awsdevops/setup.go b/internal/awsdevops/setup.go index a270a39d9..d5b2719d0 100644 --- a/internal/awsdevops/setup.go +++ b/internal/awsdevops/setup.go @@ -67,6 +67,11 @@ type SetupOptions struct { LDAccessToken string ReplaceMCPToken bool + // MCPAccessToken, when set, is the token AWS stores for the MCP server. + // Without it setup creates a service token of its own, so the agent's API + // calls are not attributed to whoever ran the command. + MCPAccessToken string + // MCPServiceID, when set, is the registration to reuse or replace instead // of the one found by endpoint. MCPServiceID string @@ -171,6 +176,12 @@ func Setup(ctx context.Context, clients Clients, opts SetupOptions) (SetupResult return result, nil } +// hasMCPToken reports whether setup can produce a token to register with, +// either one given to it or one it can create with the caller's credentials. +func (o SetupOptions) hasMCPToken() bool { + return o.MCPAccessToken != "" || o.LDAccessToken != "" +} + // mcpEndpoint is the endpoint the MCP server is registered at and found by. func (o SetupOptions) mcpEndpoint() string { if o.MCPEndpoint != "" { @@ -292,6 +303,48 @@ func RegisterMCPServer(ctx context.Context, clients Clients, opts SetupOptions, return registerMCPServer(ctx, clients, opts, result, logf) } +// resolveMCPToken returns the token AWS stores for the MCP server: the one +// the caller supplied, or a service token created for this agent space so the +// agent's API calls are identifiable in LaunchDarkly and can be revoked on +// their own. replacing asks for a supplied token to be checked first, since +// the registration it replaces cannot be restored. +func resolveMCPToken( + ctx context.Context, + opts SetupOptions, + agentSpaceID string, + replacing bool, + logf func(string, ...any), +) (string, error) { + if opts.MCPAccessToken != "" { + if replacing { + if err := ValidateAccessToken(ctx, opts.LDBaseURI, opts.MCPAccessToken); err != nil { + return "", err + } + } + + return opts.MCPAccessToken, nil + } + + name := "AWS DevOps Agent" + if agentSpaceID != "" { + name = fmt.Sprintf("%s (agent space %s)", name, agentSpaceID) + } + + created, err := CreateServiceToken(ctx, opts.LDBaseURI, opts.LDAccessToken, name) + if err != nil { + return "", err + } + logf( + "Created the LaunchDarkly service token %q (%s) for the MCP server, so the agent acts as "+ + "itself. Revoke it at %s to cut off the agent's access", + created.Name, + created.ID, + AccessTokenURL(opts.LDBaseURI), + ) + + return created.Value, nil +} + func registerMCPServer( ctx context.Context, clients Clients, @@ -308,32 +361,32 @@ func registerMCPServer( } existing = found } - if existing == "" && opts.LDAccessToken == "" { + if existing == "" && !opts.hasMCPToken() { // Nothing to reuse and no token to register with; the caller collects // one and calls RegisterMCPServer afterwards. return nil } - if existing != "" && (!opts.ReplaceMCPToken || opts.LDAccessToken == "") { + if existing != "" && (!opts.ReplaceMCPToken || !opts.hasMCPToken()) { result.MCPServiceID = existing logf( "Reusing the %s MCP server already registered on this account (%s); it keeps the access "+ - "token it was registered with, so pass --access-token --replace-mcp-token to change it", + "token it was registered with, so pass --replace-mcp-token to issue it a new one", MCPServerName, existing, ) return associateMCPServer(ctx, clients, result, logf) } + // AWS stores the token write-only, so the old registration cannot be + // restored once it is deregistered. Resolve the new token while the + // working registration is still in place. + token, err := resolveMCPToken(ctx, opts, result.AgentSpaceID, existing != "", logf) + if err != nil { + return err + } + var detached []string if existing != "" { - // AWS stores the token write-only, so the old registration cannot be - // restored once it is deregistered. Reject a token LaunchDarkly will - // not accept while the working registration is still in place. - if err := ValidateAccessToken(ctx, opts.LDBaseURI, opts.LDAccessToken); err != nil { - return err - } - - var err error detached, err = disassociateEverywhere(ctx, clients, existing, logf) if err != nil { return err @@ -357,7 +410,7 @@ func registerMCPServer( AuthorizationConfig: &agenttypes.MCPServerAuthorizationConfigMemberBearerToken{ Value: agenttypes.MCPServerBearerTokenConfig{ TokenName: aws.String("launchdarkly-api-token"), - TokenValue: aws.String(opts.LDAccessToken), + TokenValue: aws.String(token), }, }, }, @@ -365,7 +418,7 @@ func registerMCPServer( } registration, err := registerWithRetry(ctx, clients, input, logf, endpoint) - if err != nil { + if err != nil { //nolint:nestif // the two failure modes need different guidance if strings.Contains(err.Error(), "already exists") { return fmt.Errorf( "an MCP server named %s is already registered on this account at another endpoint, "+ diff --git a/internal/awsdevops/token.go b/internal/awsdevops/token.go index b5028bd6d..f73e1b3f9 100644 --- a/internal/awsdevops/token.go +++ b/internal/awsdevops/token.go @@ -1,7 +1,9 @@ package awsdevops import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -10,6 +12,11 @@ import ( "time" ) +// ServiceTokenRole is the base role of the service tokens this command +// creates. The agent reads projects and flags and toggles them, which the +// writer role covers without granting account administration. +const ServiceTokenRole = "writer" + // ErrInvalidAccessToken is the sentinel for a token LaunchDarkly rejects. var ErrInvalidAccessToken = errors.New("LaunchDarkly rejected the access token") @@ -55,3 +62,76 @@ func ValidateAccessToken(ctx context.Context, baseURI, token string) error { return nil } + +// ServiceToken is a token LaunchDarkly issued to this command. +type ServiceToken struct { + ID string `json:"_id"` + Name string `json:"name"` + Value string `json:"token"` +} + +// CreateServiceToken asks LaunchDarkly for a service token dedicated to the +// MCP server, so the agent's API calls are attributable to it rather than to +// the member who ran setup, and revoking the agent's access never takes the +// CLI's own token with it. +func CreateServiceToken(ctx context.Context, baseURI, token, name string) (ServiceToken, error) { + if baseURI == "" { + baseURI = DefaultLDBaseURI + } + endpoint, err := url.JoinPath(baseURI, "api/v2/tokens") + if err != nil { + return ServiceToken{}, fmt.Errorf("invalid LaunchDarkly base URI %q: %w", baseURI, err) + } + + body, err := json.Marshal(map[string]any{ + "name": name, + "description": "Created by the LaunchDarkly CLI for the AWS DevOps Agent MCP server", + "role": ServiceTokenRole, + "serviceToken": true, + }) + if err != nil { + return ServiceToken{}, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return ServiceToken{}, err + } + req.Header.Set("Authorization", token) + req.Header.Set("Content-Type", "application/json") + + res, err := ldHTTPClient.Do(req) + if err != nil { + return ServiceToken{}, fmt.Errorf("unable to reach LaunchDarkly at %s to create a service token: %w", baseURI, err) + } + defer func() { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() + + switch { + case res.StatusCode == http.StatusUnauthorized: + return ServiceToken{}, fmt.Errorf( + "%w. Pass --access-token with a token that can create service tokens", + ErrInvalidAccessToken, + ) + case res.StatusCode == http.StatusForbidden: + return ServiceToken{}, fmt.Errorf( + "your LaunchDarkly token is not allowed to create access tokens. Ask an administrator "+ + "for that permission, or create a service token at %s and pass it with --mcp-access-token", + AccessTokenURL(baseURI), + ) + case res.StatusCode >= http.StatusBadRequest: + return ServiceToken{}, fmt.Errorf("unable to create a LaunchDarkly service token: LaunchDarkly returned %s", res.Status) + } + + var created ServiceToken + if err := json.NewDecoder(res.Body).Decode(&created); err != nil { + return ServiceToken{}, fmt.Errorf("unable to read the service token LaunchDarkly created: %w", err) + } + if created.Value == "" { + return ServiceToken{}, errors.New("LaunchDarkly created a service token without returning its value") + } + + return created, nil +} From 03b6e0a2d8950bde09f12d3c4710f45a14e4a9c6 Mon Sep 17 00:00:00 2001 From: "manderjaska@launchdarkly.com" Date: Fri, 9 Oct 2026 15:26:11 +0000 Subject: [PATCH 9/9] Revert "Register the MCP server with a service token setup creates" This reverts commit 3654d6e1a162d3ca38aeea77b876cf2beb7d8897. --- README.md | 4 +- cmd/aws_devops_agent/setup.go | 25 +++---- internal/awsdevops/awsdevops_test.go | 100 ++------------------------- internal/awsdevops/setup.go | 79 ++++----------------- internal/awsdevops/token.go | 80 --------------------- 5 files changed, 30 insertions(+), 258 deletions(-) diff --git a/README.md b/README.md index 4c0bb83b5..c38b3a5b9 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,9 @@ The AWS DevOps Agent is available in `us-east-1`, `us-west-2`, `ca-central-1`, ` `setup` is safe to re-run: it reuses the IAM roles, the agent space matching `--agent-space-name`, the account and MCP associations on it, and any LaunchDarkly MCP server already registered on the account. Pass `--new-agent-space` to create an additional agent space instead. The IAM roles are account-wide and trust the agent spaces in every region, so running `setup` in a second region leaves the first one working; a role named `DevOpsAgentRole-AgentSpace` or `DevOpsAgentRole-WebappAdmin` that `setup` did not create is reused untouched, and it has to trust `aidevops.amazonaws.com` itself. -The agent never uses your own token. `setup` creates a writer [service token](https://launchdarkly.com/docs/home/account/api-create) named after the agent space and registers that with AWS, so the agent's API calls are identifiable as the agent's and revoking them does not take your token with it. Creating it uses the first available token — `--access-token`, then `LD_ACCESS_TOKEN`, then the one in your ldcli configuration — which therefore has to be allowed to create access tokens. Pass `--mcp-access-token` to hand AWS a token you created yourself instead. Re-run with `--replace-mcp-token` to re-register the MCP server with a fresh token; a token passed with `--mcp-access-token` is checked against LaunchDarkly first, because AWS stores it write-only and the previous registration cannot be restored. The MCP server already registered on the account is the one serving `https://mcp.launchdarkly.com/mcp/launchdarkly`, whatever it is named: pass `--mcp-endpoint` to connect a LaunchDarkly MCP server you host yourself, or `--mcp-service-id` to point at a specific registration. The agent may call `list-projects`, `list-flags` and `get-flag` without asking, and must ask for approval before calling `toggle-flag`. +`--access-token` is optional. The first available token — the flag, then `LD_ACCESS_TOKEN`, then the one already in your ldcli configuration — is registered with AWS as the bearer token the agent uses to call the LaunchDarkly MCP server, so it should be a [service token](https://launchdarkly.com/docs/home/account/api-create) whose permissions match what you want the agent to do. AWS keeps it until the MCP server is re-registered, so a session token written by `ldcli login` eventually expires and the agent then fails with `unauthorized` errors. Re-run with `--access-token --replace-mcp-token` to re-register an MCP server with a different token; the token is checked against LaunchDarkly first, because AWS stores it write-only and the previous registration cannot be restored. The MCP server already registered on the account is the one serving `https://mcp.launchdarkly.com/mcp/launchdarkly`, whatever it is named: pass `--mcp-endpoint` to connect a LaunchDarkly MCP server you host yourself, or `--mcp-service-id` to point at a specific registration. The agent may call `list-projects`, `list-flags` and `get-flag` without asking, and must ask for approval before calling `toggle-flag`. -With no LaunchDarkly token configured at all, `setup` prints the page where you create a service token and connects the MCP server with the token you paste there, so you do not need one ready beforehand. Pressing Enter without a token skips the step, as does running without a terminal. +With no token configured at all, `setup` prints the LaunchDarkly page where you create a service token and connects the MCP server with the token you paste there, so you do not need one ready beforehand. Pressing Enter without a token skips the step, as does running without a terminal. `setup` ends by printing the operator app URL, `https://.aidevops.global.app.aws`, which is where you use the agent, together with the AWS console page that manages it. diff --git a/cmd/aws_devops_agent/setup.go b/cmd/aws_devops_agent/setup.go index f9a0d2c7e..bb117ee5f 100644 --- a/cmd/aws_devops_agent/setup.go +++ b/cmd/aws_devops_agent/setup.go @@ -25,7 +25,6 @@ const ( replaceMCPTokenFlag = "replace-mcp-token" mcpServiceIDFlag = "mcp-service-id" mcpEndpointFlag = "mcp-endpoint" - mcpAccessTokenFlag = "mcp-access-token" ) func NewSetupCmd(analyticsTrackerFn analytics.TrackerFn) *cobra.Command { @@ -39,11 +38,11 @@ Re-running is safe: setup reuses the agent space matching --agent-space-name along with anything already attached to it. The run ends with the operator app URL, where the agent is used. -The MCP server is connected with a LaunchDarkly service token setup creates -for it, so the agent's API calls are attributable to the agent rather than to -you. Creating that token uses --access-token or the token in your ldcli -configuration; pass --mcp-access-token to hand AWS a token you made yourself -instead.`, +The MCP server is connected with --access-token, or with the token in your +ldcli configuration when you do not pass one. With neither, setup asks for a +LaunchDarkly service token. AWS keeps that token and the agent acts as its +account and role, so a session token from 'ldcli login' stops working once it +expires.`, Args: cobra.NoArgs, PreRun: trackRun(analyticsTrackerFn), RunE: runSetup, @@ -59,10 +58,9 @@ instead.`, cmd.Flags().String(issuerURLFlag, "", "OIDC issuer URL, required when --auth-flow=idp") cmd.Flags().String(idpClientIDFlag, "", "OIDC client ID, required when --auth-flow=idp") cmd.Flags().String(idpClientSecretFlag, "", "OIDC client secret, required when --auth-flow=idp") - cmd.Flags().Bool(replaceMCPTokenFlag, false, "Re-register the LaunchDarkly MCP server so it uses a new token") + cmd.Flags().Bool(replaceMCPTokenFlag, false, "Re-register the LaunchDarkly MCP server so it uses the token passed with --access-token") cmd.Flags().String(mcpServiceIDFlag, "", "Registered MCP server to use, instead of the one registered at --mcp-endpoint") cmd.Flags().String(mcpEndpointFlag, "", "MCP endpoint to register and look the server up by. Defaults to "+awsdevops.MCPServerEndpoint) - cmd.Flags().String(mcpAccessTokenFlag, "", "LaunchDarkly token to register the MCP server with, instead of a service token setup creates") cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) @@ -84,11 +82,11 @@ func runSetup(cmd *cobra.Command, args []string) error { if warning := awsdevops.CheckAWSCLIVersion(cmd.Context()); warning != "" { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", warning) } - if opts.MCPAccessToken == "" && opts.LDAccessToken != "" && accessTokenFromConfig(cmd) { + if opts.LDAccessToken != "" && accessTokenFromConfig(cmd) { _, _ = fmt.Fprintln( cmd.ErrOrStderr(), - "Creating the MCP server's service token with the access token from your ldcli "+ - "configuration. Pass --access-token if that one is expired", + "Using the access token from your ldcli configuration for the MCP server. "+ + "Pass --access-token with a service token if that one expires", ) } if plaintext { @@ -139,9 +137,9 @@ func connectMCPServer( _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nCreate a LaunchDarkly service token at:\n %s\n\n", tokenURL) _, _ = fmt.Fprint(cmd.OutOrStdout(), "Paste the token, or press Enter to skip: ") - opts.MCPAccessToken = readSecret(cmd.InOrStdin()) + opts.LDAccessToken = readSecret(cmd.InOrStdin()) _, _ = fmt.Fprintln(cmd.OutOrStdout()) - if opts.MCPAccessToken == "" { + if opts.LDAccessToken == "" { return nil } @@ -163,7 +161,6 @@ func setupOptions(cmd *cobra.Command) (awsdevops.SetupOptions, error) { ReplaceMCPToken: mustBool(cmd, replaceMCPTokenFlag), MCPServiceID: mustString(cmd, mcpServiceIDFlag), MCPEndpoint: mustString(cmd, mcpEndpointFlag), - MCPAccessToken: mustString(cmd, mcpAccessTokenFlag), } switch opts.AuthFlow { diff --git a/internal/awsdevops/awsdevops_test.go b/internal/awsdevops/awsdevops_test.go index 0faa09075..1c57390d1 100644 --- a/internal/awsdevops/awsdevops_test.go +++ b/internal/awsdevops/awsdevops_test.go @@ -2,7 +2,6 @@ package awsdevops_test import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -255,7 +254,6 @@ func TestSetupCreatesRolesAgentSpaceAndAssociations(t *testing.T) { result, err := awsdevops.Setup(context.Background(), newTestClients(agent, iamClient), awsdevops.SetupOptions{ AgentSpaceName: "launchdarkly", AuthFlow: "iam", - LDBaseURI: stubLaunchDarkly(t, http.StatusOK), LDAccessToken: "api-token", }) require.NoError(t, err) @@ -394,34 +392,15 @@ func mcpService(serviceID, name, endpoint string) agenttypes.RegisteredService { } } -// mintedToken is the service token stubLaunchDarkly issues. -const mintedToken = "minted-service-token" - -// stubLaunchDarkly stands in for the LaunchDarkly API that checks access -// tokens and issues service tokens, and returns its base URI. status is what -// it answers both with, so a test can make the caller's token unusable. +// stubLaunchDarkly stands in for the LaunchDarkly API the access token is +// checked against, and returns its base URI. func stubLaunchDarkly(t *testing.T, status int) string { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/caller-identity", r.URL.Path) assert.Equal(t, "api-token", r.Header.Get("Authorization")) - - switch { - case r.URL.Path == "/api/v2/caller-identity": - w.WriteHeader(status) - case r.URL.Path == "/api/v2/tokens" && status == http.StatusOK: - var body map[string]any - require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.Equal(t, true, body["serviceToken"]) - assert.Contains(t, body["name"], "AWS DevOps Agent") - - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"_id":"token-1","name":"` + body["name"].(string) + `","token":"` + mintedToken + `"}`)) - case r.URL.Path == "/api/v2/tokens": - w.WriteHeader(status) - default: - t.Errorf("unexpected request to %s", r.URL.Path) - } + w.WriteHeader(status) })) t.Cleanup(server.Close) @@ -576,7 +555,6 @@ func TestSetupIgnoresAnMCPServerAtAnotherEndpoint(t *testing.T) { result, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ AgentSpaceID: "space-1", AuthFlow: "iam", - LDBaseURI: stubLaunchDarkly(t, http.StatusOK), LDAccessToken: "api-token", ReplaceMCPToken: true, }) @@ -611,7 +589,6 @@ func TestSetupRegistersAtTheEndpointItIsGiven(t *testing.T) { _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ AgentSpaceID: "space-1", AuthFlow: "iam", - LDBaseURI: stubLaunchDarkly(t, http.StatusOK), LDAccessToken: "api-token", MCPEndpoint: "https://mcp.example.internal/mcp", }) @@ -623,75 +600,6 @@ func TestSetupRegistersAtTheEndpointItIsGiven(t *testing.T) { assert.Equal(t, "https://mcp.example.internal/mcp", aws.ToString(details.Value.Endpoint)) } -// registeredToken is the bearer token AWS was told to store for the MCP -// server registered by the only RegisterService call. -func registeredToken(t *testing.T, agent *fakeAgent) string { - t.Helper() - - require.Len(t, agent.registerInputs, 1) - details, ok := agent.registerInputs[0].ServiceDetails.(*agenttypes.ServiceDetailsMemberMcpserver) - require.True(t, ok) - auth, ok := details.Value.AuthorizationConfig.(*agenttypes.MCPServerAuthorizationConfigMemberBearerToken) - require.True(t, ok) - - return aws.ToString(auth.Value.TokenValue) -} - -func TestSetupRegistersAServiceTokenItCreatesRatherThanTheCallers(t *testing.T) { - agent := &fakeAgent{} - - _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ - AgentSpaceID: "space-1", - AuthFlow: "iam", - LDBaseURI: stubLaunchDarkly(t, http.StatusOK), - LDAccessToken: "api-token", - }) - require.NoError(t, err) - - assert.Equal(t, mintedToken, registeredToken(t, agent)) -} - -func TestSetupRegistersTheTokenItIsGivenWithoutCreatingOne(t *testing.T) { - agent := &fakeAgent{} - baseURI := stubLaunchDarkly(t, http.StatusInternalServerError) - - _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ - AgentSpaceID: "space-1", - AuthFlow: "iam", - LDBaseURI: baseURI, - LDAccessToken: "api-token", - MCPAccessToken: "my-own-token", - }) - require.NoError(t, err) - - // The stub fails every request, so reaching AWS at all shows setup never - // called LaunchDarkly. - assert.Equal(t, "my-own-token", registeredToken(t, agent)) -} - -func TestSetupKeepsTheMCPServerWhenItCannotCreateAServiceToken(t *testing.T) { - agent := &fakeAgent{ - services: []agenttypes.RegisteredService{launchDarklyMCPService("mcp-existing")}, - associations: []agenttypes.Association{{ - AssociationId: aws.String("assoc-mcp-existing"), - ServiceId: aws.String("mcp-existing"), - }}, - } - - _, err := awsdevops.Setup(context.Background(), newTestClients(agent, newFakeIAM()), awsdevops.SetupOptions{ - AgentSpaceID: "space-1", - AuthFlow: "iam", - LDBaseURI: stubLaunchDarkly(t, http.StatusForbidden), - LDAccessToken: "api-token", - ReplaceMCPToken: true, - }) - - assert.ErrorContains(t, err, "not allowed to create access tokens") - assert.Empty(t, agent.disassociatedIDs) - assert.Empty(t, agent.deregisteredIDs) - assert.NotContains(t, agent.calls, "RegisterService") -} - func TestStatusFollowsEveryListPage(t *testing.T) { agent := &fakeAgent{ paginate: true, diff --git a/internal/awsdevops/setup.go b/internal/awsdevops/setup.go index d5b2719d0..a270a39d9 100644 --- a/internal/awsdevops/setup.go +++ b/internal/awsdevops/setup.go @@ -67,11 +67,6 @@ type SetupOptions struct { LDAccessToken string ReplaceMCPToken bool - // MCPAccessToken, when set, is the token AWS stores for the MCP server. - // Without it setup creates a service token of its own, so the agent's API - // calls are not attributed to whoever ran the command. - MCPAccessToken string - // MCPServiceID, when set, is the registration to reuse or replace instead // of the one found by endpoint. MCPServiceID string @@ -176,12 +171,6 @@ func Setup(ctx context.Context, clients Clients, opts SetupOptions) (SetupResult return result, nil } -// hasMCPToken reports whether setup can produce a token to register with, -// either one given to it or one it can create with the caller's credentials. -func (o SetupOptions) hasMCPToken() bool { - return o.MCPAccessToken != "" || o.LDAccessToken != "" -} - // mcpEndpoint is the endpoint the MCP server is registered at and found by. func (o SetupOptions) mcpEndpoint() string { if o.MCPEndpoint != "" { @@ -303,48 +292,6 @@ func RegisterMCPServer(ctx context.Context, clients Clients, opts SetupOptions, return registerMCPServer(ctx, clients, opts, result, logf) } -// resolveMCPToken returns the token AWS stores for the MCP server: the one -// the caller supplied, or a service token created for this agent space so the -// agent's API calls are identifiable in LaunchDarkly and can be revoked on -// their own. replacing asks for a supplied token to be checked first, since -// the registration it replaces cannot be restored. -func resolveMCPToken( - ctx context.Context, - opts SetupOptions, - agentSpaceID string, - replacing bool, - logf func(string, ...any), -) (string, error) { - if opts.MCPAccessToken != "" { - if replacing { - if err := ValidateAccessToken(ctx, opts.LDBaseURI, opts.MCPAccessToken); err != nil { - return "", err - } - } - - return opts.MCPAccessToken, nil - } - - name := "AWS DevOps Agent" - if agentSpaceID != "" { - name = fmt.Sprintf("%s (agent space %s)", name, agentSpaceID) - } - - created, err := CreateServiceToken(ctx, opts.LDBaseURI, opts.LDAccessToken, name) - if err != nil { - return "", err - } - logf( - "Created the LaunchDarkly service token %q (%s) for the MCP server, so the agent acts as "+ - "itself. Revoke it at %s to cut off the agent's access", - created.Name, - created.ID, - AccessTokenURL(opts.LDBaseURI), - ) - - return created.Value, nil -} - func registerMCPServer( ctx context.Context, clients Clients, @@ -361,32 +308,32 @@ func registerMCPServer( } existing = found } - if existing == "" && !opts.hasMCPToken() { + if existing == "" && opts.LDAccessToken == "" { // Nothing to reuse and no token to register with; the caller collects // one and calls RegisterMCPServer afterwards. return nil } - if existing != "" && (!opts.ReplaceMCPToken || !opts.hasMCPToken()) { + if existing != "" && (!opts.ReplaceMCPToken || opts.LDAccessToken == "") { result.MCPServiceID = existing logf( "Reusing the %s MCP server already registered on this account (%s); it keeps the access "+ - "token it was registered with, so pass --replace-mcp-token to issue it a new one", + "token it was registered with, so pass --access-token --replace-mcp-token to change it", MCPServerName, existing, ) return associateMCPServer(ctx, clients, result, logf) } - // AWS stores the token write-only, so the old registration cannot be - // restored once it is deregistered. Resolve the new token while the - // working registration is still in place. - token, err := resolveMCPToken(ctx, opts, result.AgentSpaceID, existing != "", logf) - if err != nil { - return err - } - var detached []string if existing != "" { + // AWS stores the token write-only, so the old registration cannot be + // restored once it is deregistered. Reject a token LaunchDarkly will + // not accept while the working registration is still in place. + if err := ValidateAccessToken(ctx, opts.LDBaseURI, opts.LDAccessToken); err != nil { + return err + } + + var err error detached, err = disassociateEverywhere(ctx, clients, existing, logf) if err != nil { return err @@ -410,7 +357,7 @@ func registerMCPServer( AuthorizationConfig: &agenttypes.MCPServerAuthorizationConfigMemberBearerToken{ Value: agenttypes.MCPServerBearerTokenConfig{ TokenName: aws.String("launchdarkly-api-token"), - TokenValue: aws.String(token), + TokenValue: aws.String(opts.LDAccessToken), }, }, }, @@ -418,7 +365,7 @@ func registerMCPServer( } registration, err := registerWithRetry(ctx, clients, input, logf, endpoint) - if err != nil { //nolint:nestif // the two failure modes need different guidance + if err != nil { if strings.Contains(err.Error(), "already exists") { return fmt.Errorf( "an MCP server named %s is already registered on this account at another endpoint, "+ diff --git a/internal/awsdevops/token.go b/internal/awsdevops/token.go index f73e1b3f9..b5028bd6d 100644 --- a/internal/awsdevops/token.go +++ b/internal/awsdevops/token.go @@ -1,9 +1,7 @@ package awsdevops import ( - "bytes" "context" - "encoding/json" "errors" "fmt" "io" @@ -12,11 +10,6 @@ import ( "time" ) -// ServiceTokenRole is the base role of the service tokens this command -// creates. The agent reads projects and flags and toggles them, which the -// writer role covers without granting account administration. -const ServiceTokenRole = "writer" - // ErrInvalidAccessToken is the sentinel for a token LaunchDarkly rejects. var ErrInvalidAccessToken = errors.New("LaunchDarkly rejected the access token") @@ -62,76 +55,3 @@ func ValidateAccessToken(ctx context.Context, baseURI, token string) error { return nil } - -// ServiceToken is a token LaunchDarkly issued to this command. -type ServiceToken struct { - ID string `json:"_id"` - Name string `json:"name"` - Value string `json:"token"` -} - -// CreateServiceToken asks LaunchDarkly for a service token dedicated to the -// MCP server, so the agent's API calls are attributable to it rather than to -// the member who ran setup, and revoking the agent's access never takes the -// CLI's own token with it. -func CreateServiceToken(ctx context.Context, baseURI, token, name string) (ServiceToken, error) { - if baseURI == "" { - baseURI = DefaultLDBaseURI - } - endpoint, err := url.JoinPath(baseURI, "api/v2/tokens") - if err != nil { - return ServiceToken{}, fmt.Errorf("invalid LaunchDarkly base URI %q: %w", baseURI, err) - } - - body, err := json.Marshal(map[string]any{ - "name": name, - "description": "Created by the LaunchDarkly CLI for the AWS DevOps Agent MCP server", - "role": ServiceTokenRole, - "serviceToken": true, - }) - if err != nil { - return ServiceToken{}, err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return ServiceToken{}, err - } - req.Header.Set("Authorization", token) - req.Header.Set("Content-Type", "application/json") - - res, err := ldHTTPClient.Do(req) - if err != nil { - return ServiceToken{}, fmt.Errorf("unable to reach LaunchDarkly at %s to create a service token: %w", baseURI, err) - } - defer func() { - _, _ = io.Copy(io.Discard, res.Body) - _ = res.Body.Close() - }() - - switch { - case res.StatusCode == http.StatusUnauthorized: - return ServiceToken{}, fmt.Errorf( - "%w. Pass --access-token with a token that can create service tokens", - ErrInvalidAccessToken, - ) - case res.StatusCode == http.StatusForbidden: - return ServiceToken{}, fmt.Errorf( - "your LaunchDarkly token is not allowed to create access tokens. Ask an administrator "+ - "for that permission, or create a service token at %s and pass it with --mcp-access-token", - AccessTokenURL(baseURI), - ) - case res.StatusCode >= http.StatusBadRequest: - return ServiceToken{}, fmt.Errorf("unable to create a LaunchDarkly service token: LaunchDarkly returned %s", res.Status) - } - - var created ServiceToken - if err := json.NewDecoder(res.Body).Decode(&created); err != nil { - return ServiceToken{}, fmt.Errorf("unable to read the service token LaunchDarkly created: %w", err) - } - if created.Value == "" { - return ServiceToken{}, errors.New("LaunchDarkly created a service token without returning its value") - } - - return created, nil -}