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
63 changes: 62 additions & 1 deletion cmd/lk/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -879,10 +879,71 @@ 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, and links to the agent details page and the agent
// console for the deployment. It resolves the name 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.
//
// The version is intentionally omitted: the deploy API doesn't return the new
// version, the agent-level version reflects the production deployment (wrong
// for a non-production deploy), and the per-deployment version isn't populated
// until the agent is scraped. There is no source that is both correct and ready
// synchronously at deploy time, so reporting it would risk showing the wrong
// version.
func reportDeployment(ctx context.Context, agentID, deployment string) {
targetDeployment := deployment
if targetDeployment == "" {
targetDeployment = "production"
}

// Read the agent-level name, not the per-region AgentDeployments entry: the
// per-region fields aren't populated until the agent is scraped (a reconcile
// that runs shortly after deploy), so right after a deploy they can be empty.
// The agent-level name is set from agent metadata and is what `lk agent list`
// shows immediately after deploy.
agentName := ""
if res, err := agentsClient.ListAgents(ctx, &lkproto.ListAgentsRequest{AgentId: agentID}); err == nil {
for _, agent := range res.Agents {
if agent.AgentId != agentID {
continue
}
agentName = agent.AgentName
}
}

summary := "Completed deployment of agent"
if agentName != "" {
summary += fmt.Sprintf(" %s", util.Accented(agentName))
}
summary += fmt.Sprintf(" to %s", util.Accented(targetDeployment))
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