Files
coder/cli/stop.go
T
Andrew Aquino 3aeb7f85d9 feat: correlate coder CLI workspace sessions with client_session_id (#28313)
## Summary

Implements DEVEX-664: the coder CLI commands that open a workspace
connection now generate a client session ID and attach it to their
requests, logs, and existing network telemetry, per the connection-log
RFC.

Commands opt in explicitly with a `client_session_id` annotation. The
opted-in set is `coder ssh`, `coder start`, `coder stop`, and `coder
update`. Long-running daemon commands (`coder server`, `agent`,
`provisionerd`, and so on) deliberately stay out, so they never carry a
meaningless session ID in their logs, request baggage, or telemetry.

- **Generate / resolve**: a 16-byte lowercase-hex session ID is
generated per opted-in invocation. When `CODER_TRACE_SESSION_ID` is set
(so Toolbox and the VS Code plugin can correlate the CLI they spawn),
that value is used verbatim; a warning is logged if it is not the
canonical 32-char lowercase hex form, since coderd/agent middleware drop
non-canonical values.
- **Requests**: the ID is attached to every request as
`client_session_id` W3C baggage via a transport wrapper, independent of
whether tracing is enabled. The already-merged coderd tracing middleware
(#27671) reads it.
- **Coordinate websocket**: the ID is also set explicitly as baggage on
the tailnet coordinate handshake in `workspacesdk.DialAgent`, so
coderd's `/api/v2/workspaceagents/{id}/coordinate` endpoint logs it (it
does not rely on the CLI transport for that dial).
- **Logs**: the ID is added to the command's log context.
- **Existing telemetry**: the ID is added to tailnet `TelemetryEvent`
(`client_session_id`, new field 20; proto minor version bumped) and
plumbed from the CLI through `workspacesdk.DialAgent`.

Scope is the coder CLI client only (RFC glossary + requirement 5), and
within the CLI only the opted-in workspace-connecting commands above.
This PR is independent of #28039 (the agent middleware that consumes the
baggage); it depends only on `tracing.SessionIDBaggageKey`, which is
already on `main` via #27671.

## Out of scope

RFC requirements 7-16 (connection-state change logging,
`connection_logs` columns, log buffering, routing/disconnect-reason
logs, coordination-protocol plumbing, `agentssh`/`reconnectingpty`
command logging, support-bundle log capture) and requirements 4.2/4.3
(VS Code telemetry, Prometheus stats) are separate tickets. IDE plugins,
Toolbox, and the daemon commands are also out of scope.

## Testing

- `go test ./coderd/tracing/... ./cli/... ./tailnet/...
./codersdk/workspacesdk/...` (unit tests for the generator, the
env/generate resolver, per-command opt-in, the baggage transport
wrapper, the coordinate-dial baggage helper, and the telemetry field).
- `go build ./...`, `go vet`, and `golangci-lint` pass on the touched
packages.
- `make tailnet/proto/tailnet.pb.go` regenerated the proto; the proto
minor version was bumped and `postStartup` now connects at the current
agent API version so `TestWorkspaceAgent_Startup/OK` tracks it.

> Note: the pre-commit `enterprise/cli/.gen-golden` step could not run
in this environment (no Postgres: `open postgres connection: EOF`).
`cli/testdata/.gen-golden` passed; this change adds no serpent flags, so
there is no CLI help/golden drift.

<details>
<summary>Implementation plan</summary>

### Scope mapping (RFC -> DEVEX-664)

In scope (the CLI workspace-connecting subset of RFC phase 2):
- Req 1: generate a 16-byte session ID as 32-char lowercase hex.
- Req 2: add the session ID to every log the command makes for the
session.
- Req 3: attach the session ID to every API request via
`client_session_id` baggage, including the coordinate websocket
handshake. (The query-param fallback is browser-only; not applicable to
the CLI.)
- Req 4.1: attach the session ID to CLI network telemetry.
- Req 5 / 5.3: support `CODER_TRACE_SESSION_ID`; when set, use it
instead of generating.

### Resolved decisions

- Scope: opt-in per command via a `client_session_id` annotation;
opted-in set is `coder ssh`, `coder start`, `coder stop`, `coder
update`. Daemon commands are excluded.
- `CODER_TRACE_SESSION_ID`: when set, use the value verbatim; warn if it
is not canonical 32-char lowercase hex. When unset, generate a fresh ID.
- Existing telemetry = CLI network telemetry (tailnet `TelemetryEvent`);
requires a new proto field + plumbing and a proto minor version bump.
- Coordinate endpoint: set the session ID as baggage on the coordinate
handshake explicitly in `DialAgent` (header, not query param) so it is
logged server-side without relying on the CLI transport.
- Shared helper: add `tracing.NewSessionID()` and export the validator
as `tracing.ValidSessionID` for a single source of truth.

### Implementation

1. `coderd/tracing`: add `NewSessionID()` (16 random bytes,
hex-encoded); export `ValidSessionID`.
2. `cli`: resolve the ID from `CODER_TRACE_SESSION_ID` or generate;
attach `client_session_id` baggage to every request via
`wrapTransportWithSessionIDHeader`, gated on the
`annotationClientSessionID` opt-in; add it to the command log context.
3. `tailnet` + `codersdk/workspacesdk`: add `client_session_id` to
`TelemetryEvent`, an `Options.ClientSessionID`/`Conn.clientSessionID`
set in `newTelemetryEvent`, a `DialAgentOptions.ClientSessionID` plumbed
from the opted-in commands, and the coordinate-handshake baggage in
`DialAgent`.

</details>

---

_This PR was created by Coder Agents on behalf of @aqandrew._
2026-09-02 19:31:01 +00:00

91 lines
2.4 KiB
Go

package cli
import (
"fmt"
"time"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/cliutil"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) stop() *serpent.Command {
var bflags buildFlags
cmd := &serpent.Command{
Annotations: serpent.Annotations(workspaceCommand).Mark(annotationClientSessionID, ""),
Use: "stop <workspace>",
Short: "Stop a workspace",
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Options: serpent.OptionSet{
cliui.SkipPromptOption(),
},
Handler: func(inv *serpent.Invocation) error {
client, err := r.InitClient(inv)
if err != nil {
return err
}
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Confirm stop workspace?",
IsConfirm: true,
})
if err != nil {
return err
}
workspace, err := client.ResolveWorkspace(inv.Context(), inv.Args[0])
if err != nil {
return err
}
build, err := stopWorkspace(inv, client, workspace, bflags)
if err != nil {
return err
}
err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID)
if err != nil {
return err
}
_, _ = fmt.Fprintf(
inv.Stdout,
"\nThe %s workspace has been stopped at %s!\n",
cliui.Keyword(workspace.Name),
cliui.Timestamp(time.Now()),
)
return nil
},
}
cmd.Options = append(cmd.Options, bflags.cliOptions()...)
return cmd
}
func stopWorkspace(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, bflags buildFlags) (codersdk.WorkspaceBuild, error) {
if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobPending {
// cliutil.WarnMatchedProvisioners also checks if the job is pending
// but we still want to avoid users spamming multiple builds that will
// not be picked up.
cliui.Warn(inv.Stderr, "The workspace is already stopping!")
cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job)
if _, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Enqueue another stop?",
IsConfirm: true,
Default: cliui.ConfirmNo,
}); err != nil {
return codersdk.WorkspaceBuild{}, err
}
}
wbr := codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStop,
}
if bflags.provisionerLogDebug {
wbr.LogLevel = codersdk.ProvisionerLogLevelDebug
}
return client.CreateWorkspaceBuild(inv.Context(), workspace.ID, wbr)
}