Skip to content
Closed
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,36 @@ LaunchDarkly CLI commands:

- `setup` guides you through creating your first flag, connecting an SDK, and evaluating your flag in your Test environment
- `dev-server` lets you start a local server and retrieve flag values from a LaunchDarkly source environment so you can test your code locally. For assistance starting with or running dev-server, refer to the [reference docs](https://launchdarkly.com/docs/guides/flags/ldcli-dev-server).
- `aws-devops-agent` provisions the AWS DevOps Agent integration in your own AWS account

### AWS DevOps Agent

`ldcli aws-devops-agent` creates the AWS resources the [AWS DevOps Agent](https://aws.amazon.com/devops-agent/) needs to manage your LaunchDarkly flags: the IAM roles it assumes, an agent space, the association with your AWS account, the operator web app, and the LaunchDarkly MCP server connection.

The commands use your existing AWS session rather than any cross-account LaunchDarkly role, so authenticate first. They fail before creating anything if no credentials are available:

```sh-session
aws sso login --profile my-profile
export AWS_PROFILE=my-profile AWS_REGION=us-east-1

ldcli aws-devops-agent setup
```

The AWS DevOps Agent is available in `us-east-1`, `us-west-2`, `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.

If the account already runs the AWS DevOps Agent, `setup` lists its agent spaces and asks which one to connect LaunchDarkly to. It then only registers and associates the LaunchDarkly MCP server on the space you pick, leaving its IAM roles, account association and operator app sign-in configuration alone. Pass `--agent-space-id <id>` to pick one without being asked, or `--new-agent-space` to provision another space.

Without a terminal — in CI, or with `--output json` — `setup` reuses the space matching `--agent-space-name` so an unattended re-run stays idempotent, and fails asking for `--agent-space-id` or `--new-agent-space` when the only spaces are ones it did not create.

Otherwise `setup` is safe to re-run: it reuses the IAM roles, the agent space, the account and MCP associations on it, and any LaunchDarkly MCP server already registered on the account.

`--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 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")))
}
213 changes: 213 additions & 0 deletions cmd/aws_devops_agent/select_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package awsdevopsagent

import (
"bytes"
"context"
"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/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/launchdarkly/ldcli/internal/awsdevops"
)

type stubAgent struct {
spaces []agenttypes.AgentSpace
}

func (s stubAgent) AssociateService(context.Context, *devopsagent.AssociateServiceInput, ...func(*devopsagent.Options)) (*devopsagent.AssociateServiceOutput, error) {
return nil, nil
}

func (s stubAgent) CreateAgentSpace(context.Context, *devopsagent.CreateAgentSpaceInput, ...func(*devopsagent.Options)) (*devopsagent.CreateAgentSpaceOutput, error) {
return nil, nil
}

func (s stubAgent) DeregisterService(context.Context, *devopsagent.DeregisterServiceInput, ...func(*devopsagent.Options)) (*devopsagent.DeregisterServiceOutput, error) {
return nil, nil
}

func (s stubAgent) DisassociateService(context.Context, *devopsagent.DisassociateServiceInput, ...func(*devopsagent.Options)) (*devopsagent.DisassociateServiceOutput, error) {
return nil, nil
}

func (s stubAgent) EnableOperatorApp(context.Context, *devopsagent.EnableOperatorAppInput, ...func(*devopsagent.Options)) (*devopsagent.EnableOperatorAppOutput, error) {
return nil, nil
}

func (s stubAgent) GetAgentSpace(context.Context, *devopsagent.GetAgentSpaceInput, ...func(*devopsagent.Options)) (*devopsagent.GetAgentSpaceOutput, error) {
return nil, nil
}

func (s stubAgent) ListAgentSpaces(context.Context, *devopsagent.ListAgentSpacesInput, ...func(*devopsagent.Options)) (*devopsagent.ListAgentSpacesOutput, error) {
return &devopsagent.ListAgentSpacesOutput{AgentSpaces: s.spaces}, nil
}

func (s stubAgent) ListAssociations(context.Context, *devopsagent.ListAssociationsInput, ...func(*devopsagent.Options)) (*devopsagent.ListAssociationsOutput, error) {
return &devopsagent.ListAssociationsOutput{}, nil
}

func (s stubAgent) ListServices(context.Context, *devopsagent.ListServicesInput, ...func(*devopsagent.Options)) (*devopsagent.ListServicesOutput, error) {
return &devopsagent.ListServicesOutput{}, nil
}

func (s stubAgent) RegisterService(context.Context, *devopsagent.RegisterServiceInput, ...func(*devopsagent.Options)) (*devopsagent.RegisterServiceOutput, error) {
return nil, nil
}

func testCmd(stdin string) (*cobra.Command, *bytes.Buffer) {
out := &bytes.Buffer{}
cmd := &cobra.Command{}
cmd.SetOut(out)
cmd.SetErr(out)
cmd.SetIn(strings.NewReader(stdin))

return cmd, out
}

func testSpaces() []awsdevops.AgentSpaceSummary {
return []awsdevops.AgentSpaceSummary{
{AgentSpaceID: "space-1", Name: "launchdarkly"},
{AgentSpaceID: "space-2", Name: "platform"},
}
}

func TestSelectAgentSpaceTakesTheChosenSpace(t *testing.T) {
cmd, out := testCmd("2\n")

chosen, err := selectAgentSpace(cmd, testSpaces(), "us-east-1")
require.NoError(t, err)

assert.Equal(t, "space-2", chosen)
assert.Contains(t, out.String(), "2) platform (space-2)")
}

func TestSelectAgentSpaceDefaultsToTheFirstSpace(t *testing.T) {
cmd, _ := testCmd("\n")

chosen, err := selectAgentSpace(cmd, testSpaces(), "us-east-1")
require.NoError(t, err)

assert.Equal(t, "space-1", chosen)
}

func TestSelectAgentSpaceAsksAgainAfterAnInvalidAnswer(t *testing.T) {
cmd, out := testCmd("9\nnope\n1\n")

chosen, err := selectAgentSpace(cmd, testSpaces(), "us-east-1")
require.NoError(t, err)

assert.Equal(t, "space-1", chosen)
assert.Equal(t, 2, strings.Count(out.String(), "Enter a number between 1 and 3"))
}

func TestSelectAgentSpaceProvisionsANewSpaceOnTheLastChoice(t *testing.T) {
cmd, out := testCmd("3\n")

chosen, err := selectAgentSpace(cmd, testSpaces(), "us-east-1")
require.NoError(t, err)

assert.Empty(t, chosen)
assert.Contains(t, out.String(), "3) provision a new agent space")
}

func TestSelectAgentSpaceFailsWithoutAnAnswer(t *testing.T) {
cmd, _ := testCmd("")

_, err := selectAgentSpace(cmd, testSpaces(), "us-east-1")

assert.ErrorContains(t, err, "no agent space was chosen")
}

func newStubClients(spaces ...agenttypes.AgentSpace) awsdevops.Clients {
return awsdevops.Clients{Agent: stubAgent{spaces: spaces}, Region: "us-east-1"}
}

func TestResolveAgentSpaceProvisionsWhenTheAccountHasNone(t *testing.T) {
cmd, _ := testCmd("")
opts := awsdevops.SetupOptions{AgentSpaceName: "launchdarkly"}

provision, err := resolveAgentSpace(cmd, newStubClients(), &opts, false)
require.NoError(t, err)

assert.True(t, provision)
assert.Empty(t, opts.AgentSpaceID)
}

func TestResolveAgentSpaceTakesTheFlaggedSpace(t *testing.T) {
cmd, _ := testCmd("")
opts := awsdevops.SetupOptions{AgentSpaceID: "space-2", AgentSpaceName: "launchdarkly"}

provision, err := resolveAgentSpace(cmd, newStubClients(), &opts, false)
require.NoError(t, err)

assert.False(t, provision)
}

// Without a terminal there is nobody to ask, so a space this command owns is
// reused and anything else is an error rather than a guess.
func TestResolveAgentSpaceReusesTheOwnSpaceWithoutATerminal(t *testing.T) {
cmd, _ := testCmd("")
opts := awsdevops.SetupOptions{AgentSpaceName: "launchdarkly"}
clients := newStubClients(agenttypes.AgentSpace{
AgentSpaceId: aws.String("space-1"),
Name: aws.String("launchdarkly"),
})

provision, err := resolveAgentSpace(cmd, clients, &opts, false)
require.NoError(t, err)

assert.True(t, provision)
}

func TestResolveAgentSpaceFailsOnSomeoneElsesSpaceWithoutATerminal(t *testing.T) {
cmd, _ := testCmd("")
opts := awsdevops.SetupOptions{AgentSpaceName: "launchdarkly"}
clients := newStubClients(agenttypes.AgentSpace{
AgentSpaceId: aws.String("space-2"),
Name: aws.String("platform"),
})

_, err := resolveAgentSpace(cmd, clients, &opts, false)

assert.ErrorContains(t, err, "platform space-2")
assert.ErrorContains(t, err, "--agent-space-id")
}

func TestResolveAgentSpaceAsksAndConnectsToTheChosenSpace(t *testing.T) {
cmd, _ := testCmd("1\n")
opts := awsdevops.SetupOptions{AgentSpaceName: "launchdarkly"}
clients := newStubClients(agenttypes.AgentSpace{
AgentSpaceId: aws.String("space-2"),
Name: aws.String("platform"),
})

provision, err := resolveAgentSpace(cmd, clients, &opts, true)
require.NoError(t, err)

assert.False(t, provision)
assert.Equal(t, "space-2", opts.AgentSpaceID)
}

func TestResolveAgentSpaceProvisionsANewSpaceWhenAsked(t *testing.T) {
cmd, _ := testCmd("2\n")
opts := awsdevops.SetupOptions{AgentSpaceName: "launchdarkly"}
clients := newStubClients(agenttypes.AgentSpace{
AgentSpaceId: aws.String("space-1"),
Name: aws.String("launchdarkly"),
})

provision, err := resolveAgentSpace(cmd, clients, &opts, true)
require.NoError(t, err)

assert.True(t, provision)
assert.Empty(t, opts.AgentSpaceID)
// Without this the matching name makes Setup reuse the space the answer
// declined.
assert.True(t, opts.NewAgentSpace)
}
Loading
Loading