Skip to content
Open
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
55 changes: 49 additions & 6 deletions cmd/lk/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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]",
},
Expand Down Expand Up @@ -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 <name>")
}
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
}

Expand Down
79 changes: 79 additions & 0 deletions cmd/lk/agent_promote_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading