mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
## 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._
230 lines
8.1 KiB
Go
230 lines
8.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"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) start() *serpent.Command {
|
|
var (
|
|
parameterFlags workspaceParameterFlags
|
|
bflags buildFlags
|
|
|
|
noWait bool
|
|
)
|
|
|
|
cmd := &serpent.Command{
|
|
Annotations: serpent.Annotations(workspaceCommand).Mark(annotationClientSessionID, ""),
|
|
Use: "start <workspace>",
|
|
Short: "Start a workspace",
|
|
Middleware: serpent.Chain(
|
|
serpent.RequireNArgs(1),
|
|
),
|
|
Options: serpent.OptionSet{
|
|
{
|
|
Flag: "no-wait",
|
|
Description: "Return immediately after starting the workspace.",
|
|
Value: serpent.BoolOf(&noWait),
|
|
Hidden: false,
|
|
},
|
|
cliui.SkipPromptOption(),
|
|
},
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
client, err := r.InitClient(inv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
workspace, err := client.ResolveWorkspace(inv.Context(), inv.Args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var build codersdk.WorkspaceBuild
|
|
switch workspace.LatestBuild.Status {
|
|
case codersdk.WorkspaceStatusPending:
|
|
// The above check is technically duplicated in cliutil.WarnmatchedProvisioners
|
|
// but we still want to avoid users spamming multiple builds that will
|
|
// not be picked up.
|
|
_, _ = fmt.Fprintf(
|
|
inv.Stdout,
|
|
"\nThe %s workspace is waiting to start!\n",
|
|
cliui.Keyword(workspace.Name),
|
|
)
|
|
cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job)
|
|
if _, err := cliui.Prompt(inv, cliui.PromptOptions{
|
|
Text: "Enqueue another start?",
|
|
IsConfirm: true,
|
|
Default: cliui.ConfirmNo,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
case codersdk.WorkspaceStatusRunning:
|
|
_, _ = fmt.Fprintf(
|
|
inv.Stdout, "\nThe %s workspace is already running!\n",
|
|
cliui.Keyword(workspace.Name),
|
|
)
|
|
return nil
|
|
case codersdk.WorkspaceStatusStarting:
|
|
_, _ = fmt.Fprintf(
|
|
inv.Stdout, "\nThe %s workspace is already starting.\n",
|
|
cliui.Keyword(workspace.Name),
|
|
)
|
|
build = workspace.LatestBuild
|
|
default:
|
|
// If the last build was a failed start, run a stop
|
|
// first to clean up any partially-provisioned
|
|
// resources.
|
|
if workspace.LatestBuild.Status == codersdk.WorkspaceStatusFailed &&
|
|
workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart {
|
|
_, _ = fmt.Fprintf(inv.Stdout, "The last start build failed. Cleaning up before retrying...\n")
|
|
stopBuild, stopErr := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
|
Transition: codersdk.WorkspaceTransitionStop,
|
|
})
|
|
if stopErr != nil {
|
|
return xerrors.Errorf("cleanup stop after failed start: %w", stopErr)
|
|
}
|
|
stopErr = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, stopBuild.ID)
|
|
if stopErr != nil {
|
|
return xerrors.Errorf("wait for cleanup stop: %w", stopErr)
|
|
}
|
|
// Re-fetch workspace after stop completes so
|
|
// startWorkspace sees the latest state.
|
|
workspace, err = client.ResolveWorkspace(inv.Context(), inv.Args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
build, err = startWorkspace(inv, client, workspace, parameterFlags, bflags, WorkspaceStart)
|
|
// It's possible for a workspace build to fail due to the template requiring starting
|
|
// workspaces with the active version.
|
|
if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusForbidden {
|
|
_, _ = fmt.Fprintln(inv.Stdout, "Unable to start the workspace with the template version from the last build. Policy may require you to restart with the current active template version.")
|
|
build, err = startWorkspace(inv, client, workspace, parameterFlags, bflags, WorkspaceUpdate)
|
|
if err != nil {
|
|
return xerrors.Errorf("start workspace with active template version: %w", err)
|
|
}
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if noWait {
|
|
_, _ = fmt.Fprintf(inv.Stdout, "The %s workspace has been started in no-wait mode. Workspace is building in the background.\n", cliui.Keyword(workspace.Name))
|
|
return nil
|
|
}
|
|
|
|
err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(
|
|
inv.Stdout, "\nThe %s workspace has been started at %s!\n",
|
|
cliui.Keyword(workspace.Name), cliui.Timestamp(time.Now()),
|
|
)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Options = append(cmd.Options, parameterFlags.allOptions()...)
|
|
cmd.Options = append(cmd.Options, bflags.cliOptions()...)
|
|
|
|
return cmd
|
|
}
|
|
|
|
func buildWorkspaceStartRequest(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, parameterFlags workspaceParameterFlags, buildFlags buildFlags, action WorkspaceCLIAction) (codersdk.CreateWorkspaceBuildRequest, error) {
|
|
version := workspace.LatestBuild.TemplateVersionID
|
|
|
|
if workspace.AutomaticUpdates == codersdk.AutomaticUpdatesAlways || workspace.TemplateRequireActiveVersion || action == WorkspaceUpdate {
|
|
version = workspace.TemplateActiveVersionID
|
|
if version != workspace.LatestBuild.TemplateVersionID {
|
|
action = WorkspaceUpdate
|
|
}
|
|
}
|
|
|
|
lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID)
|
|
if err != nil {
|
|
return codersdk.CreateWorkspaceBuildRequest{}, err
|
|
}
|
|
|
|
ephemeralParameters, err := asWorkspaceBuildParameters(parameterFlags.ephemeralParameters)
|
|
if err != nil {
|
|
return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse build options: %w", err)
|
|
}
|
|
|
|
cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters)
|
|
if err != nil {
|
|
return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse rich parameters: %w", err)
|
|
}
|
|
|
|
cliRichParameterDefaults, err := asWorkspaceBuildParameters(parameterFlags.richParameterDefaults)
|
|
if err != nil {
|
|
return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse rich parameter defaults: %w", err)
|
|
}
|
|
|
|
buildParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
|
|
Action: action,
|
|
TemplateVersionID: version,
|
|
NewWorkspaceName: workspace.Name,
|
|
LastBuildParameters: lastBuildParameters,
|
|
Owner: workspace.OwnerID.String(),
|
|
|
|
PromptEphemeralParameters: parameterFlags.promptEphemeralParameters,
|
|
EphemeralParameters: ephemeralParameters,
|
|
PromptRichParameters: parameterFlags.promptRichParameters,
|
|
RichParameters: cliRichParameters,
|
|
RichParameterFile: parameterFlags.richParameterFile,
|
|
RichParameterDefaults: cliRichParameterDefaults,
|
|
UseParameterDefaults: parameterFlags.useParameterDefaults,
|
|
})
|
|
if err != nil {
|
|
return codersdk.CreateWorkspaceBuildRequest{}, err
|
|
}
|
|
|
|
wbr := codersdk.CreateWorkspaceBuildRequest{
|
|
Transition: codersdk.WorkspaceTransitionStart,
|
|
RichParameterValues: buildParameters,
|
|
TemplateVersionID: version,
|
|
}
|
|
if buildFlags.provisionerLogDebug {
|
|
wbr.LogLevel = codersdk.ProvisionerLogLevelDebug
|
|
}
|
|
if buildFlags.reason != "" {
|
|
wbr.Reason = codersdk.CreateWorkspaceBuildReason(buildFlags.reason)
|
|
}
|
|
|
|
return wbr, nil
|
|
}
|
|
|
|
func startWorkspace(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, parameterFlags workspaceParameterFlags, buildFlags buildFlags, action WorkspaceCLIAction) (codersdk.WorkspaceBuild, error) {
|
|
if workspace.DormantAt != nil {
|
|
_, _ = fmt.Fprintln(inv.Stdout, "Activating dormant workspace...")
|
|
err := client.UpdateWorkspaceDormancy(inv.Context(), workspace.ID, codersdk.UpdateWorkspaceDormancy{
|
|
Dormant: false,
|
|
})
|
|
if err != nil {
|
|
return codersdk.WorkspaceBuild{}, xerrors.Errorf("activate workspace: %w", err)
|
|
}
|
|
}
|
|
req, err := buildWorkspaceStartRequest(inv, client, workspace, parameterFlags, buildFlags, action)
|
|
if err != nil {
|
|
return codersdk.WorkspaceBuild{}, err
|
|
}
|
|
|
|
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, req)
|
|
if err != nil {
|
|
return codersdk.WorkspaceBuild{}, xerrors.Errorf("create workspace build: %w", err)
|
|
}
|
|
cliutil.WarnMatchedProvisioners(inv.Stderr, build.MatchedProvisioners, build.Job)
|
|
|
|
return build, nil
|
|
}
|