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
68 changes: 67 additions & 1 deletion cmd/lk/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -879,10 +879,76 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error {
return fmt.Errorf("unable to deploy agent: %w", err)
}

out.Status("Deployed agent")
reportDeployment(ctx, agentId, agentDeployment)
return nil
}

// reportDeployment prints a summary of a completed deployment — the agent name,
// the target deployment, the live version, and links to the agent details page
// and the agent console for the deployment. It resolves the name/version with a
// single ListAgents call; on any failure it falls back to the minimal status
// line so a successful deploy is never reported as a failure.
func reportDeployment(ctx context.Context, agentID, deployment string) {
targetDeployment := deployment
if targetDeployment == "" {
targetDeployment = "production"
}

agentName := ""
version := ""
if res, err := agentsClient.ListAgents(ctx, &lkproto.ListAgentsRequest{AgentId: agentID}); err == nil {
for _, agent := range res.Agents {
for _, regionalAgent := range agent.AgentDeployments {
regionDeployment := regionalAgent.Deployment
if regionDeployment == "" {
regionDeployment = "production"
}
if regionDeployment != targetDeployment {
continue
}
if regionalAgent.AgentName != "" {
agentName = regionalAgent.AgentName
}
if regionalAgent.Version != "" {
version = regionalAgent.Version
} else if agent.Version != "" {
version = agent.Version
}
}
}
}

summary := "Completed deployment of agent"
if agentName != "" {
summary += fmt.Sprintf(" %s", util.Accented(agentName))
}
summary += fmt.Sprintf(" to %s", util.Accented(targetDeployment))
if version != "" {
summary += fmt.Sprintf(" (%s)", version)
}
out.Status(summary)

// Links use the same ws URL the deploy ran against; both are "" for
// non-cloud projects, in which case nothing is printed.
wsURL := project.URL
if link := cloudAgentURL(wsURL, agentID); link != "" {
out.Statusf("Agent details: %s", consoleLinkLabel(link))
}
if link := cloudConsoleURL(wsURL, agentName, deployment); link != "" {
out.Statusf("Test in Agent Console: %s", consoleLinkLabel(link))
}
}

// consoleLinkLabel accents a URL and makes it a clickable OSC 8 hyperlink on
// interactive terminals, matching how `lk agent start` renders console links.
func consoleLinkLabel(link string) string {
label := util.Accented(link)
if out.Interactive() {
label = util.Hyperlink(link, label)
}
return label
}

func promoteAgent(ctx context.Context, cmd *cli.Command) error {
agentID, err := getAgentID(ctx, cmd, workingDir, tomlFilename, false)
if err != nil {
Expand Down
26 changes: 22 additions & 4 deletions cmd/lk/agent_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,34 @@ import (
// cloudConsoleURL returns the LiveKit Cloud agents-console URL for a worker that
// registered against wsURL with the given agent name, or "" when wsURL is not a
// LiveKit Cloud project (e.g. a self-hosted or localhost server), in which case
// no console link is shown.
func cloudConsoleURL(wsURL, agentName string) string {
// no console link is shown. A non-empty deployment is deep-linked so the console
// targets that deployment; an empty deployment targets production.
func cloudConsoleURL(wsURL, agentName, deployment string) string {
consoleHost, sub := cloudProject(wsURL)
if consoleHost == "" {
return ""
}
return fmt.Sprintf(
link := fmt.Sprintf(
"https://%s/projects/d_%s/agents/console?agentName=%s&autoStart=false",
consoleHost, sub, url.QueryEscape(agentName),
)
if deployment != "" {
link += "&deployment=" + url.QueryEscape(deployment)
}
return link
}

// cloudAgentURL returns the LiveKit Cloud analytics URL for a specific agent, or
// "" when wsURL is not a recognized LiveKit Cloud project.
func cloudAgentURL(wsURL, agentID string) string {
consoleHost, sub := cloudProject(wsURL)
if consoleHost == "" {
return ""
}
return fmt.Sprintf(
"https://%s/projects/d_%s/agents/%s",
consoleHost, sub, url.QueryEscape(agentID),
)
}

// cloudProject maps a LiveKit Cloud project URL to its console host and project
Expand Down Expand Up @@ -306,7 +324,7 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error {
// agent in the browser. Printed once, even across hot reloads (link stays valid).
var consoleLinkOnce sync.Once
cfg.OnServerInfo = func(agentName, wsURL string) {
if link := cloudConsoleURL(wsURL, agentName); link != "" {
if link := cloudConsoleURL(wsURL, agentName, ""); link != "" {
consoleLinkOnce.Do(func() {
// Delay briefly so the link prints after the agent's own startup
// logs rather than getting buried in them.
Expand Down
34 changes: 29 additions & 5 deletions cmd/lk/agent_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,23 +398,47 @@ func TestCloudProject(t *testing.T) {
func TestCloudConsoleURL(t *testing.T) {
assert.Equal(t,
"https://cloud.livekit.io/projects/d_dztest2/agents/console?agentName=my-agent&autoStart=false",
cloudConsoleURL("wss://dztest2.livekit.cloud", "my-agent"),
cloudConsoleURL("wss://dztest2.livekit.cloud", "my-agent", ""),
)
// staging projects point at the staging console host
assert.Equal(t,
"https://cloud.staging.livekit.io/projects/d_dztest2/agents/console?agentName=my-agent&autoStart=false",
cloudConsoleURL("wss://dztest2.staging.livekit.cloud", "my-agent"),
cloudConsoleURL("wss://dztest2.staging.livekit.cloud", "my-agent", ""),
)
// empty agent name (the common dev default) still yields a usable link
assert.Equal(t,
"https://cloud.livekit.io/projects/d_dztest2/agents/console?agentName=&autoStart=false",
cloudConsoleURL("wss://dztest2.livekit.cloud", ""),
cloudConsoleURL("wss://dztest2.livekit.cloud", "", ""),
)
// agent names are query-escaped
assert.Equal(t,
"https://cloud.livekit.io/projects/d_dztest2/agents/console?agentName=my+agent%2F1&autoStart=false",
cloudConsoleURL("wss://dztest2.livekit.cloud", "my agent/1"),
cloudConsoleURL("wss://dztest2.livekit.cloud", "my agent/1", ""),
)
// a non-production deployment is deep-linked via the deployment param
assert.Equal(t,
"https://cloud.livekit.io/projects/d_dztest2/agents/console?agentName=my-agent&autoStart=false&deployment=staging",
cloudConsoleURL("wss://dztest2.livekit.cloud", "my-agent", "staging"),
)
// deployment values are query-escaped
assert.Equal(t,
"https://cloud.livekit.io/projects/d_dztest2/agents/console?agentName=my-agent&autoStart=false&deployment=pre%2Fprod",
cloudConsoleURL("wss://dztest2.livekit.cloud", "my-agent", "pre/prod"),
)
// non-cloud URLs produce no link
assert.Empty(t, cloudConsoleURL("http://localhost:7880", "my-agent", ""))
}

func TestCloudAgentURL(t *testing.T) {
assert.Equal(t,
"https://cloud.livekit.io/projects/d_dztest2/agents/CA_abc123",
cloudAgentURL("wss://dztest2.livekit.cloud", "CA_abc123"),
)
// staging projects point at the staging console host
assert.Equal(t,
"https://cloud.staging.livekit.io/projects/d_dztest2/agents/CA_abc123",
cloudAgentURL("wss://dztest2.staging.livekit.cloud", "CA_abc123"),
)
// non-cloud URLs produce no link
assert.Empty(t, cloudConsoleURL("http://localhost:7880", "my-agent"))
assert.Empty(t, cloudAgentURL("http://localhost:7880", "CA_abc123"))
}
Loading