diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index b543e406..fbca6857 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -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 { diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index 7c56d20c..ac619750 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -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 @@ -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. diff --git a/cmd/lk/agent_run_test.go b/cmd/lk/agent_run_test.go index 275546a3..90e9506c 100644 --- a/cmd/lk/agent_run_test.go +++ b/cmd/lk/agent_run_test.go @@ -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")) }