diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index 1efb41a9..af89c8e1 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -201,6 +201,18 @@ var ( Aliases: []string{"d"}, } + promoteFromFlag = &cli.StringFlag{ + Name: "from", + Usage: "Non-production deployment whose image is promoted. Alias of --deployment", + Required: false, + } + + promoteToFlag = &cli.StringFlag{ + Name: "to", + Usage: "Deployment to promote into. Defaults to `production`", + Required: false, + } + agentPrebuiltImageFlag = &cli.StringFlag{ Name: "image", Usage: "Pre-built image from the local Docker daemon (e.g. myimage:latest). Requires Docker.", @@ -355,12 +367,14 @@ On LiveKit Cloud: "create" and "deploy" ship it, then "status", "logs", }, { Name: "promote", - Usage: "Promote an agent to a new deployment", + Usage: "Promote an agent's image from one deployment to another", Before: createAgentClient, Action: promoteAgent, Flags: []cli.Flag{ idFlag(false), deploymentFlag, + promoteFromFlag, + promoteToFlag, }, ArgsUsage: "[working-dir]", }, @@ -1042,22 +1056,51 @@ func consoleLinkLabel(link string) string { return label } +// resolvePromoteSource reads the source deployment from --from, falling back to +// --deployment so existing invocations keep working. The source must be a +// non-production deployment: the server rejects production as a source, and an +// empty value would mean "promote production onto itself". +func resolvePromoteSource(cmd *cli.Command) (string, error) { + from := cmd.String("from") + deployment := cmd.String("deployment") + + if from != "" && deployment != "" && from != deployment { + return "", fmt.Errorf("--from and --deployment both set to different values; use --from") + } + if from == "" { + from = deployment + } + if from == "" { + return "", fmt.Errorf("a source deployment is required: pass --from ") + } + return from, nil +} + func promoteAgent(ctx context.Context, cmd *cli.Command) error { agentID, err := getAgentID(ctx, cmd, workingDir, tomlFilename, false) if err != nil { return err } - agentDeployment := cmd.String("deployment") - if agentDeployment == "" { - return fmt.Errorf("cannot promote production deployment") + srcDeployment, err := resolvePromoteSource(cmd) + if err != nil { + return err } - if err := agentsClient.PromoteAgent(ctx, agentID, agentDeployment, ""); err != nil { + // Empty destination means production, which is the historical behavior and + // stays the default. + dstDeployment := cmd.String("to") + + if err := agentsClient.PromoteAgent(ctx, agentID, srcDeployment, dstDeployment); err != nil { if twerr, ok := err.(twirp.Error); ok { return fmt.Errorf("unable to promote agent: %s", twerr.Msg()) } return fmt.Errorf("unable to promote agent: %w", err) } - out.Statusf("Promoted agent from deployment [%s] to production", util.Accented(agentDeployment)) + + dstLabel := "production" + if dstDeployment != "" { + dstLabel = util.Accented(dstDeployment) + } + out.Statusf("Promoted agent from deployment [%s] to [%s]", util.Accented(srcDeployment), dstLabel) return nil } diff --git a/cmd/lk/agent_promote_test.go b/cmd/lk/agent_promote_test.go new file mode 100644 index 00000000..12a41af6 --- /dev/null +++ b/cmd/lk/agent_promote_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "testing" + + "github.com/urfave/cli/v3" +) + +// resolvePromoteSource keeps `--deployment` working while `--from` becomes the +// documented spelling, so both need coverage. +func TestResolvePromoteSource(t *testing.T) { + tests := []struct { + name string + args []string + wantSource string + wantErr bool + }{ + {name: "from", args: []string{"promote", "--from", "dev"}, wantSource: "dev"}, + {name: "deployment still works", args: []string{"promote", "--deployment", "staging"}, wantSource: "staging"}, + {name: "short alias still works", args: []string{"promote", "-d", "staging"}, wantSource: "staging"}, + {name: "both, same value", args: []string{"promote", "--from", "dev", "--deployment", "dev"}, wantSource: "dev"}, + {name: "both, conflicting", args: []string{"promote", "--from", "dev", "--deployment", "qa"}, wantErr: true}, + {name: "neither", args: []string{"promote"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got string + var gotErr error + cmd := &cli.Command{ + Name: "promote", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "from"}, + &cli.StringFlag{Name: "to"}, + &cli.StringFlag{Name: "deployment", Aliases: []string{"d"}}, + }, + Action: func(ctx context.Context, c *cli.Command) error { + got, gotErr = resolvePromoteSource(c) + return nil + }, + } + if err := cmd.Run(context.Background(), tt.args); err != nil { + t.Fatalf("run: %v", err) + } + if tt.wantErr { + if gotErr == nil { + t.Fatalf("expected an error, got source %q", got) + } + return + } + if gotErr != nil { + t.Fatalf("unexpected error: %v", gotErr) + } + if got != tt.wantSource { + t.Fatalf("source = %q, want %q", got, tt.wantSource) + } + }) + } +} + +// The destination defaults to production, which the API represents as "". +func TestPromoteDestinationDefaultsToProduction(t *testing.T) { + var dst string + cmd := &cli.Command{ + Name: "promote", + Flags: []cli.Flag{&cli.StringFlag{Name: "to"}}, + Action: func(ctx context.Context, c *cli.Command) error { + dst = c.String("to") + return nil + }, + } + if err := cmd.Run(context.Background(), []string{"promote"}); err != nil { + t.Fatalf("run: %v", err) + } + if dst != "" { + t.Fatalf("default destination = %q, want empty (production)", dst) + } +}