diff --git a/README.md b/README.md index 621da7a12..c38b3a5b9 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,32 @@ 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`, `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. 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`. + +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. + +`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 new file mode 100644 index 000000000..45afc3975 --- /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" + profileFlag = "profile" + 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.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/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 new file mode 100644 index 000000000..bb117ee5f --- /dev/null +++ b/cmd/aws_devops_agent/setup.go @@ -0,0 +1,210 @@ +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" + 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" + mcpServiceIDFlag = "mcp-service-id" + mcpEndpointFlag = "mcp-endpoint" +) + +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 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, + } + + 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().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(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()) + + 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), mustString(cmd, profileFlag)) + 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) + } + + var mcpErr error + if result.MCPServiceID == "" { + 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 mcpErr +} + +// 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, +) error { + 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 nil + } + + _, _ = 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 nil + } + + return awsdevops.RegisterMCPServer(cmd.Context(), clients, opts, result) +} + +func setupOptions(cmd *cobra.Command) (awsdevops.SetupOptions, error) { + opts := awsdevops.SetupOptions{ + 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), + MCPServiceID: mustString(cmd, mcpServiceIDFlag), + MCPEndpoint: mustString(cmd, mcpEndpointFlag), + } + + 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) + } + + 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 +} diff --git a/cmd/aws_devops_agent/status.go b/cmd/aws_devops_agent/status.go new file mode 100644 index 000000000..1830ff7e5 --- /dev/null +++ b/cmd/aws_devops_agent/status.go @@ -0,0 +1,73 @@ +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(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()) + + return cmd +} + +func runStatus(cmd *cobra.Command, args []string) error { + clients, err := awsdevops.NewClients(cmd.Context(), mustString(cmd, regionFlag), mustString(cmd, profileFlag)) + 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), " ")) + } + } + _, _ = fmt.Fprintf(out, "\nAWS DevOps Agent console: %s\n", awsdevops.ConsoleURL(status.Region)) + + 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 adb0d05a3..67106fb30 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 6283bd501..8215df15d 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,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..acba0a664 --- /dev/null +++ b/internal/awsdevops/awsdevops.go @@ -0,0 +1,257 @@ +// 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 ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "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", + "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 ( + // 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") +) + +// 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) + 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) + 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) + 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) +} + +// 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. +// 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, 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 { + 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{}, noCredentialsError(profile, err) + } + if !creds.HasKeys() { + return Clients{}, noCredentialsError(profile, nil) + } + + 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 +} + +// 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 new file mode 100644 index 000000000..1c57390d1 --- /dev/null +++ b/internal/awsdevops/awsdevops_test.go @@ -0,0 +1,737 @@ +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" + "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 + registerOutput *devopsagent.RegisterServiceOutput + registerErr error + registerInputs []*devopsagent.RegisterServiceInput + 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 +} + +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) { + 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) 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, 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: items, NextToken: next}, nil +} + +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: 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) + + return &devopsagent.ListAssociationsOutput{Associations: items, NextToken: next}, nil +} + +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 + } + + return &devopsagent.RegisterServiceOutput{ServiceId: aws.String("mcp-1")}, nil +} + +type fakeIAM struct { + existingRoles map[string]bool + roleTags map[string][]iamtypes.Tag + createdRoles []string + attachedPolicy map[string]string + inlinePolicies map[string]string + trustPolicies map[string]string +} + +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) + + 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) + 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)}, + }, 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) 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) + + 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) + + 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.addManagedRole(awsdevops.AgentSpaceRoleName) + iamClient.addManagedRole(awsdevops.OperatorAppRoleName) + + result, err := awsdevops.Setup(context.Background(), newTestClients(agent, iamClient), awsdevops.SetupOptions{ + AgentSpaceID: "space-existing", + AuthFlow: "iam", + }) + require.NoError(t, err) + + assert.Equal(t, "space-existing", result.AgentSpaceID) + assert.NotContains(t, agent.calls, "CreateAgentSpace") + 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", + }) + require.NoError(t, err) + + assert.Equal(t, "assoc-aws", result.AWSAssociationID) + assert.Len(t, agent.associationInputs, 2) +} + +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", + }) + 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 TestSetupTrustsAgentSpacesInEveryRegion(t *testing.T) { + agent := &fakeAgent{} + iamClient := newFakeIAM() + iamClient.addManagedRole(awsdevops.AgentSpaceRoleName) + iamClient.addManagedRole(awsdevops.OperatorAppRoleName) + + _, err := awsdevops.Setup(context.Background(), newTestClients(agent, iamClient), awsdevops.SetupOptions{ + AgentSpaceID: "space-existing", + AuthFlow: "iam", + }) + 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:*:"+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{launchDarklyMCPService("mcp-existing")}, + } + + 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{launchDarklyMCPService("mcp-existing")}, + 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", + LDBaseURI: stubLaunchDarkly(t, http.StatusOK), + 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 TestSetupReconnectsOtherSpacesAfterReplacingTheMCPToken(t *testing.T) { + agent := &fakeAgent{ + 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, + }) + 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 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, + 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 TestStatusReportsMissingRoles(t *testing.T) { + agent := &fakeAgent{ + associations: []agenttypes.Association{{AssociationId: aws.String("assoc-1"), ServiceId: aws.String("aws")}}, + } + 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) +} + +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) + 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") +} 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/lookup.go b/internal/awsdevops/lookup.go new file mode 100644 index 000000000..7d6baa2d3 --- /dev/null +++ b/internal/awsdevops/lookup.go @@ -0,0 +1,144 @@ +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" +) + +// The DevOps Agent list APIs page at 100 items, so every caller has to follow +// NextToken: a partial list hides resources that already exist. + +func listServices(ctx context.Context, clients Clients) ([]agenttypes.RegisteredService, error) { + var ( + services []agenttypes.RegisteredService + nextToken *string + ) + for { + page, err := clients.Agent.ListServices(ctx, &devopsagent.ListServicesInput{NextToken: nextToken}) + if err != nil { + return nil, fmt.Errorf("unable to list registered services: %w", err) + } + services = append(services, page.Services...) + if page.NextToken == nil { + return services, nil + } + nextToken = page.NextToken + } +} + +func listAgentSpaces(ctx context.Context, clients Clients) ([]agenttypes.AgentSpace, error) { + var ( + spaces []agenttypes.AgentSpace + nextToken *string + ) + for { + page, err := clients.Agent.ListAgentSpaces(ctx, &devopsagent.ListAgentSpacesInput{NextToken: nextToken}) + if err != nil { + return nil, fmt.Errorf("unable to list agent spaces: %w", err) + } + 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) + } + associations = append(associations, page.Associations...) + if page.NextToken == nil { + return associations, nil + } + nextToken = page.NextToken + } +} + +// 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 mcpEndpointOf(service) == endpoint { + return aws.ToString(service.ServiceId), 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 + } + } + + 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 + } + } + + return "", nil +} + +// 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 "" + } + + return aws.ToString(details.Value.Endpoint) +} + +// 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/lookup_test.go b/internal/awsdevops/lookup_test.go new file mode 100644 index 000000000..8457ef052 --- /dev/null +++ b/internal/awsdevops/lookup_test.go @@ -0,0 +1,75 @@ +package awsdevops_test + +import ( + "context" + "net/http" + "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{launchDarklyMCPService("mcp-1")}, + } + + 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{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, + }) + 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()), + awsdevops.MCPServerEndpoint, + ) + require.NoError(t, err) + + assert.Equal(t, "mcp-2", serviceID) +} diff --git a/internal/awsdevops/setup.go b/internal/awsdevops/setup.go new file mode 100644 index 000000000..a270a39d9 --- /dev/null +++ b/internal/awsdevops/setup.go @@ -0,0 +1,691 @@ +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" + 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" + + // 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" + + 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" + + agentSpaceDescription = "Managed by the LaunchDarkly CLI" +) + +// The agent invokes read-only tools without asking for approval, so anything +// that changes flag state has to be classified explicitly. +var ( + mcpReadOnlyTools = []string{"list-projects", "list-flags", "get-flag"} + mcpMutativeTools = []string{"toggle-flag"} +) + +// SetupOptions describes what to provision. +type SetupOptions struct { + AgentSpaceID string + AgentSpaceName string + NewAgentSpace bool + + AuthFlow string + IdcInstanceARN string + IssuerURL string + IdpClientID string + IdpClientSecret string + + LDBaseURI string + 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) +} + +// SetupResult holds the identifiers of everything the setup touched. +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"` +} + +// 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 { + 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, logf) + if err != nil { + return result, err + } + logf("Agent space role ready: %s", result.AgentSpaceRoleARN) + + result.OperatorAppRoleARN, err = ensureOperatorAppRole(ctx, clients.IAM, accountID, logf) + 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(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) + } + + 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 err := registerMCPServer(ctx, clients, opts, &result, logf); err != nil { + return result, err + } + + 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( + 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), + endpoint string, +) (*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", endpoint) + } + + 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 { + 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 + // 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 "+ + "token it was registered with, so pass --access-token --replace-mcp-token to change it", + MCPServerName, + existing, + ) + + return associateMCPServer(ctx, clients, result, logf) + } + 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 + } + 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(endpoint), + 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, endpoint) + 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, "+ + "so %s cannot be registered: pass --mcp-service-id to use that registration, "+ + "or rename it in the console", + MCPServerName, + endpoint, + ) + } + + return registrationFailed(err, detached) + } + + 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, + ) + + 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 +} + +// 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, + 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 + } + + 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(agentSpaceID), + ServiceId: aws.String(serviceID), + Configuration: &agenttypes.ServiceConfigurationMemberMcpserver{ + Value: mcpServerConfiguration(), + }, + }) + if err != nil { + return "", fmt.Errorf( + "unable to associate the LaunchDarkly MCP server with agent space %s: %w", + agentSpaceID, + err, + ) + } + + return aws.ToString(association.Association.AssociationId), 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() agenttypes.MCPServerConfiguration { + readOnly, mutative := mcpReadOnlyTools, mcpMutativeTools + + 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 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:*:%[1]s:agentspace/*"}}}]}`, accountID) + + 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. + 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 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:*:%[1]s:agentspace/*"}}}]}`, accountID) + + arn, _, err := ensureRole(ctx, client, OperatorAppRoleName, trustPolicy, OperatorAppPolicyARN, logf) + + return arn, err +} + +// 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: + arn = aws.ToString(created.Role.Arn) + case isAlreadyExists(err): + existing, getErr := client.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(name)}) + if getErr != nil { + return "", false, fmt.Errorf("unable to read the existing %s role: %w", name, getErr) + } + arn = aws.ToString(existing.Role.Arn) + + 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 "", false, fmt.Errorf("unable to update the trust policy on the %s role: %w", name, err) + } + default: + 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 "", false, fmt.Errorf("unable to attach %s to %s: %w", policyARN, name, err) + } + + 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 { + var alreadyExists *iamtypes.EntityAlreadyExistsException + + return errors.As(err, &alreadyExists) +} + +// 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..413d29bc5 --- /dev/null +++ b/internal/awsdevops/status.go @@ -0,0 +1,122 @@ +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"` +} + +type AssociationStatus struct { + AssociationID string `json:"associationId"` + ServiceID string `json:"serviceId"` + Status string `json:"status,omitempty"` +} + +// 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 := listServices(ctx, clients) + if err != nil { + return status, err + } + for _, service := range 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 := listAgentSpaces(ctx, clients) + if err != nil { + return status, err + } + spaceIDs = nil + for _, space := range spaces { + 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 := listAssociations(ctx, clients, id) + if err != nil { + return status, err + } + for _, association := range associations { + spaceStatus.Associations = append(spaceStatus.Associations, AssociationStatus{ + AssociationID: aws.ToString(association.AssociationId), + ServiceID: aws.ToString(association.ServiceId), + Status: string(association.Status), + }) + } + + status.AgentSpaces = append(status.AgentSpaces, spaceStatus) + } + + return status, nil +} 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 +}