Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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://<agent-space-id>.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

Expand Down
66 changes: 66 additions & 0 deletions cmd/aws_devops_agent/aws_devops_agent.go
Original file line number Diff line number Diff line change
@@ -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
}
37 changes: 37 additions & 0 deletions cmd/aws_devops_agent/prompt.go
Original file line number Diff line number Diff line change
@@ -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()))
}
16 changes: 16 additions & 0 deletions cmd/aws_devops_agent/prompt_test.go
Original file line number Diff line number Diff line change
@@ -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")))
}
210 changes: 210 additions & 0 deletions cmd/aws_devops_agent/setup.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading