diff --git a/.claude/docs/DOCS_STYLE_GUIDE.md b/.claude/docs/DOCS_STYLE_GUIDE.md index e5c7a60b30..ac3e649607 100644 --- a/.claude/docs/DOCS_STYLE_GUIDE.md +++ b/.claude/docs/DOCS_STYLE_GUIDE.md @@ -8,12 +8,9 @@ This guide documents structure, research, and content patterns for documentation > Read that first. When this style guide conflicts with the content > guidelines, the content guidelines govern. > -> **For prose rules**, the canonical Coder documentation style guide lives -> at [`docs/.style/style-guide.md`](../../docs/.style/style-guide.md) and -> will be enforced by the Vale rules under `docs/.style/styles/Coder/`. -> That guide is currently a scaffold; continue using the **Writing Style** -> section below until it is populated. This file also remains authoritative -> for structure, research, and content patterns. +> **For prose rules**, refer to the canonical Coder documentation style guide at [`docs/.style/style-guide/`](../../docs/.style/style-guide/README.md). +> Vale rules under `docs/.style/styles/Coder/` enforce those rules incrementally as each rule lands. +> This file remains authoritative for structure, research, and content patterns. See [CONTRIBUTING.md](../../docs/about/contributing/CONTRIBUTING.md) for general contribution guidelines. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 39ac276db1..4a900bc2bf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -183,7 +183,7 @@ jobs: # `**.md` (not `docs/**.md`) because the action's globber collapses a # `**` adjacent to `.md` to a single path segment, so `docs/**.md` # only matches top-level docs/*.md and misses nested pages such as - # docs/.style/style-guide.md. The prose step below re-filters to + # docs/.style/style-guide/README.md. The prose step below re-filters to # docs/ paths. # Cache split into restore + conditional save to avoid letting PR # runs populate a cache that other branches restore from (the @@ -318,7 +318,10 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 1 + # Depth 2 makes the PR merge commit's first parent (the base + # branch tip) available so lint/emdash can diff against HEAD^ + # without fetching the base branch at runtime. + fetch-depth: 2 persist-credentials: false - name: Set up mise tools diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml index 9eef88cf9b..7b61d41e92 100644 --- a/.github/workflows/dogfood.yaml +++ b/.github/workflows/dogfood.yaml @@ -77,6 +77,11 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + # Depth 2 makes the PR merge commit's first parent (the base + # branch tip) available so lint/emdash, run inside the image via + # scripts/dogfood_test_image.sh, can diff against HEAD^1 without + # fetching the base branch at runtime. + fetch-depth: 2 persist-credentials: false - name: Get branch name diff --git a/.github/workflows/publish-mcp-registry.yaml b/.github/workflows/publish-mcp-registry.yaml new file mode 100644 index 0000000000..d1b7111d01 --- /dev/null +++ b/.github/workflows/publish-mcp-registry.yaml @@ -0,0 +1,80 @@ +name: Publish to MCP Registry + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version to publish (semver, e.g. 2.20.0). Used only for manual runs." + required: false + type: string + publish: + description: "Actually publish to the live registry. Leave false to validate only." + required: false + default: false + type: boolean + +jobs: + publish-mcp: + runs-on: ubuntu-latest + permissions: + id-token: write # Required for GitHub OIDC + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install mcp-publisher + run: | + curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher + + - name: Determine version + id: version + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ "${EVENT_NAME}" = "release" ]; then + # Tag refs look like refs/tags/v2.20.0 + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="${INPUT_VERSION}" + fi + + if [ -z "${VERSION}" ]; then + echo "::error::No version provided. Pass the 'version' input for manual runs." + exit 1 + fi + + # Reject anything that isn't clean semver so we never publish refs/heads/... etc. + if ! echo "${VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::Refusing to publish invalid version '${VERSION}'." + exit 1 + fi + + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + + - name: Set version in server.json + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + jq --arg v "${VERSION}" '.version = $v' server.json > server.tmp + mv server.tmp server.json + cat server.json + + - name: Validate server.json (no publish) + run: ./mcp-publisher validate + + - name: Authenticate to MCP Registry + if: github.event_name == 'release' || inputs.publish + run: ./mcp-publisher login github-oidc + + - name: Publish server to MCP Registry + if: github.event_name == 'release' || inputs.publish + run: ./mcp-publisher publish diff --git a/AGENTS.md b/AGENTS.md index 7c93c4e696..988a81dcdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,8 @@ Rule #1: If you want exception to ANY rule, YOU MUST STOP and get explicit permi - Docs content scope: Use [Coder Docs Content Guidelines](docs/.style/content-guidelines.md) to decide whether a piece of content belongs in `docs/` at all. The Documentation Style Guide above covers prose and formatting; the content guidelines govern scope and routing and supersede the style guide on conflicts. - Compatibility: `.agents/docs` symlinks to `.claude/docs` for agent runtimes that look there. - Frontend: Read [Frontend Development Guidelines](site/AGENTS.md) before changing anything under `site/`. -- Docs prose: When editing anything under `docs/`, see the prose style guide at [`docs/.style/style-guide.md`](docs/.style/style-guide.md). It is currently a scaffold; until it is populated, use the **Writing Style** section in [`.claude/docs/DOCS_STYLE_GUIDE.md`](.claude/docs/DOCS_STYLE_GUIDE.md). That file also covers structure, research, and content patterns. +- Docs prose: When editing anything under `docs/`, refer to the prose style guide at [`docs/.style/style-guide/`](docs/.style/style-guide/README.md). + For supporting agent-specific guidance, refer to [`.claude/docs/DOCS_STYLE_GUIDE.md`](.claude/docs/DOCS_STYLE_GUIDE.md), which covers structure, research, and content patterns. ## Foundational rules diff --git a/agent/agentproc/api_test.go b/agent/agentproc/api_test.go index 73efa6bdf7..c718cf3248 100644 --- a/agent/agentproc/api_test.go +++ b/agent/agentproc/api_test.go @@ -964,13 +964,11 @@ func TestProcessOutput(t *testing.T) { codes [2]int ) for i := range 2 { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { w := getOutputWithWait(t, handler, id) codes[i] = w.Code _ = json.NewDecoder(w.Body).Decode(&resps[i]) - }() + }) } // Signal the process to exit so both waiters unblock. diff --git a/agent/filefinder/bench_test.go b/agent/filefinder/bench_test.go index fd36be5612..33182cfc74 100644 --- a/agent/filefinder/bench_test.go +++ b/agent/filefinder/bench_test.go @@ -300,13 +300,11 @@ func BenchmarkSearch_ConcurrentReads_Throughput(b *testing.B) { perGoroutine = 1 } for gi := 0; gi < g; gi++ { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { for j := 0; j < perGoroutine; j++ { _ = filefinder.SearchSnapshotForTest(plan, snap, maxCands) } - }() + }) } wg.Wait() totalOps := float64(g * perGoroutine) diff --git a/agent/immortalstreams/backedpipe/backed_pipe_test.go b/agent/immortalstreams/backedpipe/backed_pipe_test.go index 5e81cf7c4e..82ed838127 100644 --- a/agent/immortalstreams/backedpipe/backed_pipe_test.go +++ b/agent/immortalstreams/backedpipe/backed_pipe_test.go @@ -756,13 +756,11 @@ func TestBackedPipe_DuplicateReconnectionPrevention(t *testing.T) { // Start all goroutines for i := 0; i < numConcurrent; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() + wg.Go(func() { // Wait for the signal to start - <-startSignals[idx] - errors[idx] = bp.ForceReconnect() - }(i) + <-startSignals[i] + errors[i] = bp.ForceReconnect() + }) } // Start the first ForceReconnect and wait for it to block diff --git a/agent/immortalstreams/backedpipe/backed_writer_test.go b/agent/immortalstreams/backedpipe/backed_writer_test.go index b61425e827..20c301cbca 100644 --- a/agent/immortalstreams/backedpipe/backed_writer_test.go +++ b/agent/immortalstreams/backedpipe/backed_writer_test.go @@ -883,14 +883,12 @@ func TestBackedWriter_MultipleWritesDuringReconnect(t *testing.T) { writesStarted := make(chan struct{}, numWriters) for i := 0; i < numWriters; i++ { - wg.Add(1) - go func(id int) { - defer wg.Done() + wg.Go(func() { // Signal that this write is starting writesStarted <- struct{}{} - data := []byte{byte('A' + id)} - _, writeResults[id] = bw.Write(data) - }(i) + data := []byte{byte('A' + i)} + _, writeResults[i] = bw.Write(data) + }) } // Wait for all writes to start diff --git a/agent/unit/graph_test.go b/agent/unit/graph_test.go index f7d1117be7..287cf04442 100644 --- a/agent/unit/graph_test.go +++ b/agent/unit/graph_test.go @@ -244,16 +244,14 @@ func TestGraphThreadSafety(t *testing.T) { barrier := make(chan struct{}) // Launch writers for i := 0; i < numWriters; i++ { - wg.Add(1) - go func(writerID int) { - defer wg.Done() + wg.Go(func() { <-barrier for j := 0; j < operationsPerWriter; j++ { - from := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", writerID, j)} - to := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", writerID, j+1)} + from := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", i, j)} + to := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", i, j+1)} graph.AddEdge(from, to, testEdgeCompleted) } - }(i) + }) } // Launch readers @@ -263,20 +261,18 @@ func TestGraphThreadSafety(t *testing.T) { }, numReaders) for i := 0; i < numReaders; i++ { - wg.Add(1) - go func(readerID int) { - defer wg.Done() + wg.Go(func() { <-barrier defer func() { if r := recover(); r != nil { - readerResults[readerID].panicked = true + readerResults[i].panicked = true } }() readCount := 0 for j := 0; j < operationsPerReader; j++ { // Create a test vertex and read - testUnit := &testGraphVertex{Name: fmt.Sprintf("test-reader-%d-%d", readerID, j)} + testUnit := &testGraphVertex{Name: fmt.Sprintf("test-reader-%d-%d", i, j)} forwardEdges := graph.GetForwardAdjacentVertices(testUnit) reverseEdges := graph.GetReverseAdjacentVertices(testUnit) @@ -285,8 +281,8 @@ func TestGraphThreadSafety(t *testing.T) { _ = reverseEdges readCount++ } - readerResults[readerID].readCount = readCount - }(i) + readerResults[i].readCount = readCount + }) } close(barrier) @@ -324,13 +320,11 @@ func TestGraphThreadSafety(t *testing.T) { // Launch goroutines trying to add D→A (creates cycle) for i := 0; i < numGoroutines; i++ { - wg.Add(1) - go func(goroutineID int) { - defer wg.Done() + wg.Go(func() { <-barrier err := graph.AddEdge(unitD, unitA, testEdgeCompleted) - cycleErrors[goroutineID] = err - }(i) + cycleErrors[i] = err + }) } close(barrier) @@ -370,28 +364,24 @@ func TestGraphThreadSafety(t *testing.T) { // Launch readers calling ToDOT dotErrors := make([]error, numReaders) for i := 0; i < numReaders; i++ { - wg.Add(1) - go func(readerID int) { - defer wg.Done() + wg.Go(func() { <-barrier - dot, err := graph.ToDOT(fmt.Sprintf("test-%d", readerID)) - dotErrors[readerID] = err + dot, err := graph.ToDOT(fmt.Sprintf("test-%d", i)) + dotErrors[i] = err if err == nil { - dotResults[readerID] = dot + dotResults[i] = dot } - }(i) + }) } // Launch writers adding edges for i := 0; i < numWriters; i++ { - wg.Add(1) - go func(writerID int) { - defer wg.Done() + wg.Go(func() { <-barrier - from := &testGraphVertex{Name: fmt.Sprintf("writer-dot-%d", writerID)} - to := &testGraphVertex{Name: fmt.Sprintf("writer-dot-target-%d", writerID)} + from := &testGraphVertex{Name: fmt.Sprintf("writer-dot-%d", i)} + to := &testGraphVertex{Name: fmt.Sprintf("writer-dot-target-%d", i)} graph.AddEdge(from, to, testEdgeCompleted) - }(i) + }) } close(barrier) @@ -418,9 +408,7 @@ func BenchmarkGraph_ConcurrentMixedOperations(b *testing.B) { for i := 0; i < b.N; i++ { // Launch goroutines performing random operations for j := 0; j < numGoroutines; j++ { - wg.Add(1) - go func(goroutineID int) { - defer wg.Done() + wg.Go(func() { operationCount := 0 for operationCount < 50 { @@ -428,7 +416,7 @@ func BenchmarkGraph_ConcurrentMixedOperations(b *testing.B) { if operation < 0.6 { // 60% reads // Read operation - testUnit := &testGraphVertex{Name: fmt.Sprintf("bench-read-%d-%d", goroutineID, operationCount)} + testUnit := &testGraphVertex{Name: fmt.Sprintf("bench-read-%d-%d", j, operationCount)} forwardEdges := graph.GetForwardAdjacentVertices(testUnit) reverseEdges := graph.GetReverseAdjacentVertices(testUnit) @@ -437,14 +425,14 @@ func BenchmarkGraph_ConcurrentMixedOperations(b *testing.B) { _ = reverseEdges } else { // 40% writes // Write operation - from := &testGraphVertex{Name: fmt.Sprintf("bench-write-%d-%d", goroutineID, operationCount)} - to := &testGraphVertex{Name: fmt.Sprintf("bench-write-target-%d-%d", goroutineID, operationCount)} + from := &testGraphVertex{Name: fmt.Sprintf("bench-write-%d-%d", j, operationCount)} + to := &testGraphVertex{Name: fmt.Sprintf("bench-write-target-%d-%d", j, operationCount)} graph.AddEdge(from, to, testEdgeCompleted) } operationCount++ } - }(j) + }) } wg.Wait() diff --git a/aibridge/intercept/messages/base.go b/aibridge/intercept/messages/base.go index 39436a1ecc..b1e989b6a0 100644 --- a/aibridge/intercept/messages/base.go +++ b/aibridge/intercept/messages/base.go @@ -69,11 +69,6 @@ var bedrockSupportedBetaFlags = map[string]bool{ type BedrockRuntime struct { Cfg aibconfig.AWSBedrock Creds aws.CredentialsProvider - // ResolvedRegion is the region the AWS SDK resolved at construction (from - // the environment, shared config, or IMDS). It is used for request signing - // when Cfg.Region is empty, e.g. a custom base URL with the region supplied - // via AWS_REGION. - ResolvedRegion string } type interceptionBase struct { @@ -298,14 +293,8 @@ func (i *interceptionBase) withAWSBedrockOptions(ctx context.Context) ([]option. return nil, xerrors.Errorf("resolve AWS credentials: %w", err) } - // Fall back to the SDK-resolved region (e.g. from AWS_REGION) when no - // explicit region is configured. - region := cfg.Region - if region == "" { - region = i.bedrock.ResolvedRegion - } awsCfg := aws.Config{ - Region: region, + Region: cfg.Region, Credentials: i.bedrock.Creds, } diff --git a/aibridge/internal/integrationtest/circuit_breaker_internal_test.go b/aibridge/internal/integrationtest/circuit_breaker_internal_test.go index 57f9b27df3..bd06d09e27 100644 --- a/aibridge/internal/integrationtest/circuit_breaker_internal_test.go +++ b/aibridge/internal/integrationtest/circuit_breaker_internal_test.go @@ -474,12 +474,10 @@ func TestCircuitBreaker_HalfOpenMaxRequests(t *testing.T) { responses := make(chan int, totalRequests) for i := 0; i < totalRequests; i++ { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { status := doRequest() responses <- status - }() + }) } wg.Wait() diff --git a/aibridge/keypool/keypool_test.go b/aibridge/keypool/keypool_test.go index d1ab09e7de..9880c59e08 100644 --- a/aibridge/keypool/keypool_test.go +++ b/aibridge/keypool/keypool_test.go @@ -619,11 +619,9 @@ func TestKeyConcurrent(t *testing.T) { const numGoroutines = 10 var wg sync.WaitGroup for r := range numGoroutines { - wg.Add(1) - go func(r int) { - defer wg.Done() + wg.Go(func() { tc.run(r, key) - }(r) + }) } wg.Wait() diff --git a/aibridge/provider/anthropic.go b/aibridge/provider/anthropic.go index 01cc587ecf..3916e7c975 100644 --- a/aibridge/provider/anthropic.go +++ b/aibridge/provider/anthropic.go @@ -63,11 +63,17 @@ func NewAnthropic(ctx context.Context, cfg config.Anthropic, bedrockCfg *config. // so it is cheap to run at construction. var bedrock *messages.BedrockRuntime if bedrockCfg != nil { - creds, region, err := buildBedrockCredentials(ctx, *bedrockCfg) + creds, resolvedRegion, err := buildBedrockCredentials(ctx, *bedrockCfg) if err != nil { return nil, xerrors.Errorf("build bedrock credentials: %w", err) } - bedrock = &messages.BedrockRuntime{Cfg: *bedrockCfg, Creds: creds, ResolvedRegion: region} + runtimeCfg := *bedrockCfg + // resolvedRegion is bedrockCfg.Region if provided; + // otherwise, it is resolved from the environment via awsconfig.LoadDefaultConfig + if runtimeCfg.Region == "" { + runtimeCfg.Region = resolvedRegion + } + bedrock = &messages.BedrockRuntime{Cfg: runtimeCfg, Creds: creds} } return &Anthropic{ diff --git a/cli/exp_scaletest_bridge.go b/cli/exp_scaletest_bridge.go index 0e6a86d837..279fc7237a 100644 --- a/cli/exp_scaletest_bridge.go +++ b/cli/exp_scaletest_bridge.go @@ -42,8 +42,8 @@ func (r *RootCmd) scaletestBridge() *serpent.Command { cmd := &serpent.Command{ Use: "bridge", - Short: "Generate load on the AI Bridge service.", - Long: `Generate load for AI Bridge testing. Supports two modes: 'bridge' mode routes requests through the Coder AI Bridge, 'direct' mode makes requests directly to an upstream URL (useful for baseline comparisons). + Short: "Generate load on the AI Gateway service.", + Long: `Generate load for AI Gateway testing. Supports two modes: 'bridge' mode routes requests through the Coder AI Gateway, 'direct' mode makes requests directly to an upstream URL (useful for baseline comparisons). Examples: # Test OpenAI API through bridge @@ -100,7 +100,7 @@ Examples: userConfig = createusers.Config{ OrganizationID: me.OrganizationIDs[0], } - _, _ = fmt.Fprintln(inv.Stderr, "Bridge mode: creating users and making requests through AI Bridge...") + _, _ = fmt.Fprintln(inv.Stderr, "Bridge mode: creating users and making requests through AI Gateway...") } else { _, _ = fmt.Fprintf(inv.Stderr, "Direct mode: making requests directly to %s\n", upstreamURL) } @@ -210,7 +210,7 @@ Examples: Flag: "mode", Env: "CODER_SCALETEST_BRIDGE_MODE", Default: "direct", - Description: "Request mode: 'bridge' (create users and use AI Bridge) or 'direct' (make requests directly to upstream-url).", + Description: "Request mode: 'bridge' (create users and use AI Gateway) or 'direct' (make requests directly to upstream-url).", Value: serpent.EnumOf(&mode, string(bridge.RequestModeBridge), string(bridge.RequestModeDirect)), }, { diff --git a/cli/portforward_test.go b/cli/portforward_test.go index ac4146ef28..fd693120c3 100644 --- a/cli/portforward_test.go +++ b/cli/portforward_test.go @@ -429,11 +429,9 @@ func setupTestListener(t *testing.T, l net.Listener, prefix []byte) string { return } - wg.Add(1) - go func() { + wg.Go(func() { echoIfPrefixed(t, c, prefix) - wg.Done() - }() + }) } }() diff --git a/cli/server.go b/cli/server.go index 3697cb5fe4..8f636cec35 100644 --- a/cli/server.go +++ b/cli/server.go @@ -280,6 +280,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De IconURL: vals.OIDC.IconURL.String(), IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(), PKCEMethods: pkceSupport.CodeChallengeMethodsSupported, + EmailFallback: vals.OIDC.EmailFallback.Value(), }, nil } diff --git a/cli/ssh_test.go b/cli/ssh_test.go index eb31dc801e..2221a23e7b 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -1360,12 +1360,10 @@ func TestSSH(t *testing.T) { return } - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { defer fd.Close() agentssh.Bicopy(ctx, fd, fd) - }() + }) } }) @@ -1426,12 +1424,10 @@ func TestSSH(t *testing.T) { return } - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { defer fd.Close() agentssh.Bicopy(ctx, fd, fd) - }() + }) } }) @@ -1576,12 +1572,10 @@ func TestSSH(t *testing.T) { return } - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { defer fd.Close() agentssh.Bicopy(ctx, fd, fd) - }() + }) } }) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index cd3fc8f61e..506f31b489 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -964,6 +964,9 @@ These options are only available in the Enterprise Edition. --browser-only bool, $CODER_BROWSER_ONLY Whether Coder only allows connections to workspaces via the browser. + --cluster-host string, $CODER_CLUSTER_HOST + Hostname or (more commonly) IP to reach this replica for clustering. + --derp-server-relay-url url, $CODER_DERP_SERVER_RELAY_URL An HTTP URL that is accessible by other replicas to relay DERP traffic. Required for high availability. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index e713e7d2b2..8d7b441354 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -190,6 +190,13 @@ networking: # Whether Coder only allows connections to workspaces via the browser. # (default: , type: bool) browserOnly: false + # Configure network clustering. Coder Servers in the primary region form a cluster + # by + # communicating directly. + cluster: + # Hostname or (more commonly) IP to reach this replica for clustering. + # (default: , type: string) + clusterHost: "" # Interval to poll for scheduled workspace builds. # (default: 1m0s, type: duration) autobuildPollInterval: 1m0s @@ -434,6 +441,13 @@ oidc: # next login. # (default: true, type: bool) oidc-repair-links: true + # INSECURE: Allow OIDC logins to fall back to email-based matching when the + # linked_id (issuer+subject) does not match an existing user link. Required for + # IdP brokers that do not issue a stable 'sub' for the same user across + # connections. The existing user_link's linked_id is preserved on fallback. Only + # enable if you understand and accept the risk. + # (default: , type: bool) + dangerousOidcEmailFallback: false # Telemetry is critical to our ability to improve Coder. We strip all personal # information before sending data to our servers. Please only disable telemetry # when required by your organization's security policy. diff --git a/coderd/aibridged.go b/coderd/aibridged.go index cd97ef54fc..a088c9a041 100644 --- a/coderd/aibridged.go +++ b/coderd/aibridged.go @@ -6,7 +6,6 @@ import ( "io" "net/http" - "golang.org/x/xerrors" "storj.io/drpc/drpcmux" "storj.io/drpc/drpcserver" @@ -71,17 +70,8 @@ func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client ai if err != nil { return nil, err } - err = aibridgedproto.DRPCRegisterRecorder(mux, srv) - if err != nil { - return nil, xerrors.Errorf("register recorder service: %w", err) - } - err = aibridgedproto.DRPCRegisterMCPConfigurator(mux, srv) - if err != nil { - return nil, xerrors.Errorf("register MCP configurator service: %w", err) - } - err = aibridgedproto.DRPCRegisterAuthorizer(mux, srv) - if err != nil { - return nil, xerrors.Errorf("register key validator service: %w", err) + if err := aibridgedserver.Register(mux, srv); err != nil { + return nil, err } server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ diff --git a/coderd/aibridged/proto/version.go b/coderd/aibridged/proto/version.go new file mode 100644 index 0000000000..914189515d --- /dev/null +++ b/coderd/aibridged/proto/version.go @@ -0,0 +1,19 @@ +package proto + +import "github.com/coder/coder/v2/apiversion" + +// Version history: +// +// API v1.0: +// - Initial version. Serves the Recorder, MCPConfigurator, and Authorizer +// services to embedded and standalone AI Gateway daemons. +const ( + CurrentMajor = 1 + CurrentMinor = 0 +) + +// CurrentVersion is the current aibridged API version. +// Breaking changes to the aibridged API **MUST** increment CurrentMajor above. +// Non-breaking changes to the aibridged API **MUST** increment CurrentMinor +// above. +var CurrentVersion = apiversion.New(CurrentMajor, CurrentMinor) diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 1690f8bac7..469fac2219 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -594,13 +594,13 @@ externalAuthLoop: // IsAuthorized validates a given Coder API key and returns the user ID to which it belongs (if valid). // // SECURITY: when in.KeyId is set (the "delegated" path), this method trusts the -// caller's claim of identity and skips the key-secret check. This is safe only -// because the DRPCServer is reachable solely via the in-process -// [aibridged.MemTransportPipe]; the handler itself cannot tell whether it was -// invoked over the in-memory pipe or a network socket. If this RPC is ever -// exposed over a network boundary, any caller who knows a valid 10-char key ID -// (which is not secret) could authenticate as the key's owner without the -// secret. Do not bind this DRPCServer to a network listener. +// caller's claim of identity and skips the key-secret check. This DRPCServer is +// reachable both in-process via [aibridged.MemTransportPipe] and over the network +// via the /api/v2/ai-gateway/serve endpoint. That endpoint admits only holders of +// AI Gateway key, which are fully trusted. Standalone AI Gateway authenticates its +// own users and acts on their behalf, much like a provisioner daemon. A Gateway key +// holder can therefore act as any user without that user's secret. Per-user +// authorization on this surface is a known gap. // // NOTE: this should really be using the code from [httpmw.ExtractAPIKey]. That function not only validates the key // but handles many other cases like updating last used, expiry, etc. This code does not currently use it for diff --git a/coderd/aibridgedserver/register.go b/coderd/aibridgedserver/register.go new file mode 100644 index 0000000000..09f5a712f0 --- /dev/null +++ b/coderd/aibridgedserver/register.go @@ -0,0 +1,25 @@ +package aibridgedserver + +import ( + "golang.org/x/xerrors" + "storj.io/drpc/drpcmux" + + "github.com/coder/coder/v2/coderd/aibridged/proto" +) + +// Register registers the Recorder, MCPConfigurator, and Authorizer DRPC +// services backed by srv onto mux. It is shared by the embedded in-memory +// server and the standalone /api/v2/ai-gateway/serve WebSocket handler so both +// expose an identical service set. +func Register(mux *drpcmux.Mux, srv *Server) error { + if err := proto.DRPCRegisterRecorder(mux, srv); err != nil { + return xerrors.Errorf("register recorder service: %w", err) + } + if err := proto.DRPCRegisterMCPConfigurator(mux, srv); err != nil { + return xerrors.Errorf("register MCP configurator service: %w", err) + } + if err := proto.DRPCRegisterAuthorizer(mux, srv); err != nil { + return xerrors.Errorf("register authorizer service: %w", err) + } + return nil +} diff --git a/coderd/aibridgedtest/aibridgedtest.go b/coderd/aibridgedtest/aibridgedtest.go new file mode 100644 index 0000000000..9ae3511566 --- /dev/null +++ b/coderd/aibridgedtest/aibridgedtest.go @@ -0,0 +1,94 @@ +//go:build !slim + +// Package aibridgedtest provides helpers for starting an in-process +// aibridged daemon in tests. +package aibridgedtest + +import ( + "context" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/aibridged" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" +) + +// StartTestAIBridgeDaemon wires an in-process aibridged daemon onto the +// supplied API, mirroring what cli/server.go does in production. Tests that +// create AI provider rows with BaseURL pointing at fake upstream HTTP servers +// (e.g. chattest.NewOpenAI) will have their requests proxied through the real +// aibridged stack as they would in production. +// +// metrics is the registry the daemon reports provider reload events to. +// The caller owns the metrics instance and can assert on it after the daemon +// runs. Use [aibridged.NewMetrics] to create one, or nil for a throwaway. +func StartTestAIBridgeDaemon( + ctx context.Context, + t testing.TB, + api *coderd.API, + metrics *aibridged.Metrics, +) { + t.Helper() + + logger := api.Logger.Named("aibridged").Leveled(slog.LevelDebug) + cfg := api.DeploymentValues.AI.BridgeConfig + tracer := otel.Tracer("aibridge-test") + + providers, _, err := cli.BuildProviders(ctx, api.Database, cfg, logger, nil) + if err != nil { + t.Fatalf("build providers: %v", err) + } + + pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, logger.Named("pool"), nil, tracer) + if err != nil { + t.Fatalf("create bridge pool: %v", err) + } + t.Cleanup(func() { _ = pool.Shutdown(context.Background()) }) + + if metrics == nil { + metrics = aibridged.NewMetrics(prometheus.NewRegistry()) + } + reloader := &testPoolReloader{pool: pool, db: api.Database, cfg: cfg, logger: logger.Named("reloader"), metrics: metrics} + unsubscribe, err := aibridged.SubscribeProviderReload(ctx, api.Pubsub, reloader, logger.Named("subscriber")) + if err != nil { + t.Fatalf("subscribe provider reload: %v", err) + } + t.Cleanup(unsubscribe) + + srv, err := aibridged.New(ctx, pool, func(dialCtx context.Context) (aibridged.DRPCClient, error) { + return api.CreateInMemoryAIBridgeServer(dialCtx) + }, logger, tracer) + if err != nil { + t.Fatalf("create aibridged server: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + + api.RegisterInMemoryAIBridgedHTTPHandler(srv) +} + +type testPoolReloader struct { + pool *aibridged.CachedBridgePool + db database.Store + cfg codersdk.AIBridgeConfig + logger slog.Logger + metrics *aibridged.Metrics +} + +func (r *testPoolReloader) Reload(ctx context.Context) error { + // Stamp the attempt before building providers so the gap between + // attempt and success timestamps reveals a mid-reload hang. + r.metrics.RecordReloadAttempt() + providers, outcomes, err := cli.BuildProviders(ctx, r.db, r.cfg, r.logger, nil) + if err != nil { + return err + } + r.pool.ReplaceProviders(providers) + r.metrics.RecordReloadSuccess(outcomes) + return nil +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e5839bd177..d31e7468a7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1532,6 +1532,25 @@ const docTemplate = `{ ] } }, + "/api/v2/ai-gateway/serve": { + "get": { + "tags": [ + "Enterprise" + ], + "summary": "AI Gateway serve", + "operationId": "ai-gateway-serve", + "responses": { + "101": { + "description": "Switching Protocols" + } + }, + "security": [ + { + "AIGatewayKey": [] + } + ] + } + }, "/api/v2/ai-gateway/sessions": { "get": { "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.", @@ -15256,7 +15275,7 @@ const docTemplate = `{ "key_prefix": { "type": "string" }, - "last_used_at": { + "last_heartbeat_at": { "type": "string", "format": "date-time" }, @@ -15490,6 +15509,7 @@ const docTemplate = `{ "ai_gateway_key:create", "ai_gateway_key:delete", "ai_gateway_key:read", + "ai_gateway_key:update", "ai_model_price:*", "ai_model_price:read", "ai_model_price:update", @@ -15724,6 +15744,7 @@ const docTemplate = `{ "APIKeyScopeAiGatewayKeyCreate", "APIKeyScopeAiGatewayKeyDelete", "APIKeyScopeAiGatewayKeyRead", + "APIKeyScopeAiGatewayKeyUpdate", "APIKeyScopeAiModelPriceAll", "APIKeyScopeAiModelPriceRead", "APIKeyScopeAiModelPriceUpdate", @@ -17838,6 +17859,14 @@ const docTemplate = `{ "ChatWatchEventKindContextDirty" ] }, + "codersdk.ClusterConfig": { + "type": "object", + "properties": { + "host": { + "type": "string" + } + } + }, "codersdk.ConnectionLatency": { "type": "object", "properties": { @@ -19174,6 +19203,9 @@ const docTemplate = `{ "cli_upgrade_message": { "type": "string" }, + "cluster": { + "$ref": "#/definitions/codersdk.ClusterConfig" + }, "config": { "type": "string" }, @@ -21272,6 +21304,10 @@ const docTemplate = `{ "type": "string" } }, + "email_fallback": { + "description": "EmailFallback allows OIDC logins to fall back to email-based matching\nwhen the ` + "`" + `linked_id` + "`" + ` (issuer+subject) does not match an existing user\nlink. INSECURE: weakens the linked_id check. It exists for IdP\nbrokers that do not issue a stable ` + "`" + `sub` + "`" + ` for the same user across\nconnections.", + "type": "boolean" + }, "email_field": { "type": "string" }, @@ -28895,6 +28931,11 @@ const docTemplate = `{ } }, "securityDefinitions": { + "AIGatewayKey": { + "type": "apiKey", + "name": "X-AI-Governance-Gateway-Key", + "in": "header" + }, "Authorization": { "type": "apiKey", "name": "Authorizaiton", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9b41a6ebc0..6c7ba2819e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1355,6 +1355,23 @@ ] } }, + "/api/v2/ai-gateway/serve": { + "get": { + "tags": ["Enterprise"], + "summary": "AI Gateway serve", + "operationId": "ai-gateway-serve", + "responses": { + "101": { + "description": "Switching Protocols" + } + }, + "security": [ + { + "AIGatewayKey": [] + } + ] + } + }, "/api/v2/ai-gateway/sessions": { "get": { "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.", @@ -13604,7 +13621,7 @@ "key_prefix": { "type": "string" }, - "last_used_at": { + "last_heartbeat_at": { "type": "string", "format": "date-time" }, @@ -13830,6 +13847,7 @@ "ai_gateway_key:create", "ai_gateway_key:delete", "ai_gateway_key:read", + "ai_gateway_key:update", "ai_model_price:*", "ai_model_price:read", "ai_model_price:update", @@ -14064,6 +14082,7 @@ "APIKeyScopeAiGatewayKeyCreate", "APIKeyScopeAiGatewayKeyDelete", "APIKeyScopeAiGatewayKeyRead", + "APIKeyScopeAiGatewayKeyUpdate", "APIKeyScopeAiModelPriceAll", "APIKeyScopeAiModelPriceRead", "APIKeyScopeAiModelPriceUpdate", @@ -16091,6 +16110,14 @@ "ChatWatchEventKindContextDirty" ] }, + "codersdk.ClusterConfig": { + "type": "object", + "properties": { + "host": { + "type": "string" + } + } + }, "codersdk.ConnectionLatency": { "type": "object", "properties": { @@ -17376,6 +17403,9 @@ "cli_upgrade_message": { "type": "string" }, + "cluster": { + "$ref": "#/definitions/codersdk.ClusterConfig" + }, "config": { "type": "string" }, @@ -19402,6 +19432,10 @@ "type": "string" } }, + "email_fallback": { + "description": "EmailFallback allows OIDC logins to fall back to email-based matching\nwhen the `linked_id` (issuer+subject) does not match an existing user\nlink. INSECURE: weakens the linked_id check. It exists for IdP\nbrokers that do not issue a stable `sub` for the same user across\nconnections.", + "type": "boolean" + }, "email_field": { "type": "string" }, @@ -26664,6 +26698,11 @@ } }, "securityDefinitions": { + "AIGatewayKey": { + "type": "apiKey", + "name": "X-AI-Governance-Gateway-Key", + "in": "header" + }, "Authorization": { "type": "apiKey", "name": "Authorizaiton", diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 93da5d8df2..668772f05b 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -189,7 +189,7 @@ func (e *Executor) runOnce(t time.Time) Stats { // NOTE: If a workspace build is created with a given TTL and then the user either // changes or unsets the TTL, the deadline for the workspace build will not // have changed. This behavior is as expected per #2229. - workspaces, err := e.db.GetWorkspacesEligibleForTransition(e.ctx, currentTick) + workspaces, err := e.db.GetWorkspacesEligibleForLifecycleAction(e.ctx, currentTick) if err != nil { e.log.Error(e.ctx, "get workspaces for autostart or autostop", slog.Error(err)) return stats @@ -207,7 +207,7 @@ func (e *Executor) runOnce(t time.Time) Stats { // set of identical template versions. Then unload the files when the builds // are done. Right now, this relies on luck for the 10 goroutine workers to // overlap and keep the file reference in the cache alive. - slices.SortFunc(workspaces, func(a, b database.GetWorkspacesEligibleForTransitionRow) int { + slices.SortFunc(workspaces, func(a, b database.GetWorkspacesEligibleForLifecycleActionRow) int { return strings.Compare(a.BuildTemplateVersionID.UUID.String(), b.BuildTemplateVersionID.UUID.String()) }) @@ -232,6 +232,9 @@ func (e *Executor) runOnce(t time.Time) Stats { auditLog *auditParams shouldNotifyDormancy bool shouldNotifyTaskPause bool + shouldRemind bool + reminderDeadline time.Time + reminderBuildID uuid.UUID nextBuild *database.WorkspaceBuild activeTemplateVersion database.TemplateVersion ws database.Workspace @@ -309,11 +312,29 @@ func (e *Executor) runOnce(t time.Time) Stats { nextTransition, reason, err := getNextTransition(user, ws, latestBuild, latestJob, templateSchedule, currentTick) if err != nil { - log.Debug(e.ctx, "skipping workspace", slog.Error(err)) - // err is used to indicate that a workspace is not eligible - // so returning nil here is ok although ultimately the distinction - // doesn't matter since the transaction is read-only up to - // this point. + return xerrors.Errorf("get next transition: %w", err) + } + + // No transition is due. The workspace may still need a one-time + // autostop reminder; reuse the lock and transaction we already + // hold to stamp the marker. + if reason == "" { + log.Debug(e.ctx, "skipping workspace, no transition due") + // A deadline change (e.g. activity bump) re-arms the reminder; users near + // the boundary may receive one reminder per bump. Intentional: one-per-build + // would leave stale reminders after a bump. + if shouldRemindAutostop(latestBuild, templateSchedule, currentTick) { + if err := tx.UpdateWorkspaceBuildNotifiedAutostopDeadline(e.ctx, database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams{ + ID: latestBuild.ID, + NotifiedAutostopDeadline: latestBuild.Deadline, + UpdatedAt: dbtime.Now(), + }); err != nil { + return xerrors.Errorf("stamp autostop reminder marker: %w", err) + } + reminderDeadline = latestBuild.Deadline + reminderBuildID = latestBuild.ID + shouldRemind = true + } return nil } @@ -536,6 +557,24 @@ func (e *Executor) runOnce(t time.Time) Stats { } } } + if shouldRemind { + // At-most-once: the marker is already committed, so a failed + // enqueue only logs (no retry). + if _, err := e.notificationsEnqueuer.Enqueue( + e.ctx, + ws.OwnerID, + notifications.TemplateWorkspaceAutostopReminder, + map[string]string{ + "workspace": ws.Name, + "deadline": reminderDeadline.UTC().Format(time.RFC1123), + }, + "lifecycle_executor", + // Associate this notification with all the related entities. + ws.ID, ws.OwnerID, ws.TemplateID, ws.OrganizationID, + ); err != nil { + log.Warn(e.ctx, "failed to notify of upcoming workspace autostop", slog.F("build_id", reminderBuildID), slog.Error(err)) + } + } return nil }() if err != nil && !xerrors.Is(err, context.Canceled) { @@ -559,12 +598,50 @@ func (e *Executor) runOnce(t time.Time) Stats { return stats } +// shouldRemindAutostop reports whether a reminder notification should be sent +// for the workspace's latest build at currentTick. +// +// time_til_autostop_notify has no upper bound. If it exceeds a +// workspace's remaining lifetime, the notify window already covers "now" at +// build creation. This is still safe: we require deadline > now (so we never +// remind once the stop is due) and the marker (NotifiedAutostopDeadline == +// Deadline, stamped in the transaction before the send attempt) filters every +// subsequent tick. The result is exactly one reminder per deadline, never one +// per tick. +func shouldRemindAutostop(build database.WorkspaceBuild, templateSchedule schedule.TemplateScheduleOptions, currentTick time.Time) bool { + if templateSchedule.TimeTilAutostopNotify <= 0 { + return false + } + + if build.Transition != database.WorkspaceTransitionStart || build.Deadline.IsZero() { + return false + } + + if !build.Deadline.After(currentTick) { + return false + } + + // "now" must be within the lead window before the deadline, i.e. + // deadline <= now + time_til_autostop_notify. + if build.Deadline.After(currentTick.Add(templateSchedule.TimeTilAutostopNotify)) { + return false + } + + // Idempotence: a reminder has not yet been sent for THIS deadline. The + // marker re-arms automatically when the deadline changes (e.g. an activity + // bump), so a new reminder fires once the new deadline re-enters the window. + return !build.NotifiedAutostopDeadline.Equal(build.Deadline) +} + // getNextTransition returns the next eligible transition for the workspace -// as well as the reason for why it is transitioning. It is possible -// for this function to return a nil error as well as an empty transition. -// In such cases it means no provisioning should occur but the workspace -// may be "transitioning" to a new state (such as an inactive, stopped -// workspace transitioning to the dormant state). +// as well as the reason for why it is transitioning. It is possible for this +// function to return a nil error as well as an empty transition with a +// non-empty reason. In such cases it means no provisioning should occur but +// the workspace may be "transitioning" to a new state (such as an inactive, +// stopped workspace transitioning to the dormant state). +// +// When nothing is due, it returns an empty transition, an empty reason, and a +// nil error. Callers gate on reason == "" for the "nothing to do" case. func getNextTransition( user database.User, ws database.Workspace, @@ -604,7 +681,8 @@ func getNextTransition( case isEligibleForDelete(ws, templateSchedule, latestBuild, latestJob, currentTick): return database.WorkspaceTransitionDelete, database.BuildReasonAutodelete, nil default: - return "", "", xerrors.Errorf("last transition not valid for autostart or autostop") + // No autostart, autostop, dormancy, or deletion transition is due. + return "", "", nil } } diff --git a/coderd/autobuild/lifecycle_executor_internal_test.go b/coderd/autobuild/lifecycle_executor_internal_test.go index cde61a18d1..3505ae0705 100644 --- a/coderd/autobuild/lifecycle_executor_internal_test.go +++ b/coderd/autobuild/lifecycle_executor_internal_test.go @@ -112,6 +112,151 @@ func Test_getNextTransition_TaskAutoPause(t *testing.T) { } } +func Test_getNextTransition_NoAction(t *testing.T) { + t.Parallel() + + now := time.Now() + + // A stopped workspace with no autostart schedule, no dormancy, and no + // deletion configured has no transition due. The default case must report + // "nothing to do" via an empty transition AND empty reason, with a nil + // error (not a sentinel error). + user := database.User{Status: database.UserStatusActive} + ws := database.Workspace{ + DormantAt: sql.NullTime{Valid: false}, + } + build := database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStop, + } + job := database.ProvisionerJob{ + JobStatus: database.ProvisionerJobStatusSucceeded, + } + templateSchedule := schedule.TemplateScheduleOptions{} + + transition, reason, err := getNextTransition(user, ws, build, job, templateSchedule, now) + require.NoError(t, err) + require.Equal(t, database.WorkspaceTransition(""), transition) + require.Equal(t, database.BuildReason(""), reason) +} + +func TestShouldRemindAutostop(t *testing.T) { + t.Parallel() + + currentTick := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + const ttl = time.Hour + + // inWindow places the deadline 30m out, inside the 1h lead window. + inWindow := func() database.WorkspaceBuild { + return database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStart, + Deadline: currentTick.Add(30 * time.Minute), + } + } + + testCases := []struct { + Name string + Build database.WorkspaceBuild + TemplateSchedule schedule.TemplateScheduleOptions + Expected bool + }{ + { + Name: "InWindow", + Build: inWindow(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: true, + }, + { + Name: "TemplateDisabled", + Build: inWindow(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: 0}, + Expected: false, + }, + { + Name: "TransitionStop", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.Transition = database.WorkspaceTransitionStop + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + Name: "ZeroDeadline", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.Deadline = time.Time{} + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + Name: "DeadlineInPast", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.Deadline = currentTick.Add(-time.Minute) + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + Name: "BeforeWindow", + Build: func() database.WorkspaceBuild { + b := inWindow() + // Deadline two hours out, ttl is only one hour. + b.Deadline = currentTick.Add(2 * time.Hour) + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + Name: "AlreadyNotified", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.NotifiedAutostopDeadline = b.Deadline + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + // Deadline == currentTick: the stop is already due, so + // !build.Deadline.After(currentTick) rejects it (not a reminder). + Name: "ExactDeadline", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.Deadline = currentTick + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + // Deadline exactly at the window opening edge (now + ttl) is + // eligible: the lead-window check uses After, so the edge passes. + Name: "WindowEdge", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.Deadline = currentTick.Add(ttl) + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.Expected, shouldRemindAutostop(tc.Build, tc.TemplateSchedule, currentTick)) + }) + } +} + func Test_isEligibleForAutostart(t *testing.T) { t.Parallel() diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index c9caf339be..bda41dc3f3 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "sync" "sync/atomic" "testing" "time" @@ -13,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" + "golang.org/x/xerrors" "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" @@ -1890,6 +1892,277 @@ func setupTestDBPrebuiltWorkspace( return workspace } +// setupAutostopReminderWorkspace provisions a running workspace whose template +// has the given time_til_autostop_notify configured, using the caller-supplied +// notifications enqueuer. It returns the harness channels needed to drive ticks +// and observe notifications. +func setupAutostopReminderWorkspace(t *testing.T, timeTilAutostopNotify time.Duration, enq notifications.Enqueuer) ( + client *codersdk.Client, + tickCh chan time.Time, + statsCh chan autobuild.Stats, + workspace codersdk.Workspace, +) { + t.Helper() + + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client = coderdtest.New(t, &coderdtest.Options{ + AutobuildTicker: tickCh, + AutobuildStats: statsCh, + IncludeProvisionerDaemon: true, + NotificationsEnqueuer: enq, + // The AGPL schedule store persists and returns time_til_autostop_notify. + TemplateScheduleStore: schedule.NewAGPLTemplateScheduleStore(), + }) + + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + if timeTilAutostopNotify > 0 { + ctr.TimeTilAutostopNotifyMillis = ptr.Ref(timeTilAutostopNotify.Milliseconds()) + } + }) + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + workspace = coderdtest.MustWorkspace(t, client, ws.ID) + + // The build must have a non-zero deadline for a reminder to ever fire. + require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) + require.NotZero(t, workspace.LatestBuild.Deadline) + return client, tickCh, statsCh, workspace +} + +// failOnceEnqueuer fails its first Enqueue call and delegates every subsequent +// call to the wrapped enqueuer. It is used by the FailedEnqueueNotRetried +// subtest to verify that a failed reminder enqueue is not retried (the +// at-most-once guarantee); notificationstest.FakeEnqueuer.Enqueue always +// succeeds, so this wrapper is the only way to inject a send failure. +type failOnceEnqueuer struct { + notifications.Enqueuer + mu sync.Mutex + failed bool +} + +func (f *failOnceEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) { + f.mu.Lock() + defer f.mu.Unlock() + if !f.failed { + f.failed = true + return nil, xerrors.New("injected enqueue failure") + } + return f.Enqueuer.Enqueue(ctx, userID, templateID, labels, createdBy, targets...) +} + +func TestExecutorAutostopReminder(t *testing.T) { + t.Parallel() + + // Sent: a reminder is enqueued when a tick lands inside the lead window + // [deadline - ttl, deadline). + t.Run("Sent", func(t *testing.T) { + t.Parallel() + + timeTilNotify := 30 * time.Minute + notifyEnq := ¬ificationstest.FakeEnqueuer{} + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + go func() { + // Halfway into the lead window. + tickCh <- deadline.Add(-timeTilNotify / 2) + close(tickCh) + }() + + stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, stats.Errors, 0) + require.Len(t, stats.Transitions, 0) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)) + require.Len(t, sent, 1) + require.Equal(t, workspace.OwnerID, sent[0].UserID) + require.Equal(t, workspace.Name, sent[0].Labels["workspace"]) + require.Equal(t, deadline.UTC().Format(time.RFC1123), sent[0].Labels["deadline"]) + require.Contains(t, sent[0].Targets, workspace.ID) + require.Contains(t, sent[0].Targets, workspace.OwnerID) + require.Contains(t, sent[0].Targets, workspace.TemplateID) + require.Contains(t, sent[0].Targets, workspace.OrganizationID) + }) + + // NotBeforeWindow: no reminder when the tick precedes the lead window. + t.Run("NotBeforeWindow", func(t *testing.T) { + t.Parallel() + + timeTilNotify := 30 * time.Minute + notifyEnq := ¬ificationstest.FakeEnqueuer{} + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + go func() { + // Well before the window opens. + tickCh <- deadline.Add(-2 * timeTilNotify) + close(tickCh) + }() + + stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, stats.Errors, 0) + require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder))) + }) + + // Disabled: time_til_autostop_notify of 0 (the default) never reminds. + t.Run("Disabled", func(t *testing.T) { + t.Parallel() + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, 0, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + go func() { + tickCh <- deadline.Add(-time.Minute) + close(tickCh) + }() + + stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, stats.Errors, 0) + require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder))) + }) + + // NoDuplicate: a second tick still inside the window does not re-notify + // because the idempotence marker was stamped. + t.Run("NoDuplicate", func(t *testing.T) { + t.Parallel() + + timeTilNotify := 30 * time.Minute + notifyEnq := ¬ificationstest.FakeEnqueuer{} + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + // First tick: reminder fires. Receiving from statsCh acts as the + // per-tick barrier guaranteeing the enqueue already happened. + go func() { + tickCh <- deadline.Add(-timeTilNotify / 2) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1) + + // Second tick still inside the window: no new reminder. Sent() + // accumulates across ticks, so a cumulative count still at 1 proves + // the duplicate was suppressed. + go func() { + tickCh <- deadline.Add(-timeTilNotify / 4) + close(tickCh) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1) + }) + + // DeadlineBumped: extending the deadline re-arms the marker, so a new + // reminder fires once the new deadline re-enters the window. + t.Run("DeadlineBumped", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + timeTilNotify := 30 * time.Minute + notifyEnq := ¬ificationstest.FakeEnqueuer{} + client, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + // First tick: reminder fires for the original deadline. + go func() { + tickCh <- deadline.Add(-timeTilNotify / 2) + }() + testutil.TryReceive(ctx, t, statsCh) + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)) + require.Len(t, sent, 1) + require.Equal(t, deadline.UTC().Format(time.RFC1123), sent[0].Labels["deadline"]) + + // Move the deadline well into the future. The marker now differs from + // the build deadline, re-arming the reminder. + newDeadline := deadline.Add(2 * time.Hour) + require.NoError(t, client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{ + Deadline: newDeadline, + })) + + // Second tick inside the new window fires another reminder. Sent() + // accumulates across ticks, so two total proves the second reminder + // fired; sent[1] carries the bumped deadline. + go func() { + tickCh <- newDeadline.Add(-timeTilNotify / 2) + close(tickCh) + }() + testutil.TryReceive(ctx, t, statsCh) + sent = notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)) + require.Len(t, sent, 2) + require.Equal(t, newDeadline.UTC().Format(time.RFC1123), sent[1].Labels["deadline"]) + }) + + // ExceedsLifetime: a time_til_autostop_notify larger than the + // workspace's remaining lifetime yields exactly one reminder, not one per + // tick. + t.Run("ExceedsLifetime", func(t *testing.T) { + t.Parallel() + + // Far larger than the workspace's 8h TTL, so the lead window already + // includes "now" at build creation. + timeTilNotify := 100 * time.Hour + notifyEnq := ¬ificationstest.FakeEnqueuer{} + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + // First tick: a single reminder fires. + go func() { + tickCh <- deadline.Add(-time.Hour) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1) + + // Second tick still before the deadline: no flood of reminders. Sent() + // accumulates across ticks, so a cumulative count still at 1 proves no + // duplicate fired. + go func() { + tickCh <- deadline.Add(-30 * time.Minute) + close(tickCh) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1) + }) + + // FailedEnqueueNotRetried pins the marker-before-enqueue / at-most-once + // guarantee: the marker is committed inside the transaction before the + // post-commit enqueue, so a failed enqueue on the first tick is NOT + // retried on a later tick even though the workspace is still inside the + // lead window. failOnceEnqueuer injects that single send failure; + // notificationstest.FakeEnqueuer.Enqueue always succeeds. + t.Run("FailedEnqueueNotRetried", func(t *testing.T) { + t.Parallel() + + fake := ¬ificationstest.FakeEnqueuer{} + enq := &failOnceEnqueuer{Enqueuer: fake} + timeTilNotify := 2 * time.Hour + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, enq) + deadline := workspace.LatestBuild.Deadline.Time + + // Tick 1 inside the window: the enqueue fails. Because the marker is + // stamped before the enqueue, the failure only logs and nothing is + // sent. + go func() { + tickCh <- deadline.Add(-time.Hour) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, fake.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 0) + + // Tick 2 still inside the window: the committed marker suppresses + // re-selection, so the failed reminder is NOT retried. A cumulative + // count still at 0 proves the at-most-once guarantee described at the + // enqueue block in lifecycle_executor.go. + go func() { + tickCh <- deadline.Add(-time.Hour + time.Minute) + close(tickCh) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, fake.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 0) + }) +} + func mustProvisionWorkspace(t *testing.T, client *codersdk.Client, mut ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace { t.Helper() user := coderdtest.CreateFirstUser(t, client) diff --git a/coderd/azureidentity/azureidentity_test.go b/coderd/azureidentity/azureidentity_test.go index 14ca0c53ab..2c12e95aee 100644 --- a/coderd/azureidentity/azureidentity_test.go +++ b/coderd/azureidentity/azureidentity_test.go @@ -21,7 +21,6 @@ import ( func TestValidate(t *testing.T) { t.Parallel() - t.Skip("See https://github.com/coder/internal/issues/1602") mustTime := func(layout string, value string) time.Time { ti, err := time.Parse(layout, value) @@ -36,19 +35,38 @@ func TestValidate(t *testing.T) { vmID string }{{ name: "regular", - payload: "MIILPQYJKoZIhvcNAQcCoIILLjCCCyoCAQExDzANBgkqhkiG9w0BAQsFADCCAUUGCSqGSIb3DQEHAaCCATYEggEyeyJsaWNlbnNlVHlwZSI6IiIsIm5vbmNlIjoiMjAyMjA0MTktMDcyNzIxIiwicGxhbiI6eyJuYW1lIjoiIiwicHJvZHVjdCI6IiIsInB1Ymxpc2hlciI6IiJ9LCJza3UiOiIyMF8wNC1sdHMtZ2VuMiIsInN1YnNjcmlwdGlvbklkIjoiNWYxMzBmZmMtMGEzZS00Nzk1LWI2OTEtNGY1NmExMmE1NTQ3IiwidGltZVN0YW1wIjp7ImNyZWF0ZWRPbiI6IjA0LzE5LzIyIDAxOjI3OjIxIC0wMDAwIiwiZXhwaXJlc09uIjoiMDQvMTkvMjIgMDc6Mjc6MjEgLTAwMDAifSwidm1JZCI6ImJkOGU3NDQzLTI0YTAtNDFmMy1iOTQ5LThiYWY0ZmQxYzU3MyJ9oIIINDCCCDAwggYYoAMCAQICExIAI9QuEyMQ3mYyynwAAAAj1C4wDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEgMB4GA1UEAxMXTWljcm9zb2Z0IFJTQSBUTFMgQ0EgMDEwHhcNMjIwMjIwMTAyMjAyWhcNMjMwMjIwMTAyMjAyWjAdMRswGQYDVQQDExJtZXRhZGF0YS5henVyZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC1t3H5nZ+3x/6jlnf82B8u7GFtMxz2BX6leQhuDQnbReTGXlxsOizZmZcABJHLFG7GROn+pIXJY2mt0AYx1zDEjjmbW65BeUvmOSEj/64+Vc+X7L7ofaO+XxgegDdVqu8H0kwMJO1LPnj1g/47DSuWb+Dm2BqGKRSqvDgM56WuLsZHkCBUC0W2IVZvkOGrUSv1wfMf3vDTl26yB1zr0n9h+uxZfOOaLaKLerzYik/begJbqmUtNTCWpr+llqY+xHf1UShXuv1Bhyq+QzPi66d3WCfzvePm4704j2pZsyHiw/IxndXqdPUX8VEQJkWAw21YFnuabE1cfnnx+VIkBUA5AgMBAAGjggQ1MIIEMTCCAX0GCisGAQQB1nkCBAIEggFtBIIBaQFnAHYArfe++nz/EMiLnT2cHj4YarRnKV3PsQwkyoWGNOvcgooAAAF/FrBJlgAABAMARzBFAiAxACMcHfnjY0aDr7lOfviB2O/XGHCrpyfsCXkgkbW07wIhANwIsAt9JOSeFiirXfKKYJAOHZTnZaF6mzqsiY9QZb/qAHYAs3N3B+GEUPhjhtYFqdwRCUp5LbFnDAuH3PADDnk2pZoAAAF/FrBKsgAABAMARzBFAiAeGLAsEwbtemha4hXZhbhkuGXVjAY36mtFzVj/UMneUAIhAOpOjmAuCvVphrDDR8C76lDV7BOHSP1C/lQCtv6dISccAHUA6D7Q2j71BjUy51covIlryQPTy9ERa+zraeF3fW0GvW4AAAF/FrBJoAAABAMARjBEAiBn3xayoXdrWNpxuq4nHgD4l7h9tTvqXo3rdOPeoihIcgIgczj0VkMqtmw1RP7ezYiB2/KqCz4KN/P5RYfxdByWWzkwJwYJKwYBBAGCNxUKBBowGDAKBggrBgEFBQcDATAKBggrBgEFBQcDAjA+BgkrBgEEAYI3FQcEMTAvBicrBgEEAYI3FQiH2oZ1g+7ZAYLJhRuBtZ5hhfTrYIFdhYaOQYfCmFACAWQCAScwgYcGCCsGAQUFBwEBBHsweTBTBggrBgEFBQcwAoZHaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraS9tc2NvcnAvTWljcm9zb2Z0JTIwUlNBJTIwVExTJTIwQ0ElMjAwMS5jcnQwIgYIKwYBBQUHMAGGFmh0dHA6Ly9vY3NwLm1zb2NzcC5jb20wHQYDVR0OBBYEFO08JtykconiZxO7lGCvQwKSvCLWMA4GA1UdDwEB/wQEAwIEsDBABgNVHREEOTA3ghJtZXRhZGF0YS5henVyZS5jb22CIXNvdXRoY2VudHJhbHVzLm1ldGFkYXRhLmF6dXJlLmNvbTCBsAYDVR0fBIGoMIGlMIGioIGfoIGchk1odHRwOi8vbXNjcmwubWljcm9zb2Z0LmNvbS9wa2kvbXNjb3JwL2NybC9NaWNyb3NvZnQlMjBSU0ElMjBUTFMlMjBDQSUyMDAxLmNybIZLaHR0cDovL2NybC5taWNyb3NvZnQuY29tL3BraS9tc2NvcnAvY3JsL01pY3Jvc29mdCUyMFJTQSUyMFRMUyUyMENBJTIwMDEuY3JsMFcGA1UdIARQME4wQgYJKwYBBAGCNyoBMDUwMwYIKwYBBQUHAgEWJ2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvbXNjb3JwL2NwczAIBgZngQwBAgEwHwYDVR0jBBgwFoAUtXYMMBHOx5JCTUzHXCzIqQzoC2QwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMA0GCSqGSIb3DQEBCwUAA4ICAQCYIcFM1ac5B1ak7eVaJz0RMcBxMPPcubCoooeIkZmDbCo4B9MLoxdRcvlaqSTZZsiKrn4fgIaj6oPpXKNHsSdHCPp64XItFNTa7Nvwkv6D2SCbd3smLhR85U8gqriFmoY0jgrzpHwD+P//yzJL9gGVis4kVzecNPjVApwY3rSPbZP1wXjyK++MHLjL8L0rZnal2WV6ktO50LExR5DNG1WmoDWw9EZSDHL6RlxRYnxjmp/7mjDSy8qrDFf3YKKft43jNSkCC2Yc+8xiQLZ1ibfdRIScWK3kcE423qLqm26mVaY6nXpn1IFnXEV3bD/46OKo/Y89mUNB3/MbZVnhn4o+BU7yQk8Q0ZUHqj6lNmrM56v4pwelAS1ab6Dmuf4gq9Q+Q9n0z7wdM0466V7ZbFd4Zd335pyhFyqysNLL6n7bCqQzlM+I2v/z/SsqW26lHvvlo/lycBLu5SbZ5j1TS+H4I+Ph9gH8uus9xRSbUT/lDXGK3qge3ClwnMvB1ffZH3MNppfQEOBJDQumVuk2Ag0oz0LqM/jKmEWOcfybAg8NrwARdDrhLK8Ma/QwbhstQqJXieqzmJJaSfQXwhLkyhTNk09hwJEKg/K4KasSliYU/pA4ts1XEvUKOk3vAXb+y30oQuaiJqA6KI6tg+O2XkBTCPQPI0CPQhAVvjZc37bRqTGCAZEwggGNAgEBMGYwTzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEgMB4GA1UEAxMXTWljcm9zb2Z0IFJTQSBUTFMgQ0EgMDECExIAI9QuEyMQ3mYyynwAAAAj1C4wDQYJKoZIhvcNAQELBQAwDQYJKoZIhvcNAQEBBQAEggEAKpu78aO06Z3AjxN5SOmv3kVPHPxqiWZPeuG+PcGfhAyu7kmuaorPW/xgAtiZCd7gJ5ILxdlFc7TBvY0Ar8ctpF5yk8OFp88cHkxFdWjoC/S9OhqiG7N50Cai8rje3rgJxuFPmptZMhlcVco6GisuV+gy2fZY+SleU4hSOXkAZ5oTDNycDONW3gGqGFV1/7KW+y0dYAyXZCq6nnMDLvIuIRqSXuns1WBV2FSFmj2vyGPoy5+AYuRTkG6izce+xFj+tGaSJLo+hFfLkJARV1r2BzMsZIEyKQ/6ZfFsoFW3kAkyZc0CokJarIESBIEGD2/sPlw650lT5Ohphtj5VFyp+Q==", - vmID: "bd8e7443-24a0-41f3-b949-8baf4fd1c573", - date: mustTime(time.RFC3339, "2023-02-01T00:00:00Z"), + payload: "MIIMWwYJKoZIhvcNAQcCoIIMTDCCDEgCAQExDzANBgkqhkiG9w0BAQsFADCCAUUGCSqGSIb3DQEHAaCCATYEggEyeyJsaWNlbnNlVHlwZSI6IiIsIm5vbmNlIjoiMjAyNjA2MjYtMDA1NjQ1IiwicGxhbiI6eyJuYW1lIjoiIiwicHJvZHVjdCI6IiIsInB1Ymxpc2hlciI6IiJ9LCJza3UiOiIyMF8wNC1sdHMtZ2VuMiIsInN1YnNjcmlwdGlvbklkIjoiMDVlOGIyODUtNGNlMS00NmEzLWI0YzktZjUxYmE2N2Q2YWNjIiwidGltZVN0YW1wIjp7ImNyZWF0ZWRPbiI6IjA2LzI1LzI2IDE4OjU2OjQ1IC0wMDAwIiwiZXhwaXJlc09uIjoiMDYvMjYvMjYgMDA6NTY6NDUgLTAwMDAifSwidm1JZCI6ImRjMThkZTU4LTI5MmYtNDc5NC05YTVkLWE0MTkyYmFkMDAzOSJ9oIIJSjCCCUYwggcuoAMCAQICE0EALqSXTgsqkQZ6COsAAAAupJcwDQYJKoZIhvcNAQEMBQAwVzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEoMCYGA1UEAxMfTWljcm9zb2Z0IFRMUyBHMiBSU0EgQ0EgT0NTUCAwMjAeFw0yNjA1MTUwNjA1NTdaFw0yNjExMTEwNjA1NTdaMGkxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJXQTEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMRswGQYDVQQDExJtZXRhZGF0YS5henVyZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFUMeP7nY+B8wjCDEynDf1f3RcPLg8xHh2pvyPPItd643gm+mIyCQp46JDPmnjdTQpqwGX2iHhJBgXCMW5eY5s2qJNxUH6sGsl9sSYgOrpiSbnb+ziPqsn+yTsQArkEXeGZY7LAtT37PsTNJHLb5FlULat+ZvGWE9Ul2qjx3Dz06JzzTAJfKharBANq5A1+UaipuAHgNT/pYigWoVOxlbsL101bgu6AUBRV4gkWX6jjSnc2iuGVJww056GuJ4wBlO/rsoJqnpYlYtKnzoOYxoisM46P/mV94ZC05TkkuleiGaq5MhRDtu1yLUSG3nr7nkdJSjuQ+IbppkcMHZT1/7lAgMBAAGjggT3MIIE8zCCAXwGCisGAQQB1nkCBAIEggFsBIIBaAFmAHUA1219ENGn9XfCx+lf1wC/+YLJM1pl4dCzAXMXwMjFaXcAAAGeKkcOowAABAMARjBEAiBQxxq8aaBhsaTybeByYwrTJ8iK115F55DDFQosuQqOVgIgHQ9bewVDO1CJm0A4q6am1+UNcVyTrJYF2HwmORfbyqMAdgDCMX5XRRmjRe5/ON6ykEHrx8IhWiK/f9W1rXaa2Q5SzQAAAZ4qRw6yAAAEAwBHMEUCIQD/bJczftma4J3yW8ykE3Fi/ZnZ+rZFkcjYGxoiB0uPfwIgXv7kbsIcnBZ3vsjPlmFtLJLbI/SLoCf1g1ArGOCkRGQAdQDIo8R/x7OtuTVrAT9qehJt4zpOQ6XGRvmXrTl1mR3PmgAAAZ4qRw7TAAAEAwBGMEQCICAb+0Fr9dMgbLqu43Ub5hX8WIKNXYV3aa9o9OTUhrUFAiBlGy781agUbCEB58We1zK3b2T1IbIhyjx/Baas9IMleDAbBgkrBgEEAYI3FQoEDjAMMAoGCCsGAQUFBwMBMDwGCSsGAQQBgjcVBwQvMC0GJSsGAQQBgjcVCIe91xuB5+tGgoGdLo7QDIfw2h1dg+nDZ4K0o0wCAWQCASAwggELBggrBgEFBQcBAQSB/jCB+zBhBggrBgEFBQcwAoZVaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jZXJ0cy9NaWNyb3NvZnQlMjBUTFMlMjBHMiUyMFJTQSUyMENBJTIwT0NTUCUyMDAyLmNydDBnBggrBgEFBQcwAoZbaHR0cDovL2NhaXNzdWVycy5taWNyb3NvZnQuY29tL3BraW9wcy9jZXJ0cy9NaWNyb3NvZnQlMjBUTFMlMjBHMiUyMFJTQSUyMENBJTIwT0NTUCUyMDAyLmNydDAtBggrBgEFBQcwAYYhaHR0cDovL29uZW9jc3AubWljcm9zb2Z0LmNvbS9vY3NwMB0GA1UdDgQWBBRoMv9LxNxB8rTiBvbP5VrSH7Z4uzAOBgNVHQ8BAf8EBAMCBaAwOAYDVR0RBDEwL4IZZWFzdHVzLm1ldGFkYXRhLmF6dXJlLmNvbYISbWV0YWRhdGEuYXp1cmUuY29tMAwGA1UdEwEB/wQCMAAwgfEGA1UdHwSB6TCB5jCB46CB4KCB3YZsaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvcGFydGl0aW9uL01pY3Jvc29mdCUyMFRMUyUyMEcyJTIwUlNBJTIwQ0ElMjBPQ1NQJTIwMDJfUGFydGl0aW9uMDAwNDUuY3Jshm1odHRwOi8vY3JsMi5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvcGFydGl0aW9uL01pY3Jvc29mdCUyMFRMUyUyMEcyJTIwUlNBJTIwQ0ElMjBPQ1NQJTIwMDJfUGFydGl0aW9uMDAwNDUuY3JsMGYGA1UdIARfMF0wCAYGZ4EMAQICMFEGDCsGAQQBgjdMg30BATBBMD8GCCsGAQUFBwIBFjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL0RvY3MvUmVwb3NpdG9yeS5odG0wHwYDVR0jBBgwFoAUuC8zpnxRT38fLdXIFUI4pLIOjy8wEwYDVR0lBAwwCgYIKwYBBQUHAwEwDQYJKoZIhvcNAQEMBQADggIBAJ5k6mdkczx86V+JuUDjTdXRB2hTncJ7sYIVlKgL59VhrchQZKTvqbwyj1SySCQxPkjHZ5uoNC2GxAAFMdE6qLN4mynkp5rHuR87JYptnbysGb7oLcRgDdV84R6ROSOrhgTimjshUmlb5wQBUI857FZ2e0d5gz3oDX+q8FphUCnNRCyDmxd4nwI95OcauuuA4lLW3fxmx7puwSJhpFch2l+ja0ky0C6MhAm/1n+JqNQhr11aHOOhokySw53a7MJLiGBP+/NJZCoW4R353MIzUFSR/1OREEofICVH8JMDd7seYqUhu8QQqGURxn4+04JIC0MCkU+b+R4/qnwyDVZMkKOeWvu5nxb0osTogfiOZ/sJb2sR8cnr7dRrGNENtWXFdVqxedvimxfAGVl0kXPxwIrzAvlFCmzd3CVrsRvuzNqeSzs5h+8D/esqTSSWSgfVYADQE4r9RZNErnxsoRAijIQOwok5zRFwjZ0VwkRUSzFhmQPOoGFLeDNibSE7Gt3yn8ImmFDHzryxwr7RjPjf6lDO/dQrV8yRZkk1zItOspybEctdWplnjp+N6LtBYBLXkNMBwzmGCCwAqP8MN1CAF/sw33jupoke10Jr5cQ9UpiOUEaWhkFE+g3uVTBLSY+zdXtTWBmNQHncgrCOiEgNc3RwmTPvjmeytnQBpkp479S8MYIBmTCCAZUCAQEwbjBXMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSgwJgYDVQQDEx9NaWNyb3NvZnQgVExTIEcyIFJTQSBDQSBPQ1NQIDAyAhNBAC6kl04LKpEGegjrAAAALqSXMA0GCSqGSIb3DQEBCwUAMA0GCSqGSIb3DQEBAQUABIIBAB7JzdET7tluzF+I9yyaCgmsWlPmvndXZAWtq6YCJqEYvy4OBf4smb/H7e5rK72yaO1jMZnZ6/0IYl1N4DxeNSX3LuLirU7w2r0+sNypte+JH+Gzf1vBO9y57ARCHLLXPRS33T1XQVTsCXPu+7BeH5m6xNIcShxqAlWAAD2g4iR8uqhwJ6FLiA0LHTevqfxC0MQfEmTQE33eifwT3OYgujrLXqalM7MyQncZDIXXJWdgYtyMRh22QGDRb4FAXYs/BPvOBwzlUQuV3TWaHtAwdQUP1jgxlkXxa/xp0lz7O/OnihXY4H8F/vGfFtr3h26inmfsI7nyKiyfopaE6aD6/9c=", + vmID: "dc18de58-292f-4794-9a5d-a4192bad0039", + // This cert uses intermediates: + // 1. Microsoft TLS G2 RSA CA OCSP 02 (expires 2029-06-03T20:03:00Z) + // 2. Microsoft TLS RSA Root G2 (expires 2029-06-19T23:59:59Z) + // It uses root: + // DigiCert Global Root G2 (expires 2038-01-15T12:00:00Z) + // So this test should be good until 2038 provided that we don't remove the above intermediates, and the + // root doesn't get removed from OS trust stores (would be very surprising and a huge security deal). + date: mustTime(time.RFC3339, "2026-06-25T00:00:00Z"), }, { name: "govcloud", payload: "MIILiQYJKoZIhvcNAQcCoIILejCCC3YCAQExDzANBgkqhkiG9w0BAQsFADCCAUAGCSqGSIb3DQEHAaCCATEEggEteyJsaWNlbnNlVHlwZSI6IiIsIm5vbmNlIjoiMjAyMzAzMDgtMjMwOTMzIiwicGxhbiI6eyJuYW1lIjoiIiwicHJvZHVjdCI6IiIsInB1Ymxpc2hlciI6IiJ9LCJza3UiOiIxOC4wNC1MVFMiLCJzdWJzY3JpcHRpb25JZCI6IjBhZmJmZmZhLTVkZjktNGEzYi05ODdlLWZlNzU3NzYyNDI3MiIsInRpbWVTdGFtcCI6eyJjcmVhdGVkT24iOiIwMy8wOC8yMyAxNzowOTozMyAtMDAwMCIsImV4cGlyZXNPbiI6IjAzLzA4LzIzIDIzOjA5OjMzIC0wMDAwIn0sInZtSWQiOiI5OTA4NzhkNC0wNjhhLTRhYzQtOWVlOS0xMjMxZDIyMThlZjIifaCCCHswggh3MIIGX6ADAgECAhMzAIXQK9n2YdJHP1paAAAAhdArMA0GCSqGSIb3DQEBDAUAMFkxCzAJBgNVBAYTAlVTMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKjAoBgNVBAMTIU1pY3Jvc29mdCBBenVyZSBUTFMgSXNzdWluZyBDQSAwNTAeFw0yMzAyMDMxOTAxMThaFw0yNDAxMjkxOTAxMThaMGgxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJXQTEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMRowGAYDVQQDExFtZXRhZGF0YS5henVyZS51czCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMrbkY7Z8ffglHPokuGfRDOBjFt6n68OuReoq2CbnhyEdosDsfJBsoCr5vV3mVcpil1+y0HeabKr+PdJ6GWCXiymxxgMtNMIuz/kt4OVOJSkV3wJyMNYRjGUAB53jw2cJnhIgLy6QmxOm2cnDb+IBFGn7WAw/XqT8taDd6RPDHR6P+XqpWuMN/MheCOdJRagmr8BUNt95eOhRAGZeUWHKcCssBa9xZNmTzgd26NuBRpeGVrjuPCaQXiGWXvJ7zujWOiMopgw7UWXMiJp6J+Nn75Dx+MbPjlLYYBhFEEBaXj0iKuj/3/lm3nkkMLcYPxEJE0lPuX1yQQLUx3l1bBYyykCAwEAAaOCBCcwggQjMIIBfQYKKwYBBAHWeQIEAgSCAW0EggFpAWcAdgDuzdBk1dsazsVct520zROiModGfLzs3sNRSFlGcR+1mwAAAYYYsLzVAAAEAwBHMEUCIQD+BaiDS1uFyVGdeMc5vBUpJOmBhxgRyTkH3kQG+KD6RwIgWIMxqyGtmM9rH5CrWoruToiz7NNfDmp11LLHZNaKpq4AdgBz2Z6JG0yWeKAgfUed5rLGHNBRXnEZKoxrgBB6wXdytQAAAYYYsL0bAAAEAwBHMEUCIQDNxRWECEZmEk9zRmRPNv3QP0lDsUzaKhYvFPmah/wkKwIgXyCv+fvWga+XB2bcKQqom10nvTDBExIZeoOWBSfKVLgAdQB2/4g/Crb7lVHCYcz1h7o0tKTNuyncaEIKn+ZnTFo6dAAAAYYYsL0bAAAEAwBGMEQCICCTSeyEisZwmi49g941B6exndOFwF4JqtoXbWmFcxRcAiBCDaVJJN0e0ZVSPkx9NVMGWvBjQbIYtSG4LEkCdDsMejAnBgkrBgEEAYI3FQoEGjAYMAoGCCsGAQUFBwMCMAoGCCsGAQUFBwMBMDwGCSsGAQQBgjcVBwQvMC0GJSsGAQQBgjcVCIe91xuB5+tGgoGdLo7QDIfw2h1dgoTlaYLzpz4CAWQCASUwga4GCCsGAQUFBwEBBIGhMIGeMG0GCCsGAQUFBzAChmFodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY3Jvc29mdCUyMEF6dXJlJTIwVExTJTIwSXNzdWluZyUyMENBJTIwMDUlMjAtJTIweHNpZ24uY3J0MC0GCCsGAQUFBzABhiFodHRwOi8vb25lb2NzcC5taWNyb3NvZnQuY29tL29jc3AwHQYDVR0OBBYEFBcZK26vkjWcbAk7XwJHTP/lxgeXMA4GA1UdDwEB/wQEAwIEsDA9BgNVHREENjA0gh91c2dvdnZpcmdpbmlhLm1ldGFkYXRhLmF6dXJlLnVzghFtZXRhZGF0YS5henVyZS51czAMBgNVHRMBAf8EAjAAMGQGA1UdHwRdMFswWaBXoFWGU2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY3Jvc29mdCUyMEF6dXJlJTIwVExTJTIwSXNzdWluZyUyMENBJTIwMDUuY3JsMGYGA1UdIARfMF0wUQYMKwYBBAGCN0yDfQEBMEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvRG9jcy9SZXBvc2l0b3J5Lmh0bTAIBgZngQwBAgIwHwYDVR0jBBgwFoAUx7KcfxzjuFrv6WgaqF2UwSZSamgwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMA0GCSqGSIb3DQEBDAUAA4ICAQCUExuLe7D71C5kek65sqKXUodQJXVVpFG0Y4l9ZacBFql8BgHvu2Qvt8zfWsyCHy4A2KcMeHLwi2DdspyTjxSnwkuPcQ4ndhgAqrLkfoTc435NnnsiyzCUNDeGIQ+g+QSRPV86u6LmvFr0ZaOqxp6eJDPYewHhKyGLQuUyBjUNkhS+tGzuvsHaeCUYclmbZFN75IQSvBmL0XOsOD7wXPZB1a68D26wyCIbIC8MuFwxreTrvdRKt/5zIfBnku6S6xRgkzH64gfBLbU5e2VCdaKzElWEKRLJgl3R6raNRqFot+XNfa26H5sMZpZkuHrvkPZcvd5zOfL7fnVZoMLo4A3kFpet7tr1ls0ifqodzlOBMNrUdf+o3kJ1seCjzx2WdFP+2liO80d0oHKiv8djuttlPfQkV8WATmyLoZVoPcNovayrVUjTWFMXqIShhhTbIJ3ZRSZrz6rZLok0Xin3+4d28iMsi7tjxnBW/A/eiPrqs7f2v2rLXuf5/XHuzHIYQpiZpnvA90mE1HBB9fv4sETsw9TuL2nXai/c06HGGM06i4o+lRuyvymrlt/QPR7SCPXl5fZFVAavLtu1UtafrK/qcKQTHnVJeZ20+JdDIJDP2qcxQvdw7XA88aa/Y/olM+yHIjpaPpsRFa2o8UB0ct+x1cTAhLhj3vNwhZHoFlVcFzGCAZswggGXAgEBMHAwWTELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEqMCgGA1UEAxMhTWljcm9zb2Z0IEF6dXJlIFRMUyBJc3N1aW5nIENBIDA1AhMzAIXQK9n2YdJHP1paAAAAhdArMA0GCSqGSIb3DQEBCwUAMA0GCSqGSIb3DQEBAQUABIIBAFuEf//loqaib860Ys5yZkrRj1QiSDSzkU+Vxx9fYXzWzNT4KgMhkEhRRvoE6TR/tIUzbKFQxIVRrlW2lbGSj8JEeLoEVlp2Pc4gNRJeX2N9qVDPvy9lmYuBm1XjypLPwvYjvfPjsLRKkNdQ5MWzrC3F2q2OOQP4sviy/DCcoDitEmqmqiCuog/DiS5xETivde3pTZGiFwKlgzptj4/KYN/iZTzU25fFSCD5Mq2IxHRj39gFkqpFekdSRihSH0W3oyPfic/E3H0rVtSkiFm2SL6nPjILjhaJcV7az+X7Qu4AXYZ/TrabX+OW5dJ69SoJ01DfnqGD0sll0+P3QSUHEvA=", vmID: "990878d4-068a-4ac4-9ee9-1231d2218ef2", - date: mustTime(time.RFC3339, "2023-04-01T00:00:00Z"), + // This cert uses intermediate: + // Microsoft Azure TLS Issuing CA 05 (expires 2024-06-27T23:59:59Z) + // It uses root: + // DigiCert Global Root G2 (expires 2038-01-15T12:00:00Z) + // So this test should be good until 2038 provided that we don't remove the above intermediates, and the + // root doesn't get removed from OS trust stores (would be very surprising and a huge security deal). + date: mustTime(time.RFC3339, "2023-04-01T00:00:00Z"), }, { name: "rsa", payload: "MIILnwYJKoZIhvcNAQcCoIILkDCCC4wCAQExDzANBgkqhkiG9w0BAQsFADCCAUUGCSqGSIb3DQEHAaCCATYEggEyeyJsaWNlbnNlVHlwZSI6IiIsIm5vbmNlIjoiMjAyNDA0MjItMjMzMjQ1IiwicGxhbiI6eyJuYW1lIjoiIiwicHJvZHVjdCI6IiIsInB1Ymxpc2hlciI6IiJ9LCJza3UiOiIyMF8wNC1sdHMtZ2VuMiIsInN1YnNjcmlwdGlvbklkIjoiMDVlOGIyODUtNGNlMS00NmEzLWI0YzktZjUxYmE2N2Q2YWNjIiwidGltZVN0YW1wIjp7ImNyZWF0ZWRPbiI6IjA0LzIyLzI0IDE3OjMyOjQ1IC0wMDAwIiwiZXhwaXJlc09uIjoiMDQvMjIvMjQgMjM6MzI6NDUgLTAwMDAifSwidm1JZCI6Ijk2MGE0YjRhLWRhYjItNDRlZi05YjczLTc3NTMwNDNiNGYxNiJ9oIIIiDCCCIQwggZsoAMCAQICEzMAJtj/yBIW1kk+vsIAAAAm2P8wDQYJKoZIhvcNAQEMBQAwXTELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEuMCwGA1UEAxMlTWljcm9zb2Z0IEF6dXJlIFJTQSBUTFMgSXNzdWluZyBDQSAwODAeFw0yNDA0MTgwODM1MzdaFw0yNTA0MTMwODM1MzdaMGkxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJXQTEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMRswGQYDVQQDExJtZXRhZGF0YS5henVyZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQD0T031XgxaebNQjKFQZ4BudeN+wOEHQoFq/x+cKSXM8HJrC2pF8y/ngSsuCLGt72M+30KxdbPHl56kd52uwDw1ZBrQO6Xw+GorRbtM4YQi+gLr8t9x+GUfuOX7E+5juidXax7la5ZhpVVLb3f+8NyxbphvEdFadXcgyQga1pl4v1U8elkbX3PPtEQXzwYotU+RU/ZTwXMYqfvJuaKwc4T2s083kaL3DwAfVxL0f6ey/MXuNQb4+ho15y9/f9gwMyzMDLlYChmY6cGSS4tsyrG5SrybE3jl8LZ1ZLVJ2fAIxbmJzBn1q+Eu4G6TZlnMDEsjznf7gqnP+n/o7N6l0sY1AgMBAAGjggQvMIIEKzCCAX4GCisGAQQB1nkCBAIEggFuBIIBagFoAHYAzxFW7tUufK/zh1vZaS6b6RpxZ0qwF+ysAdJbd87MOwgAAAGO8GIJ/QAABAMARzBFAiEAvJQ2mDRow9TMvLddWpYqNXLiehSFsj2+xUqh8yP/B8YCIBJjVoELj3kdVr3ceAuZFte9FH6sBsgeMsIgfndho6hRAHUAfVkeEuF4KnscYWd8Xv340IdcFKBOlZ65Ay/ZDowuebgAAAGO8GIK2AAABAMARjBEAiAxXD1R9yLASrpMh4ie0wn3AjCoSPniZ8virEVz8tKnkwIgWxGU9DjjQk7gPWYVBsiXP9t1WPJ6mNJ1UkmAw8iDdFoAdwBVgdTCFpA2AUrqC5tXPFPwwOQ4eHAlCBcvo6odBxPTDAAAAY7wYgrtAAAEAwBIMEYCIQCaSjdXbUhrDyPNsRqewp5UdVYABGQAIgNwfKsq/JpbmAIhAPy5qQ6H2enXwuKsorEZTwIkKIoMgLsWs4anx9lXTJMeMCcGCSsGAQQBgjcVCgQaMBgwCgYIKwYBBQUHAwIwCgYIKwYBBQUHAwEwPAYJKwYBBAGCNxUHBC8wLQYlKwYBBAGCNxUIh73XG4Hn60aCgZ0ujtAMh/DaHV2ChOVpgvOnPgIBZAIBJjCBtAYIKwYBBQUHAQEEgacwgaQwcwYIKwYBBQUHMAKGZ2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwQXp1cmUlMjBSU0ElMjBUTFMlMjBJc3N1aW5nJTIwQ0ElMjAwOCUyMC0lMjB4c2lnbi5jcnQwLQYIKwYBBQUHMAGGIWh0dHA6Ly9vbmVvY3NwLm1pY3Jvc29mdC5jb20vb2NzcDAdBgNVHQ4EFgQUnqRq3WHOZDoNmLD/arJg9RscxLowDgYDVR0PAQH/BAQDAgWgMDgGA1UdEQQxMC+CGWVhc3R1cy5tZXRhZGF0YS5henVyZS5jb22CEm1ldGFkYXRhLmF6dXJlLmNvbTAMBgNVHRMBAf8EAjAAMGoGA1UdHwRjMGEwX6BdoFuGWWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY3Jvc29mdCUyMEF6dXJlJTIwUlNBJTIwVExTJTIwSXNzdWluZyUyMENBJTIwMDguY3JsMGYGA1UdIARfMF0wUQYMKwYBBAGCN0yDfQEBMEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvRG9jcy9SZXBvc2l0b3J5Lmh0bTAIBgZngQwBAgIwHwYDVR0jBBgwFoAU9n4vvYCjSrJwW+vfmh/Y7cphgAcwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMA0GCSqGSIb3DQEBDAUAA4ICAQB4FwyqZFVdmB9Hu+YUJOJrGUYRlXbnCmdXlLi5w2QRCf9RKIykGdv28dH1ezhXJUCj3jCVZMav4GaSl0dPUcTetfnc/UrwsmbGRIMubbGjCz75FcNz/kXy7E/jPeyJrxsuO/ijyZNUSy0EQF3NuhTJw/SfAQtXv48NmVFDM2QMMhMRLDfOV4CPcialAFACFQTt6LMdG2hlB972Bffl+BVPkUKDLj89xQRd/cyWYweYfPCsNLYLDml98rY3v4yVKAvv+l7IOuKOzhlOe9U1oPJK7AP7GZzojKrisPQt4HlP4zEmeUzJtL6RqGdHac7/lUMVPOniE/L+5gBDBsN3nOGJ/QE+bBsmfdn4ewuLj6/LCd/JhCZFDeyTvtuX43JWIr9e0UOtENCG3Ub4SuUftf58+NuedCaNMZW2jqrFvQl+sCX+v1kkxxmRphU7B8TZP0SHaBDqeIqHPNWD7eyn/7+VTY54wrwF1v5S6b5zpL1tjZ55c9wpVBT6m77mNuR/2l7/VSh/qL2LgKVVo06q+Qz2c0pIjOI+7FobLRNtb7C8SqkdwuT1b0vnZslA8ZUEtwUm5RHcGu66sg/hb4lGNZbAklxGeAR3uQju0OQN/Lj4kXiii737dci0lIpIKA92hUKybLrYCyZDhp5I6is0gTdm4+rxVEY1K39R3cF3U5thuzGCAZ8wggGbAgEBMHQwXTELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEuMCwGA1UEAxMlTWljcm9zb2Z0IEF6dXJlIFJTQSBUTFMgSXNzdWluZyBDQSAwOAITMwAm2P/IEhbWST6+wgAAACbY/zANBgkqhkiG9w0BAQsFADANBgkqhkiG9w0BAQEFAASCAQDRukRXI01EvAoF0J+C1aYCmjwAtMlnQr5fBKod8T75FhM+mTJ2GApCyc5H8hn7IDl8ki8DdKfLjipnuEvjknZcVkfrzE72R9Pu+C2ffKfrSsJmsBHPMEKBPtlzhexCYiPamMGdVg8HqX6mhQkjjavk1SY+ewZvyEeuq+RSQIBVL1lw0UOWv+txDKlu9v69skb1DQ2HSet0sejEb48vqGeN4TMSoQFNeBOzHDkEeoqXxtZqsUhMtQzbwrpAFcUREB8DaCOXcv1DOminJB3Q19bpuMQ/2+Fc3HJtTTWRV3+3b7VnQl/sUDzTjcWXvwjrLGKk3MSTcQ+1rJRlBzkOJ+aK", vmID: "960a4b4a-dab2-44ef-9b73-7753043b4f16", - date: mustTime(time.RFC3339, "2024-04-22T17:32:44Z"), + // This cert uses intermediate: + // Microsoft Azure RSA TLS Issuing CA 08 (expires 2026-08-25T23:59:59Z) + // It uses root: + // DigiCert Global Root G2 (expires 2038-01-15T12:00:00Z) + // So this test should be good until 2038 provided that we don't remove the above intermediates, and the + // root doesn't get removed from OS trust stores (would be very surprising and a huge security deal). + date: mustTime(time.RFC3339, "2024-04-22T17:32:44Z"), }} { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -62,31 +80,6 @@ func TestValidate(t *testing.T) { } } -func TestExpiresSoon(t *testing.T) { - t.Parallel() - // TODO (@kylecarbs): It's unknown why Microsoft does not have new certificates live... - // The certificate is automatically fetched if it's not found in our database, - // so in a worst-case scenario expired certificates will only impact 100% airgapped users. - t.Skip() - const threshold = 1 - - certs, err := azureidentity.ParseCertificates() - require.NoError(t, err) - - for _, cert := range certs { - expiresSoon := cert.NotAfter.Before(time.Now().AddDate(0, threshold, 0)) - if expiresSoon { - t.Errorf("certificate expires within %d months %s: %s", threshold, cert.NotAfter, cert.Subject.CommonName) - } else { - url := "no issuing url" - if len(cert.IssuingCertificateURL) > 0 { - url = cert.IssuingCertificateURL[0] - } - t.Logf("certificate %q doesn't expire for a while (%s)", cert.Subject.CommonName, url) - } - } -} - func TestIsAllowedCertificateURL(t *testing.T) { t.Parallel() tests := []struct { diff --git a/coderd/boundaryusage/tracker_test.go b/coderd/boundaryusage/tracker_test.go index a351647512..a271f7eed2 100644 --- a/coderd/boundaryusage/tracker_test.go +++ b/coderd/boundaryusage/tracker_test.go @@ -124,15 +124,13 @@ func TestTracker_Track_Concurrent(t *testing.T) { var wg sync.WaitGroup for i := 0; i < numGoroutines; i++ { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { workspaceID := uuid.New() ownerID := uuid.New() for j := 0; j < requestsPerGoroutine; j++ { tracker.Track(workspaceID, ownerID, 1, 1) } - }() + }) } wg.Wait() @@ -507,22 +505,18 @@ func TestTracker_ConcurrentFlushAndTrack(t *testing.T) { var wg sync.WaitGroup // Goroutine 1: Continuously track. - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { for i := 0; i < numOperations; i++ { tracker.Track(uuid.New(), uuid.New(), 1, 1) } - }() + }) // Goroutine 2: Continuously flush. - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { for i := 0; i < numOperations; i++ { _ = tracker.FlushToDB(ctx, db, replicaID) } - }() + }) wg.Wait() diff --git a/coderd/coderd.go b/coderd/coderd.go index 2688b55c03..ae0df0eb88 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -338,6 +338,10 @@ type Options struct { // @securitydefinitions.apiKey CoderSessionToken // @in header // @name Coder-Session-Token + +// @securitydefinitions.apiKey AIGatewayKey +// @in header +// @name X-AI-Governance-Gateway-Key // New constructs a Coder API handler. func New(options *Options) *API { if options == nil { diff --git a/coderd/coderdtest/swaggerparser.go b/coderd/coderdtest/swaggerparser.go index dcb65fac8b..70d973c184 100644 --- a/coderd/coderdtest/swaggerparser.go +++ b/coderd/coderdtest/swaggerparser.go @@ -370,6 +370,11 @@ func assertSecurityDefined(t *testing.T, comment SwaggerComment) { comment.router == "/api/v2/init-script/{os}/{arch}" { return // endpoints do not require authorization } + if comment.router == "/api/v2/ai-gateway/serve" { + assert.Equal(t, "AIGatewayKey", comment.security, "@Security must be AIGatewayKey") + return + } + assert.Containsf(t, authorizedSecurityTags, comment.security, "@Security must be either of these options: %v", authorizedSecurityTags) } diff --git a/coderd/cryptokeys/rotate.go b/coderd/cryptokeys/rotate.go index e768d53273..4f30f84259 100644 --- a/coderd/cryptokeys/rotate.go +++ b/coderd/cryptokeys/rotate.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "slices" "time" "golang.org/x/xerrors" @@ -27,6 +28,24 @@ const ( DefaultKeyDuration = time.Hour * 24 * 30 ) +// defaultRotatedFeatures are the crypto key features the rotator manages. It +// intentionally excludes features that are gated behind an experiment or +// deployment flag so that a dormant feature's enum value does not cause the +// rotator to mint keys it has no generator for. Gated features are opted in by +// the caller that owns their generator. +var defaultRotatedFeatures = []database.CryptoKeyFeature{ + database.CryptoKeyFeatureWorkspaceAppsToken, + database.CryptoKeyFeatureWorkspaceAppsAPIKey, + database.CryptoKeyFeatureOIDCConvert, + database.CryptoKeyFeatureTailnetResume, +} + +// DefaultRotatedFeatures returns the crypto key features the rotator manages by +// default. It excludes experiment-gated features such as the NATS CA. +func DefaultRotatedFeatures() []database.CryptoKeyFeature { + return slices.Clone(defaultRotatedFeatures) +} + // rotator is responsible for rotating keys in the database. type rotator struct { db database.Store @@ -62,7 +81,7 @@ func StartRotator(ctx context.Context, logger slog.Logger, db database.Store, op logger: logger.Named("keyrotator"), clock: quartz.NewReal(), keyDuration: DefaultKeyDuration, - features: database.AllCryptoKeyFeatureValues(), + features: defaultRotatedFeatures, } for _, opt := range opts { diff --git a/coderd/cryptokeys/rotate_internal_test.go b/coderd/cryptokeys/rotate_internal_test.go index a8202320ae..4d43f24e18 100644 --- a/coderd/cryptokeys/rotate_internal_test.go +++ b/coderd/cryptokeys/rotate_internal_test.go @@ -358,7 +358,7 @@ func Test_rotateKeys(t *testing.T) { keyDuration: keyDuration, clock: clock, logger: logger, - features: database.AllCryptoKeyFeatureValues(), + features: defaultRotatedFeatures, } now := dbnow(clock) @@ -409,7 +409,7 @@ func Test_rotateKeys(t *testing.T) { require.NoError(t, err) require.Len(t, keys, 5) - kbf, err := keysByFeature(keys, database.AllCryptoKeyFeatureValues()) + kbf, err := keysByFeature(keys, defaultRotatedFeatures) require.NoError(t, err) // No actions on OIDC convert. diff --git a/coderd/cryptokeys/rotate_test.go b/coderd/cryptokeys/rotate_test.go index 4a5c458772..df5db4413e 100644 --- a/coderd/cryptokeys/rotate_test.go +++ b/coderd/cryptokeys/rotate_test.go @@ -37,7 +37,7 @@ func TestRotator(t *testing.T) { // are as expected. dbkeys, err = db.GetCryptoKeys(ctx) require.NoError(t, err) - require.Len(t, dbkeys, len(database.AllCryptoKeyFeatureValues())) + require.Len(t, dbkeys, len(cryptokeys.DefaultRotatedFeatures())) requireContainsAllFeatures(t, dbkeys) }) @@ -64,7 +64,7 @@ func TestRotator(t *testing.T) { cryptokeys.StartRotator(ctx, logger, db, cryptokeys.WithClock(clock)) - initialKeyLen := len(database.AllCryptoKeyFeatureValues()) + initialKeyLen := len(cryptokeys.DefaultRotatedFeatures()) // Fetch the keys from the database and ensure they // are as expected. dbkeys, err := db.GetCryptoKeys(ctx) @@ -113,7 +113,7 @@ func requireContainsAllFeatures(t *testing.T, keys []database.CryptoKey) { for _, key := range keys { features[key.Feature] = true } - for _, feature := range database.AllCryptoKeyFeatureValues() { + for _, feature := range cryptokeys.DefaultRotatedFeatures() { require.True(t, features[feature]) } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3ad04ab92c..d6e5a27e77 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -486,6 +486,7 @@ var ( rbac.ResourceOauth2AppSecret.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceChat.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceAIProvider.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceAIGatewayKey.Type: {policy.ActionRead, policy.ActionUpdate}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, @@ -2753,6 +2754,16 @@ func (q *querier) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, in return q.db.GetAIBridgeUserPromptsByInterceptionID(ctx, interceptionID) } +// Authenticates a standalone AI Gateway replica by its hashed key secret, returning the matched key. +func (q *querier) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (database.AIGatewayKey, error) { + // Standalone AI Gateway has no Coder identity, so this runs under the + // system actor reading the AI Gateway key it authenticates against. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIGatewayKey); err != nil { + return database.AIGatewayKey{}, err + } + return q.db.GetAIGatewayKeyByHashedSecret(ctx, hashedSecret) +} + func (q *querier) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AIModelPrice, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAiModelPrice); err != nil { return database.AIModelPrice{}, err @@ -5686,8 +5697,8 @@ func (q *querier) GetWorkspacesByTemplateID(ctx context.Context, templateID uuid return q.db.GetWorkspacesByTemplateID(ctx, templateID) } -func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) { - return q.db.GetWorkspacesEligibleForTransition(ctx, now) +func (q *querier) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForLifecycleActionRow, error) { + return q.db.GetWorkspacesEligibleForLifecycleAction(ctx, now) } func (q *querier) GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]database.GetWorkspacesForWorkspaceMetricsRow, error) { @@ -7022,6 +7033,16 @@ func (q *querier) UpdateAIBridgeInterceptionEnded(ctx context.Context, params da return q.db.UpdateAIBridgeInterceptionEnded(ctx, params) } +// Records heartbeat liveness for a key used in active DRPC session between coderd and standalone AI Gateway. +func (q *querier) UpdateAIGatewayKeyLastHeartbeatAt(ctx context.Context, id uuid.UUID) (int64, error) { + // Standalone AI Gateway has no Coder identity, so this runs under the + // system actor recording connection liveness on the AI Gateway key. + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAIGatewayKey); err != nil { + return 0, err + } + return q.db.UpdateAIGatewayKeyLastHeartbeatAt(ctx, id) +} + func (q *querier) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAIProvider); err != nil { return database.AIProvider{}, err @@ -8414,6 +8435,24 @@ func (q *querier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg databas return q.db.UpdateWorkspaceBuildFlagsByID(ctx, arg) } +func (q *querier) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error { + build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) + if err != nil { + return err + } + + workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) + if err != nil { + return err + } + + err = q.authorizeContext(ctx, policy.ActionUpdate, workspace.RBACObject()) + if err != nil { + return err + } + return q.db.UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx, arg) +} + func (q *querier) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 8e77219654..865d075f0b 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4142,6 +4142,15 @@ func (s *MethodTestSuite) TestWorkspace() { dbm.EXPECT().UpdateWorkspaceBuildDeadlineByID(gomock.Any(), arg).Return(nil).AnyTimes() check.Args(arg).Asserts(w, policy.ActionUpdate) })) + s.Run("UpdateWorkspaceBuildNotifiedAutostopDeadline", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + w := testutil.Fake(s.T(), faker, database.Workspace{}) + b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: w.ID}) + arg := database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams{ID: b.ID, NotifiedAutostopDeadline: b.Deadline} + dbm.EXPECT().GetWorkspaceBuildByID(gomock.Any(), b.ID).Return(b, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes() + dbm.EXPECT().UpdateWorkspaceBuildNotifiedAutostopDeadline(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(w, policy.ActionUpdate) + })) s.Run("UpdateWorkspaceBuildFlagsByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) o := testutil.Fake(s.T(), faker, database.Organization{}) @@ -5289,9 +5298,9 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().GetWorkspacesByTemplateID(gomock.Any(), id).Return([]database.WorkspaceTable{}, nil).AnyTimes() check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionRead) })) - s.Run("GetWorkspacesEligibleForTransition", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + s.Run("GetWorkspacesEligibleForLifecycleAction", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { t := time.Time{} - dbm.EXPECT().GetWorkspacesEligibleForTransition(gomock.Any(), t).Return([]database.GetWorkspacesEligibleForTransitionRow{}, nil).AnyTimes() + dbm.EXPECT().GetWorkspacesEligibleForLifecycleAction(gomock.Any(), t).Return([]database.GetWorkspacesEligibleForLifecycleActionRow{}, nil).AnyTimes() check.Args(t).Asserts() })) s.Run("InsertTemplateVersionVariable", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { @@ -6959,6 +6968,17 @@ func (s *MethodTestSuite) TestAIBridge() { dbm.EXPECT().DeleteAIGatewayKey(gomock.Any(), id).Return(database.DeleteAIGatewayKeyRow{}, nil).AnyTimes() check.Args(id).Asserts(rbac.ResourceAIGatewayKey, policy.ActionDelete).Returns(database.DeleteAIGatewayKeyRow{}) })) + s.Run("GetAIGatewayKeyByHashedSecret", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + hashedSecret := []byte("hashed-secret") + key := database.AIGatewayKey{ID: uuid.New(), HashedSecret: hashedSecret} + dbm.EXPECT().GetAIGatewayKeyByHashedSecret(gomock.Any(), hashedSecret).Return(key, nil).AnyTimes() + check.Args(hashedSecret).Asserts(rbac.ResourceAIGatewayKey, policy.ActionRead).Returns(key) + })) + s.Run("UpdateAIGatewayKeyLastHeartbeatAt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + id := uuid.New() + dbm.EXPECT().UpdateAIGatewayKeyLastHeartbeatAt(gomock.Any(), id).Return(int64(1), nil).AnyTimes() + check.Args(id).Asserts(rbac.ResourceAIGatewayKey, policy.ActionUpdate).Returns(int64(1)) + })) } func (s *MethodTestSuite) TestTelemetry() { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 8fc504de35..11e785f8ab 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1130,6 +1130,14 @@ func (m queryMetricsStore) GetAIBridgeUserPromptsByInterceptionID(ctx context.Co return r0, r1 } +func (m queryMetricsStore) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (database.AIGatewayKey, error) { + start := time.Now() + r0, r1 := m.s.GetAIGatewayKeyByHashedSecret(ctx, hashedSecret) + m.queryLatencies.WithLabelValues("GetAIGatewayKeyByHashedSecret").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIGatewayKeyByHashedSecret").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AIModelPrice, error) { start := time.Now() r0, r1 := m.s.GetAIModelPriceByProviderModel(ctx, arg) @@ -3858,11 +3866,11 @@ func (m queryMetricsStore) GetWorkspacesByTemplateID(ctx context.Context, templa return r0, r1 } -func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) { +func (m queryMetricsStore) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForLifecycleActionRow, error) { start := time.Now() - r0, r1 := m.s.GetWorkspacesEligibleForTransition(ctx, now) - m.queryLatencies.WithLabelValues("GetWorkspacesEligibleForTransition").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspacesEligibleForTransition").Inc() + r0, r1 := m.s.GetWorkspacesEligibleForLifecycleAction(ctx, now) + m.queryLatencies.WithLabelValues("GetWorkspacesEligibleForLifecycleAction").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspacesEligibleForLifecycleAction").Inc() return r0, r1 } @@ -5042,6 +5050,14 @@ func (m queryMetricsStore) UpdateAIBridgeInterceptionEnded(ctx context.Context, return r0, r1 } +func (m queryMetricsStore) UpdateAIGatewayKeyLastHeartbeatAt(ctx context.Context, id uuid.UUID) (int64, error) { + start := time.Now() + r0, r1 := m.s.UpdateAIGatewayKeyLastHeartbeatAt(ctx, id) + m.queryLatencies.WithLabelValues("UpdateAIGatewayKeyLastHeartbeatAt").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateAIGatewayKeyLastHeartbeatAt").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) { start := time.Now() r0, r1 := m.s.UpdateAIProvider(ctx, arg) @@ -5946,6 +5962,14 @@ func (m queryMetricsStore) UpdateWorkspaceBuildFlagsByID(ctx context.Context, ar return r0 } +func (m queryMetricsStore) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error { + start := time.Now() + r0 := m.s.UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildNotifiedAutostopDeadline").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateWorkspaceBuildNotifiedAutostopDeadline").Inc() + return r0 +} + func (m queryMetricsStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { start := time.Now() r0 := m.s.UpdateWorkspaceBuildProvisionerStateByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 2051eed4ec..248c26d0a1 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1947,6 +1947,21 @@ func (mr *MockStoreMockRecorder) GetAIBridgeUserPromptsByInterceptionID(ctx, int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIBridgeUserPromptsByInterceptionID", reflect.TypeOf((*MockStore)(nil).GetAIBridgeUserPromptsByInterceptionID), ctx, interceptionID) } +// GetAIGatewayKeyByHashedSecret mocks base method. +func (m *MockStore) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (database.AIGatewayKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIGatewayKeyByHashedSecret", ctx, hashedSecret) + ret0, _ := ret[0].(database.AIGatewayKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIGatewayKeyByHashedSecret indicates an expected call of GetAIGatewayKeyByHashedSecret. +func (mr *MockStoreMockRecorder) GetAIGatewayKeyByHashedSecret(ctx, hashedSecret any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIGatewayKeyByHashedSecret", reflect.TypeOf((*MockStore)(nil).GetAIGatewayKeyByHashedSecret), ctx, hashedSecret) +} + // GetAIModelPriceByProviderModel mocks base method. func (m *MockStore) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AIModelPrice, error) { m.ctrl.T.Helper() @@ -7212,19 +7227,19 @@ func (mr *MockStoreMockRecorder) GetWorkspacesByTemplateID(ctx, templateID any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesByTemplateID", reflect.TypeOf((*MockStore)(nil).GetWorkspacesByTemplateID), ctx, templateID) } -// GetWorkspacesEligibleForTransition mocks base method. -func (m *MockStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) { +// GetWorkspacesEligibleForLifecycleAction mocks base method. +func (m *MockStore) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForLifecycleActionRow, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetWorkspacesEligibleForTransition", ctx, now) - ret0, _ := ret[0].([]database.GetWorkspacesEligibleForTransitionRow) + ret := m.ctrl.Call(m, "GetWorkspacesEligibleForLifecycleAction", ctx, now) + ret0, _ := ret[0].([]database.GetWorkspacesEligibleForLifecycleActionRow) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetWorkspacesEligibleForTransition indicates an expected call of GetWorkspacesEligibleForTransition. -func (mr *MockStoreMockRecorder) GetWorkspacesEligibleForTransition(ctx, now any) *gomock.Call { +// GetWorkspacesEligibleForLifecycleAction indicates an expected call of GetWorkspacesEligibleForLifecycleAction. +func (mr *MockStoreMockRecorder) GetWorkspacesEligibleForLifecycleAction(ctx, now any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesEligibleForTransition", reflect.TypeOf((*MockStore)(nil).GetWorkspacesEligibleForTransition), ctx, now) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesEligibleForLifecycleAction", reflect.TypeOf((*MockStore)(nil).GetWorkspacesEligibleForLifecycleAction), ctx, now) } // GetWorkspacesForWorkspaceMetrics mocks base method. @@ -9503,6 +9518,21 @@ func (mr *MockStoreMockRecorder) UpdateAIBridgeInterceptionEnded(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAIBridgeInterceptionEnded", reflect.TypeOf((*MockStore)(nil).UpdateAIBridgeInterceptionEnded), ctx, arg) } +// UpdateAIGatewayKeyLastHeartbeatAt mocks base method. +func (m *MockStore) UpdateAIGatewayKeyLastHeartbeatAt(ctx context.Context, id uuid.UUID) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAIGatewayKeyLastHeartbeatAt", ctx, id) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateAIGatewayKeyLastHeartbeatAt indicates an expected call of UpdateAIGatewayKeyLastHeartbeatAt. +func (mr *MockStoreMockRecorder) UpdateAIGatewayKeyLastHeartbeatAt(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAIGatewayKeyLastHeartbeatAt", reflect.TypeOf((*MockStore)(nil).UpdateAIGatewayKeyLastHeartbeatAt), ctx, id) +} + // UpdateAIProvider mocks base method. func (m *MockStore) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) { m.ctrl.T.Helper() @@ -11151,6 +11181,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildFlagsByID(ctx, arg any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildFlagsByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildFlagsByID), ctx, arg) } +// UpdateWorkspaceBuildNotifiedAutostopDeadline mocks base method. +func (m *MockStore) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWorkspaceBuildNotifiedAutostopDeadline", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateWorkspaceBuildNotifiedAutostopDeadline indicates an expected call of UpdateWorkspaceBuildNotifiedAutostopDeadline. +func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildNotifiedAutostopDeadline", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildNotifiedAutostopDeadline), ctx, arg) +} + // UpdateWorkspaceBuildProvisionerStateByID mocks base method. func (m *MockStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 7bb6c2c972..a984421d82 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -257,7 +257,8 @@ CREATE TYPE api_key_scope AS ENUM ( 'ai_gateway_key:*', 'ai_gateway_key:create', 'ai_gateway_key:delete', - 'ai_gateway_key:read' + 'ai_gateway_key:read', + 'ai_gateway_key:update' ); CREATE TYPE app_sharing_level AS ENUM ( @@ -373,7 +374,8 @@ CREATE TYPE crypto_key_feature AS ENUM ( 'workspace_apps_token', 'workspace_apps_api_key', 'oidc_convert', - 'tailnet_resume' + 'tailnet_resume', + 'nats_ca' ); CREATE TYPE display_app AS ENUM ( @@ -1439,7 +1441,7 @@ CREATE TABLE ai_gateway_keys ( name text NOT NULL, secret_prefix character varying(11) NOT NULL, hashed_secret bytea NOT NULL, - last_used_at timestamp with time zone, + last_heartbeat_at timestamp with time zone, CONSTRAINT ai_gateway_keys_hashed_secret_check CHECK ((length(hashed_secret) > 0)), CONSTRAINT ai_gateway_keys_name_check CHECK (((length(name) <= 64) AND (name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text))), CONSTRAINT ai_gateway_keys_secret_prefix_check CHECK ((length((secret_prefix)::text) = 11)) diff --git a/coderd/database/migrations/000531_ai_gateway_key_update_scope.down.sql b/coderd/database/migrations/000531_ai_gateway_key_update_scope.down.sql new file mode 100644 index 0000000000..04f101ceb4 --- /dev/null +++ b/coderd/database/migrations/000531_ai_gateway_key_update_scope.down.sql @@ -0,0 +1,2 @@ +-- Enum additions to api_key_scope are intentionally not reverted because +-- Postgres cannot drop enum values safely. diff --git a/coderd/database/migrations/000531_ai_gateway_key_update_scope.up.sql b/coderd/database/migrations/000531_ai_gateway_key_update_scope.up.sql new file mode 100644 index 0000000000..d196bef408 --- /dev/null +++ b/coderd/database/migrations/000531_ai_gateway_key_update_scope.up.sql @@ -0,0 +1 @@ +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:update'; diff --git a/coderd/database/migrations/000532_rename_ai_gateway_key_last_heartbeat_at.down.sql b/coderd/database/migrations/000532_rename_ai_gateway_key_last_heartbeat_at.down.sql new file mode 100644 index 0000000000..4257f129d1 --- /dev/null +++ b/coderd/database/migrations/000532_rename_ai_gateway_key_last_heartbeat_at.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE ai_gateway_keys + RENAME COLUMN last_heartbeat_at TO last_used_at; diff --git a/coderd/database/migrations/000532_rename_ai_gateway_key_last_heartbeat_at.up.sql b/coderd/database/migrations/000532_rename_ai_gateway_key_last_heartbeat_at.up.sql new file mode 100644 index 0000000000..a5c3cdf5c3 --- /dev/null +++ b/coderd/database/migrations/000532_rename_ai_gateway_key_last_heartbeat_at.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE ai_gateway_keys + RENAME COLUMN last_used_at TO last_heartbeat_at; diff --git a/coderd/database/migrations/000533_nats_ca_crypto_key_feature.down.sql b/coderd/database/migrations/000533_nats_ca_crypto_key_feature.down.sql new file mode 100644 index 0000000000..ec36128a51 --- /dev/null +++ b/coderd/database/migrations/000533_nats_ca_crypto_key_feature.down.sql @@ -0,0 +1,16 @@ +DELETE FROM crypto_keys WHERE feature = 'nats_ca'; + +CREATE TYPE old_crypto_key_feature AS ENUM ( + 'workspace_apps_token', + 'workspace_apps_api_key', + 'oidc_convert', + 'tailnet_resume' +); + +ALTER TABLE crypto_keys + ALTER COLUMN feature TYPE old_crypto_key_feature + USING (feature::text::old_crypto_key_feature); + +DROP TYPE crypto_key_feature; + +ALTER TYPE old_crypto_key_feature RENAME TO crypto_key_feature; diff --git a/coderd/database/migrations/000533_nats_ca_crypto_key_feature.up.sql b/coderd/database/migrations/000533_nats_ca_crypto_key_feature.up.sql new file mode 100644 index 0000000000..c37227451d --- /dev/null +++ b/coderd/database/migrations/000533_nats_ca_crypto_key_feature.up.sql @@ -0,0 +1 @@ +ALTER TYPE crypto_key_feature ADD VALUE IF NOT EXISTS 'nats_ca'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 5a5e2cbe67..93ae445581 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -386,6 +386,7 @@ const ( ApiKeyScopeAIGatewayKeyCreate APIKeyScope = "ai_gateway_key:create" ApiKeyScopeAIGatewayKeyDelete APIKeyScope = "ai_gateway_key:delete" ApiKeyScopeAIGatewayKeyRead APIKeyScope = "ai_gateway_key:read" + ApiKeyScopeAIGatewayKeyUpdate APIKeyScope = "ai_gateway_key:update" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -654,7 +655,8 @@ func (e APIKeyScope) Valid() bool { ApiKeyScopeAIGatewayKey, ApiKeyScopeAIGatewayKeyCreate, ApiKeyScopeAIGatewayKeyDelete, - ApiKeyScopeAIGatewayKeyRead: + ApiKeyScopeAIGatewayKeyRead, + ApiKeyScopeAIGatewayKeyUpdate: return true } return false @@ -892,6 +894,7 @@ func AllAPIKeyScopeValues() []APIKeyScope { ApiKeyScopeAIGatewayKeyCreate, ApiKeyScopeAIGatewayKeyDelete, ApiKeyScopeAIGatewayKeyRead, + ApiKeyScopeAIGatewayKeyUpdate, } } @@ -1884,6 +1887,7 @@ const ( CryptoKeyFeatureWorkspaceAppsAPIKey CryptoKeyFeature = "workspace_apps_api_key" CryptoKeyFeatureOIDCConvert CryptoKeyFeature = "oidc_convert" CryptoKeyFeatureTailnetResume CryptoKeyFeature = "tailnet_resume" + CryptoKeyFeatureNATSCA CryptoKeyFeature = "nats_ca" ) func (e *CryptoKeyFeature) Scan(src interface{}) error { @@ -1926,7 +1930,8 @@ func (e CryptoKeyFeature) Valid() bool { case CryptoKeyFeatureWorkspaceAppsToken, CryptoKeyFeatureWorkspaceAppsAPIKey, CryptoKeyFeatureOIDCConvert, - CryptoKeyFeatureTailnetResume: + CryptoKeyFeatureTailnetResume, + CryptoKeyFeatureNATSCA: return true } return false @@ -1938,6 +1943,7 @@ func AllCryptoKeyFeatureValues() []CryptoKeyFeature { CryptoKeyFeatureWorkspaceAppsAPIKey, CryptoKeyFeatureOIDCConvert, CryptoKeyFeatureTailnetResume, + CryptoKeyFeatureNATSCA, } } @@ -4615,9 +4621,9 @@ type AIGatewayKey struct { CreatedAt time.Time `db:"created_at" json:"created_at"` Name string `db:"name" json:"name"` // Public token prefix for display and audit correlation. Auth uses hashed_secret. - SecretPrefix string `db:"secret_prefix" json:"secret_prefix"` - HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` - LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"` + SecretPrefix string `db:"secret_prefix" json:"secret_prefix"` + HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` + LastHeartbeatAt sql.NullTime `db:"last_heartbeat_at" json:"last_heartbeat_at"` } // Per-model token prices used by AI Bridge to compute interception cost. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 27ec63a4e1..d3b745e3f7 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -301,6 +301,10 @@ type sqlcQuerier interface { GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeTokenUsage, error) GetAIBridgeToolUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeToolUsage, error) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeUserPrompt, error) + // Authenticates a standalone AI Gateway replica by its hashed key secret, + // returning the matched key. The lookup is an exact match on a unique index, + // so a returned row is itself proof the secret is valid. + GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (AIGatewayKey, error) GetAIModelPriceByProviderModel(ctx context.Context, arg GetAIModelPriceByProviderModelParams) (AIModelPrice, error) GetAIProviderByID(ctx context.Context, id uuid.UUID) (AIProvider, error) // Lock the provider row until the model-config write completes. The @@ -973,7 +977,11 @@ type sqlcQuerier interface { GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]GetWorkspacesAndAgentsByOwnerIDRow, error) GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error) - GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) + // Returns workspaces the lifecycle executor must act on this tick. An + // "action" is a state transition (autostart/autostop/dormancy/delete), a + // dormancy mark (which has no build transition), or a one-time autostop + // reminder notification (which only stamps a marker, no transition). + GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForLifecycleActionRow, error) GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]GetWorkspacesForWorkspaceMetricsRow, error) // Reports whether the given file is referenced as cached module files by any // template version in the given organization. Used to authorize provisioner @@ -1303,6 +1311,10 @@ type sqlcQuerier interface { UnpinChatByID(ctx context.Context, id uuid.UUID) error UnsetDefaultChatModelConfigs(ctx context.Context) error UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error) + // Records heartbeat liveness for an active Gateway DRPC session. The database sets the + // timestamp so it stays consistent regardless of clock drift between API + // replicas. + UpdateAIGatewayKeyLastHeartbeatAt(ctx context.Context, id uuid.UUID) (int64, error) UpdateAIProvider(ctx context.Context, arg UpdateAIProviderParams) (AIProvider, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error UpdateChatACLByID(ctx context.Context, arg UpdateChatACLByIDParams) error @@ -1472,6 +1484,12 @@ type sqlcQuerier interface { UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg UpdateWorkspaceBuildFlagsByIDParams) error + // Stamps the deadline value that an autostop reminder was last sent for. Once + // this equals the build's deadline the reminder is considered handled and the + // lifecycle executor will not send another for this deadline, which makes the + // reminder idempotent and HA-safe. It re-arms automatically when the deadline + // changes (e.g. an activity bump). + UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 5d8d4a600e..b6f1ca0ecc 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -14938,9 +14938,9 @@ func TestAIGatewayKeysQueries(t *testing.T) { require.Len(t, keys, 2) requireAIGatewayKeysRow(t, keys[0], first, firstRow.CreatedAt) - require.False(t, keys[0].LastUsedAt.Valid) + require.False(t, keys[0].LastHeartbeatAt.Valid) requireAIGatewayKeysRow(t, keys[1], second, secondRow.CreatedAt) - require.False(t, keys[1].LastUsedAt.Valid) + require.False(t, keys[1].LastHeartbeatAt.Valid) deleted, err := db.DeleteAIGatewayKey(ctx, first.ID) require.NoError(t, err) @@ -14958,6 +14958,85 @@ func TestAIGatewayKeysQueries(t *testing.T) { requireAIGatewayKeysRow(t, keys[0], second, secondRow.CreatedAt) } +func TestGetAIGatewayKeyByHashedSecret(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + first := aiGatewayKeyParams("lookup-first", "key_lookup1") + second := aiGatewayKeyParams("lookup-second", "key_lookup2") + + _, err := db.InsertAIGatewayKey(ctx, first) + require.NoError(t, err) + _, err = db.InsertAIGatewayKey(ctx, second) + require.NoError(t, err) + + key, err := db.GetAIGatewayKeyByHashedSecret(ctx, first.HashedSecret) + require.NoError(t, err) + require.Equal(t, first.ID, key.ID) + require.Equal(t, first.Name, key.Name) + require.Equal(t, first.SecretPrefix, key.SecretPrefix) + require.Equal(t, first.HashedSecret, key.HashedSecret) + + key, err = db.GetAIGatewayKeyByHashedSecret(ctx, second.HashedSecret) + require.NoError(t, err) + require.Equal(t, second.ID, key.ID) + + // An unknown secret returns no rows + key, err = db.GetAIGatewayKeyByHashedSecret(ctx, []byte("does-not-exist")) + require.ErrorIs(t, err, sql.ErrNoRows) + require.Empty(t, key.ID) +} + +func TestUpdateAIGatewayKeyLastHeartbeatAt(t *testing.T) { + t.Parallel() + + db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + params := aiGatewayKeyParams("liveness-key", "key_live___") + row, err := db.InsertAIGatewayKey(ctx, params) + require.NoError(t, err) + + // last_heartbeat_at starts NULL until a session records liveness. + keys, err := db.ListAIGatewayKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + require.False(t, keys[0].LastHeartbeatAt.Valid) + + rows, err := db.UpdateAIGatewayKeyLastHeartbeatAt(ctx, params.ID) + require.NoError(t, err) + require.EqualValues(t, 1, rows) + + keys, err = db.ListAIGatewayKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + require.True(t, keys[0].LastHeartbeatAt.Valid) + // The database stamps the timestamp, so compare against the row's + // DB-generated CreatedAt to avoid client clock skew. + require.False(t, keys[0].LastHeartbeatAt.Time.Before(row.CreatedAt)) + + // Updating a key that does not exist is a no-op, not an error. + rows, err = db.UpdateAIGatewayKeyLastHeartbeatAt(ctx, uuid.New()) + require.NoError(t, err) + require.EqualValues(t, 0, rows) + + // Set last_heartbeat_at to old time to confirm the update overwrites it with a fresh timestamp. + staleTime := row.CreatedAt.Add(-time.Hour) + _, err = sqlDB.ExecContext(ctx, "UPDATE ai_gateway_keys SET last_heartbeat_at = $1 WHERE id = $2", staleTime, params.ID) + require.NoError(t, err) + + rows, err = db.UpdateAIGatewayKeyLastHeartbeatAt(ctx, params.ID) + require.NoError(t, err) + require.EqualValues(t, 1, rows) + + keys, err = db.ListAIGatewayKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + require.True(t, keys[0].LastHeartbeatAt.Time.After(staleTime)) +} + func aiGatewayKeyParams(name string, secretPrefix string) database.InsertAIGatewayKeyParams { return database.InsertAIGatewayKeyParams{ ID: uuid.New(), diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6b44efc737..44c0b73b20 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -113,15 +113,15 @@ func (q *sqlQuerier) ActivityBumpWorkspace(ctx context.Context, arg ActivityBump const deleteAIGatewayKey = `-- name: DeleteAIGatewayKey :one DELETE FROM ai_gateway_keys WHERE id = $1 -RETURNING id, name, secret_prefix, created_at, last_used_at +RETURNING id, name, secret_prefix, created_at, last_heartbeat_at ` type DeleteAIGatewayKeyRow struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` - SecretPrefix string `db:"secret_prefix" json:"secret_prefix"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"` + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + SecretPrefix string `db:"secret_prefix" json:"secret_prefix"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + LastHeartbeatAt sql.NullTime `db:"last_heartbeat_at" json:"last_heartbeat_at"` } func (q *sqlQuerier) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) (DeleteAIGatewayKeyRow, error) { @@ -132,7 +132,30 @@ func (q *sqlQuerier) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) (Dele &i.Name, &i.SecretPrefix, &i.CreatedAt, - &i.LastUsedAt, + &i.LastHeartbeatAt, + ) + return i, err +} + +const getAIGatewayKeyByHashedSecret = `-- name: GetAIGatewayKeyByHashedSecret :one +SELECT id, created_at, name, secret_prefix, hashed_secret, last_heartbeat_at +FROM ai_gateway_keys +WHERE hashed_secret = $1 +` + +// Authenticates a standalone AI Gateway replica by its hashed key secret, +// returning the matched key. The lookup is an exact match on a unique index, +// so a returned row is itself proof the secret is valid. +func (q *sqlQuerier) GetAIGatewayKeyByHashedSecret(ctx context.Context, hashedSecret []byte) (AIGatewayKey, error) { + row := q.db.QueryRowContext(ctx, getAIGatewayKeyByHashedSecret, hashedSecret) + var i AIGatewayKey + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.Name, + &i.SecretPrefix, + &i.HashedSecret, + &i.LastHeartbeatAt, ) return i, err } @@ -175,17 +198,17 @@ func (q *sqlQuerier) InsertAIGatewayKey(ctx context.Context, arg InsertAIGateway } const listAIGatewayKeys = `-- name: ListAIGatewayKeys :many -SELECT id, name, secret_prefix, created_at, last_used_at +SELECT id, name, secret_prefix, created_at, last_heartbeat_at FROM ai_gateway_keys ORDER BY created_at ASC ` type ListAIGatewayKeysRow struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` - SecretPrefix string `db:"secret_prefix" json:"secret_prefix"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"` + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + SecretPrefix string `db:"secret_prefix" json:"secret_prefix"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + LastHeartbeatAt sql.NullTime `db:"last_heartbeat_at" json:"last_heartbeat_at"` } func (q *sqlQuerier) ListAIGatewayKeys(ctx context.Context) ([]ListAIGatewayKeysRow, error) { @@ -202,7 +225,7 @@ func (q *sqlQuerier) ListAIGatewayKeys(ctx context.Context) ([]ListAIGatewayKeys &i.Name, &i.SecretPrefix, &i.CreatedAt, - &i.LastUsedAt, + &i.LastHeartbeatAt, ); err != nil { return nil, err } @@ -217,6 +240,23 @@ func (q *sqlQuerier) ListAIGatewayKeys(ctx context.Context) ([]ListAIGatewayKeys return items, nil } +const updateAIGatewayKeyLastHeartbeatAt = `-- name: UpdateAIGatewayKeyLastHeartbeatAt :execrows +UPDATE ai_gateway_keys +SET last_heartbeat_at = NOW() +WHERE id = $1 +` + +// Records heartbeat liveness for an active Gateway DRPC session. The database sets the +// timestamp so it stays consistent regardless of clock drift between API +// replicas. +func (q *sqlQuerier) UpdateAIGatewayKeyLastHeartbeatAt(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.ExecContext(ctx, updateAIGatewayKeyLastHeartbeatAt, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const deleteAIProviderKey = `-- name: DeleteAIProviderKey :exec DELETE FROM ai_provider_keys @@ -36200,6 +36240,31 @@ func (q *sqlQuerier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg Upda return err } +const updateWorkspaceBuildNotifiedAutostopDeadline = `-- name: UpdateWorkspaceBuildNotifiedAutostopDeadline :exec +UPDATE + workspace_builds +SET + notified_autostop_deadline = $1::timestamptz, + updated_at = $2::timestamptz +WHERE id = $3::uuid +` + +type UpdateWorkspaceBuildNotifiedAutostopDeadlineParams struct { + NotifiedAutostopDeadline time.Time `db:"notified_autostop_deadline" json:"notified_autostop_deadline"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +// Stamps the deadline value that an autostop reminder was last sent for. Once +// this equals the build's deadline the reminder is considered handled and the +// lifecycle executor will not send another for this deadline, which makes the +// reminder idempotent and HA-safe. It re-arms automatically when the deadline +// changes (e.g. an activity bump). +func (q *sqlQuerier) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceBuildNotifiedAutostopDeadline, arg.NotifiedAutostopDeadline, arg.UpdatedAt, arg.ID) + return err +} + const updateWorkspaceBuildProvisionerStateByID = `-- name: UpdateWorkspaceBuildProvisionerStateByID :exec UPDATE workspace_builds @@ -38083,7 +38148,7 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, templateID u return items, nil } -const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many +const getWorkspacesEligibleForLifecycleAction = `-- name: GetWorkspacesEligibleForLifecycleAction :many SELECT workspaces.id, workspaces.name, @@ -38206,6 +38271,34 @@ WHERE provisioner_jobs.job_status = 'failed'::provisioner_job_status AND provisioner_jobs.completed_at IS NOT NULL AND ($1 :: timestamptz) - provisioner_jobs.completed_at > (INTERVAL '1 millisecond' * (templates.failure_ttl / 1000000)) + ) OR + + -- A workspace may be eligible for an autostop reminder if the following are true: + -- * The latest build is a successfully provisioned start build. + -- * The workspace is not dormant and its owner is not suspended. + -- * The build has a deadline in the future (we never remind about a stop already due). + -- * The template opts in (time_til_autostop_notify > 0) and now is within the lead window. + -- * A reminder has not yet been sent for THIS deadline. + -- + -- NOTE: time_til_autostop_notify has no upper bound. If it exceeds a + -- workspace's remaining lifetime, the notify window already includes "now" + -- at build creation. This arm intentionally still only matches builds whose + -- deadline is in the future (deadline > now) and whose marker has not yet + -- been stamped (notified_autostop_deadline != deadline), so at most ONE + -- reminder is ever produced for a given deadline regardless of how large the + -- field is. The field is stored in nanoseconds, so convert to an interval + -- the same way the dormancy arm does: nanoseconds / 1000000 yields + -- milliseconds. + ( + provisioner_jobs.job_status = 'succeeded'::provisioner_job_status AND + workspace_builds.transition = 'start'::workspace_transition AND + workspaces.dormant_at IS NULL AND + users.status != 'suspended'::user_status AND + workspace_builds.deadline != '0001-01-01 00:00:00+00'::timestamptz AND + workspace_builds.deadline > $1::timestamptz AND + templates.time_til_autostop_notify > 0 AND + workspace_builds.deadline <= ($1::timestamptz) + (INTERVAL '1 millisecond' * (templates.time_til_autostop_notify / 1000000)) AND + workspace_builds.notified_autostop_deadline != workspace_builds.deadline ) ) AND workspaces.deleted = 'false' @@ -38215,21 +38308,25 @@ WHERE AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` -type GetWorkspacesEligibleForTransitionRow struct { +type GetWorkspacesEligibleForLifecycleActionRow struct { ID uuid.UUID `db:"id" json:"id"` Name string `db:"name" json:"name"` BuildTemplateVersionID uuid.NullUUID `db:"build_template_version_id" json:"build_template_version_id"` } -func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) { - rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForTransition, now) +// Returns workspaces the lifecycle executor must act on this tick. An +// "action" is a state transition (autostart/autostop/dormancy/delete), a +// dormancy mark (which has no build transition), or a one-time autostop +// reminder notification (which only stamps a marker, no transition). +func (q *sqlQuerier) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForLifecycleActionRow, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForLifecycleAction, now) if err != nil { return nil, err } defer rows.Close() - var items []GetWorkspacesEligibleForTransitionRow + var items []GetWorkspacesEligibleForLifecycleActionRow for rows.Next() { - var i GetWorkspacesEligibleForTransitionRow + var i GetWorkspacesEligibleForLifecycleActionRow if err := rows.Scan(&i.ID, &i.Name, &i.BuildTemplateVersionID); err != nil { return nil, err } diff --git a/coderd/database/queries/ai_gateway_keys.sql b/coderd/database/queries/ai_gateway_keys.sql index 308d0cb89d..635bcdc5ce 100644 --- a/coderd/database/queries/ai_gateway_keys.sql +++ b/coderd/database/queries/ai_gateway_keys.sql @@ -4,10 +4,26 @@ VALUES ($1, @name, $2, $3, NOW()) RETURNING id, name, secret_prefix, created_at; -- name: ListAIGatewayKeys :many -SELECT id, name, secret_prefix, created_at, last_used_at +SELECT id, name, secret_prefix, created_at, last_heartbeat_at FROM ai_gateway_keys ORDER BY created_at ASC; -- name: DeleteAIGatewayKey :one DELETE FROM ai_gateway_keys WHERE id = $1 -RETURNING id, name, secret_prefix, created_at, last_used_at; +RETURNING id, name, secret_prefix, created_at, last_heartbeat_at; + +-- name: GetAIGatewayKeyByHashedSecret :one +-- Authenticates a standalone AI Gateway replica by its hashed key secret, +-- returning the matched key. The lookup is an exact match on a unique index, +-- so a returned row is itself proof the secret is valid. +SELECT * +FROM ai_gateway_keys +WHERE hashed_secret = $1; + +-- name: UpdateAIGatewayKeyLastHeartbeatAt :execrows +-- Records heartbeat liveness for an active Gateway DRPC session. The database sets the +-- timestamp so it stays consistent regardless of clock drift between API +-- replicas. +UPDATE ai_gateway_keys +SET last_heartbeat_at = NOW() +WHERE id = $1; diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 7767cd0b6f..390ffefab9 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -141,6 +141,19 @@ SET updated_at = @updated_at::timestamptz WHERE id = @id::uuid; +-- name: UpdateWorkspaceBuildNotifiedAutostopDeadline :exec +-- Stamps the deadline value that an autostop reminder was last sent for. Once +-- this equals the build's deadline the reminder is considered handled and the +-- lifecycle executor will not send another for this deadline, which makes the +-- reminder idempotent and HA-safe. It re-arms automatically when the deadline +-- changes (e.g. an activity bump). +UPDATE + workspace_builds +SET + notified_autostop_deadline = @notified_autostop_deadline::timestamptz, + updated_at = @updated_at::timestamptz +WHERE id = @id::uuid; + -- name: GetActiveWorkspaceBuildsByTemplateID :many SELECT wb.* FROM ( diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index c9ed2ed446..9be84a6b6f 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -739,7 +739,11 @@ SELECT stopped_workspaces.count AS stopped_workspaces FROM pending_workspaces, building_workspaces, running_workspaces, failed_workspaces, stopped_workspaces; --- name: GetWorkspacesEligibleForTransition :many +-- name: GetWorkspacesEligibleForLifecycleAction :many +-- Returns workspaces the lifecycle executor must act on this tick. An +-- "action" is a state transition (autostart/autostop/dormancy/delete), a +-- dormancy mark (which has no build transition), or a one-time autostop +-- reminder notification (which only stamps a marker, no transition). SELECT workspaces.id, workspaces.name, @@ -862,6 +866,34 @@ WHERE provisioner_jobs.job_status = 'failed'::provisioner_job_status AND provisioner_jobs.completed_at IS NOT NULL AND (@now :: timestamptz) - provisioner_jobs.completed_at > (INTERVAL '1 millisecond' * (templates.failure_ttl / 1000000)) + ) OR + + -- A workspace may be eligible for an autostop reminder if the following are true: + -- * The latest build is a successfully provisioned start build. + -- * The workspace is not dormant and its owner is not suspended. + -- * The build has a deadline in the future (we never remind about a stop already due). + -- * The template opts in (time_til_autostop_notify > 0) and now is within the lead window. + -- * A reminder has not yet been sent for THIS deadline. + -- + -- NOTE: time_til_autostop_notify has no upper bound. If it exceeds a + -- workspace's remaining lifetime, the notify window already includes "now" + -- at build creation. This arm intentionally still only matches builds whose + -- deadline is in the future (deadline > now) and whose marker has not yet + -- been stamped (notified_autostop_deadline != deadline), so at most ONE + -- reminder is ever produced for a given deadline regardless of how large the + -- field is. The field is stored in nanoseconds, so convert to an interval + -- the same way the dormancy arm does: nanoseconds / 1000000 yields + -- milliseconds. + ( + provisioner_jobs.job_status = 'succeeded'::provisioner_job_status AND + workspace_builds.transition = 'start'::workspace_transition AND + workspaces.dormant_at IS NULL AND + users.status != 'suspended'::user_status AND + workspace_builds.deadline != '0001-01-01 00:00:00+00'::timestamptz AND + workspace_builds.deadline > @now::timestamptz AND + templates.time_til_autostop_notify > 0 AND + workspace_builds.deadline <= (@now::timestamptz) + (INTERVAL '1 millisecond' * (templates.time_til_autostop_notify / 1000000)) AND + workspace_builds.notified_autostop_deadline != workspace_builds.deadline ) ) AND workspaces.deleted = 'false' diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index abc19ee1ae..42ba273df3 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -254,6 +254,7 @@ sql: login_type_oauth2_provider_app: LoginTypeOAuth2ProviderApp crypto_key_feature_workspace_apps_api_key: CryptoKeyFeatureWorkspaceAppsAPIKey crypto_key_feature_oidc_convert: CryptoKeyFeatureOIDCConvert + crypto_key_feature_nats_ca: CryptoKeyFeatureNATSCA stale_interval_ms: StaleIntervalMS has_ai_task: HasAITask ai_task_sidebar_app_id: AITaskSidebarAppID diff --git a/coderd/httpmw/ratelimit_test.go b/coderd/httpmw/ratelimit_test.go index 49e46ccf46..1e4ca1828b 100644 --- a/coderd/httpmw/ratelimit_test.go +++ b/coderd/httpmw/ratelimit_test.go @@ -286,9 +286,7 @@ func TestConcurrencyLimit(t *testing.T) { var wg sync.WaitGroup for i := 0; i < maxConcurrency; i++ { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/", nil) if err != nil { results <- result{err: err} @@ -301,7 +299,7 @@ func TestConcurrencyLimit(t *testing.T) { } defer resp.Body.Close() results <- result{statusCode: resp.StatusCode} - }() + }) } // Wait for all requests to enter the handler with a timeout. diff --git a/coderd/notifications/dispatch/smtp_test.go b/coderd/notifications/dispatch/smtp_test.go index 34aed0feed..ee9b6a3d7a 100644 --- a/coderd/notifications/dispatch/smtp_test.go +++ b/coderd/notifications/dispatch/smtp_test.go @@ -445,11 +445,9 @@ func TestSMTP(t *testing.T) { // Start mock SMTP server in the background. var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { assert.NoError(t, srv.Serve(listen)) - }() + }) // Wait for the server to become pingable. require.Eventually(t, func() bool { @@ -590,11 +588,9 @@ func TestSMTPEnvelopeAndHeaders(t *testing.T) { handler := dispatch.NewSMTPHandler(cfg, logger.Named("smtp")) var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { assert.NoError(t, srv.Serve(listen)) - }() + }) require.Eventually(t, func() bool { cl, err := smtptest.PingClient(listen, false, false) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index aaceb4fe3c..839958f91c 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1541,11 +1541,9 @@ func TestNotificationTemplates_Golden(t *testing.T) { // Start mock SMTP server in the background. var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { assert.NoError(t, srv.Serve(listen)) - }() + }) // Wait for the server to become pingable. require.Eventually(t, func() bool { diff --git a/coderd/oauth2_security_test.go b/coderd/oauth2_security_test.go index baab37e3d3..47190cd2bf 100644 --- a/coderd/oauth2_security_test.go +++ b/coderd/oauth2_security_test.go @@ -421,13 +421,10 @@ func TestOAuth2ConcurrentSecurityOperations(t *testing.T) { // Launch concurrent attempts to access the client configuration for i := 0; i < numGoroutines; i++ { - wg.Add(1) - go func(index int) { - defer wg.Done() - + wg.Go(func() { _, err := client.GetOAuth2ClientConfiguration(ctx, regResp.ClientID, regResp.RegistrationAccessToken) - errors[index] = err - }(i) + errors[i] = err + }) } wg.Wait() @@ -448,23 +445,20 @@ func TestOAuth2ConcurrentSecurityOperations(t *testing.T) { // Launch concurrent attempts with invalid tokens for i := 0; i < numGoroutines; i++ { - wg.Add(1) - go func(index int) { - defer wg.Done() - - _, err := client.GetOAuth2ClientConfiguration(ctx, regResp.ClientID, fmt.Sprintf("invalid-token-%d", index)) + wg.Go(func() { + _, err := client.GetOAuth2ClientConfiguration(ctx, regResp.ClientID, fmt.Sprintf("invalid-token-%d", i)) if err == nil { - t.Errorf("Expected error for goroutine %d", index) + t.Errorf("Expected error for goroutine %d", i) return } var httpErr *codersdk.Error if !errors.As(err, &httpErr) { - t.Errorf("Expected codersdk.Error for goroutine %d", index) + t.Errorf("Expected codersdk.Error for goroutine %d", i) return } - statusCodes[index] = httpErr.StatusCode() - }(i) + statusCodes[i] = httpErr.StatusCode() + }) } wg.Wait() @@ -494,13 +488,10 @@ func TestOAuth2ConcurrentSecurityOperations(t *testing.T) { // Launch concurrent deletion attempts for i := 0; i < numGoroutines; i++ { - wg.Add(1) - go func(index int) { - defer wg.Done() - + wg.Go(func() { err := client.DeleteOAuth2ClientConfiguration(ctx, deleteRegResp.ClientID, deleteRegResp.RegistrationAccessToken) - deleteResults[index] = err - }(i) + deleteResults[i] = err + }) } wg.Wait() diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 5ff60562b1..a18b02405b 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -20,6 +20,7 @@ var ( // - "ActionCreate" :: create an AI Gateway key // - "ActionDelete" :: delete an AI Gateway key // - "ActionRead" :: read AI Gateway keys + // - "ActionUpdate" :: update an AI Gateway key ResourceAIGatewayKey = Object{ Type: "ai_gateway_key", } diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index f97b2a78bc..035a0a3475 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -434,6 +434,7 @@ var RBACPermissions = map[string]PermissionDefinition{ Actions: map[Action]ActionDefinition{ ActionCreate: "create an AI Gateway key", ActionRead: "read AI Gateway keys", + ActionUpdate: "update an AI Gateway key", ActionDelete: "delete an AI Gateway key", }, }, diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 4404c071f2..3dbaa162dc 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -409,12 +409,16 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec. // Owners can inspect and delete personal skills for operability and // abuse handling, but cannot create or edit user-authored instructions. - allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat), + allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat, ResourceAIGatewayKey), // This adds back in the Workspace permissions. Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ownerWorkspaceActions, ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent}, ResourceUserSkill.Type: {policy.ActionRead, policy.ActionDelete}, + // Owners manage AI Gateway keys but cannot update them. The + // update action records last-used liveness and is reserved + // for the system actor authenticating Gateway replicas. + ResourceAIGatewayKey.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, // PrebuiltWorkspaces are a subset of Workspaces. // Explicitly setting PrebuiltWorkspace permissions for clarity. // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions. diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 9b0054d97b..341a89cf97 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1311,6 +1311,25 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + // Updating an AI Gateway key records last-used liveness when a + // Gateway replica authenticates. It is reserved for the system + // actor, so no user-facing role, including owner, is authorized. + Name: "AIGatewayKeyUpdate", + Actions: []policy.Action{policy.ActionUpdate}, + Resource: rbac.ResourceAIGatewayKey, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {}, + false: { + owner, + orgWorkspaceAccessUser, memberMe, agentsAccessUser, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, { Name: "BoundaryUsage", Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index 3adad84a59..e58519bddf 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -10,6 +10,7 @@ const ( ScopeAiGatewayKeyCreate ScopeName = "ai_gateway_key:create" ScopeAiGatewayKeyDelete ScopeName = "ai_gateway_key:delete" ScopeAiGatewayKeyRead ScopeName = "ai_gateway_key:read" + ScopeAiGatewayKeyUpdate ScopeName = "ai_gateway_key:update" ScopeAiModelPriceRead ScopeName = "ai_model_price:read" ScopeAiModelPriceUpdate ScopeName = "ai_model_price:update" ScopeAiProviderCreate ScopeName = "ai_provider:create" @@ -193,6 +194,7 @@ func (e ScopeName) Valid() bool { ScopeAiGatewayKeyCreate, ScopeAiGatewayKeyDelete, ScopeAiGatewayKeyRead, + ScopeAiGatewayKeyUpdate, ScopeAiModelPriceRead, ScopeAiModelPriceUpdate, ScopeAiProviderCreate, @@ -377,6 +379,7 @@ func AllScopeNameValues() []ScopeName { ScopeAiGatewayKeyCreate, ScopeAiGatewayKeyDelete, ScopeAiGatewayKeyRead, + ScopeAiGatewayKeyUpdate, ScopeAiModelPriceRead, ScopeAiModelPriceUpdate, ScopeAiProviderCreate, diff --git a/coderd/templatebuilder/modules/aider/module.json b/coderd/templatebuilder/modules/aider/module.json index 8d3922c220..2426fa71fe 100644 --- a/coderd/templatebuilder/modules/aider/module.json +++ b/coderd/templatebuilder/modules/aider/module.json @@ -53,7 +53,7 @@ { "name": "api_key", "type": "string", - "description": "API key for the selected AI provider. This will be set as the appropriate environment variable based on the provider.", + "description": "API key for the selected AI provider.", "default": "", "required": false, "sensitive": true, @@ -62,7 +62,7 @@ { "name": "base_aider_config", "type": "string", - "description": "Base Aider configuration in yaml format. Will be stored in .aider.conf.yml file.\n \noptions include:\nread:\n - CONVENTIONS.md\n - anotherfile.txt\n - thirdfile.py\nmodel: xxx\n##Specify the OpenAI API key\nopenai-api-key: xxx\n## (deprecated, use --set-env OPENAI_API_TYPE=\u003cvalue\u003e)\nopenai-api-type: xxx\n## (deprecated, use --set-env OPENAI_API_VERSION=\u003cvalue\u003e)\nopenai-api-version: xxx\n## (deprecated, use --set-env OPENAI_API_DEPLOYMENT_ID=\u003cvalue\u003e)\nopenai-api-deployment-id: xxx\n## Set an environment variable (to control API settings, can be used multiple times)\nset-env: xxx\n## Specify multiple values like this:\nset-env:\n - xxx\n - yyy\n - zzz\n\nReference : https://aider.chat/docs/config/aider_conf.html\n", + "description": "Base Aider configuration in YAML format. Stored in .aider.conf.yml.", "required": false, "sensitive": false, "computed": false @@ -132,7 +132,7 @@ { "name": "model", "type": "string", - "description": "AI model to use with Aider. Can use Aider's built-in aliases like '4o' (gpt-4o), 'sonnet' (claude-3-7-sonnet), 'opus' (claude-3-opus), etc.", + "description": "AI model to use with Aider.", "required": true, "sensitive": false, "computed": false diff --git a/coderd/templatebuilder/modules/amazon-q/module.json b/coderd/templatebuilder/modules/amazon-q/module.json index 261ea4b28a..f9cee45e00 100644 --- a/coderd/templatebuilder/modules/amazon-q/module.json +++ b/coderd/templatebuilder/modules/amazon-q/module.json @@ -36,7 +36,7 @@ { "name": "agentapi_chat_based_path", "type": "bool", - "description": "Whether to use chat-based path for AgentAPI.Required if CODER_WILDCARD_ACCESS_URL is not defined in coder deployment", + "description": "Use chat-based path for AgentAPI.", "default": false, "required": false, "sensitive": false, @@ -99,7 +99,7 @@ { "name": "coder_mcp_instructions", "type": "string", - "description": "Instructions for the Coder MCP server integration. This defines how the agent should report tasks to Coder.", + "description": "Instructions for the Coder MCP server integration.", "default": "YOU MUST REPORT ALL TASKS TO CODER.\nWhen reporting tasks you MUST follow these EXACT instructions:\n- IMMEDIATELY report status after receiving ANY user message\n- Be granular If you are investigating with multiple steps report each step to coder.\n\nTask state MUST be one of the following:\n- Use \"state\": \"working\" when actively processing WITHOUT needing additional user input\n- Use \"state\": \"complete\" only when finished with a task\n- Use \"state\": \"failure\" when you need ANY user input lack sufficient details or encounter blockers.\n\nTask summaries MUST:\n- Include specifics about what you're doing\n- Include clear and actionable steps for the user\n- Be less than 160 characters in length\n", "required": false, "sensitive": false, diff --git a/coderd/templatebuilder/modules/claude-code/module.json b/coderd/templatebuilder/modules/claude-code/module.json index 02350ee65c..60a4042685 100644 --- a/coderd/templatebuilder/modules/claude-code/module.json +++ b/coderd/templatebuilder/modules/claude-code/module.json @@ -37,7 +37,7 @@ { "name": "claude_binary_path", "type": "string", - "description": "Directory where the Claude Code binary is located. Use this if Claude is pre-installed or installed outside the module to a non-default location.", + "description": "Directory where the Claude Code binary is located.", "default": "$HOME/.local/bin", "required": false, "sensitive": false, @@ -46,7 +46,7 @@ { "name": "claude_code_oauth_token", "type": "string", - "description": "OAuth token passed to Claude Code via the CLAUDE_CODE_OAUTH_TOKEN env var. Generate one with `claude setup-token`.", + "description": "OAuth token for Claude Code.", "default": "", "required": false, "sensitive": true, @@ -100,7 +100,7 @@ { "name": "mcp", "type": "string", - "description": "JSON-encoded string of MCP server configurations. When set, servers are added at Claude Code's user scope so they are available across every project the workspace owner opens.", + "description": "JSON-encoded MCP server configurations for Claude Code.", "default": "", "required": false, "sensitive": false, @@ -109,7 +109,7 @@ { "name": "model", "type": "string", - "description": "Sets the default model for Claude Code via ANTHROPIC_MODEL env var. If empty, Claude Code uses its default. Supports aliases (sonnet, opus) or full model names.", + "description": "Default model for Claude Code via ANTHROPIC_MODEL env var.", "default": "", "required": false, "sensitive": false, @@ -126,7 +126,7 @@ { "name": "pre_install_script", "type": "string", - "description": "Custom script to run before installing Claude Code. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Claude Code initialization).", + "description": "Custom script to run before installing Claude Code.", "required": false, "sensitive": false, "computed": false @@ -134,7 +134,7 @@ { "name": "workdir", "type": "string", - "description": "Optional project directory. When set, the module pre-creates it if missing and pre-accepts the Claude Code trust/onboarding prompt for it in ~/.claude.json.", + "description": "Project directory. Pre-created if missing.", "required": false, "sensitive": false, "computed": false diff --git a/coderd/templatebuilder/modules/code-server/module.json b/coderd/templatebuilder/modules/code-server/module.json index b3334bb0c7..d26dba2ad4 100644 --- a/coderd/templatebuilder/modules/code-server/module.json +++ b/coderd/templatebuilder/modules/code-server/module.json @@ -82,7 +82,7 @@ { "name": "open_in", "type": "string", - "description": "Determines where the app will be opened. Valid values are `\"tab\"` and `\"slim-window\" (default)`.\n`\"tab\"` opens in a new tab in the same browser window.\n`\"slim-window\"` opens a new browser window without navigation controls.\n", + "description": "Where to open the app: \"tab\" or \"slim-window\" (default).", "default": "slim-window", "required": false, "sensitive": false, diff --git a/coderd/templatebuilder/modules/cursor/module.json b/coderd/templatebuilder/modules/cursor/module.json index f00ba76515..5920aa3e42 100644 --- a/coderd/templatebuilder/modules/cursor/module.json +++ b/coderd/templatebuilder/modules/cursor/module.json @@ -44,7 +44,7 @@ { "name": "open_recent", "type": "bool", - "description": "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open.", + "description": "Open the most recent workspace or folder.", "default": false, "required": false, "sensitive": false, diff --git a/coderd/templatebuilder/modules/dotfiles/module.json b/coderd/templatebuilder/modules/dotfiles/module.json index 9244f78169..5186887c3b 100644 --- a/coderd/templatebuilder/modules/dotfiles/module.json +++ b/coderd/templatebuilder/modules/dotfiles/module.json @@ -43,7 +43,7 @@ { "name": "description", "type": "string", - "description": "A custom description for the dotfiles parameter. This is shown in the UI - and allows you to customize the instructions you give to your users.", + "description": "Custom description for the dotfiles parameter shown in the UI.", "default": "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace. Use an SSH URL (e.g. `git@host:user/repo`) if your Git provider restricts HTTPS cloning.", "required": false, "sensitive": false, @@ -52,7 +52,7 @@ { "name": "dotfiles_branch", "type": "string", - "description": "The branch to use for the dotfiles repository (optional, when set, the user isn't prompted for the branch)", + "description": "Branch to use for the dotfiles repository.", "required": false, "sensitive": false, "computed": false @@ -77,7 +77,7 @@ { "name": "post_clone_script", "type": "string", - "description": "Custom script to run after applying dotfiles. Runs every time, even if dotfiles were already applied.", + "description": "Custom script to run after applying dotfiles.", "required": false, "sensitive": false, "computed": false diff --git a/coderd/templatebuilder/modules/filebrowser/module.json b/coderd/templatebuilder/modules/filebrowser/module.json index c2e95e7534..2dade08edc 100644 --- a/coderd/templatebuilder/modules/filebrowser/module.json +++ b/coderd/templatebuilder/modules/filebrowser/module.json @@ -25,7 +25,7 @@ { "name": "agent_name", "type": "string", - "description": "The name of the coder_agent resource. Required when `subdomain` is `false` so the path-based base URL matches the URL Coder serves.", + "description": "The name of the coder_agent resource.", "required": false, "sensitive": false, "computed": false diff --git a/coderd/templatebuilder/modules/git-clone/module.json b/coderd/templatebuilder/modules/git-clone/module.json index f00afe9b38..eab5cd73d1 100644 --- a/coderd/templatebuilder/modules/git-clone/module.json +++ b/coderd/templatebuilder/modules/git-clone/module.json @@ -52,7 +52,7 @@ { "name": "post_clone_script", "type": "string", - "description": "Custom script to run after cloning the repository. Runs always after git clone, even if the repository already exists.", + "description": "Custom script to run after cloning the repository.", "required": false, "sensitive": false, "computed": false @@ -60,7 +60,7 @@ { "name": "pre_clone_script", "type": "string", - "description": "Custom script to run before cloning the repository. Runs before git clone, even if the repository already exists.", + "description": "Custom script to run before cloning the repository.", "required": false, "sensitive": false, "computed": false diff --git a/coderd/templatebuilder/modules/jupyterlab/module.json b/coderd/templatebuilder/modules/jupyterlab/module.json index 1a292d55a5..57447445ad 100644 --- a/coderd/templatebuilder/modules/jupyterlab/module.json +++ b/coderd/templatebuilder/modules/jupyterlab/module.json @@ -26,7 +26,7 @@ { "name": "config", "type": "string", - "description": "A JSON string of JupyterLab server configuration settings. When set, writes ~/.jupyter/jupyter_server_config.json.", + "description": "JupyterLab server configuration as a JSON string.", "default": "{}", "required": false, "sensitive": false, diff --git a/coderd/templatebuilder/modules/kiro/module.json b/coderd/templatebuilder/modules/kiro/module.json index 65abaf5250..07882e344f 100644 --- a/coderd/templatebuilder/modules/kiro/module.json +++ b/coderd/templatebuilder/modules/kiro/module.json @@ -45,7 +45,7 @@ { "name": "open_recent", "type": "bool", - "description": "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open.", + "description": "Open the most recent workspace or folder.", "default": false, "required": false, "sensitive": false, diff --git a/coderd/templatebuilder/modules/vscode-desktop/module.json b/coderd/templatebuilder/modules/vscode-desktop/module.json index 0190214cd0..6295c890c8 100644 --- a/coderd/templatebuilder/modules/vscode-desktop/module.json +++ b/coderd/templatebuilder/modules/vscode-desktop/module.json @@ -34,7 +34,7 @@ { "name": "open_recent", "type": "bool", - "description": "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open.", + "description": "Open the most recent workspace or folder.", "default": false, "required": false, "sensitive": false, diff --git a/coderd/templatebuilder/modules/vscode-web/module.json b/coderd/templatebuilder/modules/vscode-web/module.json index a215fbf214..238a1fd65b 100644 --- a/coderd/templatebuilder/modules/vscode-web/module.json +++ b/coderd/templatebuilder/modules/vscode-web/module.json @@ -46,7 +46,7 @@ { "name": "commit_id", "type": "string", - "description": "Specify the commit ID of the VS Code Web binary to pin to a specific version. If left empty, the latest stable version is used.", + "description": "Commit ID to pin VS Code Web to a specific version.", "default": "", "required": false, "sensitive": false, diff --git a/coderd/templatebuilder/modules/windsurf/module.json b/coderd/templatebuilder/modules/windsurf/module.json index 2dc10624b7..c06ad69498 100644 --- a/coderd/templatebuilder/modules/windsurf/module.json +++ b/coderd/templatebuilder/modules/windsurf/module.json @@ -35,7 +35,7 @@ { "name": "mcp", "type": "string", - "description": "JSON-encoded string to configure MCP servers for Windsurf. When set, writes ~/.codeium/windsurf/mcp_config.json.", + "description": "JSON-encoded MCP server configurations for Windsurf.", "default": "", "required": false, "sensitive": false, @@ -44,7 +44,7 @@ { "name": "open_recent", "type": "bool", - "description": "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open.", + "description": "Open the most recent workspace or folder.", "default": false, "required": false, "sensitive": false, diff --git a/coderd/templateversions.go b/coderd/templateversions.go index ef7f6e0899..682a7bb0b1 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -31,6 +31,7 @@ import ( "github.com/coder/coder/v2/coderd/dynamicparameters" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpapi/httperror" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" @@ -337,14 +338,28 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ templateVersion = httpmw.TemplateVersionParam(r) ) + providers, err := api.templateVersionExternalAuthForUser(ctx, templateVersion, apiKey.UserID) + if err != nil { + httperror.WriteResponseError(ctx, rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, providers) +} + +// templateVersionExternalAuthForUser returns the external auth providers +// referenced by the template version, with Authenticated reporting whether +// the given user has a usable token for each provider. Failures are returned +// as httperror response errors suitable for writing directly to an API +// response. +func (api *API) templateVersionExternalAuthForUser(ctx context.Context, templateVersion database.TemplateVersion, userID uuid.UUID) ([]codersdk.TemplateVersionExternalAuth, error) { var rawProviders []database.ExternalAuthProvider err := json.Unmarshal(templateVersion.ExternalAuthProviders, &rawProviders) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading auth config from database", Detail: err.Error(), }) - return } providers := make([]codersdk.TemplateVersionExternalAuth, 0) @@ -357,21 +372,19 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ } } if config == nil { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusNotFound, codersdk.Response{ Message: fmt.Sprintf("The template version references a Git auth provider %q that no longer exists.", rawProvider.ID), Detail: "You'll need to update the template version to use a different provider.", }) - return } // This is the URL that will redirect the user with a state token. redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", config.ID)) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to parse access URL.", Detail: err.Error(), }) - return } provider := codersdk.TemplateVersionExternalAuth{ @@ -385,7 +398,7 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ authLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{ ProviderID: config.ID, - UserID: apiKey.UserID, + UserID: userID, }) // If there isn't an auth link, then the user just isn't authenticated. if errors.Is(err, sql.ErrNoRows) { @@ -393,27 +406,25 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ continue } if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching external auth link.", Detail: err.Error(), }) - return } _, err = config.RefreshToken(ctx, api.Database, authLink) if err != nil && !externalauth.IsInvalidTokenError(err) { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + return nil, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Failed to refresh external auth token.", Detail: err.Error(), }) - return } provider.Authenticated = err == nil providers = append(providers, provider) } - httpapi.Write(ctx, rw, http.StatusOK, providers) + return providers, nil } // @Summary Get template variables by template version diff --git a/coderd/userauth.go b/coderd/userauth.go index bdcaad7397..91e0d0f5e5 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1037,7 +1037,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { }) return } - user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, verifiedEmail.GetEmail()) + user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, false, verifiedEmail.GetEmail()) if errors.Is(err, errLinkedIDAlreadyBound) { logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity", slog.F("email", verifiedEmail.GetEmail()), @@ -1187,6 +1187,12 @@ type OIDCConfig struct { // SignupsDisabledText is the text do display on the static error page. SignupsDisabledText string PKCEMethods []promoauth.Oauth2PKCEChallengeMethod + // EmailFallback, when true, allows OIDC logins to fall back to + // email-based user matching when the linked_id (issuer+subject) does + // not match an existing user link. INSECURE: weakens the linked_id + // check. Used for IdP brokers that do not issue a stable `sub` for the + // same user across connections. + EmailFallback bool } // PKCESupported is to prevent nil pointer dereference. @@ -1458,7 +1464,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { } ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name)) - user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, email) + user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, api.OIDCConfig.EmailFallback, email) if errors.Is(err, errLinkedIDAlreadyBound) { logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity", slog.F("email", email), @@ -1526,6 +1532,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { UserInfoClaims: supplementaryClaims, MergedClaims: mergedClaims, }, + AllowInsecureLinkedIDMismatch: api.OIDCConfig.EmailFallback, }).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) { return audit.InitRequest[database.User](rw, params) }) @@ -1679,6 +1686,13 @@ type oauthLoginParams struct { // It is used to save the user's claims on login. UserClaims database.UserLinkClaims + // AllowInsecureLinkedIDMismatch, when true, allows the login to proceed + // when the existing user_link's linked_id differs from LinkedID. The + // existing linked_id is preserved (no overwrite). INSECURE: opt-in + // escape hatch for IdP brokers that emit different subjects for the + // same user across connections. + AllowInsecureLinkedIDMismatch bool + commitLock sync.Mutex initAuditRequest func(params *audit.RequestParams) *audit.Request[database.User] commits []func() @@ -1929,7 +1943,10 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C // Defense-in-depth: if a concurrent transaction backfilled // linked_id between findLinkedUser and this point, reject the // login with a 403 instead of letting it bubble up as a 500. - if link.LinkedID != "" && link.LinkedID != params.LinkedID { + // The INSECURE AllowInsecureLinkedIDMismatch escape hatch + // preserves the existing linked_id and lets the login proceed; + // the warning was already emitted by the caller. + if link.LinkedID != "" && link.LinkedID != params.LinkedID && !params.AllowInsecureLinkedIDMismatch { return &idpsync.HTTPError{ Code: http.StatusForbidden, Msg: "Account already linked", @@ -2180,7 +2197,16 @@ var errLinkedIDAlreadyBound = xerrors.New("user account is already linked to a d // legacy links (empty linked_id) only. If the user found by email // already has a link with a different linked_id, errLinkedIDAlreadyBound // is returned to prevent account takeover via IdP email reuse. -func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, emails ...string) (database.User, database.UserLink, error) { +// +// When allowInsecureLinkedIDMismatch is true, the linked_id mismatch +// check is skipped and the email fallback resolves the login even when +// the existing link's linked_id differs from the current login's. The +// existing linked_id is left intact (no overwrite). This is an INSECURE +// opt-in for IdP brokers that do not issue a stable `sub` for the same +// user across connections. +// +//nolint:revive // allowInsecureLinkedIDMismatch is intentionally a control flag; it gates an INSECURE opt-in. +func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, allowInsecureLinkedIDMismatch bool, emails ...string) (database.User, database.UserLink, error) { var ( user database.User link database.UserLink @@ -2233,8 +2259,10 @@ func findLinkedUser(ctx context.Context, db database.Store, linkedID string, log // Block email fallback when an existing link has a different linked_id. // Prevents account takeover via IdP email reuse; first-time and legacy - // (empty linked_id) links pass through. - if err == nil && link.LinkedID != "" && link.LinkedID != linkedID { + // (empty linked_id) links pass through. The INSECURE + // allowInsecureLinkedIDMismatch escape hatch keeps the existing link + // (and its original linked_id) and lets the login proceed. + if err == nil && link.LinkedID != "" && link.LinkedID != linkedID && !allowInsecureLinkedIDMismatch { return database.User{}, database.UserLink{}, errLinkedIDAlreadyBound } diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 9c656b9c7b..8e4c230c4f 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -2001,6 +2001,176 @@ func TestUserOIDC(t *testing.T) { "linked_id must not be modified when the login is blocked") }) + // Tests the INSECURE OIDC email fallback escape hatch. When the + // deployment flag is set, an OIDC login whose subject differs from an + // existing user_link's linked_id but whose email matches must be + // allowed through. The original linked_id is preserved (no overwrite), + // so the user can keep logging in with either subject. + t.Run("OIDCInsecureEmailFallbackAllowed", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Seed a user whose link records the IdP's first connection. + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + originalLinkedID := fake.IssuerURL().String() + "||" + "first-connection-sub" + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: originalLinkedID, + }) + + // Login with a different subject (the broker emitted a new `sub` + // for the same user) but the same email. With EmailFallback + // enabled the email match resolves the login despite the linked_id + // mismatch. + client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": "second-connection-sub", + }) + require.Equal(t, http.StatusOK, resp.StatusCode, + "insecure email fallback must allow login with a mismatched subject") + + me, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, user.ID, me.ID, + "should authenticate as the existing user") + + // The original linked_id must be preserved, not overwritten by the + // new subject. This keeps the next login from the original subject + // (which hits the primary linked_id path) working. + link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, originalLinkedID, link.LinkedID, + "linked_id must be preserved on insecure email fallback") + }) + + // Tests that with the INSECURE OIDC email fallback enabled, the + // original subject's login still resolves via the primary linked_id + // path after a fallback login from a different subject. The fallback + // path does not overwrite the link, so the original subject keeps + // matching directly. + t.Run("OIDCInsecureEmailFallbackPreservesOriginalLogin", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + const originalSub = "first-connection-sub" + originalLinkedID := fake.IssuerURL().String() + "||" + originalSub + dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + LinkedID: originalLinkedID, + }) + + // Fallback login with a different subject. + _, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": "second-connection-sub", + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Subsequent login with the original subject must hit the primary + // linked_id match and succeed. + client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": originalSub, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + me, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, user.ID, me.ID) + + link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err) + require.Equal(t, originalLinkedID, link.LinkedID, + "linked_id must stay anchored to the original subject") + }) + + // Tests that the INSECURE OIDC email fallback does NOT extend to + // signups: an attacker logging in with a brand-new email (no existing + // user) still goes through the normal signup gate. The escape hatch is + // only about resolving subject-mismatch on existing accounts. + t.Run("OIDCInsecureEmailFallbackDoesNotCreateUsers", func(t *testing.T) { + t.Parallel() + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = false + cfg.EmailFallback = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Seed an existing user so the deployment's user count is > 0; + // otherwise the first signup is always allowed. + dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + + // New email, no existing user. Signups are disabled, so the + // fallback flag must not let the login through. + _, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": "stranger@example.com", + "sub": "stranger-subject", + }) + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "insecure email fallback must not bypass the signup gate") + }) + t.Run("OIDCSuspended", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index b625bb6f7c..ca18cdc400 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -1341,7 +1341,10 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) { template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - require.Equal(t, 1, validateCalls) // Ensure the external link is working + // Ensure the external link is working. Workspace creation validates the + // owner's required external auth, and the build's token injection + // validates it again. + require.Equal(t, 2, validateCalls) // Suspend the user ctx := testutil.Context(t, testutil.WaitLong) @@ -1355,7 +1358,7 @@ func TestWorkspaceDeleteSuspendedUser(t *testing.T) { }) require.NoError(t, err) build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, owner, build.ID) - require.Equal(t, 2, validateCalls) + require.Equal(t, 3, validateCalls) require.Equal(t, codersdk.WorkspaceStatusDeleted, build.Status) } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 2c296d6ffc..9d429ccbf5 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -9,6 +9,7 @@ import ( "net/http" "slices" "strconv" + "strings" "time" "github.com/dustin/go-humanize" @@ -595,6 +596,27 @@ func createWorkspace( }) } + // Required external auth is otherwise only enforced by client-side preflight + // checks in the CLI and UI, so API-created workspaces must be validated here + // before any workspace row is inserted or prebuilt workspace is claimed. + templateVersionID := req.TemplateVersionID + if templateVersionID == uuid.Nil { + templateVersionID = template.ActiveVersionID + } + templateVersion, err := api.Database.GetTemplateVersionByID(ctx, templateVersionID) + if err != nil { + if httpapi.Is404Error(err) { + return codersdk.Workspace{}, httperror.ErrResourceNotFound + } + return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template version.", + Detail: err.Error(), + }) + } + if err := api.requireWorkspaceOwnerExternalAuth(ctx, templateVersion, owner.ID); err != nil { + return codersdk.Workspace{}, err + } + dbAutostartSchedule, err := validWorkspaceSchedule(req.AutostartSchedule) if err != nil { return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ @@ -892,6 +914,50 @@ func createWorkspace( return w, nil } +// requireWorkspaceOwnerExternalAuth returns a 403 response error when the +// workspace owner has not authenticated with every required (non-optional) +// external auth provider referenced by the template version. Token injection +// at build time uses the owner's external auth links, so the owner is the +// subject of the check even when another user initiates the build. +func (api *API) requireWorkspaceOwnerExternalAuth(ctx context.Context, templateVersion database.TemplateVersion, ownerID uuid.UUID) error { + //nolint:gocritic // System access is required to validate the workspace owner's external auth links because admins and API clients may create workspaces for other users. + providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsSystemRestricted(ctx), templateVersion, ownerID) + if err != nil { + return err + } + + var ( + missingNames []string + validations []codersdk.ValidationError + ) + for _, provider := range providers { + if provider.Optional || provider.Authenticated { + continue + } + name := provider.DisplayName + if name == "" { + name = provider.ID + } + missingNames = append(missingNames, name) + validations = append(validations, codersdk.ValidationError{ + Field: "external_auth", + Detail: provider.ID, + }) + } + if len(missingNames) == 0 { + return nil + } + + return httperror.NewResponseError(http.StatusForbidden, codersdk.Response{ + Message: "External authentication is required to create a workspace with this template.", + Detail: fmt.Sprintf( + "The workspace owner must authenticate with the following external auth providers: %s.", + strings.Join(missingNames, ", "), + ), + Validations: validations, + }) +} + func requestTemplate(ctx context.Context, req codersdk.CreateWorkspaceRequest, db database.Store) (database.Template, error) { // If we were given a `TemplateVersionID`, we need to determine the `TemplateID` from it. templateID := req.TemplateID diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 5d8cc7c150..20284c4bbf 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -8,6 +8,8 @@ import ( "fmt" "math" "net/http" + "net/http/httptest" + "regexp" "slices" "strings" "testing" @@ -31,6 +33,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/provisionerdserver" @@ -1466,6 +1469,244 @@ func TestPostWorkspacesByOrganization(t *testing.T) { }) } +func TestCreateWorkspaceExternalAuth(t *testing.T) { + t.Parallel() + + // The expected 403 message returned by createWorkspace when the workspace + // owner is missing required external auth. + const externalAuthRequiredMessage = "External authentication is required to create a workspace with this template." + + // externalAuthVersion returns echo responses for a template version whose + // graph references the given external auth providers. + externalAuthVersion := func(providers ...*proto.ExternalAuthProviderResource) *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionGraph: []*proto.Response{{ + Type: &proto.Response_Graph{ + Graph: &proto.GraphComplete{ + ExternalAuthProviders: providers, + }, + }, + }}, + } + } + + t.Run("RequiredAuthMissing", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + } + _, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, req) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + require.Equal(t, "The workspace owner must authenticate with the following external auth providers: GitHub.", apiErr.Detail) + require.Equal(t, []codersdk.ValidationError{{ + Field: "external_auth", + Detail: "github", + }}, apiErr.Validations) + + // The rejection must happen before any workspace row is inserted. + _, err = memberClient.WorkspaceByOwnerAndName(ctx, codersdk.Me, req.Name, codersdk.WorkspaceOptions{}) + apiErr = nil + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + + // Authenticating with the provider lifts the rejection. + resp := coderdtest.RequestExternalAuthCallback(t, "github", memberClient) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + workspace, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, req) + require.NoError(t, err) + require.Equal(t, member.ID, workspace.OwnerID) + }) + + t.Run("OwnerVsInitiator", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // The initiating admin is authenticated with the provider, but the + // workspace owner (the member) is not. Token injection at build time + // uses the owner's links, so the owner's auth state is what matters. + resp := coderdtest.RequestExternalAuthCallback(t, "github", client) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + } + _, err := client.CreateUserWorkspace(ctx, member.Username, req) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + + // Once the owner authenticates, the same create succeeds. + resp = coderdtest.RequestExternalAuthCallback(t, "github", memberClient) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + workspace, err := client.CreateUserWorkspace(ctx, member.Username, req) + require.NoError(t, err) + require.Equal(t, member.ID, workspace.OwnerID) + }) + + t.Run("OptionalProvider", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github", Optional: true})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, member := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Optional providers must not block creation even when the owner has + // never authenticated with them. + workspace, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + }) + require.NoError(t, err) + require.Equal(t, member.ID, workspace.OwnerID) + }) + + t.Run("InvalidToken", func(t *testing.T) { + t.Parallel() + // The validation endpoint always reports the token as revoked. The + // external auth callback stores the link without validating it, so the + // link row exists but RefreshToken classifies it as invalid. + validateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(validateSrv.Close) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "github", + Regex: regexp.MustCompile(`github\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + DisplayName: "GitHub", + ValidateURL: validateSrv.URL, + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "github"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + // Create the external auth link for the owner. + resp := coderdtest.RequestExternalAuthCallback(t, "github", memberClient) + _ = resp.Body.Close() + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + ctx := testutil.Context(t, testutil.WaitLong) + + // A link that fails validation counts as unauthenticated. + _, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + require.Equal(t, []codersdk.ValidationError{{ + Field: "external_auth", + Detail: "github", + }}, apiErr.Validations) + }) + + t.Run("DisplayNameFallback", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + ExternalAuthConfigs: []*externalauth.Config{{ + InstrumentedOAuth2Config: &testutil.OAuth2Config{}, + ID: "fallback-provider", + Regex: regexp.MustCompile(`fallback\.example\.com`), + Type: codersdk.EnhancedExternalAuthProviderGitHub.String(), + }}, + }) + first := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, + externalAuthVersion(&proto.ExternalAuthProviderResource{Id: "fallback-provider"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Without a DisplayName, the response falls back to the provider ID. + _, err := memberClient.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusForbidden, apiErr.StatusCode()) + require.Equal(t, externalAuthRequiredMessage, apiErr.Message) + require.Contains(t, apiErr.Detail, "fallback-provider") + require.Len(t, apiErr.Validations, 1) + require.Equal(t, "external_auth", apiErr.Validations[0].Field) + require.Equal(t, "fallback-provider", apiErr.Validations[0].Detail) + }) +} + func TestWorkspaceByOwnerAndName(t *testing.T) { t.Parallel() t.Run("NotFound", func(t *testing.T) { diff --git a/coderd/workspacestats/tracker_test.go b/coderd/workspacestats/tracker_test.go index 1ea81f63fb..cd60e32294 100644 --- a/coderd/workspacestats/tracker_test.go +++ b/coderd/workspacestats/tracker_test.go @@ -87,11 +87,9 @@ func TestTracker(t *testing.T) { var wg sync.WaitGroup count = 0 for i := 0; i < len(ids); i++ { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { tickCh <- now - }() + }) wut.Add(ids[i]) } @@ -173,18 +171,14 @@ func TestTracker_MultipleInstances(t *testing.T) { nowB := now.Add(2 * time.Minute) var wg sync.WaitGroup var flushedA, flushedB int - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { wuTickA <- nowA flushedA = <-wuFlushA - }() - wg.Add(1) - go func() { - defer wg.Done() + }) + wg.Go(func() { wuTickB <- nowB flushedB = <-wuFlushB - }() + }) wg.Wait() // We expect 5 flushed IDs each diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index b82a22289e..d2db52f031 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -3624,9 +3624,9 @@ func builtinPlanToolAllowed(name string, isRootChat bool) bool { return true case "write_file", "edit_files", "list_templates", "read_template", "create_workspace", "start_workspace", "stop_workspace", "propose_plan", "spawn_agent", - "spawn_explore_agent", "wait_agent", "ask_user_question", "attach_file": + "spawn_explore_agent", "wait_agent", "list_agents", "ask_user_question", "attach_file": return isRootChat - case "process_list", "process_signal", "message_agent", "close_agent", + case "process_list", "process_signal", "message_agent", "interrupt_agent", "close_agent", "spawn_computer_use_agent": return false default: @@ -3708,7 +3708,9 @@ func allowedExploreToolNames(allTools []fantasy.AgentTool) []string { "spawn_agent": false, "wait_agent": false, "message_agent": false, + "interrupt_agent": false, "close_agent": false, + "list_agents": false, "read_skill": true, "read_skill_file": true, "ask_user_question": false, diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index bafde28c61..3c15609a3c 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -428,7 +428,8 @@ func TestActiveToolNamesForTurn(t *testing.T) { "spawn_agent", "wait_agent", "message_agent", - "close_agent", + "interrupt_agent", + "list_agents", "read_skill", "read_skill_file", "ask_user_question", @@ -448,6 +449,7 @@ func TestActiveToolNamesForTurn(t *testing.T) { "propose_plan", "spawn_agent", "wait_agent", + "list_agents", "read_skill", "read_skill_file", "ask_user_question", diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 125db2b485..c36aea4efb 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -11,7 +11,6 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "os" "path/filepath" "slices" @@ -78,87 +77,6 @@ func testAPIKeyID(t testing.TB, db database.Store, userID uuid.UUID) string { return key.ID } -type chatAIGatewayRecordedRequest struct { - ProviderName string - Source aibridge.Source - APIKeyID string - Path string - Authorization string - XAPIKey string - CoderToken string -} - -type chatAIGatewayTestFactory struct { - target *url.URL - transport http.RoundTripper - preservePath bool - mu sync.Mutex - requests []chatAIGatewayRecordedRequest -} - -func newChatAIGatewayTestFactory(t testing.TB, targetBaseURL string) *chatAIGatewayTestFactory { - t.Helper() - - target, err := url.Parse(targetBaseURL) - require.NoError(t, err) - return &chatAIGatewayTestFactory{target: target, transport: http.DefaultTransport} -} - -func newChatAIGatewayPreservePathTestFactory(t testing.TB, targetBaseURL string) *chatAIGatewayTestFactory { - t.Helper() - - target, err := url.Parse(targetBaseURL) - require.NoError(t, err) - return &chatAIGatewayTestFactory{target: target, transport: http.DefaultTransport, preservePath: true} -} - -func (f *chatAIGatewayTestFactory) TransportFor(providerName string, source aibridge.Source) (http.RoundTripper, error) { - return chatAIGatewayRoundTripper{factory: f, providerName: providerName, source: source}, nil -} - -func (f *chatAIGatewayTestFactory) requestsSnapshot() []chatAIGatewayRecordedRequest { - f.mu.Lock() - defer f.mu.Unlock() - return slices.Clone(f.requests) -} - -type chatAIGatewayRoundTripper struct { - factory *chatAIGatewayTestFactory - providerName string - source aibridge.Source -} - -func (t chatAIGatewayRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - apiKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(req.Context()) - t.factory.mu.Lock() - t.factory.requests = append(t.factory.requests, chatAIGatewayRecordedRequest{ - ProviderName: t.providerName, - Source: t.source, - APIKeyID: apiKeyID, - Path: req.URL.Path, - Authorization: req.Header.Get("Authorization"), - XAPIKey: req.Header.Get("X-Api-Key"), - CoderToken: req.Header.Get(aibridge.HeaderCoderToken), - }) - t.factory.mu.Unlock() - - targetURL := *t.factory.target - if t.factory.preservePath { - targetURL.Path = req.URL.Path - } else { - targetURL.Path = strings.TrimPrefix(req.URL.Path, "/v1") - if targetURL.Path == "" { - targetURL.Path = "/" - } - } - targetURL.RawQuery = req.URL.RawQuery - - cloned := req.Clone(req.Context()) - cloned.URL = &targetURL - cloned.Host = t.factory.target.Host - return t.factory.transport.RoundTrip(cloned) -} - func chatAIGatewayTransportFactoryPointer(factory aibridge.TransportFactory) *atomic.Pointer[aibridge.TransportFactory] { var ptr atomic.Pointer[aibridge.TransportFactory] ptr.Store(&factory) @@ -387,7 +305,7 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) { "list_templates", "read_template", "create_workspace", "start_workspace", "stop_workspace", } - subagentTools := []string{"spawn_agent", "wait_agent", "message_agent", "close_agent"} + subagentTools := []string{"spawn_agent", "wait_agent", "message_agent", "interrupt_agent", "list_agents"} // Identify root and subagent calls. Root chat calls include // spawn_agent; the subagent call does not. Because the root chat @@ -5401,7 +5319,7 @@ func TestActiveServer_AIGatewayRoutingPreservesAPIKeyAfterCompaction(t *testing. return chattest.AnthropicStreamingResponse() } }) - factory := newChatAIGatewayPreservePathTestFactory(t, anthropicURL) + factory := chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath()) user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL) model = updateChatModelCompressionThreshold(t, db, model, contextLimit, thresholdPercent) provider, err := db.GetAIProviderByID(ctx, model.AIProviderID.UUID) @@ -5480,14 +5398,14 @@ func TestActiveServer_AIGatewayRoutingPreservesAPIKeyAfterCompaction(t *testing. require.True(t, compressed.summaries[0].APIKeyID.Valid) require.Equal(t, apiKey.ID, compressed.summaries[0].APIKeyID.String) - requests := factory.requestsSnapshot() + requests := factory.RequestsSnapshot() require.NotEmpty(t, requests) for _, req := range requests { require.Equal(t, provider.Name, req.ProviderName) require.Equal(t, aibridge.SourceAgents, req.Source) require.Equal(t, apiKey.ID, req.APIKeyID) - require.Equal(t, "sk-user-aibridge", req.XAPIKey) - require.Equal(t, "delegated", req.CoderToken) + require.Equal(t, "sk-user-aibridge", req.Request.Header.Get("X-Api-Key")) + require.Equal(t, "delegated", req.Request.Header.Get(aibridge.HeaderCoderToken)) } } @@ -9537,7 +9455,7 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) { // 5. Verify subagent tools are NOT present. subagentTools := []string{ "spawn_agent", - "wait_agent", "message_agent", "close_agent", + "wait_agent", "message_agent", "interrupt_agent", "list_agents", } for _, tool := range subagentTools { require.NotContains(t, childTools, tool, @@ -9790,7 +9708,7 @@ func TestProcessChat_AIGatewayRoutingUsesDelegatedAPIKey(t *testing.T) { } return chattest.OpenAINonStreamingResponse(`{"title":"AI Gateway Chat"}`) }) - factory := newChatAIGatewayTestFactory(t, openAIURL) + factory := chattest.NewMockAIBridgeTransport(t, openAIURL) user, org, provider, model, apiKey := seedAIGatewayOpenAITestDependencies(t, db, openAIURL) @@ -9824,24 +9742,19 @@ func TestProcessChat_AIGatewayRoutingUsesDelegatedAPIKey(t *testing.T) { require.Equal(t, database.ChatStatusWaiting, chatResult.Status) require.False(t, chatResult.LastError.Valid) - requests := factory.requestsSnapshot() + requests := factory.RequestsSnapshot() require.NotEmpty(t, requests) - require.Contains(t, requests, chatAIGatewayRecordedRequest{ - ProviderName: provider.Name, - Source: aibridge.SourceAgents, - APIKeyID: apiKey.ID, - Path: "/v1/responses", - Authorization: "Bearer sk-user-aibridge", - CoderToken: "delegated", - }) + require.True(t, slices.ContainsFunc(requests, func(req chattest.RecordedRequest) bool { + return req.Request.URL.Path == "/v1/responses" + }), "no request to /v1/responses found") for _, req := range requests { require.Equal(t, provider.Name, req.ProviderName) require.Equal(t, aibridge.SourceAgents, req.Source) require.Equal(t, apiKey.ID, req.APIKeyID) - require.Equal(t, "Bearer sk-user-aibridge", req.Authorization) - require.Empty(t, req.XAPIKey) - require.Equal(t, "delegated", req.CoderToken) - require.True(t, strings.HasPrefix(req.Path, "/v1/"), "unexpected aibridge path %q", req.Path) + require.Equal(t, "Bearer sk-user-aibridge", req.Request.Header.Get("Authorization")) + require.Empty(t, req.Request.Header.Get("X-Api-Key")) + require.Equal(t, "delegated", req.Request.Header.Get(aibridge.HeaderCoderToken)) + require.True(t, strings.HasPrefix(req.Request.URL.Path, "/v1/"), "unexpected aibridge path %q", req.Request.URL.Path) } } @@ -9859,7 +9772,7 @@ func TestProcessChat_AIGatewayRoutingPreservesAPIKeyAfterWorkspaceContext(t *tes } return chattest.OpenAINonStreamingResponse(`{"title":"AI Gateway Workspace"}`) }) - factory := newChatAIGatewayTestFactory(t, openAIURL) + factory := chattest.NewMockAIBridgeTransport(t, openAIURL) user, org, provider, model, apiKey := seedAIGatewayOpenAITestDependencies(t, db, openAIURL) ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) @@ -9908,14 +9821,14 @@ func TestProcessChat_AIGatewayRoutingPreservesAPIKeyAfterWorkspaceContext(t *tes require.NoError(t, err) require.NotEmpty(t, pinned, "workspace context should be pinned to the chat") - requests := factory.requestsSnapshot() + requests := factory.RequestsSnapshot() require.NotEmpty(t, requests) for _, req := range requests { require.Equal(t, provider.Name, req.ProviderName) require.Equal(t, aibridge.SourceAgents, req.Source) require.Equal(t, apiKey.ID, req.APIKeyID) - require.Equal(t, "Bearer sk-user-aibridge", req.Authorization) - require.Equal(t, "delegated", req.CoderToken) + require.Equal(t, "Bearer sk-user-aibridge", req.Request.Header.Get("Authorization")) + require.Equal(t, "delegated", req.Request.Header.Get(aibridge.HeaderCoderToken)) } } diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index 90f6be61af..0560cfd21e 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -260,6 +260,11 @@ type ExecuteLocalToolsOptions struct { // case a default budget applies. ContextLimit int64 + // ToolNameAliases maps a non-advertised tool name to the canonical + // tool it dispatches to. Used for backward compatibility when a tool + // is renamed but old chat histories still reference the old name. + ToolNameAliases map[string]string + PublishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart) Logger slog.Logger Metrics *Metrics @@ -540,6 +545,7 @@ func ExecuteLocalTools(ctx context.Context, opts ExecuteLocalToolsOptions) (Tool modelName, opts.BuiltinToolNames, maxResultBytes, + opts.ToolNameAliases, func(tr fantasy.ToolResultContent, completedAt time.Time) { recordToolResultTimestamp(&result, tr.ToolCallID, completedAt) publishToolAttachments(ctx, opts.Logger, tr, completedAt, publishMessagePart) @@ -1006,6 +1012,7 @@ func executeTools( provider, model string, builtinToolNames map[string]bool, maxResultBytes int, + toolNameAliases map[string]string, onResult func(fantasy.ToolResultContent, time.Time), ) []fantasy.ToolResultContent { if len(toolCalls) == 0 { @@ -1085,6 +1092,7 @@ func executeTools( providerRunnerNames, resultProviderMetadata, maxResultBytes, + toolNameAliases, ) }() } @@ -1205,6 +1213,7 @@ func executeSingleTool( providerRunnerNames map[string]struct{}, resultProviderMetadata map[string]func(fantasy.ToolResponse) fantasy.ProviderMetadata, maxResultBytes int, + toolNameAliases map[string]string, ) fantasy.ToolResultContent { result := fantasy.ToolResultContent{ ToolCallID: tc.ToolCallID, @@ -1224,31 +1233,40 @@ func executeSingleTool( } }() - _, isProviderRunner := providerRunnerNames[tc.ToolName] - if !isProviderRunner && !isToolActive(tc.ToolName, activeTools) { + // Resolve backward-compatible tool aliases (for example a renamed + // tool whose old name still appears in chat history) to the canonical + // tool before the active-tool and dispatch lookups. + resolvedName := tc.ToolName + if alias, ok := toolNameAliases[tc.ToolName]; ok { + resolvedName = alias + } + + _, isProviderRunner := providerRunnerNames[resolvedName] + if !isProviderRunner && !isToolActive(resolvedName, activeTools) { result.Result = fantasy.ToolResultOutputContentError{ - Error: xerrors.New("Tool not active in this turn: " + tc.ToolName), + Error: xerrors.New("Tool not active in this turn: " + resolvedName), } return result } - tool, exists := toolMap[tc.ToolName] + tool, exists := toolMap[resolvedName] if !exists { result.Result = fantasy.ToolResultOutputContentError{ - Error: xerrors.New("Tool not found: " + tc.ToolName), + Error: xerrors.New("Tool not found: " + resolvedName), } return result } logger.Debug(ctx, "tool execution", slog.F("tool_name", tc.ToolName), + slog.F("resolved_tool_name", resolvedName), slog.F("tool_call_id", tc.ToolCallID), - slog.F("builtin", builtinToolNames[tc.ToolName]), + slog.F("builtin", builtinToolNames[resolvedName]), slog.F("is_provider_runner", isProviderRunner), ) resp, err := tool.Run(ctx, fantasy.ToolCall{ ID: tc.ToolCallID, - Name: tc.ToolName, + Name: resolvedName, Input: tc.Input, }) if err != nil { diff --git a/coderd/x/chatd/chatloop/chatloop_run_internal_test.go b/coderd/x/chatd/chatloop/chatloop_run_internal_test.go index 02e3c1f25d..71af2ca7f1 100644 --- a/coderd/x/chatd/chatloop/chatloop_run_internal_test.go +++ b/coderd/x/chatd/chatloop/chatloop_run_internal_test.go @@ -915,6 +915,7 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) { map[string]struct{}{}, nil, defaultToolResultBytes, + nil, ) media, ok := result.Result.(fantasy.ToolResultOutputContentMedia) @@ -963,6 +964,7 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) { map[string]struct{}{}, nil, defaultToolResultBytes, + nil, ) media, ok := result.Result.(fantasy.ToolResultOutputContentMedia) @@ -1006,6 +1008,7 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) { map[string]struct{}{}, nil, defaultToolResultBytes, + nil, ) textOutput, ok := result.Result.(fantasy.ToolResultOutputContentText) @@ -1015,3 +1018,88 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) { require.Contains(t, textOutput.Text, "world") }) } + +func TestExecuteSingleTool_ResolvesToolNameAlias(t *testing.T) { + t.Parallel() + + metrics := NewMetrics(prometheus.NewRegistry()) + logger := slog.Make() + + var gotName string + tool := fantasy.NewAgentTool( + "interrupt_agent", + "interrupts an agent", + func(_ context.Context, _ struct{}, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + gotName = call.Name + return fantasy.ToolResponse{Content: `{"interrupted":true}`}, nil + }, + ) + toolMap := map[string]fantasy.AgentTool{"interrupt_agent": tool} + + // The model emits the deprecated name from old history; only the + // canonical name is advertised/active. + tc := fantasy.ToolCallContent{ + ToolCallID: "call-alias", + ToolName: "close_agent", + Input: "{}", + } + + result := executeSingleTool( + context.Background(), + toolMap, + tc, + metrics, + logger, + "fake", "fake-model", + map[string]bool{}, + []string{"interrupt_agent"}, + map[string]struct{}{}, + nil, + defaultToolResultBytes, + map[string]string{"close_agent": "interrupt_agent"}, + ) + + textOutput, ok := result.Result.(fantasy.ToolResultOutputContentText) + require.True(t, ok, "expected text output, got %T", result.Result) + require.Contains(t, textOutput.Text, "interrupted") + // The handler receives the resolved canonical name. + require.Equal(t, "interrupt_agent", gotName) + // The persisted result keeps the original alias so existing history + // renders consistently. + require.Equal(t, "close_agent", result.ToolName) +} + +func TestExecuteSingleTool_UnknownAliasFallsThrough(t *testing.T) { + t.Parallel() + + metrics := NewMetrics(prometheus.NewRegistry()) + logger := slog.Make() + + tc := fantasy.ToolCallContent{ + ToolCallID: "call-missing", + ToolName: "close_agent", + Input: "{}", + } + + // No alias provided: the deprecated name is neither active nor in the + // tool map, so it surfaces a clear not-active error and the model can + // self-correct to the advertised name. + result := executeSingleTool( + context.Background(), + map[string]fantasy.AgentTool{}, + tc, + metrics, + logger, + "fake", "fake-model", + map[string]bool{}, + []string{"interrupt_agent"}, + map[string]struct{}{}, + nil, + defaultToolResultBytes, + nil, + ) + + errOutput, ok := result.Result.(fantasy.ToolResultOutputContentError) + require.True(t, ok, "expected error output, got %T", result.Result) + require.Contains(t, errOutput.Error.Error(), "close_agent") +} diff --git a/coderd/x/chatd/chatloop/tooltruncate.go b/coderd/x/chatd/chatloop/tooltruncate.go index 610ae07ce3..0233fecc8d 100644 --- a/coderd/x/chatd/chatloop/tooltruncate.go +++ b/coderd/x/chatd/chatloop/tooltruncate.go @@ -7,19 +7,25 @@ import ( const ( // toolResultContextDivisor bounds how much of a model's context - // window a single tool result may occupy: at most 1/N of the + // window a single tool result may occupy: at most 1/3 of the // window. This caps a single oversized result (most often a large // MCP response) so it cannot overflow the prompt on its own, while - // still letting a generous amount of output through. Cumulative + // still letting a useful amount of output through. The divisor is + // deliberately larger than 2 so the absolute cap stays sane on + // large-context (e.g. 1M-token) models, where a more generous + // fraction would still admit multi-megabyte results. Cumulative // growth across many results is handled separately by context // compaction. - toolResultContextDivisor = 2 + toolResultContextDivisor = 3 // bytesPerTokenEstimate converts a token budget into a byte budget. // Tool output is capped before tokenization, so this is a coarse, - // provider-agnostic estimate. Roughly 4 bytes per token for typical - // text. - bytesPerTokenEstimate = 4 + // provider-agnostic estimate. It is deliberately conservative at ~3 + // bytes per token: dense payloads (JSON, logs, code, non-ASCII) run + // well below 4 bytes per token, so the lower estimate yields a + // smaller byte budget that is less likely to underestimate the true + // token cost. + bytesPerTokenEstimate = 3 // minToolResultBytes is the floor for the per-result byte budget so // small or unknown context windows still let useful output through. diff --git a/coderd/x/chatd/chatprompt/chatprompt.go b/coderd/x/chatd/chatprompt/chatprompt.go index 2e955ae47d..29028e6d25 100644 --- a/coderd/x/chatd/chatprompt/chatprompt.go +++ b/coderd/x/chatd/chatprompt/chatprompt.go @@ -902,10 +902,12 @@ func matchingAttachmentForMedia( return chattool.AttachmentMetadata{}, false } -// Keep in sync with coderd/x/chatd/subagent.go. +// isSubagentLifecycleToolName lists subagent tools whose error results +// may carry structured JSON. Keep in sync with coderd/x/chatd/subagent.go. +// See subagentToolNameAliases for the full alias map. func isSubagentLifecycleToolName(name string) bool { switch name { - case "spawn_agent", "wait_agent", "message_agent", "close_agent": + case "spawn_agent", "wait_agent", "message_agent", "interrupt_agent", "close_agent": return true default: return false diff --git a/coderd/x/chatd/chattest/mock_aibridge_transport.go b/coderd/x/chatd/chattest/mock_aibridge_transport.go new file mode 100644 index 0000000000..25b1b1c9ea --- /dev/null +++ b/coderd/x/chatd/chattest/mock_aibridge_transport.go @@ -0,0 +1,125 @@ +package chattest + +import ( + "net/http" + "net/url" + "slices" + "strings" + "sync" + "testing" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/aibridge" +) + +// RecordedRequest captures metadata from a single request that passed +// through the mock transport factory. Fields not already available on +// [http.Request] are included here; tests can access headers and path +// via [RecordedRequest.Request]. +type RecordedRequest struct { + // Request is a clone of the original [http.Request]. + Request *http.Request + // ProviderName is the AI provider instance name passed to + // [TransportFactory.TransportFor]. + ProviderName string + // Source is the aibridge source passed to TransportFor. + Source aibridge.Source + // APIKeyID is the delegated API key ID attached to request ctx. + APIKeyID string +} + +// MockAIBridgeTransportOption configures a [MockAIBridgeTransport]. +type MockAIBridgeTransportOption func(*MockAIBridgeTransport) + +// WithPreservePath disables the default "/v1" path stripping so the +// target server receives the full original request path. +func WithPreservePath() MockAIBridgeTransportOption { + return func(f *MockAIBridgeTransport) { f.preservePath = true } +} + +// MockAIBridgeTransport is a test [aibridge.TransportFactory] that +// redirects requests to a target URL (typically a [chattest.NewOpenAI] +// or [chattest.NewAnthropic] server) and records each request for +// later inspection. +// +// By default it strips the leading "/v1" path segment before +// forwarding, matching how the real AI Gateway transport rewrites +// upstream-shaped requests. Pass [WithPreservePath] when the target +// server expects the full original path. +type MockAIBridgeTransport struct { + target *url.URL + transport http.RoundTripper + preservePath bool + mu sync.Mutex + requests []RecordedRequest +} + +// NewMockAIBridgeTransport creates a [MockAIBridgeTransport] that +// forwards to targetBaseURL. +func NewMockAIBridgeTransport(t testing.TB, targetBaseURL string, opts ...MockAIBridgeTransportOption) *MockAIBridgeTransport { + t.Helper() + target, err := url.Parse(targetBaseURL) + if err != nil { + t.Fatalf("parse target URL: %v", err) + } + f := &MockAIBridgeTransport{target: target, transport: http.DefaultTransport} + for _, opt := range opts { + opt(f) + } + return f +} + +// TransportFor implements [aibridge.TransportFactory]. +func (f *MockAIBridgeTransport) TransportFor(providerName string, source aibridge.Source) (http.RoundTripper, error) { + if len(providerName) == 0 { + return nil, xerrors.New("provider name is required") + } + return mockRoundTripper{factory: f, providerName: providerName, source: source}, nil +} + +// RequestsSnapshot returns a copy of all recorded requests. +func (f *MockAIBridgeTransport) RequestsSnapshot() []RecordedRequest { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.requests) +} + +type mockRoundTripper struct { + factory *MockAIBridgeTransport + providerName string + source aibridge.Source +} + +func (rt mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Mirror the real aibridged transport: a delegated API key must be + // on the context, otherwise aibridged has no identity to act under. + apiKeyID, ok := aibridge.DelegatedAPIKeyIDFromContext(req.Context()) + if !ok { + return nil, xerrors.New("mock aibridged transport requires WithDelegatedAPIKeyID on the request context") + } + rt.factory.mu.Lock() + rt.factory.requests = append(rt.factory.requests, RecordedRequest{ + Request: req.Clone(req.Context()), + ProviderName: rt.providerName, + Source: rt.source, + APIKeyID: apiKeyID, + }) + rt.factory.mu.Unlock() + + targetURL := *rt.factory.target + if rt.factory.preservePath { + targetURL.Path = req.URL.Path + } else { + targetURL.Path = strings.TrimPrefix(req.URL.Path, "/v1") + if targetURL.Path == "" { + targetURL.Path = "/" + } + } + targetURL.RawQuery = req.URL.RawQuery + + cloned := req.Clone(req.Context()) + cloned.URL = &targetURL + cloned.Host = rt.factory.target.Host + return rt.factory.transport.RoundTrip(cloned) +} diff --git a/coderd/x/chatd/configcache_internal_test.go b/coderd/x/chatd/configcache_internal_test.go index 4686254241..f868c321a9 100644 --- a/coderd/x/chatd/configcache_internal_test.go +++ b/coderd/x/chatd/configcache_internal_test.go @@ -416,12 +416,10 @@ func TestConfigCache_Singleflight(t *testing.T) { var wg sync.WaitGroup start := make(chan struct{}) for i := 0; i < callers; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() + wg.Go(func() { <-start results[i], errs[i] = cache.EnabledProviders(ctx) - }(i) + }) } close(start) diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index e22c797895..84f7efbbdc 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -670,6 +670,7 @@ func (s *taskStarter) executeLocalTools( ModelProvider: provider, ModelName: modelName, ContextLimit: prepared.ContextLimitFallback, + ToolNameAliases: subagentToolNameAliases, PublishMessagePart: publish, Logger: s.opts.Logger, Metrics: s.server.metrics, diff --git a/coderd/x/chatd/instruction_internal_test.go b/coderd/x/chatd/instruction_internal_test.go index 13717f0347..fd0d72aaaf 100644 --- a/coderd/x/chatd/instruction_internal_test.go +++ b/coderd/x/chatd/instruction_internal_test.go @@ -132,6 +132,15 @@ func TestDefaultSystemPromptContainsVersionControlSafety(t *testing.T) { require.Contains(t, DefaultSystemPrompt, "Never treat the original request as confirmation") } +func TestDefaultSystemPromptContainsSubagentOrchestration(t *testing.T) { + t.Parallel() + + require.Contains(t, DefaultSystemPrompt, "") + require.Contains(t, DefaultSystemPrompt, "") + require.Contains(t, DefaultSystemPrompt, "An error status is often recoverable") + require.Contains(t, DefaultSystemPrompt, "call list_agents to recover them") +} + func TestWorkspaceAwarenessDelaysWorkspaceCreation(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/prompt.go b/coderd/x/chatd/prompt.go index 76b6940448..288f789256 100644 --- a/coderd/x/chatd/prompt.go +++ b/coderd/x/chatd/prompt.go @@ -4,6 +4,14 @@ import "github.com/coder/coder/v2/coderd/x/chatd/chattool" const defaultSystemPromptPlanPathBlockPlaceholder = "{{CODER_CHAT_PLAN_FILE_PATH_BLOCK}}" +// subagentOrchestrationPromptBlock is the root-only orchestration guidance. +// Delegated child chats cannot call list_agents or message_agent, so this +// block is stripped from their system prompt at creation time. +const subagentOrchestrationPromptBlock = ` +An error status is often recoverable. Resume the agent with message_agent to retry; treat only genuine, repeating failures as terminal. +If you lose track of your spawned agents, call list_agents to recover them before finishing. +` + const workspaceAttachedAwareness = "This chat is attached to a workspace. You can use workspace tools like execute, read_file, write_file, etc." const workspaceDetachedAwarenessBase = `No workspace is attached to this chat yet. @@ -131,7 +139,9 @@ Once a workspace is available: Write the file first, then present it. All file paths must be absolute. When the block below is present, use that exact path. ` + defaultSystemPromptPlanPathBlockPlaceholder + ` -` + + +` + subagentOrchestrationPromptBlock var planningOverlayPrompt = `You are in Plan Mode. Every response must work toward producing a plan. diff --git a/coderd/x/chatd/recording_internal_test.go b/coderd/x/chatd/recording_internal_test.go index 23d4f2ff2a..8cf2b37d20 100644 --- a/coderd/x/chatd/recording_internal_test.go +++ b/coderd/x/chatd/recording_internal_test.go @@ -590,8 +590,12 @@ func TestWaitAgentTimeoutLeavesRecordingRunning(t *testing.T) { result := testutil.RequireReceive(ctx, t, resultCh) require.NoError(t, result.err) - assert.True(t, result.resp.IsError, "expected error response on timeout") - assert.Contains(t, result.resp.Content, "timed out") + // On timeout the agent is still working, so wait_agent now + // returns a non-error payload rather than a tool error. The + // recording is intentionally left running: the gomock controller + // fails the test if StopDesktopRecording is called. + require.False(t, result.resp.IsError, "timeout must return a non-error payload, not an error") + assert.Contains(t, result.resp.Content, `"timed_out":true`) } // TestStopAndStoreRecording_Oversized verifies that when the diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index 6a7b6bc916..cbfd335150 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -31,6 +31,30 @@ import ( var ErrSubagentNotDescendant = xerrors.New("target chat is not a descendant of current chat") +// ErrSubagentWaitTimeout is returned by awaitSubagentCompletion when the +// wait deadline elapses before the subagent reaches a terminal status. The +// agent is still working and the wait can be retried. +var ErrSubagentWaitTimeout = xerrors.New("timed out waiting for delegated subagent completion") + +// subagentToolNameAliases maps deprecated subagent tool names to their +// current names so historical close_agent calls in chat history still +// dispatch to interrupt_agent without advertising the old name in the +// tool list. +var subagentToolNameAliases = map[string]string{ + "close_agent": "interrupt_agent", +} + +// subagentStatusError wraps a subagent that reached error status. It +// carries the chat and report so callers can surface a structured, +// recoverable-aware payload instead of a bare tool error. +type subagentStatusError struct { + chat database.Chat + report string + reason string +} + +func (e *subagentStatusError) Error() string { return e.reason } + var errInvalidModelOverrideMetadata = xerrors.New("invalid model override metadata") type modelOverrideConfigResolver func( @@ -48,6 +72,10 @@ const ( subagentAwaitPollInterval = 200 * time.Millisecond subagentAwaitFallbackPoll = 5 * time.Second defaultSubagentWaitTimeout = 5 * time.Minute + + defaultListAgentsLimit = 10 + maxListAgentsLimit = 50 + subagentRecordingStopTimeout = 90 * time.Second ) // computerUseSubagentSystemPrompt is the system prompt prepended to @@ -77,10 +105,15 @@ type messageAgentArgs struct { Interrupt bool `json:"interrupt,omitempty"` } -type closeAgentArgs struct { +type interruptAgentArgs struct { ChatID string `json:"chat_id"` } +type listAgentsArgs struct { + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` +} + func (p *Server) isDesktopEnabled(ctx context.Context) bool { enabled, err := p.db.GetChatDesktopEnabled(ctx) if err != nil { @@ -609,9 +642,9 @@ func (p *Server) subagentTools( fantasy.NewAgentTool( "wait_agent", "Wait until a spawned child agent finishes its task. "+ - "Returns the agent's final response and status. "+ - "Call this after "+spawnAgentToolName+" to collect the "+ - "result before continuing your own work.", + "Returns the agent's response and status. A timeout is not "+ + "a failure: the agent is still running. Call wait_agent again "+ + "or use list_agents to check its status.", func(ctx context.Context, args waitAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { if currentChat == nil { return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil @@ -694,41 +727,63 @@ func (p *Server) subagentTools( // On timeout or error, leave the recording running on // the agent so the next wait_agent call continues it. if awaitErr != nil { + if xerrors.Is(awaitErr, ErrSubagentWaitTimeout) { + // The agent may have completed in the gap between + // the last poll and the timer firing. Re-check + // completion with a fresh DB read to avoid acting + // on a stale status (TOCTOU). + checkedChat, checkedReport, done, checkErr := p.checkSubagentCompletion(ctx, targetChatID) + if checkErr != nil { + return subagentErrorResponse(checkErr, targetChatInfo), nil + } + if !done { + return toolJSONResponse(withSubagentType(map[string]any{ + "chat_id": targetChatID.String(), + "title": checkedChat.Title, + "status": string(checkedChat.Status), + "timed_out": true, + }, checkedChat)), nil + } + // The agent completed in the gap. Classify through + // the same handler as the normal poll path. If the + // agent errored, handleSubagentDone returns a + // subagentStatusError that the error-status block + // below catches. + targetChat, report, awaitErr = handleSubagentDone(checkedChat, checkedReport) + if awaitErr == nil { + return p.waitAgentSuccessResponse(ctx, recordingID, agentConn, parent, targetChat, report), nil + } + } + if errStatus, ok := errors.AsType[*subagentStatusError](awaitErr); ok { + errChat := errStatus.chat + lastError := subagentLastErrorMessage(errChat.LastError) + if lastError == "" { + lastError = errStatus.reason + } + return toolJSONResponse(withSubagentType(map[string]any{ + "chat_id": errChat.ID.String(), + "title": errChat.Title, + "status": string(errChat.Status), + "last_error": lastError, + "report": errStatus.report, + }, errChat)), nil + } return subagentErrorResponse(awaitErr, targetChatInfo), nil } // Only stop and store the recording on success. - var recResult recordingResult - if recordingID != "" && agentConn != nil { - // Use a fresh context for cleanup so a canceled - // parent context does not prevent recording storage. - stopCtx, stopCancel := context.WithTimeout(context.WithoutCancel(ctx), 90*time.Second) - defer stopCancel() - recResult = p.stopAndStoreRecording(stopCtx, agentConn, - recordingID, parent.ID, parent.OwnerID, parent.WorkspaceID) - } - resp := withSubagentType(map[string]any{ - "chat_id": targetChat.ID.String(), - "title": targetChat.Title, - "report": report, - "status": string(targetChat.Status), - }, targetChat) - if recResult.recordingFileID != "" { - resp["recording_file_id"] = recResult.recordingFileID - } - if recResult.thumbnailFileID != "" { - resp["thumbnail_file_id"] = recResult.thumbnailFileID - } - return toolJSONResponse(resp), nil + return p.waitAgentSuccessResponse(ctx, recordingID, agentConn, parent, targetChat, report), nil }, ), fantasy.NewAgentTool( "message_agent", "Send a follow-up message to a previously spawned child "+ - "agent. Use this to provide additional instructions, "+ - "corrections, or context to a running or completed "+ - "agent. After sending, use wait_agent to collect the "+ - "updated response.", + "agent. If the agent is idle, it resumes work on the "+ + "message. If the agent is busy, the message is queued and "+ + "processed after current work. Set interrupt to true to "+ + "stop the agent's current work; the message is queued and "+ + "processed next, after any already-queued messages. "+ + "After sending, use wait_agent to retrieve the response.", func(ctx context.Context, args messageAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { if currentChat == nil { return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil @@ -764,20 +819,26 @@ func (p *Server) subagentTools( return subagentErrorResponse(err, targetChatInfo), nil } + interrupted := false + if args.Interrupt && targetChatInfo != nil { + interrupted = targetChatInfo.Status == database.ChatStatusRunning || + targetChatInfo.Status == database.ChatStatusPending + } return toolJSONResponse(withSubagentType(map[string]any{ "chat_id": targetChat.ID.String(), "title": targetChat.Title, "status": string(targetChat.Status), - "interrupted": args.Interrupt, + "interrupted": interrupted, }, targetChat)), nil }, ), fantasy.NewAgentTool( - "close_agent", - "Immediately stop a spawned child agent. Use this to "+ - "cancel a subagent that is stuck, no longer needed, "+ - "or working on the wrong approach.", - func(ctx context.Context, args closeAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + "interrupt_agent", + "Interrupt a spawned child agent's current work. The "+ + "status may briefly read interrupting before transitioning "+ + "to waiting, or running if there are queued messages. "+ + "Resume with message_agent or leave it idle.", + func(ctx context.Context, args interruptAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { if currentChat == nil { return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil } @@ -792,12 +853,12 @@ func (p *Server) subagentTools( if chat, lookupErr := p.db.GetChatByID(ctx, targetChatID); lookupErr == nil { targetChatInfo = &chat } else if !xerrors.Is(lookupErr, sql.ErrNoRows) { - p.logger.Warn(ctx, "unexpected error looking up chat for close", + p.logger.Warn(ctx, "unexpected error looking up chat for interrupt", slog.F("chat_id", targetChatID), slog.Error(lookupErr), ) } - targetChat, err := p.closeSubagent( + targetChat, interrupted, err := p.interruptSubagent( ctx, parent.ID, targetChatID, @@ -807,13 +868,85 @@ func (p *Server) subagentTools( } return toolJSONResponse(withSubagentType(map[string]any{ - "chat_id": targetChat.ID.String(), - "title": targetChat.Title, - "terminated": true, - "status": string(targetChat.Status), + "chat_id": targetChat.ID.String(), + "title": targetChat.Title, + "interrupted": interrupted, + "status": string(targetChat.Status), }, targetChat)), nil }, ), + fantasy.NewAgentTool( + "list_agents", + "List the child agents spawned by this chat, most recently "+ + "active first. Returns up to `limit` agents (default 10) "+ + "with `total` and `has_more`; use `offset` to page. The "+ + "sort order is best-effort: an agent's position may shift "+ + "if its updated_at changes between calls. Each "+ + "agent has chat_id, title, type, status, created_at, "+ + "updated_at. Status: pending/running = working, "+ + "interrupting = transient, waiting/completed = idle, "+ + "error = stopped on error.", + func(ctx context.Context, args listAgentsArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if currentChat == nil { + return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil + } + + limit := defaultListAgentsLimit + if args.Limit != nil { + limit = min(max(*args.Limit, 1), maxListAgentsLimit) + } + offset := 0 + if args.Offset != nil && *args.Offset > 0 { + offset = *args.Offset + } + + parent := currentChat() + if parent.ParentChatID.Valid { + return fantasy.NewTextErrorResponse("list_agents is only available on root chats"), nil + } + rows, err := p.db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{ + ParentIds: []uuid.UUID{parent.ID}, + // Exclude archived children by default. Do not pass an + // invalid NullBool, which would include archived rows. + Archived: sql.NullBool{Bool: false, Valid: true}, + }) + if err != nil { + return fantasy.NewTextErrorResponse(xerrors.Errorf("list child agents: %w", err).Error()), nil + } + + slices.SortStableFunc(rows, func(a, b database.GetChildChatsByParentIDsRow) int { + if c := b.Chat.UpdatedAt.Compare(a.Chat.UpdatedAt); c != 0 { + return c + } + return strings.Compare(b.Chat.ID.String(), a.Chat.ID.String()) + }) + + total := len(rows) + start := min(offset, total) + end := min(start+limit, total) + page := rows[start:end] + + agents := make([]map[string]any, 0, len(page)) + for _, row := range page { + child := row.Chat + agents = append(agents, withSubagentType(map[string]any{ + "chat_id": child.ID.String(), + "title": child.Title, + "status": string(child.Status), + "created_at": child.CreatedAt.Format(time.RFC3339), + "updated_at": child.UpdatedAt.Format(time.RFC3339), + }, child)) + } + + return toolJSONResponse(map[string]any{ + "agents": agents, + "total": total, + "returned": len(agents), + "offset": offset, + "has_more": end < total, + }), nil + }, + ), } } @@ -988,6 +1121,9 @@ func (p *Server) createChildSubagentChatWithOptions( // child chat creation does not hold one DB connection while waiting // for another pool checkout. deploymentPrompt := p.resolveDeploymentSystemPrompt(ctx) + // Delegated chats cannot call list_agents or message_agent, so + // strip the root-only orchestration guidance from their prompt. + deploymentPrompt = strings.Replace(deploymentPrompt, subagentOrchestrationPromptBlock, "", 1) if limitErr := p.checkUsageLimit(ctx, p.db, parent.OwnerID, uuid.NullUUID{UUID: parent.OrganizationID, Valid: true}); limitErr != nil { return database.Chat{}, limitErr @@ -1183,7 +1319,7 @@ func (p *Server) awaitSubagentCompletion( case <-notifyCh: case <-ticker.C: case <-timer.C: - return database.Chat{}, "", xerrors.New("timed out waiting for delegated subagent completion") + return database.Chat{}, "", ErrSubagentWaitTimeout case <-ctx.Done(): return database.Chat{}, "", ctx.Err() } @@ -1199,7 +1335,9 @@ func (p *Server) awaitSubagentCompletion( } // handleSubagentDone translates a completed subagent check into the -// appropriate return value, surfacing error-status chats as errors. +// appropriate return value. An error-status chat is returned as a typed +// subagentStatusError that carries the chat and report so the +// wait_agent handler can surface a structured, recoverable-aware payload. func handleSubagentDone( chat database.Chat, report string, @@ -1209,31 +1347,83 @@ func handleSubagentDone( if reason == "" { reason = "agent reached error status" } - return database.Chat{}, "", xerrors.New(reason) + return database.Chat{}, "", &subagentStatusError{ + chat: chat, + report: report, + reason: reason, + } } return chat, report, nil } -func (p *Server) closeSubagent( +// subagentLastErrorMessage extracts the normalized, user-facing message +// from a chat's last_error payload, falling back to the raw JSON when the +// payload is not a recognized ChatError. +func subagentLastErrorMessage(raw pqtype.NullRawMessage) string { + if !raw.Valid { + return "" + } + var payload codersdk.ChatError + if err := json.Unmarshal(raw.RawMessage, &payload); err == nil && payload.Message != "" { + return payload.Message + } + return string(raw.RawMessage) +} + +// waitAgentSuccessResponse stops and stores the recording (if active) and +// builds the normal completion payload for a wait_agent call. +func (p *Server) waitAgentSuccessResponse( + ctx context.Context, + recordingID string, + agentConn workspacesdk.AgentConn, + parent database.Chat, + targetChat database.Chat, + report string, +) fantasy.ToolResponse { + var recResult recordingResult + if recordingID != "" && agentConn != nil { + // Use a fresh context for cleanup so a canceled + // parent context does not prevent recording storage. + stopCtx, stopCancel := context.WithTimeout(context.WithoutCancel(ctx), subagentRecordingStopTimeout) + defer stopCancel() + recResult = p.stopAndStoreRecording(stopCtx, agentConn, + recordingID, parent.ID, parent.OwnerID, parent.WorkspaceID) + } + resp := withSubagentType(map[string]any{ + "chat_id": targetChat.ID.String(), + "title": targetChat.Title, + "report": report, + "status": string(targetChat.Status), + }, targetChat) + if recResult.recordingFileID != "" { + resp["recording_file_id"] = recResult.recordingFileID + } + if recResult.thumbnailFileID != "" { + resp["thumbnail_file_id"] = recResult.thumbnailFileID + } + return toolJSONResponse(resp) +} + +func (p *Server) interruptSubagent( ctx context.Context, parentChatID uuid.UUID, targetChatID uuid.UUID, -) (database.Chat, error) { +) (database.Chat, bool, error) { isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID) if err != nil { - return database.Chat{}, err + return database.Chat{}, false, err } if !isDescendant { - return database.Chat{}, ErrSubagentNotDescendant + return database.Chat{}, false, ErrSubagentNotDescendant } targetChat, err := p.db.GetChatByID(ctx, targetChatID) if err != nil { - return database.Chat{}, xerrors.Errorf("get target chat: %w", err) + return database.Chat{}, false, xerrors.Errorf("get target chat: %w", err) } if targetChat.Status == database.ChatStatusWaiting { - return targetChat, nil + return targetChat, false, nil } updatedChat, err := p.InterruptChat(ctx, targetChat) @@ -1242,13 +1432,13 @@ func (p *Server) closeSubagent( // chatstate.Interrupt precondition. Surface the error // so the caller can decide whether the parent expected // the subagent to already be waiting. - return database.Chat{}, xerrors.Errorf("interrupt subagent chat: %w", err) + return database.Chat{}, false, xerrors.Errorf("interrupt subagent chat: %w", err) } // chatstate.Interrupt lands active runs in `interrupting` // and requires-action chats in `running`. Workers finalize // the transition; accept either non-active status as long as // the transition committed. - return updatedChat, nil + return updatedChat, true, nil } func (p *Server) checkSubagentCompletion( @@ -1260,8 +1450,14 @@ func (p *Server) checkSubagentCompletion( return database.Chat{}, "", false, xerrors.Errorf("get chat: %w", err) } - if chat.Status == database.ChatStatusPending || chat.Status == database.ChatStatusRunning { - return database.Chat{}, "", false, nil + // interrupting is transient: the worker transitions it to + // waiting (no queued messages) or running (queued messages). + // Treat it as not-done so the agent settles before + // classification, avoiding stale partial output. + if chat.Status == database.ChatStatusPending || + chat.Status == database.ChatStatusRunning || + chat.Status == database.ChatStatusInterrupting { + return chat, "", false, nil } report, err := latestSubagentAssistantMessage(ctx, p.db, chatID) diff --git a/coderd/x/chatd/subagent_catalog.go b/coderd/x/chatd/subagent_catalog.go index e567631271..dd704ac73d 100644 --- a/coderd/x/chatd/subagent_catalog.go +++ b/coderd/x/chatd/subagent_catalog.go @@ -299,7 +299,12 @@ func buildSpawnAgentDescription( "subagents modify the same files they will conflict with each other, " + "so ensure parallel subagent tasks are independent. The child agent " + "receives the same workspace tools but cannot spawn its own subagents. " + - "After spawning, use wait_agent to collect the result." + "After spawning, use wait_agent to retrieve the result. Agents persist " + + "after completion; reuse an agent via message_agent for follow-up work " + + "when it already has relevant context. Spawned agents are your " + + "responsibility: do not abandon one in a working state (pending or " + + "running); retrieve its result, redirect it with message_agent, or stop " + + "it with interrupt_agent." if currentChat.PlanMode.Valid && currentChat.PlanMode.ChatPlanMode == database.ChatPlanModePlan { description += " During plan mode, type=\"" + subagentTypeGeneral + "\" is for non-mutating substantial investigation and planning support, " + @@ -340,7 +345,7 @@ func planningOverlaySubagentGuidance() string { return "Use read_file, execute, process_output, list_templates, read_template, " + spawnAgentToolName + ", and approved external MCP tools when available to gather context. " + - "Workspace MCP tools are not available in root plan mode, and side-effecting built-in tools such as process_list, process_signal, message_agent, close_agent, and computer-use actions remain unavailable. In Plan Mode, " + + "Workspace MCP tools are not available in root plan mode, and side-effecting built-in tools such as process_list, process_signal, message_agent, interrupt_agent, and computer-use actions remain unavailable. In Plan Mode, " + spawnAgentToolName + " delegation is for investigation and planning " + "support, not code writing or implementation. Use type=\"" + subagentTypeGeneral + "\" for substantial investigation, reasoning, and planning support. " + diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index bb02bf66cd..1d8f544829 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -2665,16 +2665,16 @@ func TestSubagentLifecycleToolsIncludePersistedSubagentTypeAcrossVariants(t *tes require.Equal(t, tt.variant, messageResult["type"]) setChatStatus(ctx, t, db, childID, database.ChatStatusRunning, "") - closeResult := requireToolResponseMap(t, runSubagentTool( + interruptResult := requireToolResponseMap(t, runSubagentTool( ctx, t, server, parentChat, parentChat.LastModelConfigID, - "close_agent", - closeAgentArgs{ChatID: childID.String()}, + "interrupt_agent", + interruptAgentArgs{ChatID: childID.String()}, ), false) - require.Equal(t, tt.variant, closeResult["type"]) + require.Equal(t, tt.variant, interruptResult["type"]) }) } } @@ -2719,9 +2719,9 @@ func TestSubagentLifecycleToolErrorsIncludePersistedSubagentType(t *testing.T) { wantError: ErrSubagentNotDescendant.Error(), }, { - name: "CloseAgent", - toolName: "close_agent", - args: closeAgentArgs{ChatID: child.ID.String()}, + name: "InterruptAgent", + toolName: "interrupt_agent", + args: interruptAgentArgs{ChatID: child.ID.String()}, wantError: ErrSubagentNotDescendant.Error(), }, } @@ -3700,3 +3700,338 @@ func TestAwaitSubagentCompletion(t *testing.T) { assert.Equal(t, "zero timeout ok", report) }) } + +func TestWaitAgentTimeoutReturnsInformationalPayload(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + mClock := quartz.NewMock(t) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerClock(mClock)) + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(t, db) + parent, child := createParentChildChats(ctx, t, server, user, org, model) + + WaitUntilIdleForTest(server) + setChatStatus(ctx, t, db, child.ID, database.ChatStatusRunning, "") + + timerTrap := mClock.Trap().NewTimer("chatd", "subagent_await") + + type toolResult struct { + resp fantasy.ToolResponse + } + resultCh := make(chan toolResult, 1) + oneSecond := 1 + go func() { + resp := runSubagentTool( + ctx, + t, + server, + parent, + parent.LastModelConfigID, + "wait_agent", + waitAgentArgs{ChatID: child.ID.String(), TimeoutSeconds: &oneSecond}, + ) + resultCh <- toolResult{resp: resp} + }() + + // Wait for the timer to be created, then advance past it. + timerTrap.MustWait(ctx).MustRelease(ctx) + timerTrap.Close() + mClock.Advance(time.Second).MustWait(ctx) + + result := testutil.RequireReceive(ctx, t, resultCh) + m := requireToolResponseMap(t, result.resp, false) + + require.Equal(t, true, m["timed_out"]) + require.Equal(t, child.ID.String(), m["chat_id"]) + require.Equal(t, string(database.ChatStatusRunning), m["status"]) + require.Equal(t, subagentTypeGeneral, m["type"]) +} + +func TestWaitAgentErrorStatusReturnsStructuredPayload(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(t, db) + parent, child := createParentChildChats(ctx, t, server, user, org, model) + + // An errored, non-archived agent is often recoverable. wait_agent + // must surface a structured payload (status, last_error, report) + // rather than a bare tool error. + WaitUntilIdleForTest(server) + setChatStatus(ctx, t, db, child.ID, database.ChatStatusError, "provider overloaded") + insertAssistantMessage(t, db, child.ID, model.ID, "partial progress") + + result := requireToolResponseMap(t, runSubagentTool( + ctx, + t, + server, + parent, + parent.LastModelConfigID, + "wait_agent", + waitAgentArgs{ChatID: child.ID.String()}, + ), false) + + require.Equal(t, string(database.ChatStatusError), result["status"]) + require.Equal(t, child.ID.String(), result["chat_id"]) + require.Equal(t, "provider overloaded", result["last_error"]) + require.Equal(t, "partial progress", result["report"]) + require.Equal(t, subagentTypeGeneral, result["type"]) + require.NotContains(t, result, "timed_out") +} + +func TestWaitAgentTimeoutGapCompletesWithError(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + mClock := quartz.NewMock(t) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerClock(mClock)) + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(t, db) + parent, child := createParentChildChats(ctx, t, server, user, org, model) + + WaitUntilIdleForTest(server) + setChatStatus(ctx, t, db, child.ID, database.ChatStatusRunning, "") + + timerTrap := mClock.Trap().NewTimer("chatd", "subagent_await") + + type toolResult struct { + resp fantasy.ToolResponse + } + resultCh := make(chan toolResult, 1) + oneSecond := 1 + go func() { + resp := runSubagentTool( + ctx, + t, + server, + parent, + parent.LastModelConfigID, + "wait_agent", + waitAgentArgs{ChatID: child.ID.String(), TimeoutSeconds: &oneSecond}, + ) + resultCh <- toolResult{resp: resp} + }() + + // Wait for the timer to be created, then advance past it. + timerTrap.MustWait(ctx).MustRelease(ctx) + timerTrap.Close() + + // Flip the child to error before the timer fires so the + // timeout-gap branch (checkSubagentCompletion after timeout) + // classifies it through handleSubagentDone. + setChatStatus(ctx, t, db, child.ID, database.ChatStatusError, "provider overloaded") + insertAssistantMessage(t, db, child.ID, model.ID, "partial progress") + + mClock.Advance(time.Second).MustWait(ctx) + + result := testutil.RequireReceive(ctx, t, resultCh) + m := requireToolResponseMap(t, result.resp, false) + + require.Equal(t, string(database.ChatStatusError), m["status"]) + require.Equal(t, "provider overloaded", m["last_error"]) + require.Equal(t, "partial progress", m["report"]) + require.Equal(t, child.ID.String(), m["chat_id"]) + require.Equal(t, subagentTypeGeneral, m["type"]) + require.NotContains(t, m, "timed_out") +} + +func listAgentsChatIDs(t *testing.T, result map[string]any) []string { + t.Helper() + agents, ok := result["agents"].([]any) + require.True(t, ok, "agents must be an array") + ids := make([]string, 0, len(agents)) + for _, raw := range agents { + agent, ok := raw.(map[string]any) + require.True(t, ok, "each agent must be an object") + id, ok := agent["chat_id"].(string) + require.True(t, ok, "each agent must have a chat_id") + ids = append(ids, id) + } + return ids +} + +func TestListAgents(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) + user, org, model := seedInternalChatDeps(t, db) + + // Helpers take the running subtest's t and ctx so a failed require + // fires on the correct goroutine. + newParent := func(t *testing.T, ctx context.Context, title string) database.Chat { + t.Helper() + parent, err := server.CreateChat(ctx, CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + APIKeyID: testAPIKeyID(t, db, user.ID), + Title: title, + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, + }) + require.NoError(t, err) + return parent + } + newChild := func(t *testing.T, ctx context.Context, parent database.Chat, title string, mode database.NullChatMode) database.Chat { + t.Helper() + child, err := server.CreateChat(ctx, CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + APIKeyID: testAPIKeyID(t, db, user.ID), + ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true}, + RootChatID: uuid.NullUUID{UUID: parent.ID, Valid: true}, + Title: title, + ModelConfigID: model.ID, + ChatMode: mode, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("do work"), + }, + }) + require.NoError(t, err) + return child + } + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-empty") + + result := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{}, + ), false) + + require.Equal(t, float64(0), result["total"]) + require.Equal(t, float64(0), result["returned"]) + require.Equal(t, false, result["has_more"]) + require.Empty(t, listAgentsChatIDs(t, result)) + }) + + t.Run("ReturnsChildren", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-children") + generalChild := newChild(t, ctx, parent, "general-child", database.NullChatMode{}) + exploreChild := newChild(t, ctx, parent, "explore-child", database.NullChatMode{ + ChatMode: database.ChatModeExplore, + Valid: true, + }) + + result := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{}, + ), false) + + require.Equal(t, float64(2), result["total"]) + require.Equal(t, float64(2), result["returned"]) + require.Equal(t, false, result["has_more"]) + ids := listAgentsChatIDs(t, result) + require.Contains(t, ids, generalChild.ID.String()) + require.Contains(t, ids, exploreChild.ID.String()) + + agents, ok := result["agents"].([]any) + require.True(t, ok) + typesByID := map[string]string{} + for _, raw := range agents { + agent := raw.(map[string]any) + typesByID[agent["chat_id"].(string)] = agent["type"].(string) + require.NotEmpty(t, agent["created_at"]) + require.NotEmpty(t, agent["updated_at"]) + } + require.Equal(t, subagentTypeGeneral, typesByID[generalChild.ID.String()]) + require.Equal(t, subagentTypeExplore, typesByID[exploreChild.ID.String()]) + }) + + t.Run("Pagination", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-pagination") + newChild(t, ctx, parent, "child-a", database.NullChatMode{}) + newChild(t, ctx, parent, "child-b", database.NullChatMode{}) + newChild(t, ctx, parent, "child-c", database.NullChatMode{}) + + limit := 2 + first := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{Limit: &limit}, + ), false) + require.Equal(t, float64(3), first["total"]) + require.Equal(t, float64(2), first["returned"]) + require.Equal(t, true, first["has_more"]) + firstIDs := listAgentsChatIDs(t, first) + require.Len(t, firstIDs, 2) + + offset := 2 + second := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{Limit: &limit, Offset: &offset}, + ), false) + require.Equal(t, float64(3), second["total"]) + require.Equal(t, float64(1), second["returned"]) + require.Equal(t, false, second["has_more"]) + secondIDs := listAgentsChatIDs(t, second) + require.Len(t, secondIDs, 1) + require.NotContains(t, firstIDs, secondIDs[0]) + }) + + t.Run("OrderByUpdatedAtDesc", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-order") + older := newChild(t, ctx, parent, "older-child", database.NullChatMode{}) + newChild(t, ctx, parent, "newer-child", database.NullChatMode{}) + + // Touch the older child so its updated_at advances past the + // newer one; it must then sort first. + setChatStatus(ctx, t, db, older.ID, database.ChatStatusWaiting, "") + + result := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{}, + ), false) + ids := listAgentsChatIDs(t, result) + require.Len(t, ids, 2) + require.Equal(t, older.ID.String(), ids[0]) + }) + + t.Run("ExcludesArchived", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-archived") + archivedChild := newChild(t, ctx, parent, "archived-child", database.NullChatMode{}) + + WaitUntilIdleForTest(server) + // SetArchived is only allowed from a waiting/error state, so + // settle the family into waiting first. Archiving then marks + // the children archived; they must be excluded from + // list_agents by default. + setChatStatus(ctx, t, db, parent.ID, database.ChatStatusWaiting, "") + setChatStatus(ctx, t, db, archivedChild.ID, database.ChatStatusWaiting, "") + require.NoError(t, server.ArchiveChat(ctx, parent)) + + result := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{}, + ), false) + require.Equal(t, float64(0), result["total"]) + require.Empty(t, listAgentsChatIDs(t, result)) + }) + + t.Run("DelegatedChatRejected", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-delegated") + child := newChild(t, ctx, parent, "delegated-caller", database.NullChatMode{}) + + resp := runSubagentTool( + ctx, t, server, child, child.LastModelConfigID, + "list_agents", listAgentsArgs{}, + ) + require.True(t, resp.IsError, "list_agents on a delegated chat must return an error") + msg := resp.Content + require.Contains(t, msg, "only available on root chats") + }) +} diff --git a/coderd/x/nats/cluster.go b/coderd/x/nats/cluster.go index aa12c748fe..d70de4dc3a 100644 --- a/coderd/x/nats/cluster.go +++ b/coderd/x/nats/cluster.go @@ -17,12 +17,15 @@ const defaultClusterTokenUsername = "coder" // PeerFetcher fetches NATS peer route addresses. type PeerFetcher interface { - PrimaryPeerAddresses() []string + FetchNATSPeers() []string + SetSelfNATSPort(port int32) } type NopPeerFetcher struct{} -func (NopPeerFetcher) PrimaryPeerAddresses() []string { +func (NopPeerFetcher) SetSelfNATSPort(int32) {} + +func (NopPeerFetcher) FetchNATSPeers() []string { return nil } @@ -35,6 +38,14 @@ func (p *Pubsub) SetPeerFetcher(fetcher PeerFetcher) { } p.peerFetcher = fetcher p.mu.Unlock() + if ca := p.Server.ClusterAddr(); ca != nil { + if ca.Port >= 1 && ca.Port <= 65535 { + //nolint:gosec // range checked above so conversion is safe. + fetcher.SetSelfNATSPort(int32(ca.Port)) + } else { + p.logger.Warn(p.ctx, "unexpected NATS cluster port", slog.F("port", ca.Port)) + } + } p.RefreshPeers() } @@ -53,7 +64,7 @@ func (p *Pubsub) runPeerRefresh() { fetcher := p.peerFetcher p.mu.Unlock() - addrs := fetcher.PrimaryPeerAddresses() + addrs := fetcher.FetchNATSPeers() if err := p.setPeerAddresses(addrs); err != nil { if errors.Is(err, errClosed) && p.ctx.Err() != nil { return @@ -81,7 +92,7 @@ func (p *Pubsub) setPeerAddresses(addresses []string) error { return xerrors.New("nats pubsub was not started with clustering enabled") } - routes, err := p.parsePeerAddresses(addresses) + routes, err := parsePeerAddresses(addresses) if err != nil { return err } @@ -109,7 +120,7 @@ func (p *Pubsub) setPeerAddresses(addresses []string) error { return nil } -func (p *Pubsub) parsePeerAddresses(addresses []string) ([]*url.URL, error) { +func parsePeerAddresses(addresses []string) ([]*url.URL, error) { routesByAddress := make(map[string]*url.URL, len(addresses)) for i, address := range addresses { trimmed := strings.TrimSpace(address) @@ -122,16 +133,6 @@ func (p *Pubsub) parsePeerAddresses(addresses []string) ([]*url.URL, error) { return nil, err } - // This is a hack to enable testing with an arbitrary port. The logic here - // is to presume if the default port is being used then we are running in prod - // and all peers are using the same port. If the port is not the default then - // we are running a test in which case we should pass through the custom port. - // This hack will be removed when https://github.com/coder/scaletest/issues/149 - // is resolved. - if p.opts.ClusterPort == defaultClusterPort { - port = defaultClusterPort - } - hostPort := net.JoinHostPort(host, strconv.Itoa(port)) routesByAddress[hostPort] = &url.URL{ Scheme: "nats", @@ -168,6 +169,9 @@ func normalizeHostPort(address string) (string, int, error) { if route.Path != "" || route.RawQuery != "" || route.Fragment != "" { return "", 0, xerrors.Errorf("peer address %q must not include path, query, or fragment", address) } + if route.Scheme != "nats" { + return "", 0, xerrors.Errorf("peer address %q must use nats scheme", address) + } host, port, err := net.SplitHostPort(route.Host) if err != nil { diff --git a/coderd/x/nats/cluster_internal_test.go b/coderd/x/nats/cluster_internal_test.go index e3dc10c29f..174ebfd29d 100644 --- a/coderd/x/nats/cluster_internal_test.go +++ b/coderd/x/nats/cluster_internal_test.go @@ -10,15 +10,19 @@ import ( "github.com/coder/coder/v2/testutil" ) +const ( + minTCPPort int32 = 1 + maxTCPPort int32 = 65535 +) + func Test_parsePeerAddresses(t *testing.T) { t.Parallel() t.Run("Valid", func(t *testing.T) { t.Parallel() - ps := &Pubsub{} - routes, err := ps.parsePeerAddresses([]string{ - "whatever://127.0.0.1:4222 ", - "http://[::1]:7222", + routes, err := parsePeerAddresses([]string{ + "nats://127.0.0.1:4222 ", + "nats://[::1]:7222", "nats://example.com:6222", }) require.NoError(t, err) @@ -29,51 +33,16 @@ func Test_parsePeerAddresses(t *testing.T) { }, routeStrings(routes)) }) - // Test that when a pubsub is running with the default port, it assumes all peers are also using - // the default port. - t.Run("PrefersDefaultPort", func(t *testing.T) { - t.Parallel() - ps := &Pubsub{} - ps.opts.ClusterPort = defaultClusterPort - routes, err := ps.parsePeerAddresses([]string{ - "whatever://127.0.0.1:4222 ", - "http://[::1]:7222", - "nats://example.com:1234", - }) - require.NoError(t, err) - require.ElementsMatch(t, []string{ - "nats://127.0.0.1:6222", - "nats://[::1]:6222", - "nats://example.com:6222", - }, routeStrings(routes)) - }) - - // Regression: in production the relay URL host carries the coderd HTTP - // port (e.g. 8080), and routes must be rewritten to the NATS cluster - // port. This only works because New defaults ClusterPort to - // defaultClusterPort; if it were left at the zero value the rewrite - // would be skipped and routes would dial the HTTP port. - t.Run("RewritesRelayHTTPPort", func(t *testing.T) { - t.Parallel() - ps := &Pubsub{} - ps.opts.ClusterPort = defaultClusterPort - routes, err := ps.parsePeerAddresses([]string{"http://10.0.0.7:8080"}) - require.NoError(t, err) - require.Equal(t, []string{"nats://10.0.0.7:6222"}, routeStrings(routes)) - }) - t.Run("Empty", func(t *testing.T) { t.Parallel() - ps := &Pubsub{} - routes, err := ps.parsePeerAddresses(nil) + routes, err := parsePeerAddresses(nil) require.NoError(t, err) require.Empty(t, routes) }) t.Run("Dedupes", func(t *testing.T) { t.Parallel() - ps := &Pubsub{} - routes, err := ps.parsePeerAddresses([]string{ + routes, err := parsePeerAddresses([]string{ "nats://b.example:6222", "nats://a.example:6222", "nats://b.example:6222", @@ -103,11 +72,12 @@ func Test_parsePeerAddresses(t *testing.T) { "nats://127.0.0.1:4222/path", "nats://127.0.0.1:4222?x=1", "nats://127.0.0.1:4222#frag", + "whatever://127.0.0.1:4222 ", + "http://[::1]:7222", } { t.Run(address, func(t *testing.T) { t.Parallel() - ps := &Pubsub{} - _, err := ps.parsePeerAddresses([]string{address}) + _, err := parsePeerAddresses([]string{address}) require.Error(t, err) }) } @@ -117,10 +87,9 @@ func Test_parsePeerAddresses(t *testing.T) { func Test_filterSelfRoutes(t *testing.T) { t.Parallel() - ps := &Pubsub{} - routes, err := ps.parsePeerAddresses([]string{ + routes, err := parsePeerAddresses([]string{ "nats://b.example:6222", - "http://self.example:6222", + "nats://self.example:6222", }) require.NoError(t, err) @@ -141,6 +110,8 @@ func TestPubsub_RefreshPeers(t *testing.T) { opts := clusterTestOptions(t) opts.PeerFetcher = fetcher a := newTestPubsub(t, opts) + require.GreaterOrEqual(t, fetcher.port, minTCPPort) + require.LessOrEqual(t, fetcher.port, maxTCPPort) require.Eventually(t, func() bool { routes := currentRouteURLs(a) @@ -159,11 +130,13 @@ func TestPubsub_RefreshPeers(t *testing.T) { "nats://127.0.0.1:1234", "nats://127.0.0.1:1235", } - fetcher := &testPeerFetcher{routes} + fetcher := &testPeerFetcher{addresses: routes} expectedRoutes := routesWithAuth(mustParsePeerAddresses(t, fetcher.addresses...), opts.ClusterAuthToken) a.SetPeerFetcher(fetcher) + require.GreaterOrEqual(t, fetcher.port, minTCPPort) + require.LessOrEqual(t, fetcher.port, maxTCPPort) require.Eventually(t, func() bool { return sortedURLsEqual(currentRouteURLs(a), sortRouteURLs(expectedRoutes)) }, testutil.WaitShort, testutil.IntervalFast) @@ -194,26 +167,17 @@ func currentRouteURLs(ps *Pubsub) []*url.URL { type testPeerFetcher struct { addresses []string + port int32 } -func (f *testPeerFetcher) PrimaryPeerAddresses() []string { +func (f *testPeerFetcher) SetSelfNATSPort(port int32) { + f.port = port +} + +func (f *testPeerFetcher) FetchNATSPeers() []string { return f.addresses } -// TestPubsub_New_DefaultsClusterPort guards the production wiring: New -// must persist the default cluster port onto opts so the peer route -// rewrite in parsePeerAddresses recognizes prod and forces routes to the -// NATS port. The cli constructs Options without a ClusterPort, so leaving -// it at the zero value made every replica dial peers at the relay URL's -// HTTP port instead of the NATS route port. -func TestPubsub_New_DefaultsClusterPort(t *testing.T) { - t.Parallel() - // defaultTestOptions disables clustering (no fixed-port listener to - // collide with parallel tests) and leaves ClusterPort unset. - ps := newTestPubsub(t, defaultTestOptions()) - require.Equal(t, defaultClusterPort, ps.opts.ClusterPort) -} - func TestPubsub_setPeerAddresses(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { diff --git a/coderd/x/nats/natsbench/topology.go b/coderd/x/nats/natsbench/topology.go index f44744743c..0fb7308887 100644 --- a/coderd/x/nats/natsbench/topology.go +++ b/coderd/x/nats/natsbench/topology.go @@ -34,9 +34,11 @@ type staticPeerFetcher struct { addrs []string } +func (*staticPeerFetcher) SetSelfNATSPort(int32) {} + var _ nats.PeerFetcher = (*staticPeerFetcher)(nil) -func (f *staticPeerFetcher) PrimaryPeerAddresses() []string { +func (f *staticPeerFetcher) FetchNATSPeers() []string { f.mu.Lock() defer f.mu.Unlock() return slices.Clone(f.addrs) diff --git a/coderd/x/nats/pubsub.go b/coderd/x/nats/pubsub.go index 0f00f60056..57ced2aeb3 100644 --- a/coderd/x/nats/pubsub.go +++ b/coderd/x/nats/pubsub.go @@ -113,7 +113,8 @@ type Options struct { ClusterHost string // ClusterPort is the embedded NATS route listener port. Zero means - // 6222 when cluster mode is enabled. + // 6222 when cluster mode is enabled. NATS `server.RANDOM_PORT` can be + // used to select a random port. ClusterPort int // ClusterAuthToken is the shared route authentication token for @@ -297,18 +298,7 @@ func (p *Pubsub) buildConnHandlers() connHandlers { // New creates an embedded NATS Pubsub. The returned *Pubsub owns the // embedded server and the publisher and subscriber connection pools. // Close shuts down all owned resources. -func New(ctx context.Context, logger slog.Logger, opts Options) (*Pubsub, error) { - // Persist the default cluster port onto opts so it is the same value the - // listener (buildServerOptions) binds and the value parsePeerAddresses - // compares against. parsePeerAddresses overwrites each peer's parsed port - // with defaultClusterPort, but only when opts.ClusterPort already equals - // defaultClusterPort. Callers like the cli leave ClusterPort at 0, so - // without this that branch is skipped and peers are dialed on the relay - // URL's port (e.g. 8080) instead of the NATS route port (6222). - if opts.ClusterPort == 0 { - opts.ClusterPort = defaultClusterPort - } - +func New(ctx context.Context, logger slog.Logger, opts Options) (pubSub *Pubsub, retErr error) { sopts, err := buildServerOptions(opts) if err != nil { return nil, err @@ -318,6 +308,12 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Pubsub, error) if err != nil { return nil, err } + defer func() { + if retErr != nil { + ns.Shutdown() + ns.WaitForShutdown() + } + }() logger.Info(context.Background(), "embedded nats server started", slog.F("client_url", ns.ClientURL()), @@ -328,6 +324,11 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Pubsub, error) } p := newPubsub(ctx, logger, opts) + defer func() { + if retErr != nil { + p.cancel() + } + }() p.Server = ns p.clustered = !opts.disableCluster p.serverOpts = sopts.Clone() @@ -336,29 +337,43 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Pubsub, error) publishPool, err := newConnPool(ns, opts, handlers, opts.PublishConns, "coder-pubsub-pub") if err != nil { - p.cancel() - ns.Shutdown() - ns.WaitForShutdown() return nil, err } + defer func() { + if retErr != nil { + for _, c := range publishPool { + c.Close() + } + } + }() + p.publishPool = publishPool subscribePool, err := newConnPool(ns, opts, handlers, opts.SubscribeConns, "coder-pubsub-sub") if err != nil { - p.cancel() - for _, c := range publishPool { - c.Close() - } - ns.Shutdown() - ns.WaitForShutdown() return nil, err } - - p.publishPool = publishPool + defer func() { + if retErr != nil { + for _, c := range subscribePool { + c.Close() + } + } + }() p.subscribePool = subscribePool // All owned connections dialed successfully above. p.metrics.markConnected(len(publishPool) + len(subscribePool)) if p.clustered { + ca := ns.ClusterAddr() + if ca == nil { + return nil, xerrors.New("no cluster address") + } + // sec checks, just to be sure + if ca.Port < 0 || ca.Port > 65535 { + return nil, xerrors.Errorf("invalid cluster port: %d", ca.Port) + } + //nolint:gosec // range checked above so conversion is safe. + opts.PeerFetcher.SetSelfNATSPort(int32(ca.Port)) go p.runPeerRefresh() } go func() { diff --git a/coderd/x/nats/pubsub_test.go b/coderd/x/nats/pubsub_test.go index 7b65228b7a..057ed0e1c0 100644 --- a/coderd/x/nats/pubsub_test.go +++ b/coderd/x/nats/pubsub_test.go @@ -139,11 +139,9 @@ func TestPubsub(t *testing.T) { var first, second error var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { first = ps.Close() - }() + }) wg.Wait() second = ps.Close() assert.NoError(t, first) diff --git a/codersdk/aigatewaykeys.go b/codersdk/aigatewaykeys.go index 92aaee48d6..3bd0d46448 100644 --- a/codersdk/aigatewaykeys.go +++ b/codersdk/aigatewaykeys.go @@ -14,11 +14,11 @@ import ( // AIGatewayKey is a shared secret used by a standalone AI Gateway // to authenticate into coderd. type AIGatewayKey struct { - ID uuid.UUID `json:"id" table:"id" format:"uuid"` - Name string `json:"name" table:"name,default_sort"` - KeyPrefix string `json:"key_prefix" table:"key prefix"` - CreatedAt time.Time `json:"created_at" table:"created at" format:"date-time"` - LastUsedAt *time.Time `json:"last_used_at,omitempty" table:"last used at" format:"date-time"` + ID uuid.UUID `json:"id" table:"id" format:"uuid"` + Name string `json:"name" table:"name,default_sort"` + KeyPrefix string `json:"key_prefix" table:"key prefix"` + CreatedAt time.Time `json:"created_at" table:"created at" format:"date-time"` + LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty" table:"last heartbeat at" format:"date-time"` } // CreateAIGatewayKeyRequest requests a new AI Gateway key. diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index f227129816..5471823e7b 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -10,6 +10,7 @@ const ( APIKeyScopeAiGatewayKeyCreate APIKeyScope = "ai_gateway_key:create" APIKeyScopeAiGatewayKeyDelete APIKeyScope = "ai_gateway_key:delete" APIKeyScopeAiGatewayKeyRead APIKeyScope = "ai_gateway_key:read" + APIKeyScopeAiGatewayKeyUpdate APIKeyScope = "ai_gateway_key:update" APIKeyScopeAiModelPriceAll APIKeyScope = "ai_model_price:*" APIKeyScopeAiModelPriceRead APIKeyScope = "ai_model_price:read" APIKeyScopeAiModelPriceUpdate APIKeyScope = "ai_model_price:update" diff --git a/codersdk/client.go b/codersdk/client.go index b01b5e4fb3..834dfa465e 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -96,6 +96,9 @@ const ( // ProvisionerDaemonKey contains the authentication key for an external provisioner daemon ProvisionerDaemonKey = "Coder-Provisioner-Daemon-Key" + // AIGatewayKeyHeader contains the authentication key for a standalone AI Gateway replica. + AIGatewayKeyHeader = "X-Coder-AI-Governance-Gateway-Key" + // BuildVersionHeader contains build information of Coder. BuildVersionHeader = "X-Coder-Build-Version" diff --git a/codersdk/deployment.go b/codersdk/deployment.go index a0447cff9c..2b4d92006a 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -623,6 +623,7 @@ type DeploymentValues struct { HTTPAddress serpent.String `json:"http_address,omitempty" typescript:",notnull"` AutobuildPollInterval serpent.Duration `json:"autobuild_poll_interval,omitempty"` JobReaperDetectorInterval serpent.Duration `json:"job_hang_detector_interval,omitempty"` + Cluster ClusterConfig `json:"cluster,omitempty" typescript:",notnull"` DERP DERP `json:"derp,omitempty" typescript:",notnull"` Prometheus PrometheusConfig `json:"prometheus,omitempty" typescript:",notnull"` Pprof PprofConfig `json:"pprof,omitempty" typescript:",notnull"` @@ -882,6 +883,10 @@ type DERPConfig struct { Path serpent.String `json:"path" typescript:",notnull"` } +type ClusterConfig struct { + Host serpent.String `json:"host" typescript:",notnull"` +} + type UsageStatsConfig struct { Enable serpent.Bool `json:"enable" typescript:",notnull"` } @@ -967,6 +972,13 @@ type OIDCConfig struct { RedirectURL serpent.URL `json:"redirect_url" typescript:",notnull"` AutoRepairLinks serpent.Bool `json:"auto_repair_links" typescript:",notnull"` + + // EmailFallback allows OIDC logins to fall back to email-based matching + // when the `linked_id` (issuer+subject) does not match an existing user + // link. INSECURE: weakens the linked_id check. It exists for IdP + // brokers that do not issue a stable `sub` for the same user across + // connections. + EmailFallback serpent.Bool `json:"email_fallback" typescript:",notnull"` } type TelemetryConfig struct { @@ -1450,6 +1462,13 @@ func (c *DeploymentValues) Options() serpent.OptionSet { Tailscale and WireGuard.`, YAML: "derp", } + deploymentGroupNetworkingCluster = serpent.Group{ + Parent: &deploymentGroupNetworking, + Name: "Cluster", + Description: `Configure network clustering. Coder Servers in the primary region form a cluster by +communicating directly.`, + YAML: "cluster", + } deploymentGroupIntrospection = serpent.Group{ Name: "Introspection", Description: `Configure logging, tracing, stat collection, and metrics exporting.`, @@ -3022,6 +3041,20 @@ func (c *DeploymentValues) Options() serpent.OptionSet { // as a flag as an escape hatch for now. Hidden: true, }, + { + Name: "OIDC Insecure Email Fallback (DANGEROUS)", + Description: "INSECURE: Allow OIDC logins to fall back to email-based matching when " + + "the linked_id (issuer+subject) does not match an existing user link. " + + "Required for IdP brokers that do not issue a stable 'sub' for the same user across connections. " + + "The existing user_link's linked_id is preserved on fallback. " + + "Only enable if you understand and accept the risk.", + Flag: "dangerous-oidc-email-fallback", + Env: "CODER_DANGEROUS_OIDC_EMAIL_FALLBACK", + YAML: "dangerousOidcEmailFallback", + Value: &c.OIDC.EmailFallback, + Group: &deploymentGroupOIDC, + Hidden: true, + }, // Telemetry settings telemetryEnable, { @@ -3571,6 +3604,16 @@ func (c *DeploymentValues) Options() serpent.OptionSet { Group: &deploymentGroupNetworking, YAML: "browserOnly", }, + { + Name: "Cluster Host", + Description: "Hostname or (more commonly) IP to reach this replica for clustering.", + Flag: "cluster-host", + Env: "CODER_CLUSTER_HOST", + Annotations: serpent.Annotations{}.Mark(annotationEnterpriseKey, "true"), + Value: &c.Cluster.Host, + Group: &deploymentGroupNetworkingCluster, + YAML: "clusterHost", + }, { Name: "SCIM API Key", Description: "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.", diff --git a/codersdk/drpcsdk/transport.go b/codersdk/drpcsdk/transport.go index 82a0921b41..8aef96db41 100644 --- a/codersdk/drpcsdk/transport.go +++ b/codersdk/drpcsdk/transport.go @@ -18,6 +18,10 @@ const ( // MaxMessageSize is the maximum payload size that can be // transported without error. MaxMessageSize = 4 << 20 + + // YamuxDefaultStreamWindowSize matches hashicorp/yamux's unexported + // initialStreamWindow, which DefaultConfig uses as MaxStreamWindowSize. + YamuxDefaultStreamWindowSize = 256 * 1024 ) func DefaultDRPCOptions(options *drpcmanager.Options) drpcmanager.Options { diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 46238d7d48..1dced9ee73 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -343,13 +343,11 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisione } return nil, ReadBodyAsError(res) } - // Align with the frame size of yamux. - conn.SetReadLimit(256 * 1024) - config := yamux.DefaultConfig() config.LogOutput = io.Discard // Use background context because caller should close the client. _, wsNetConn := WebsocketNetConn(context.Background(), conn, websocket.MessageBinary) + conn.SetReadLimit(drpcsdk.YamuxDefaultStreamWindowSize) session, err := yamux.Client(wsNetConn, config) if err != nil { _ = conn.Close(websocket.StatusGoingAway, "") diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 622c59c54b..bc71930ef3 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -83,7 +83,7 @@ const ( // said resource type. var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceWildcard: {}, - ResourceAIGatewayKey: {ActionCreate, ActionDelete, ActionRead}, + ResourceAIGatewayKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceAiModelPrice: {ActionRead, ActionUpdate}, ResourceAIProvider: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceAiSeat: {ActionCreate, ActionRead}, diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index b520f27e4f..074acb6f90 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -520,6 +520,8 @@ type WorkspaceFilter struct { Name string `json:"name,omitempty" typescript:"-"` // Status is a workspace status, which is really the status of the latest build Status string `json:"status,omitempty" typescript:"-"` + // Organization is an organization name or ID + Organization string `json:"organization,omitempty" typescript:"-"` // Offset is the number of workspaces to skip before returning results. Offset int `json:"offset,omitempty" typescript:"-"` // Limit is a limit on the number of workspaces returned. @@ -553,6 +555,9 @@ func (f WorkspaceFilter) asRequestOption() RequestOption { if f.Status != "" { params = append(params, fmt.Sprintf("status:%q", f.Status)) } + if f.Organization != "" { + params = append(params, fmt.Sprintf("organization:%q", f.Organization)) + } if f.Shared != nil { params = append(params, fmt.Sprintf("shared:%v", *f.Shared)) } diff --git a/codersdk/workspaces_internal_test.go b/codersdk/workspaces_internal_test.go new file mode 100644 index 0000000000..58174b407d --- /dev/null +++ b/codersdk/workspaces_internal_test.go @@ -0,0 +1,88 @@ +package codersdk + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWorkspaceFilterAsRequestOption(t *testing.T) { + t.Parallel() + + // applyFilter applies the filter's request option to a blank request and + // returns the resulting "q" query parameter. + applyFilter := func(f WorkspaceFilter) string { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.com", nil) + require.NoError(t, err) + f.asRequestOption()(req) + return req.URL.Query().Get("q") + } + + tests := []struct { + name string + filter WorkspaceFilter + contains []string + empty bool + }{ + { + name: "Empty", + filter: WorkspaceFilter{}, + empty: true, + }, + { + name: "Owner", + filter: WorkspaceFilter{Owner: "alice"}, + contains: []string{`owner:"alice"`}, + }, + { + name: "Name", + filter: WorkspaceFilter{Name: "my-workspace"}, + contains: []string{`name:"my-workspace"`}, + }, + { + name: "Template", + filter: WorkspaceFilter{Template: "base"}, + contains: []string{`template:"base"`}, + }, + { + name: "Status", + filter: WorkspaceFilter{Status: "running"}, + contains: []string{`status:"running"`}, + }, + { + name: "Organization", + filter: WorkspaceFilter{Organization: "acme"}, + contains: []string{`organization:"acme"`}, + }, + { + name: "OrganizationByUUID", + filter: WorkspaceFilter{Organization: "550e8400-e29b-41d4-a716-446655440000"}, + contains: []string{`organization:"550e8400-e29b-41d4-a716-446655440000"`}, + }, + { + name: "MultipleFields", + filter: WorkspaceFilter{Owner: "alice", Organization: "acme", Status: "running"}, + contains: []string{ + `owner:"alice"`, + `organization:"acme"`, + `status:"running"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + q := applyFilter(tt.filter) + if tt.empty { + require.Empty(t, q) + return + } + for _, s := range tt.contains { + require.Contains(t, q, s) + } + }) + } +} diff --git a/docs/.style/README.md b/docs/.style/README.md index db03fefec4..e9649ff9f8 100644 --- a/docs/.style/README.md +++ b/docs/.style/README.md @@ -56,9 +56,9 @@ directory from the surgical-reindex payload on mixed commits. `markdownlint-cli2 --fix $(find docs -name '*.md')`. - `make fmt/markdown` (markdown-table-formatter) reflows tables here for the same reason. -- Vale lints the entire `docs/**/*.md` set, including - `docs/.style/style-guide.md`. See the repo-root `.vale.ini` for the - active configuration; run `make lint/prose` locally to reproduce. +- Vale lints the entire `docs/**/*.md` set, including `docs/.style/style-guide/`. + Refer to the repo-root `.vale.ini` for the active configuration. + Run `make lint/prose` locally to reproduce. ## What does not run against this directory @@ -80,8 +80,8 @@ with another style or contributing doc in the repo, it governs. ## Editing the style guide -Open a PR against `docs/.style/style-guide.md`. Follow-up PRs add each -rule and the matching style-guide section together. +Open a PR against the appropriate subpage of `docs/.style/style-guide/`. +Follow-up PRs add each rule and the matching style-guide section together. ## Adding a Vale rule @@ -92,12 +92,8 @@ The PR that adds a rule is the rule's complete unit: 1. **Cleanup commit**: fix every existing-content violation of the new rule so `make lint/prose` reports zero findings for it. The cleanup ships in the same PR as the enable, ordered first. -2. **Enable commit**: add the rule to `.vale.ini` at its chosen - severity, write a corresponding section under - `docs/.style/style-guide.md`, and add the custom rule YAML under - `docs/.style/styles/Coder/` if applicable. - The rule's `message:` field points at the relevant `style-guide.md` - anchor. +2. **Enable commit**: add the rule to `.vale.ini` at its chosen severity, write a corresponding section under the matching subpage of `docs/.style/style-guide/`, and add the custom rule YAML under `docs/.style/styles/Coder/` if applicable. + The rule's `message:` field points at the relevant style-guide subpage anchor. Severity is a deliberate per-rule choice: diff --git a/docs/.style/style-guide.md b/docs/.style/style-guide.md deleted file mode 100644 index 1065287a86..0000000000 --- a/docs/.style/style-guide.md +++ /dev/null @@ -1,104 +0,0 @@ -# Coder documentation style guide - -This is the canonical style guide for the Coder documentation. It is the -source of truth that the Vale rules in `docs/.style/styles/Coder/` enforce. - -Status: scaffold. Sections below are populated by follow-up PRs; this -page starts as a table of contents and grows as those PRs land. - -## How to use this guide - -This page is a scaffold while follow-up PRs land. Sections marked "To be -filled in" are placeholders. For anything not yet covered, see the -public summary at -[`docs/about/contributing/documentation.md`](../about/contributing/documentation.md). - -- **Contributors**: read the section that matches what you are writing. - Each rule notes the Vale rule ID, if any, so you can reproduce the - warning locally. -- **Reviewers**: cite the section in a review comment. Reviews are easier - when the guidance is in one place. -- **AI agents**: read this page in full before editing anything under - `docs/`. The Coder Agents and Claude Code guides - ([`AGENTS.md`](../../AGENTS.md), - [`.claude/docs/DOCS_STYLE_GUIDE.md`](../../.claude/docs/DOCS_STYLE_GUIDE.md)) - link here. - -## Voice and tone - -To be filled in by follow-up PRs. Planned coverage: - -- Active voice -- Second person -- Plural nouns and pronouns where number is uncertain -- Product voice (`stop` over `kill`, `turn off` over `disable` in - user-facing copy) -- Limiting "we" - -## Word choice - -To be filled in by follow-up PRs. Planned coverage: - -- Inclusive-language substitutions -- HashiCorp casing -- Dev Container terminology -- "Setup" vs "set up" and Quickstart casing -- "Next steps" vs "Learn more" -- Weasel words - -## Capitalization and punctuation - -To be filled in by follow-up PRs. Planned coverage: - -- Sentence case in titles and headings -- General capitalization policy -- Em-dash and en-dash ban (use comma, semicolon, or period) - -## Formatting - -To be filled in by follow-up PRs. Planned coverage: - -- Bold for UI elements -- Italics for parameter names and version variables -- Code font for user input, command-line utility names, filenames, - environment variables, HTTP verbs and status codes, placeholder - variables -- Code blocks with explicit language fences - -## Vale enforcement - -The repo-root `.vale.ini` configures Vale to read styles from -`docs/.style/styles/`. The starter configuration combines: - -- Google's developer-docs base style -- A curated subset of `alex` (inclusive-language) -- A curated subset of `write-good` (wordiness) -- Coder-specific custom rules in `docs/.style/styles/Coder/` - -The rationale for the cherry-picked base styles and the severity -policy lives in `.vale.ini`'s inline comments. Run `make lint/prose` -to reproduce the baseline locally. - -## Editor setup - -To be filled in by a follow-up PR. Will cover VS Code, Cursor, -JetBrains, and Neovim. - -## Relationship to `docs/about/contributing/documentation.md` - -A public-facing prose summary lives today at -[`docs/about/contributing/documentation.md`](../about/contributing/documentation.md). -A follow-up PR will redirect that page to this guide; until then, -follow the public summary for anything the scaffolded sections above do -not yet cover. New prose rules land here; the public page is frozen -pending the redirect. - -## Third-party references - -When this guide does not cover something, consult: - -| Type of guidance | Reference | -|---------------------|-----------------------------------------------------------------------------------------| -| Spelling | [Merriam-Webster](https://www.merriam-webster.com/) | -| Style, nontechnical | [The Chicago Manual of Style](https://www.chicagomanualofstyle.org/home.html) | -| Style, technical | [Microsoft Writing Style Guide](https://learn.microsoft.com/en-us/style-guide/welcome/) | diff --git a/docs/.style/style-guide/README.md b/docs/.style/style-guide/README.md new file mode 100644 index 0000000000..10ba7c1875 --- /dev/null +++ b/docs/.style/style-guide/README.md @@ -0,0 +1,109 @@ +# Coder documentation style guide + +This is the canonical prose style guide for the Coder documentation. +It tells you *how* to write the words that go in the docs. +For decisions about what belongs in the docs and what does not, refer to [`content-guidelines.md`](../content-guidelines.md). + +Each rule on the pages below is a policy decision the Coder docs team has made. +Where a Vale rule already enforces the policy, the rule name is listed in a parenthetical so you can reproduce the warning locally. +Where the rule is documentation-only, the parenthetical says so. +The doctrine for adding Vale rules lives in [`README.md`](../README.md). + +## How to use this guide + +- **Contributors**: read the section that matches what you are writing. + Each rule includes a brief rationale and **Do** / **Don't** examples. +- **Reviewers**: cite the section in a review comment. + Reviews are easier when the guidance lives in one place. +- **AI agents**: read every section before editing anything under `docs/`. + The Coder Agents and Claude Code guides ([`AGENTS.md`](../../../AGENTS.md), [`.claude/docs/DOCS_STYLE_GUIDE.md`](../../../.claude/docs/DOCS_STYLE_GUIDE.md)) link here. + +## Sections + +| Page | Covers | +|-----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [Audience and scope](./audience-and-scope.md) | One audience per page; one outcome per page; declare both up front; Coder personas | +| [Voice and tone](./voice-and-tone.md) | Second person; no first-person singular; "we" as the company, not the software; active voice; present tense | +| [Word choice](./word-choice.md) | Canonical brand and product names; "refer to" over "see"; "select" over "click"; weasel words; plain English for product actions; keep internal-only references out of published docs | +| [Accessibility and inclusion](./accessibility-and-inclusion.md) | WCAG target; inclusive pronouns and substitutions; descriptive link text; alt text; page descriptions; heading structure; reading level | +| [Capitalization and punctuation](./capitalization-and-punctuation.md) | Sentence-case headings; no gerund leads; no em-dashes; commas; US-style quotation | +| [Formatting](./formatting.md) | Bold for UI; italics for emphasis; code font for identifiers; language fences on code blocks; callouts; tabs; lists; tables; links; images; screenshots sparingly | +| [Numbers, units, and dates](./numbers-units-and-dates.md) | Digits everywhere; non-breaking space between number and unit; `Month Day, Year` dates; 12-hour time with AM/PM | +| [Editor setup](./editor-setup.md) | Vale editor integration for VS Code, Cursor, JetBrains, and Neovim (placeholder) | + +## Conventions for editing Coder docs + +These conventions apply to every Markdown file under `docs/`. +The style guide subpages dogfood them so contributors can see the rules in action. + +### One sentence per line + +Source lines in Coder documentation follow a one-sentence-per-line policy. +Each sentence sits on its own Markdown source line. +Sentences are not split across lines, and lines do not wrap to a fixed column width. + +The rendered Markdown joins lines inside a paragraph back together, so the source line breaks do not appear in the rendered output. +Reviewers reading the diff do encounter them, and they make diffs land cleanly at the sentence level. + +`markdownlint`'s `MD013` (line length) is already disabled, so the convention is editorial. +Editors that auto-wrap on save should be configured to leave the source alone. + +#### Incremental adoption + +The Coder docs corpus predates this convention. +Much of the existing prose still wraps to a fixed column width or runs on a single long line, and some paragraphs on the other pages of this style guide still carry semantic line breaks (sembr) from earlier commits in this PR. +The convention is adopted incrementally. + +When a contributor edits any line inside a paragraph, the entire paragraph is reformatted to one sentence per line as part of the same edit. +The contributor does not reformat surrounding paragraphs they did not otherwise touch. + +For this rule, a bullet item, a numbered list entry, and a blockquote line are each their own paragraph. +Headings, fenced code blocks, and tables are out of scope: headings are single lines by convention, code blocks render their source verbatim, and table rows are governed by `markdown-table-formatter`. + +### The style guide does not use "see" for navigation + +The [Word choice page](./word-choice.md) bans "see" as a navigational verb across all docs. +The style guide itself follows the rule: "refer to" for formal cross-references, "check out" for informal pointers in tutorial-style passages, "visit" for external URLs. +Reserve "see" for the rare case where the prose describes what a reader observes in the product UI. + +## Vale enforcement + +The repo-root `.vale.ini` loads only the Coder rule package by default. +Third-party rules from Google, alex, and write-good are not enabled until a per-rule PR brings each back in. + +Each enabled rule lands via a dedicated PR that: + +1. Cleans the corpus to zero baseline findings. +2. Adds the rule line in `.vale.ini` at the rule author's chosen severity. +3. Adds the corresponding section to the appropriate subpage of this guide. + +Severity is a deliberate per-rule choice from the three-tier ladder: + +- `error` blocks merge in CI. + Use for hard policy where any violation is wrong. +- `warning` surfaces an annotation without failing CI. + Use for strong guidance with legitimate human-judgment exceptions. +- `suggestion` surfaces a `notice` annotation. + Use for soft guidance where the right fix is contextual. + +The full doctrine, including the false-positive policy, lives in [`README.md`](../README.md). +Run `make lint/prose` to reproduce the baseline locally. + +## Relationship to `docs/about/contributing/documentation.md` + +A public-facing prose summary lives today at [`docs/about/contributing/documentation.md`](../../about/contributing/documentation.md). +A follow-up PR will redirect that page to this guide. +Until then, follow the public summary for anything the subpages of this guide do not cover. +New prose rules land here. +The public page is frozen pending the redirect. + +## Third-party references + +When this guide does not cover something, consult: + +| Type of guidance | Reference | +|--------------------------|-----------------------------------------------------------------------------------------| +| Spelling | [Merriam-Webster](https://www.merriam-webster.com/) | +| Style, nontechnical | [The Chicago Manual of Style](https://www.chicagomanualofstyle.org/home.html) | +| Style, technical | [Microsoft Writing Style Guide](https://learn.microsoft.com/en-us/style-guide/welcome/) | +| Style, developer-focused | [Google developer documentation style guide](https://developers.google.com/style) | diff --git a/docs/.style/style-guide/accessibility-and-inclusion.md b/docs/.style/style-guide/accessibility-and-inclusion.md new file mode 100644 index 0000000000..ac5849ba8c --- /dev/null +++ b/docs/.style/style-guide/accessibility-and-inclusion.md @@ -0,0 +1,401 @@ +# Accessibility and inclusion + +The Coder documentation aims for [WCAG 2.1](https://www.w3.org/TR/WCAG21/) Level AA conformance as a minimum, with Level AAA as a stretch goal where it does not sacrifice clarity. +The rules on this page support that target. +They cover heading structure, inclusive language, link text, images, plain English for international readers, page descriptions, and reading level. + +> [!NOTE] Color contrast and other rendered-output a11y concerns belong to the docs site theme, not to prose conventions. +> The Coder docs team tracks color-contrast conformance separately. + +## Heading structure and placement + +Each page has exactly one H1. +The H1 is the page title and appears once at the top of the page. +Subsequent headings descend by one level at a time. +A page goes H1, then H2, then H3. +A page does not jump from H2 to H4. + +Each heading is followed by at least one paragraph (or other content block) before the next heading. +A bare H2 followed immediately by an H3 with no prose in between reads as a broken document outline, and SEO crawlers flag the pattern as a potential site error. +If a parent heading does not yet have introductory content, write a short paragraph that frames what the section covers before the subheadings. + +The rule is a [WCAG 2.1 Level A](https://www.w3.org/TR/WCAG21/#info-and-relationships) requirement: assistive technology relies on heading levels to convey document structure. +Skipping a level breaks the outline. + +**Do**: + +```markdown +# Configure your workspace + +This page walks through the configuration options exposed on a Coder workspace. +The sections below cover SSH access and environment variables. + +## Set up SSH access + +SSH access uses the agent that runs inside your workspace. +Two client setups are documented below. + +### Connect through JetBrains Toolbox + +Install the Coder plugin in JetBrains Toolbox, +then connect to your workspace by name. + +### Connect through VS Code Remote SSH + +The Coder VS Code extension wraps the standard Remote SSH client and configures it automatically. + +## Configure environment variables + +Environment variables persist across workspace restarts. +Define them in the template or in the workspace's parameters. +``` + +**Don't**: + +```markdown +# Configure your workspace + +# Configure your environment + +#### Connect through JetBrains Toolbox + +Install the Coder plugin in JetBrains Toolbox, +then connect to your workspace by name. +``` + +The second H1 creates two competing page titles. +The H1 to H4 jump skips H2 and H3. +Even if the levels were correct, the first H1 has no paragraph before the next heading, which also fails the rule. + +*Enforced by `markdownlint` rules `MD001` (heading-increment) and `MD025` (single-h1). +The "content between headings" rule is documentation-only.* + +## Inclusive pronouns + +Use the singular `they` when the subject's gender is unknown or irrelevant. +Avoid `he or she`, `(s)he`, and similar constructions. + +**Do**: + +> When a user opens a workspace, they connect to the agent over a Tailscale tunnel. + +**Don't**: + +> When a user opens a workspace, he or she connects to the agent over a Tailscale tunnel. + +*Enforced by `Google.Gender` and `Google.GenderBias`.* + +## Inclusive-language substitutions + +Use the industry-standard inclusive substitutions for terms that have transitioned across the broader developer-tooling ecosystem. + +| Do | Don't | +|-------------------------------------------------------|-----------------------------------------------| +| allowlist | whitelist | +| blocklist, denylist | blacklist | +| primary, main | master (for the primary branch or controller) | +| primary, hub, reference | master (general usage) | +| replica, secondary | slave | +| placeholder, sample, mock | dummy | +| smoke testing, confidence testing, acceptance testing | sanity check, sanity test | + +*Enforced by `Coder.InclusiveLanguage` (planned), with additional coverage from the curated `alex.*` lexicon.* + +## Descriptive link text + +Link text describes what the reader gets at the destination. +Generic phrases like "click here" and "this link" tell the reader nothing if they scan the link out of context. +Screen readers announce link text out of context too, which is the [WCAG 2.1 Level A](https://www.w3.org/TR/WCAG21/#link-purpose-in-context) requirement the rule supports. + +**Do**: + +> Refer to the [Coder CLI reference](../../reference/cli/index.md) for the full command list. + +**Don't**: + +> Refer to the Coder CLI reference [here](../../reference/cli/index.md). +> +> [Click here](../../reference/cli/index.md) for the full command list. + +*Enforced by `Coder.LinkText` (planned).* + +## Alt text for images + +Every image declares descriptive alt text. +The alt text describes what the image shows or what purpose it serves. +It is not a caption. +Captions go below the image in a `` tag. + +Aim for one or two sentences that convey the same information a sighted reader would extract from the image. +Lead with the subject, not "An image of" or "A screenshot showing". + +```markdown +![Template Insights dashboard with weekly active users and connection latency charts](../../images/admin/templates/template-insights.png) + +The Template Insights dashboard. Active users in the left panel; connection latency in the right panel. +``` + +For complex diagrams that cannot be summarized in alt text, provide a longer description in the body of the page and reference it from the alt text. + +*Enforced by `markdownlint` rule `MD045` for the alt-text-required requirement.* + +## Decorative images + +Mark images that carry no information beyond visual decoration with empty alt text. +Empty alt text tells the screen reader to skip the image rather than announce a meaningless filename. + +```markdown +![](../../images/decorative/divider.png) +``` + +Decorative images are rare in the Coder docs. +Most images shown to a reader are screenshots or diagrams that convey information, and those images need descriptive alt text. +When in doubt, write descriptive alt text. + +*Documentation-only. +No Vale rule.* + +## Plain English for international readers + +Keep prose accessible to readers whose first language is not English. +Two patterns add friction for non-native speakers without adding meaning, so the guide bans them: + +### Avoid idioms and figurative language + +Idioms (`under the weather`, `ballpark figure`, `get the ball rolling`, `at the eleventh hour`) and figurative language (`unleash`, `supercharge`, `dive in`, `out of the box`) rely on cultural context that does not translate. +They also rarely add precision. +Replace them with the literal meaning. + +**Do**: + +> The estimated startup time is between 30 and 60 seconds. +> +> Run `coder login` to begin. +> +> Coder ships with a default template. + +**Don't**: + +> The ballpark figure for startup time is 30 to 60 seconds. +> +> Run `coder login` to get the ball rolling. +> +> Coder ships with a default template out of the box. + +*Documentation-only. +Planned Vale rule `Coder.Idioms`.* + +### Latin abbreviations + +The following Latin abbreviations are fine in Coder docs. +Use them when they fit the sentence; the English equivalent is also fine. + +| Abbreviation | Meaning | Notes | +|--------------|------------------------------------------------|--------------------------------------------------------------------------------| +| `e.g.` | for example | Followed by a comma. Prefer parentheses around the clause, as described below. | +| `i.e.` | that is | Followed by a comma. Prefer parentheses around the clause, as described below. | +| `etc.` | and so on | Closes a list. The Oxford comma applies before it: `apples, oranges, etc.` | +| `vs.` | versus, against, as opposed to, in contrast to | No comma. Example: `coder server vs. coder agent`. | +| `et al.` | and others | Citation contexts only. Follow the citation style's punctuation rules. | + +**Prefer parentheses around `e.g.` and `i.e.` clauses.** The parentheses make the sentence structure obvious and avoid a cascade of commas around the abbreviation. + +**Do**: + +> Many compute platforms work (e.g., AWS, GCP, or a self-managed Kubernetes cluster). +> +> The agent exits when the workspace stops (i.e., when the build phase tears down). + +**Don't**: + +> Many compute platforms work, e.g., AWS, GCP, or a self-managed Kubernetes cluster. +> +> The agent exits when the workspace stops, i.e., when the build phase tears down. + +The **Don't** versions are grammatical, but the comma cascade makes the sentence structure harder to follow. + +**One period when `etc.` ends a sentence.** The period in `etc.` doubles as the sentence-ending period. + +**Do**: + +> The provisioner installs apples, oranges, etc. + +**Don't**: + +> The provisioner installs apples, oranges, etc.. + +When `etc.` ends a parenthetical at the end of a sentence, keep both periods. +The abbreviation's period closes `etc.`, the closing parenthesis follows, and the sentence-ending period falls outside the parenthesis. + +**Do**: + +> The provisioner handles produce (apples, oranges, etc.). + +The same rule applies if `e.g.` or `i.e.` ever sits at the end of a sentence, though that placement is unusual. + +**Citation form for `et al.`** In an author-date citation, place a comma between the author phrase and the year, and keep the abbreviation's period. + +**Do**: + +> The protocol is described by Smith et al., 2020. +> +> The protocol is described by Smith et al. (2020). + +**Less common Latin abbreviations are not allowed.** Latin abbreviations beyond the five in the table, such as `a priori`, `q.v.`, `viz.`, `n.b.`, `cf.`, and `ibid.`, are unfamiliar to many readers and easy to misuse. +Replace them with plain English. + +**Don't**: + +> The default configuration is acceptable a priori. +> +> Refer to the deployment guide, q.v. for benchmarks. + +
+Why these specific abbreviations are allowed + +Major plain-language guides such as the [Google developer documentation style guide](https://developers.google.com/style/abbreviations), the [Microsoft Writing Style Guide](https://learn.microsoft.com/en-us/style-guide/abbreviations/), the [18F Content Guide](https://content-guide.18f.gov/our-style/inclusive-language/), and the [Plain Language Action and Information Network (PLAIN) federal guidance](https://www.plainlanguage.gov/guidelines/words/use-simple-words-phrases/) recommend English equivalents for all Latin abbreviations. +The argument is that the abbreviations are unfamiliar to many readers and frequently misused (`i.e.` confused with `e.g.`). + +The Coder docs follow the spirit of that guidance for less common Latin but make an exception for `e.g.`, `i.e.`, `etc.`, `vs.`, and `et al.` These five are near-universal in industry technical writing; restricting them adds friction for writers without a clear payoff for readers familiar with the conventions of the genre. + +
+ +*Documentation-only. +No Vale rule.* + +## Page title and sidebar title + +A page's H1 and its sidebar title serve different jobs and may diverge. + +- The **H1** is the page's grammatical declaration of what the page does. + It works as the only line of text when the page is opened in isolation (Markdown source, RSS feed, mobile view that hides the sidebar, or a permalink shared in chat). + Refer to [Declare audience and scope up front](./audience-and-scope.md#declare-audience-and-scope-up-front) for how the H1 names the outcome. +- The **sidebar title** is a navigation label. + It fits the limited horizontal space of the sidebar and reads fast when the reader is scanning a tree of dozens of pages. + The Coder docs site reads the sidebar title from the `title` field in [`docs/manifest.json`](../../manifest.json). + +The two must each stand alone, but they do not need to be identical. +Breadcrumb depth gives one layer of context for free. +The sidebar title can drop redundancy that the parent breadcrumbs already imply. + +Worked example. +A page reachable through **Administration** > **Authentication** > **Google** has parent breadcrumbs that already say "Administration" and "Authentication". +The sidebar title can be `Google` alone, and the H1 can be `Configure Google authentication with Coder`. +Both labels stand alone in their own context. + +When the H1 and the sidebar title coincide (often the case for short-titled pages), that is fine. +When they diverge, the divergence is intentional and serves the reader. +The same pattern is common in mature docs sites. +AWS, Microsoft Learn, and GitHub Docs all pair task-focused H1s with shorter noun-focused sidebar titles. + +**Do**: + +| Sidebar title | H1 | +|---------------|------------------------------------------------| +| Google | Configure Google authentication with Coder | +| Helm chart | Deploy Coder on Kubernetes with the Helm chart | +| OIDC | Configure single sign-on with OIDC | + +**Don't**: + +| Sidebar title | H1 | +|--------------------------------------------|------------------------------------------------| +| Configure Google authentication with Coder | Configure Google authentication with Coder | +| Click here for Helm install | Deploy Coder on Kubernetes with the Helm chart | +| Page | Configure single sign-on with OIDC | + +The first **Don't** row uses the full H1 as the sidebar title. +The sidebar title is redundant with the parent breadcrumbs and crowds the navigation tree. +The second row has a sidebar title that does not stand alone. +The third row has a sidebar title that tells the reader nothing. + +*Documentation-only. +No Vale rule.* + +## Page descriptions + +Each page declares a description that appears in search engine results, in social-media previews, and in screen-reader page summaries. +The Coder docs site reads descriptions from [`docs/manifest.json`](../../manifest.json), not from YAML front matter inside the Markdown file. +The manifest maps each page to a `title` and a `description`: + +```json +{ + "title": "Configure your workspace", + "description": "Configure SSH access, environment variables, and autostart for a Coder workspace.", + "path": "./admin/workspaces/configure.md" +} +``` + +A good description: + +- States what the page covers in one sentence. +- Stays under roughly 160 characters so search engines do not truncate it. +- Avoids marketing language and superlatives. +- Reads as a complete sentence. + +**Do**: + +```json +"description": "Configure SSH access, environment variables, and autostart for a Coder workspace." +``` + +**Don't**: + +```json +"description": "Workspace configuration" +``` + +```json +"description": "The best, fastest, most reliable way to configure everything you need to know about Coder workspaces." +``` + +The short description tells the reader nothing. +The marketing description does not survive truncation and adds no information. + +If a page does not yet have a description in the manifest, add one in the same PR that touches the page. + +*Documentation-only. +No Vale rule.* + +## Reading level + +Aim for a Flesch-Kincaid grade level of 8 to 10 in body prose. +The target supports comprehension for non-native English readers, ESL audiences, and anyone skimming under time pressure. +The reading-level rule decomposes into prose rules covered elsewhere in this guide: + +- Short sentences. + Aim for 25 words or fewer. +- [Active voice by default](./voice-and-tone.md#active-voice-by-default). +- [Present tense by default](./voice-and-tone.md#present-tense-by-default). +- Common words. + Define jargon on first use. +- [Plain English for international readers](#plain-english-for-international-readers). +- [Plain language for product actions](./word-choice.md#stop-not-kill-turn-off-not-disable). +- [No weasel words](./word-choice.md#avoid-weasel-words). + +A reading-level rule is part of [WCAG 2.1 Level AAA](https://www.w3.org/TR/WCAG21/#reading-level) Success Criterion 3.1.5. +The criterion is satisfied either by writing at the lower-secondary reading level or by providing an alternative version. +Coder docs write at the target reading level directly. + +Editors that surface a grade-level score (Hemingway, Vale's `write-good.Reading`) are a useful spot check. +The grade level is not a hard ceiling. +A reference page that requires technical vocabulary will read higher than a tutorial, and that is correct. + +*Documentation-only. +No Vale rule wired.* + +## Color contrast + +The docs site theme controls color contrast, not the prose written on each page. +Tracked separately from this guide. +The target is WCAG 2.1 Level AA for normal text (contrast ratio 4.5:1) and Level AA for large text (3:1), with AAA (7:1 normal, 4.5:1 large) as the stretch goal. + +*Out of scope for this guide. +Tracked by the docs site theme.* + +## Related + +- [Style guide landing page](./README.md) +- [Voice and tone](./voice-and-tone.md) +- [Word choice](./word-choice.md) +- [Formatting](./formatting.md) diff --git a/docs/.style/style-guide/audience-and-scope.md b/docs/.style/style-guide/audience-and-scope.md new file mode 100644 index 0000000000..20970b96e1 --- /dev/null +++ b/docs/.style/style-guide/audience-and-scope.md @@ -0,0 +1,352 @@ +# Audience and scope + +Every page in the Coder documentation targets one audience working toward one outcome. +The audience determines vocabulary, depth, and the prior knowledge the page assumes. +The outcome determines what the page covers and where it stops. + +Pages that try to serve two audiences, or chain multiple unrelated outcomes, serve none of their readers well. +A reader who is one persona away from the page's target has to skip past content that does not apply to them, guess which sentences are for them, and trust the writer not to have buried a step they need inside a section labeled for someone else. + +The single canonical Coder example is **install Coder**. +An end user wants to connect their local editor to a Coder workspace and start coding. +A platform engineer wants to deploy the Coder control plane to their company's Kubernetes cluster. +Both groups search for "install Coder. +A page that tries to cover both forces the end user to read past Helm chart values, and forces the platform engineer to read past Visual Studio Code download links. +Two pages, one per audience and one per outcome, serve both groups better than one page that combines them. + +## Pick one audience per page + +Choose the audience before you choose the words. +The audience determines: + +- The product vocabulary the reader already knows (for example, whether `workspace` needs a definition). +- The infrastructure context the reader brings (for example, whether Kubernetes is assumed). +- The level of depth the reader expects (overview, how-to, reference, or in-depth tutorial). + +If a topic genuinely needs to serve two audiences, write two pages and cross-link them. +Resist the temptation to write one page with audience-tagged sections. +Section tags do not save readers from scanning content that does not apply to them. + +**Do**: + +```markdown +# Connect Visual Studio Code to your Coder workspace + +*Audience: a developer with an existing Coder workspace.* + +This page covers the Visual Studio Code IDE. +For Cursor, refer to [Cursor](./cursor.md). +For Windsurf, refer to [Windsurf](./windsurf.md). +``` + +**Don't**: + +```markdown +# Connect to your Coder workspace + +This page covers Visual Studio Code, Cursor, Windsurf, JetBrains, Vim, the web terminal, and SSH. +Operators provisioning the workspace template should refer to the section below on template configuration. +``` + +## Pick one outcome per page + +The outcome is the specific task, or the small set of related tasks, the page helps the reader accomplish. +A how-to page covers one task. +A tutorial covers one chained workflow. +A reference page covers one stable surface (one CLI command, one API endpoint, one schema). +An overview page introduces one concept. + +If the page has more than one outcome, split it. +"Configure SSO with Okta" is one outcome. +"Configure SSO" is not. +"Deploy Coder on AWS" is one outcome. +"Deploy Coder" is not. + +A page that helps the reader accomplish two unrelated outcomes hides each outcome from the readers who need the other. + +**Do**: + +```markdown +# Configure single sign-on with Okta + +This page walks through configuring OIDC single sign-on against an Okta tenant. +For Azure Active Directory, refer to [Configure SSO with Azure AD](./sso-azure-ad.md). +For Google Workspace, refer to [Configure SSO with Google Workspace](./sso-google.md). +``` + +**Don't**: + +```markdown +# Authentication + +This page covers OIDC providers (Okta, Azure AD, Google Workspace, generic OIDC), +SAML providers, GitHub OAuth, password authentication, and the API token model. +``` + +## Hub pages and category landing pages + +Some pages exist to orient the reader and route them to the child page that owns the actual content. +A hub page may have a broad title and a short body when its job is to direct the reader to a child page, not to teach. + +Hub pages are not an exemption from the audience and outcome rules. +The audience is the reader looking for the right child page. +The outcome is making the routing decision in three or four lines. + +A hub page is appropriate when: + +- The topic has several distinct sub-topics. +- Each sub-topic deserves its own page for scope reasons. +- A reader entering the section needs to choose between them. + +**Do**: + +```markdown +# Authentication + +This page is the entry point for configuring authentication in Coder. +Pick the provider that matches your identity source: + +- [OpenID Connect (OIDC)](./oidc.md), for Okta, Auth0, Azure AD, Google Workspace, and other OIDC providers. +- [SAML](./saml.md), for SAML 2.0 identity providers. +- [GitHub OAuth](./github-oauth.md), for GitHub-hosted teams. +- [Password authentication](./password.md), for self-hosted local accounts. +``` + +**Don't**: + +```markdown +# Authentication + +This page covers OIDC, SAML, GitHub OAuth, password authentication, and the API token model. + +## OIDC + +[300 lines of provider-specific configuration] + +## SAML + +[300 lines of provider-specific configuration] +``` + +The Don't example forces every reader to scan a wall of content for the section that applies to them. +The Do example routes them to the right page in four lines. + +A hub page does not need every link to be a direct child page in the file tree. +Cross-references to sibling sections of the docs are valid when that is where the reader's next step lives. + +## Declare audience and scope up front + +The first paragraph of the page names the audience and the outcome. +The reader should know within the first two or three sentences whether the page is for them and whether it covers their task. + +Conventions: + +- The H1 names the outcome. +- The first paragraph names the audience and confirms the outcome. +- The first paragraph also links to sibling pages for adjacent audiences or outcomes when those exist. + +Do not put a metadata line such as `*Audience: a developer.*` above the first paragraph. +The audience appears in the prose itself. +Do not use the [persona names](#personas-the-coder-docs-serve) inside the page body either. +Persona names are vocabulary for writers planning the page, not for readers reading it. +Name the audience by the role the reader recognizes from their own work (`developer`, `template author`, `Coder deployment administrator`, `organization owner`). + +**Do**: + +```markdown +# Connect Visual Studio Code to your Coder workspace + +This guide is for a developer with an existing Coder workspace. +It covers the Visual Studio Code IDE. +For Cursor, refer to [Cursor](./cursor.md). +For Windsurf, refer to [Windsurf](./windsurf.md). +``` + +**Don't**: + +```markdown +# Kubernetes + +Coder runs on Kubernetes. +This page covers many topics related to running Coder on Kubernetes. +``` + +The Don't title does not name an outcome. +The body does not name an audience. +If the page is a hub that routes the reader, use the pattern in [Hub pages and category landing pages](#hub-pages-and-category-landing-pages). +If the page teaches a single outcome, rename the title and rewrite the opening paragraph. + +### Gate privileged pages with a prerequisite callout + +Some pages walk through steps that only one role should run. +If a reader from the wrong role follows the steps, they may misconfigure the deployment, escalate their own permissions, or break something for everyone else. + +For pages of that kind, add an `IMPORTANT` callout at the top of the page that names the required role and tells the wrong-role reader who to ask. + +**Do**: + +```markdown +# Configure single sign-on with Okta + +This guide is for a Coder deployment administrator +who has access to both the Coder control plane and the Okta tenant. + +> [!IMPORTANT] +> You must be a Coder deployment administrator to complete this guide. +> If you are not a deployment administrator, +> ask your administrator to complete the steps for you. +``` + +The prerequisite callout uses the role the reader recognizes (`Coder deployment administrator`), not the writer-facing persona name (`Perry the Platform Engineer`). + +## Give the audience only what it needs + +Choosing the audience is also choosing what to leave out. +A page written for a known audience gives that reader what they need to reach the outcome, and nothing that belongs to a different audience. + +Knowing the audience means knowing what that audience can already do. +When the page assumes a reader who runs their own Coder deployment, that reader is their own administrator. +Do not hedge a step with "ask your administrator" or "if you have permission". +Those caveats are written for a reader this page does not target, and they make the real reader doubt whether the step is meant for them. + +Before you add a caveat, a permission note, or an "if you don't have access" aside, check it against the audience and the full context of the page: + +- Does the reader this page targets actually hit this limitation? +- Has the page already established that this reader has the access? +- Does the caveat help this reader, or only a reader who belongs on a different page? + +If the caveat serves a different audience, cut it, or move it to the page that audience reads. + +**Do** (a local-first Quickstart, where the reader started the server two pages earlier): + +> Configure a GitHub provider on your deployment, then create the workspace again. + +**Don't**: + +> Configure a GitHub provider on your deployment. +> If you are not a deployment administrator, ask your administrator to do this for you. + +The **Don't** aside is correct on an enterprise how-to page, where the reader may not own the deployment. +On a Quickstart that walked the same reader through starting the server, the reader already has the access, so the aside only adds doubt. + +A page may assume a persona, as long as it knows which persona it assumes and matches its depth and its caveats to what that persona can already do. +This is the complement of [gating privileged pages](#gate-privileged-pages-with-a-prerequisite-callout): add a prerequisite callout when the reader might be the wrong role, and cut wrong-role caveats when the audience is, by definition, the right role. + +## Personas the Coder docs serve + +When deciding which audience a page targets, match the reader to one of the canonical personas the Coder docs serve. +Each persona summary captures who the reader is, what they need from the docs, and the Coder surface they typically work with. + +If a page does not cleanly target one of these personas, revisit the scope. +A page without a clear persona is a page that serves no one well. + +The persona names are vocabulary for writers planning a page. +They do not appear in published prose. +Inside a page, name the audience by the role the reader recognizes from their own work (`developer`, `template author`, `Coder deployment administrator`). + +### Primary personas + +#### Perry the Platform Engineer + +Perry builds self-service platforms for development teams at a mid-to-large enterprise. +They own the templates, governance, and integrations that turn the Coder control plane Ada deploys into the default workflow developers actually use. +They need template authoring docs, RBAC and organization design, integration patterns, prebuilds, cost reporting, and policy-as-code. + +*Coder surface:* template authoring (Terraform, modules, prebuilds), RBAC, organizations and groups, policy and governance, integrations (CI/CD, observability, secrets, Git), audit logs. + +#### Dave the Developer + +Dave is a software engineer at a company that has adopted Coder. +They were not involved in the procurement decision and are expected to use the workspace the company provisioned for them. +They need day-to-day workspace usage docs: connecting from their preferred IDE, running CLI commands inside the workspace, port forwarding, SSH, and recovering when something breaks. + +*Coder surface:* workspaces, `coder` CLI, IDE integrations (VS Code, Cursor, JetBrains, Windsurf, Zed, Vim, Emacs), web terminal, dotfiles, SSH and port forwarding. + +#### Elliot the End User + +Elliot uses a Coder workspace day-to-day but is not a software engineer. +They may be a data scientist, product manager, customer success engineer, operations analyst, or another team member whose primary work happens inside a workspace the organization provisioned for them. +They need workspace-usage docs in plain language: connecting to the workspace, running the tools their team has standardized on, and recovering when something breaks. +They do not need template authoring or infrastructure context. + +*Coder surface:* workspaces, web terminal, IDE and notebook integrations (VS Code, Jupyter, RStudio), dotfiles, port forwarding, SSH, file uploads and downloads. + +> [!NOTE] Elliot is a stopgap umbrella persona for non-developer end users. +> The Coder docs team plans to revisit the persona model with product and design once the broader audience is mapped out. + +#### Ada the Infrastructure Admin + +Ada runs the underlying infrastructure that Coder deploys onto: Kubernetes clusters, cloud accounts, networking, storage, identity, and security policy. +They need deployment, operation, and recovery docs: install paths, upgrade and rollback, IAM and SSO, monitoring and alerting, capacity planning, and incident playbooks. +Their success metric is uptime, so they trust proven, well-documented configurations over bleeding-edge defaults. + +*Coder surface:* control plane install (Helm, Docker, VM, airgapped), database, networking and DERP, IAM and SSO/OIDC/SAML, telemetry and audit logs, backup and disaster recovery. + +#### Steven the Sponsor + +Steven is the CTO. +They approve the Coder purchase and stay close enough to the architecture to ask sharp questions, but they no longer write code. +They need overview pages that explain what Coder is, how it fits the existing stack, what it costs, and what its security and compliance posture looks like. + +*Coder surface:* architecture overviews, why-Coder framing, pricing and licensing, security and compliance summaries, release notes, success-metric dashboards. + +### Secondary personas + +#### Melissa the Machine Learner + +Melissa is an ML engineer who lives between Jupyter notebooks, Python, ML frameworks, and large datasets. +They need docs for GPU-enabled workspaces, persistent storage for datasets and model artifacts, ML-friendly templates, and integrations with experiment tracking and model registries. +They are comfortable in the CLI but expect the dev environment to be reproducible without per-experiment setup. + +*Coder surface:* GPU-enabled workspaces and templates, devcontainers, persistent storage, large-resource workspace configurations. + +#### Tommy the Tester + +Tommy is a QA engineer. +They need docs for reproducible test environments, CI integration patterns, and workspace configurations that let them run regression suites in isolation. +They value clear logs, traceable errors, and clean rollback paths over flashy features. + +*Coder surface:* workspaces for test environments, CI integrations, reproducible build patterns, workspace lifecycle (start, stop, rebuild). + +#### Caitlin the Citizen Developer + +Caitlin is non-technical (customer success) but uses agentic AI tools to make small product changes without writing code. +They need docs that explain Coder Tasks and the agent-driven flows in plain language, with no assumed dev-environment knowledge and no manual setup steps. +They avoid anything that requires opening a terminal or editing a config file. + +*Coder surface:* Coder Tasks, AI Bridge, prompt-driven workflows, web-based interfaces. + +#### Felipe the FinOps + +Felipe owns financial operations and tracks where the budget goes. +They need docs for usage and cost reporting, license counts, telemetry exports for finance dashboards, and per-team or per-template attribution. +They value precise, traceable numbers over feature descriptions. + +*Coder surface:* usage reports, audit logs, license management, billing and seat counts, telemetry exports. + +#### Sergio the Security Officer + +Sergio is the IT security officer at an organization with strict compliance requirements. +They need docs for the security architecture, identity and access control, secrets management, audit and compliance evidence (SOC 2, FedRAMP-style controls), data residency, and the supply chain story. +They are skeptical of new tools by default and want documented, auditable behavior. + +*Coder surface:* SSO and OIDC/SAML, RBAC, secrets management, audit logs, security architecture pages, compliance and trust-center content, allowlists and network policies. + +#### Tara the Team Leader + +Tara is an engineering manager or senior tech lead assigned the Group Admin role in Coder RBAC. +They need docs for team-scope administration: group memberships, group-owned secrets, group-scoped templates, and the audit log entries that explain who changed what. +They are not the platform owner. +They run their team inside the guardrails Perry or Ada set up. + +*Coder surface:* groups, group memberships, group-owned secrets, group-scoped templates, group audit logs. + +*Documentation-only. +No Vale rule.* + +## Related + +- [Voice and tone](./voice-and-tone.md) +- [Word choice](./word-choice.md) +- [Coder documentation content guidelines](../content-guidelines.md) +- [Style guide landing page](./README.md) diff --git a/docs/.style/style-guide/capitalization-and-punctuation.md b/docs/.style/style-guide/capitalization-and-punctuation.md new file mode 100644 index 0000000000..928638d444 --- /dev/null +++ b/docs/.style/style-guide/capitalization-and-punctuation.md @@ -0,0 +1,262 @@ +# Capitalization and punctuation + +Coder documentation uses sentence-case headings, the Oxford comma, US-style quotation, and no em-dashes or en-dashes in prose. +The rules on this page set those defaults. + +For heading structure (H1, H2, H3 placement and order), refer to [Accessibility and inclusion](./accessibility-and-inclusion.md#heading-structure-and-placement). + +## Sentence-case headings + +Capitalize the first word of a heading or page title, plus any proper nouns. +Everything else is lowercase. +This rule covers H1 through H6 and matches the way the heading reads aloud. + +**Do**: + +```markdown +# Configure your workspace +## Set up SSH access +### Connect through JetBrains Toolbox +``` + +**Don't**: + +```markdown +# Configure Your Workspace +## Set Up SSH Access +### Connect Through JetBrains Toolbox +``` + +*Enforced by `Google.Headings` (scope adjusted to skip CLI flag fragments and acronyms).* + +## No gerund-leading headings + +Do not start a heading with a present participle or gerund (an `-ing` word acting as a verb form). +The imperative form reads better for task headings. +The noun form reads better for concept headings. +Reserve gerund-leading headings for the rare case where neither alternative reads cleanly. + +**Do**: + +```markdown +## Install Coder +## Installation +## Configure your workspace +## Configuration reference +``` + +**Don't**: + +```markdown +## Installing Coder +## Configuring your workspace +``` + +### Exceptions + +Not every `-ing` word is a gerund-leading violation. +The rule targets verb forms (`installing`, `configuring`, `deploying`), not the following: + +- Nouns that happen to end in `-ing` and have no verb counterpart in the heading: `String formatting`, `Heading structure`. +- Compound nouns where the `-ing` word names a category or feature: `Pricing`, `Billing`, `Logging`, `Monitoring`, `Tracing`, `Networking`. +- Adjectives derived from verbs that modify the head noun: `Running workspaces`, `Pending invitations`. + +When the `-ing` word is the actual subject the section describes (a feature, a noun, or an attribute), the heading is fine. +When the `-ing` word is the verb form of a task the section walks through, rewrite as an imperative or as the noun form. + +*Enforced by `Coder.GerundHeading`, with the exceptions above scoped in the rule.* + +## No trailing punctuation in headings + +Headings are labels, not sentences. +Drop terminal periods and exclamation points. +Use trailing question marks sparingly, and only when the heading is an actual question that the section answers. + +The rule has scoped exceptions: + +- **Periods (`.`) and exclamation points (`!`) inside backticks** are allowed when the heading names a literal identifier that contains the character (a config file ending in `.yml`, a CLI flag like `--force!`, a programming macro like `panic!`). + The backticks tell the reader the punctuation is part of the identifier, not a sentence ender. +- **Question marks (`?`) inside backticks** are also allowed for the same reason (a query operator, a regex modifier, a UI element literally named `?`). + +**Do**: + +```markdown +## What is a workspace +## Quick reference +## What does the `panic!` macro do? +## Configure the `.vale.ini` file +``` + +**Don't**: + +```markdown +## What is a workspace? +## Quick reference! +## Workspaces are great! +## Configure your workspace. +``` + +The first **Don't** uses a trailing question mark for a label that is not actually a question. +Reword as a noun phrase ("What a workspace is") or drop the question mark. +The second and third are decorative. +The fourth treats the heading as a sentence. + +*Periods and exclamation points enforced by `Google.HeadingPunctuation` at `error` severity. +Question marks enforced by `Google.HeadingPunctuation` at `suggestion` severity. +Both ignore characters inside backticks.* + +## No em-dashes or en-dashes + +Em-dashes (—, U+2014), en-dashes (–, U+2013), and the ASCII `--` fallback are banned in prose. +Em-dashes typically set off a parenthetical aside or a break in thought. +Replace them with commas (for a tight aside), parentheses (for a clearly secondary aside), or a period and a new sentence (for a thought that stands on its own). + +**Do**: + +> The provisioner, which Coder builds on top of Terraform, creates the workspace. +> +> The provisioner (which Coder builds on top of Terraform) creates the workspace. +> +> The provisioner creates the workspace. +> Coder builds the provisioner on top of Terraform. + +**Don't**: + +> The provisioner—which Coder builds on top of Terraform—creates the workspace. +> +> The provisioner -- which Coder builds on top of Terraform -- creates the workspace. + +*Enforced by `scripts/check_emdash.sh` (existing CI script) and `Coder.EmDash` (planned).* + +## Commas + +### Comma after an introductory element + +Place a comma after an introductory word, phrase, or clause that comes before the main clause. +The comma marks where the introduction ends and the main clause begins. + +**Do**: + +> In this guide, you add Ruby as a parameter option. +> +> After you authorize Coder, the workspace starts. +> +> To pull the template, run `coder templates pull`. + +**Don't**: + +> In this guide you add Ruby as a parameter option. +> +> After you authorize Coder the workspace starts. + +*Documentation-only. +No Vale rule.* + +### No comma in a short compound predicate + +When `and`, `or`, or `but` joins two verbs that share one subject, do not put a comma before the conjunction. +The comma belongs there only when the conjunction joins two independent clauses, each with its own subject. + +**Do**: + +> Log in to Coder and select **Templates**. +> +> The agent opens a tunnel and forwards traffic over it. + +**Don't**: + +> Log in to Coder, and select **Templates**. +> +> The agent opens a tunnel, and forwards traffic over it. + +When each side of the conjunction is a full clause with its own subject, the comma returns: + +> Log in to Coder, and the dashboard opens. + +*Documentation-only. +No Vale rule.* + +### Oxford comma + +Use a comma before the conjunction in a list of three or more items. + +**Do**: + +> The provisioner builds, configures, and starts the workspace. + +**Don't**: + +> The provisioner builds, configures and starts the workspace. + +*Enforced by `Google.OxfordComma`.* + +## US-style quotation + +Place commas and periods inside closing quotation marks. +Semicolons and colons stay outside. +This is the United States convention and matches the dominant style of the surrounding tech-docs ecosystem. + +**Do**: + +> The error message reads, "workspace not found." + +**Don't**: + +> The error message reads, "workspace not found". + +*Enforced by `Google.Quotes`.* + +## Semicolons sparingly + +Prefer two sentences. +A semicolon joins two complete thoughts when they are tightly related and a period would lose the connection, but in technical prose two sentences almost always read more clearly. + +**Do**: + +> The provisioner uses Terraform. +> It reads the template files and creates the workspace. + +**Don't**: + +> The provisioner uses Terraform; it reads the template files and creates the workspace. + +*Documentation-only. +No Vale rule.* + +## Exclamation points rare in prose + +Exclamation points in body prose read as marketing copy or shouted emphasis. +Reserve them for code blocks, direct quotes from error messages, and rare moments where genuine emphasis serves the reader. + +**Do**: + +> Coder is ready to use. + +**Don't**: + +> Coder is ready to use! + +*Enforced by `Google.Exclamation`.* + +## Numeric ranges + +Spell out the joiner in prose. +Use `5 to 10` or `between 5 and 10`, not `5-10`. +In code blocks, terse reference material, and tables where space matters, the hyphenated form is acceptable. + +**Do**: + +> The agent retries 5 to 10 times before giving up. + +**Don't**: + +> The agent retries 5-10 times before giving up. + +*Enforced by `Google.Ranges`.* + +## Related + +- [Style guide landing page](./README.md) +- [Accessibility and inclusion](./accessibility-and-inclusion.md) +- [Formatting](./formatting.md) +- [Numbers, units, and dates](./numbers-units-and-dates.md) diff --git a/docs/.style/style-guide/editor-setup.md b/docs/.style/style-guide/editor-setup.md new file mode 100644 index 0000000000..959308e765 --- /dev/null +++ b/docs/.style/style-guide/editor-setup.md @@ -0,0 +1,11 @@ +# Editor setup + +A future revision of this guide will cover Vale editor integration for VS Code, Cursor, JetBrains, and Neovim, so contributors get inline feedback before commit instead of CI failure after push. + +This page is a placeholder. +The contents land in a follow-up PR. + +## Related + +- [Style guide landing page](./README.md) +- [Vale doctrine and tooling](../README.md) diff --git a/docs/.style/style-guide/formatting.md b/docs/.style/style-guide/formatting.md new file mode 100644 index 0000000000..e966aa99d1 --- /dev/null +++ b/docs/.style/style-guide/formatting.md @@ -0,0 +1,440 @@ +# Formatting + +Coder documentation uses bold for UI elements, italics for emphasis, and code font for identifiers. +Code blocks declare a language. +The rules on this page set those defaults and the conventions for callouts, tabs, lists, tables, links, and images. + +For descriptive link text and image alt text, refer to [Accessibility and inclusion](./accessibility-and-inclusion.md). +The accessibility-driven rules live on that page so heading structure, language, link text, and alt text stay together. + +## One sentence per line + +Write each sentence on its own Markdown source line. +Do not split a sentence across multiple lines, and do not wrap to a fixed column width. + +The payoff is cleaner diffs and easier authoring. +A sentence-level edit changes one line, not a paragraph reflow, so reviewers see exactly which sentence moved. +The rule is straightforward to apply for both humans and LLMs: end a sentence, start a new line. + +What counts as a single line: + +- One declarative, interrogative, or imperative sentence ending in a period, question mark, or exclamation point. +- The full text of a single bullet item, numbered list entry, or blockquote line. + +What does not get its own line: + +- Mid-sentence clauses or phrases. +- Source inside fenced code blocks, where the language's own conventions apply. +- Table rows, which are governed by `markdown-table-formatter`. + +**Do**: + +> The Coder agent connects to the workspace, opens a Tailscale tunnel, and forwards SSH and IDE traffic over the tunnel. + +**Don't** (mid-sentence clause breaks): + +> The Coder agent connects to the workspace, opens a Tailscale tunnel, and forwards SSH and IDE traffic over the tunnel. + +**Don't** (fixed column wrap): + +> The Coder agent connects to the workspace, opens a Tailscale tunnel, and forwards SSH and IDE traffic over the tunnel. + +Both **Don't** versions add noise to the source and produce diff churn on small edits. + +`markdownlint`'s `MD013` (line length) is already disabled, so the convention is editorial. +Editors that auto-wrap on save should be configured to leave the source alone. + +*Documentation-only. +No Vale rule.* + +## Text formatting + +The rules in this section cover inline formatting that lives inside a paragraph. + +### Bold for UI elements + +Use bold for the literal text of UI elements the reader interacts with: buttons, menu items, page titles, field labels, tab names. +Bold tells the reader "this is the thing you select or read". + +When the reader navigates across multiple UI elements, join each element with a greater-than sign (`>`) surrounded by spaces. +The separator makes the navigation path scannable and matches the convention in Microsoft and Google developer documentation. + +**Do**: + +> Select **Templates** > **Settings** > **Schedule**. +> +> Navigate to **Workspaces** > **New workspace**. + +**Don't**: + +> Navigate to "Templates" > "Settings" and select the Schedule tab. +> +> Navigate to *Templates* > *Settings* and select the *Schedule* tab. +> +> Click **Templates**, then click **Settings**, then click **Schedule**. + +*Documentation-only. +No Vale rule.* + +### Italics for emphasis only + +Reserve italics for genuine emphasis where bold would be too loud. +Do not use italics for UI elements, identifiers, or product names. + +**Do**: + +> Restarting the workspace deletes ephemeral state. +> Save your work *before* you select **Restart**. + +**Don't**: + +> Navigate to *Templates* > *Settings*. + +*Documentation-only. +No Vale rule.* + +### Code font + +Use backticks (inline code font) for the following: + +- User input. +- Command names and flag names. +- Filenames, file paths, and directory names. +- Environment variables. +- HTTP verbs and status codes. +- Configuration keys. +- Code identifiers (function names, struct names, package names). +- Placeholder variables. + +**Do**: + +> Run `coder login --token ` to authenticate. +> Set `CODER_URL` in your environment first. +> +> The server returns `404 Not Found` when the workspace does not exist. + +**Don't**: + +> Run "coder login --token \" to authenticate. +> Set CODER_URL in your environment first. +> +> The server returns 404 when the workspace does not exist. + +*Documentation-only. +No Vale rule.* + +## Block elements + +The rules in this section cover block-level structures that stand on their own line or own region. + +### Code blocks with language fences + +Every fenced code block declares a language. +Use the most specific language tag available: + +- `sh` for a shell command or a shell script. + Use `sh` when the block is input the reader types or a script they save, and the block does not also show output. +- `console` for an interactive session that shows the typed command and its output together. + Prefix each typed line with `$`. +- `powershell` for Windows command-line blocks. + PowerShell is the default Windows shell in the Coder docs. +- `tf` for Terraform and HCL. +- `yaml` for YAML. +- `go` for Go. +- `json` for JSON. +- `text` for command output shown on its own, and for any block with no syntax to highlight. + +`bash` and `shell` are aliases of `sh`. +Use `sh` so the corpus stays consistent. + +A command with no output shown is `sh`, not `console`. +To show a command together with its output, either use one `console` block with `$` before the typed line, or split the command into an `sh` block and the output into a `text` block. + +The auto-generated Coder CLI reference under `docs/reference/cli/` labels its command-usage blocks `console`. +That output is generated. +Do not copy the pattern into hand-written pages. + +The docs site highlights code with [Speed-Highlight](https://github.com/speed-highlight/core), which detects the language from the code content, not from the fence label. +The fence label still drives highlighting on GitHub and in most editors, and `markdownlint` rule `MD040` requires one, so always declare the most specific language. +For content with no sensible language tag, fall back to `text`. + +**Do**: + +````markdown +```sh +coder templates push -d ~/coder-quickstart -y quickstart +``` + +```console +$ coder templates list NAME LAST UPDATED quickstart 2 minutes ago +``` +```` + +**Don't**: + +````markdown +``` +coder login --token +``` + +```console +coder templates push -d ~/coder-quickstart -y quickstart +``` +```` + +The first **Don't** omits the language. +The second labels a bare command `console` but shows no output, so `sh` is correct. + +*Enforced by `markdownlint` rule `MD040` for the missing-language case.* + +### Callouts + +Use the GitHub callout syntax for asides. +Use them sparingly. +Prose should carry the message. + +| Callout | Use for | +|------------------|----------------------------------------------------------------------------------------------------------------------| +| `> [!NOTE]` | Supplementary context the reader benefits from but does not need to act on before proceeding | +| `> [!TIP]` | An optional optimization, shortcut, or related feature | +| `> [!IMPORTANT]` | A required step or prerequisite the reader will miss if they skim | +| `> [!WARNING]` | An action with a serious side effect (data loss, downtime, security exposure) that the reader must read before doing | +| `> [!CAUTION]` | A severe or irreversible consequence; reserve for cases where `WARNING` is not strong enough | + +A follow-up PR will demonstrate each callout rendered against an existing docs page so reviewers can calibrate when each one fits. + +*Documentation-only. +No Vale rule.* + +### Tabs for parallel content + +Use tabs when the reader picks one path that applies to their situation: installation methods on different operating systems, platform-specific commands, or API client SDKs in different languages. +Do not use tabs to hide information the reader needs regardless of choice. + +The docs site renders a `
` wrapper with H3 children as a tabbed interface. +The H3 heading text becomes the tab label, and everything from that H3 to the next H3 (or to the closing `
`) becomes the tab panel. + +**Do**: + +````markdown +
+ +### macOS + +```sh +brew install coder/coder/coder +``` + +### Linux + +```sh +curl -L https://coder.com/install.sh | sh +``` + +### Windows + +```powershell +winget install Coder.Coder +``` + +
+```` + +Leave a blank line after the opening `
` and before the closing `
` so the markdown processor parses the inner content as markdown rather than HTML. + +*Documentation-only. +No Vale rule.* + +### Lists + +If a sentence enumerates more than five items, rewrite as a bulleted list. +A prose list of six or more items reads as a wall of commas. +A bulleted list is easier to scan and to maintain. + +Unordered lists are for items that have no required order. +Ordered lists are for sequential steps the reader follows in order. +Steps in an ordered list start with an imperative verb. + +Punctuation on list items follows the structure of each item: + +- **Complete sentences**: end with a period. +- **Phrases that complete the lead-in clause from the preceding paragraph**: end with a period when the combined paragraph plus item reads as a sentence. +- **Single-word or short-phrase labels**: no terminal punctuation. + +Do not mix the styles inside one list. +If one item is a complete sentence, rewrite the rest so every item is a complete sentence. + +**Do**: + +```markdown +1. Run `coder login` to authenticate. +2. Create the workspace template. +3. Build the workspace from the template. +``` + +```markdown +The provisioner supports: + +- AWS +- Azure +- Google Cloud +``` + +```markdown +The agent reconnect logic uses the following timeouts: + +- Initial reconnect: 1 second. +- Backoff factor: 2. +- Maximum delay: 30 seconds. +``` + +**Don't**: + +```markdown +1. The user runs `coder login` to authenticate +2. Creating the workspace template comes next. +3. Then the workspace gets built from the template +``` + +```markdown +The provisioner supports: + +- AWS. +- Azure +- Google Cloud. +``` + +The first **Don't** mixes punctuation styles and uses non-imperative leads. +The second mixes punctuation inside one list and uses periods on single-word labels. + +For a "Learn more" or "See also" list of links, treat each item as a label: no terminal period, and no leading "And" or "Or". +When such a list needs a lead-in, end the lead-in with a colon on a clause that stands on its own, rather than dangling the colon off a sentence the bullets then finish. + +**Do**: + +```markdown +You have two options: + +- Install the tool with `apt-get` in the template's startup script. +- Bake the tool into the workspace image. +``` + +```markdown +## Learn more + +- [Extending templates](./extending-templates.md) +- [Terraform modules](https://developer.hashicorp.com/terraform/language/modules) +``` + +**Don't**: + +```markdown +Install it where it persists across rebuilds: + +- Add it to the template's startup script with `apt-get`. +- Or bake it into the workspace image. +``` + +The **Don't** dangles the colon off a sentence and starts a bullet with "Or". + +*Documentation-only. +No Vale rule.* + +### Tables + +Use tables to compare options, list parameters, or show permissions. +Keep tables simple. +Avoid nested formatting and avoid tables that would read better as prose. + +Keep tables narrow enough that they fit the readable text column without horizontal scrolling. +If a column needs more than a short phrase, rewrite the cell into the page body or break the table into two narrower tables. +A table that crushes column widths so words split across lines reads worse than the equivalent prose. + +If a table needs many columns to capture the data, reconsider whether a table is the right structure. +A definition list or a sequence of subsections may serve the reader better. + +*Documentation-only. +No Vale rule.* + +### Links + +Use Markdown link syntax (`[text](url)`). +Prefer relative paths within the docs (`../reference/cli/index.md`) over absolute URLs (`https://coder.com/docs/reference/cli`), so the link survives a future move of the docs site. + +Links to non-docs locations in the Coder codebase (source files, CI workflows, tests) also use relative paths. +The docs site renderer resolves those paths to the canonical GitHub URLs automatically. +A relative link to [`scripts/develop.sh`](../../../scripts/develop.sh) reads correctly on GitHub when browsing the repo and on the docs site when reading the published page. + +Anchor links to a specific section use the GitHub-flavored slug: lowercase the heading, replace spaces with hyphens, and drop punctuation (`./word-choice.md#refer-to-check-out-visit-not-see`). + +External URLs use the full `https://` form. +Do not strip the protocol. + +For the link-text rule that screen readers and reading-out-of-context demand, refer to [Descriptive link text](./accessibility-and-inclusion.md#descriptive-link-text). + +*Documentation-only. +No Vale rule for the syntax conventions.* + +### Images + +Place image assets under the matching subdirectory of `docs/images/`. +Use lowercase filenames with hyphens between words (`template-insights-dashboard.png`). +Reference the asset with a relative path from the Markdown source. + +Captions go below the image in a `` tag. + +```markdown +![Template Insights dashboard with weekly active users and connection latency charts](../../images/admin/templates/template-insights.png) + +The Template Insights dashboard. Active users in the left panel; connection latency in the right panel. +``` + +For alt text and decorative-image conventions, refer to [Alt text for images](./accessibility-and-inclusion.md#alt-text-for-images) and [Decorative images](./accessibility-and-inclusion.md#decorative-images). + +*Documentation-only for asset path conventions. +Alt-text requirement enforced by `markdownlint` rule `MD045`.* + +### Screenshots sparingly + +Use screenshots only when a sighted reader would be confused without the visual aid. +A worked example, a code block, or a precise written instruction is almost always better than a screenshot. + +> If a picture is worth a thousand words, then a good example is worth at least twice that amount. +> +> Adapted from Lorna Jane Mitchell's [Short tech writing style guide for developers](https://lornajane.net/posts/2024/short-tech-writing-style-guide-for-developers). + +Screenshots carry an ongoing maintenance burden. +The product UI changes, strings get renamed, themes get retuned, and a screenshot that was accurate at merge time silently rots. +Readers who hit a stale screenshot lose confidence in the page, and a reader using a screen reader cannot use the screenshot at all. +The writer who adds a screenshot owns the cost of replacing it every time the captured surface changes. + +When a screenshot is the right answer: + +- Capture the minimum surface area. + Crop to the smallest region that resolves the confusion the page is addressing. +- Provide alt text that conveys the purpose of the screenshot, per [Alt text for images](./accessibility-and-inclusion.md#alt-text-for-images). +- Pair the screenshot with the written instruction. + The written instruction is the source of truth. + The screenshot is a check on the reader's understanding, not a replacement for the words. + +**Do**: + +> Open the workspace settings page. +> Set **Autostart** to **Weekdays at 9 AM** and select **Save**. + +**Don't**: + +> ![Workspace settings page with Autostart set to Weekdays at 9 AM](../../images/workspaces/autostart.png) +> +> Configure autostart as shown above. + +The authoritative screenshot policy, including the obfuscation, PHI, and PII rules, lives in [`content-guidelines.md`](../content-guidelines.md). + +*Documentation-only. +Enforcement is editorial.* + +## Related + +- [Style guide landing page](./README.md) +- [Accessibility and inclusion](./accessibility-and-inclusion.md) +- [Capitalization and punctuation](./capitalization-and-punctuation.md) diff --git a/docs/.style/style-guide/numbers-units-and-dates.md b/docs/.style/style-guide/numbers-units-and-dates.md new file mode 100644 index 0000000000..294d7b86c7 --- /dev/null +++ b/docs/.style/style-guide/numbers-units-and-dates.md @@ -0,0 +1,144 @@ +# Numbers, units, and dates + +Coder documentation uses digits for all numbers in prose, a non-breaking space between a number and its unit, and the `Month Day, Year` date format. +The rules on this page set those defaults. + +## Digits everywhere + +Use digits for all numbers in prose, including small whole numbers. +The traditional Chicago-style rule of "spell out one through nine" optimizes for print journalism. +Digits are more accessible for the international and non-native-English audience that reads Coder docs, scan faster in technical prose, and stay legible through machine translation. + +If a sentence would start with a digit, restructure the sentence so a word comes first. +Do not spell out the number to avoid the leading digit. +That reintroduces the rule the digits-everywhere policy is meant to remove. + +**Do**: + +> The agent retries 3 times before giving up. +> +> Workspaces auto-stop after 8 hours of inactivity. +> +> The workspace has 5 connected users. + +**Don't**: + +> The agent retries three times before giving up. +> +> Workspaces auto-stop after eight hours of inactivity. +> +> 5 users connected to the workspace. + +The first and second **Don't** examples spell out small numbers. +The third example starts a sentence with a digit. +Restructure to put a word first ("The workspace has 5 connected users."). + +*Enforced by `Coder.DigitsEverywhere` (planned, ships at `warning` severity because the rule is preference, not hard policy).* + +## Non-breaking space between number and unit + +Insert a non-breaking space between a number and its unit so the pair never breaks across a line. +The Markdown source uses ` ` (HTML entity) or the Unicode character `U+00A0` (the literal non-breaking space). +The visible result is the same as a regular space, but the line breaker treats the number and unit as one token. + +**Do**: + +In the Markdown source (what you type): + +```markdown +The default timeout is 30 seconds. +Connection latency under 150 ms shows green. +``` + +In the rendered output (what the reader reads): + +> The default timeout is 30 seconds. +> Connection latency under 150 ms shows green. + +The rendered output looks identical to text written with a regular space. +The difference shows up only at the end of a line: the browser will never split `30` and `seconds` across two lines. +To see the rule in action, shrink the browser window until the sentence wraps. +The number and the unit move to the next line together rather than separating. + +**Don't**: + +In the Markdown source: + +```markdown +The default timeout is 30 seconds. +Connection latency under 150ms shows green. +``` + +The first line allows the browser to split `30` from `seconds`. +The second line omits the space entirely, which also reads worse. + +In code blocks, configuration values, and CLI output, the original format is preserved (`30s`, `150ms`). +The non-breaking-space rule applies to prose only. + +*Enforced by `Google.Units` (planned).* + +## Date format + +Write dates as `Month Day, Year` with a full month name and a comma between day and year. +The format is unambiguous across locales, which the all-numeric forms (`07/31/2026` versus `31/07/2026`) are not. + +**Do**: + +> Coder released version 2.20 on July 31, 2026. + +**Don't**: + +> Coder released version 2.20 on 07/31/2026. +> +> Coder released version 2.20 on 31 July 2026. +> +> Coder released version 2.20 on 2026-07-31. + +In code blocks, configuration values, log lines, and API responses, keep whatever format the source uses. +ISO 8601 (`2026-07-31`) is correct in those contexts. + +*Enforced by `Google.DateFormat` (planned).* + +## Time format + +Write times in 12-hour format with a space and uppercase AM or PM. + +**Do**: + +> The maintenance window starts at 9 AM and ends at 5 PM. + +**Don't**: + +> The maintenance window starts at 9am and ends at 5pm. +> +> The maintenance window starts at 09:00 and ends at 17:00. + +In code blocks and timestamps from logs or APIs, keep the source format. +The 12-hour rule is for prose only. + +*Enforced by `Google.AMPM` (planned).* + +## Ordinals + +Spell out ordinals `first` through `ninth`. +Use digits with a suffix for `10th` and up. This is the one place the digits-everywhere rule yields, because ordinals spelled out read more naturally in prose at low counts. + +**Do**: + +> The first time you run `coder login`, the CLI prompts you for an access URL. +> +> The 10th workspace in the list is the oldest. + +**Don't**: + +> The 1st time you run `coder login`, the CLI prompts you for an access URL. +> +> The tenth workspace in the list is the oldest. + +*Enforced by `Google.Ordinal` (planned).* + +## Related + +- [Style guide landing page](./README.md) +- [Capitalization and punctuation](./capitalization-and-punctuation.md) +- [Formatting](./formatting.md) diff --git a/docs/.style/style-guide/voice-and-tone.md b/docs/.style/style-guide/voice-and-tone.md new file mode 100644 index 0000000000..0fd43d16e0 --- /dev/null +++ b/docs/.style/style-guide/voice-and-tone.md @@ -0,0 +1,172 @@ +# Voice and tone + +Coder documentation addresses the reader directly, uses active voice, and describes the product in the present tense. +The rules on this page set those defaults. + +For pronoun conventions that center inclusive language, refer to [Accessibility and inclusion](./accessibility-and-inclusion.md). + +## Address the reader directly + +Use the second person ("you") in prose that gives the reader an instruction or describes what the reader sees, types, or gets back. +Second person is direct, scales across audiences, and avoids the ambiguity of "the user" (which user?) or generic constructions. + +**Do**: + +> You can connect to a workspace over SSH after you have installed the Coder CLI. + +**Don't**: + +> Users can connect to workspaces over SSH after the user has installed the Coder CLI. + +*Documentation-only. +No Vale rule.* + +## Avoid first-person singular + +First-person singular pronouns (`I`, `my`, `me`, `mine`, `I'm`, `I've`) imply a single author speaking to a single reader, which is the wrong register for product documentation. +Rewrite in the second person or in a neutral voice. + +**Do**: + +> You can configure the workspace timeout in the template settings. + +**Don't**: + +> I usually set the workspace timeout in the template settings. + +*Enforced by `Coder.FirstPersonSingular`.* + +## Reserve first-person plural for Coder Technologies + +`We`, `us`, and `our` refer to **Coder Technologies, Inc.**, the company that makes the Coder platform. +For formal references to the company, use the full name ("Coder Technologies, Inc."). +For informal references in body prose, use `we`, `us`, or `our`. + +Do not use first-person plural for: + +- The product itself. + The product is `Coder`, not "we". + Rewrite to put the product, a release, or a feature as the subject. +- A combined "you and the docs" or "you and the author". + That construction obscures who is taking the action. + +**Do**: + +> For more information about enterprise licensing, [contact us](https://coder.com/contact/sales). +> +> Coder Technologies, Inc. publishes a new agent binary in each release. +> +> Each release includes a new agent binary. +> You can install the agent with the workspace template. + +**Don't**: + +> We ship a new agent binary in each release. +> +> Coder ships new agent binaries: we release them on the first of each month. +> +> We can install the agent by running this command on our workspace. + +The first **Don't** uses "we" to mean the product rather than the company. +Rewrite with the product, release, or feature as the subject ("Each release includes ..."). +The second **Don't** uses "we" to refer to the product's release behavior. +The third **Don't** uses "we" to mean "the docs and the reader together", which obscures who runs the command. + +*Enforced by `Coder.FirstPersonPlural`.* + +## Active voice by default + +Active voice puts the actor first and reads faster. +Passive voice is acceptable when the actor is genuinely unknown or irrelevant (`The token is rotated every 24 hours.`), but the default is active. + +**Do**: + +> Coder rotates the agent token every 24 hours. + +**Don't**: + +> The agent token is rotated every 24 hours by Coder. + +*Documentation-only. +No Vale rule. +Imprecise rules like `Google.Passive` and `write-good.Passive` fire on every passive construction including the legitimate ones, so they stay out of the package per the rule-authoring doctrine.* + +## Present tense by default + +Describe how the product works in the present tense. +Future tense ("will") implies an event that has not happened yet at read time. +Reserve future tense for: + +- Genuine future events, like scheduled rollouts or deprecations with a known date. +- Conditional or predictive statements where "will" carries the meaning "is guaranteed to". + "If you stop the workspace, the agent will disconnect within 30 seconds" describes a guaranteed consequence and reads more naturally with `will` than with the present tense. + +**Do**: + +> The provisioner reads the template files and creates the workspace. +> +> If you delete the template, Coder will refuse to create new workspaces from it. + +**Don't**: + +> The provisioner will read the template files and will create the workspace. +> +> When you run the install script, it will download the latest release. + +The second **Don't** uses future tense to describe normal behavior of the install script. +Use plain present tense for behavior the product already exhibits. + +*Documentation-only. +No Vale rule.* + +## Trailing prepositions are a judgment call + +A sentence that ends with a preposition (`with`, `to`, `from`, `for`, `on`, `of`, `at`, `by`, `into`, `over`, `under`, `about`) can leave its object implicit, which adds a small comprehension cost. +Avoiding the trailing preposition, though, can produce a more awkward sentence. +There is no one-size-fits-all rule. +Read both versions and keep the one that reads more naturally. + +Lean toward rewriting when the trailing preposition is redundant, or when the reordered version is still easy to read: + +**Do**: + +> The CLI prompts you for the directory in which to store the template. +> +> Where is the config file? + +**Don't**: + +> The CLI prompts you for the directory you want to store the template in. +> +> Where is the config file at? + +The second **Don't** keeps a redundant `at`. +"Where is the config file?" says the same thing. + +Keep the trailing preposition when avoiding it contorts the sentence: + +**Do**: + +> This is some nonsense that I will not put up with. +> +> Open the repository you want to clone from. + +**Don't**: + +> This is some nonsense up with which I will not put. +> +> Open the repository from which you want to clone. + +The first **Don't** is the classic over-correction: the rewrite is harder to read than the preposition it avoids. + +When both versions read equally well, the writer chooses. +Treat avoiding a trailing preposition as a default to reach for, not a rule to enforce. + +*Documentation-only. +No Vale rule.* + +## Related + +- [Style guide landing page](./README.md) +- [Word choice](./word-choice.md) +- [Accessibility and inclusion](./accessibility-and-inclusion.md) diff --git a/docs/.style/style-guide/word-choice.md b/docs/.style/style-guide/word-choice.md new file mode 100644 index 0000000000..08e3b6ea4a --- /dev/null +++ b/docs/.style/style-guide/word-choice.md @@ -0,0 +1,386 @@ +# Word choice + +Coder documentation uses canonical brand and product names, plain language for product actions, and "refer to" instead of "see" for navigational pointers. +The rules on this page set those defaults. + +For inclusive-language substitutions like `allowlist` or `primary`, refer to [Accessibility and inclusion](./accessibility-and-inclusion.md). + +## Coder product and feature names + +`Coder`, the company and the product, is always capitalized. +Feature names are capitalized as proper nouns when the prose names the feature. +The underlying generic concept stays lowercase. + +When the prose refers to the Coder command-line interface as a tool, wrap it in backticks: `coder`. +The bare lowercase `coder` (no backticks) is wrong. +It reads as a misspelling of the product name. + +| Do | Don't | +|---------------------------------|------------------------------------------------| +| Coder | coder (referring to the product, no backticks) | +| `coder` (the CLI, in backticks) | coder (the CLI, no backticks) | +| AI Bridge | AI bridge, AIBridge | +| Workspace Proxy | workspace proxy (referring to the feature) | +| workspace | Workspace (referring to the generic concept) | +| template | Template (referring to the generic concept) | +| agent | Agent (referring to the generic concept) | +| provisioner | Provisioner (referring to the generic concept) | + +**Do**: + +> Coder runs `coder login` to authenticate against the Coder server. +> +> Open the AI Bridge integration page to configure model providers. + +**Don't**: + +> coder runs coder login to authenticate against the coder server. +> +> Open the ai bridge integration page to configure model providers. + +*Enforced by `Coder.ProductTerms` (planned).* + +## Brand names + +Use the canonical casing for third-party brand and product names. +The Coder docs team keeps a substitution list. + +When the prose refers to a third-party command-line tool, wrap the tool name in backticks the same way as for the Coder CLI. +The product name (`Terraform`) stays capitalized in prose. +The CLI tool (`terraform`) lives in backticks. + +| Do | Don't | +|-------------------------------------|---------------------------------------| +| HashiCorp | Hashicorp, HASHICORP | +| GitHub | Github, GITHUB | +| OpenTofu | Opentofu, OpenTOFU | +| Kubernetes | kubernetes (in prose), K8s (in prose) | +| Terraform | terraform (in prose, no backticks) | +| `terraform` (the CLI, in backticks) | terraform (the CLI, no backticks) | +| JetBrains | Jetbrains, jetbrains | +| VS Code | VSCode, VSC, VS code | + +Lowercase forms remain correct in code blocks, URLs, package names, and Terraform provider sources, where the canonical form is lowercase by convention. + +*Enforced by `Coder.BrandNames`.* + +## Dev container terminology + +The open standard at [containers.dev](https://containers.dev/) uses two forms in its own documentation: + +- **`Development Container Specification`** (or **`Dev Container Spec`** for short) when naming the open specification, the Features ecosystem, or the Templates ecosystem. +- **`dev container`** (lowercase, two words) for the category and for any instance. + +The Coder docs follow the same conventions. + +`envbuilder` is the implementation tool Coder uses to build dev containers. +It is not itself the concept, so it stays in backticks as a tool name. + +> [!NOTE] The Coder feature that integrates the open standard with Coder workspaces is named `Dev Containers` in product context. +> The capitalization there comes from the [Coder product and feature names](#coder-product-and-feature-names) rule for Coder features, not from the underlying concept. + +| Do | Don't | +|-------------------------------------|------------------------------------------------------| +| Development Container Specification | DevContainer Specification | +| Dev Container Spec | dev container spec (as the proper-noun shorthand) | +| dev container | Dev Container, DevContainer, devcontainer (in prose) | +| `devcontainer.json` | `dev-container.json`, `DevContainer.json` | +| `envbuilder` | EnvBuilder, Envbuilder, env builder | + +**Do**: + +> Coder builds dev containers that conform to the Development Container Specification. +> The template defines the dev container in a `devcontainer.json` file. +> The provisioner builds the dev container with `envbuilder` and starts the agent inside it. + +**Don't**: + +> Coder supports DevContainers as a workspace runtime. +> (Wrong casing. +> The spec writes the abbreviated form as two words.) +> +> Coder supports Dev Containers as a workspace runtime. +> (Capital-D `Dev Container` is reserved for the proper-noun shorthand `Dev Container Spec`. +> The category is lowercase.) + +*Enforced by `Coder.DevContainer` (planned).* + +## Phrasal verbs and their noun forms + +English uses two spellings for many product actions: two words when the term is a verb (`set up`, `log in`), and one word (or hyphenated) when the term is a noun (`setup`, `login`). +Treat them consistently across the docs. + +| Verb (two words) | Noun (one word or hyphenated) | +|------------------|-------------------------------| +| set up | setup | +| log in | login | +| sign in | sign-in | +| log out | logout | +| back up | backup | +| roll out | rollout | +| start up | startup | +| shut down | shutdown | + +`Quickstart` is one word, always, even though it derives from "quick start". + +**Do**: + +> Follow the Quickstart to set up your first workspace. +> +> The setup takes about 10 minutes. +> +> Log in to Coder, then check that the login appears in the audit log. +> +> Back up the database before the upgrade. +> The backup file lives in `/var/lib/coder/backups`. + +**Don't**: + +> Follow the Quick Start to setup your first workspace. +> +> The set-up takes about 10 minutes. +> +> Login to Coder, then check that the log in appears in the audit log. +> +> Backup the database before the upgrade. + +*Enforced by `Coder.PhrasalVerbs` (planned).* + +## Refer to, check out, visit, not see + +When the prose points the reader at another page, section, or external resource, choose the verb that matches the register: + +- **Refer to** is the formal default for cross-references inside the docs. + Use it when the destination is a reference page, a specification, or any resource the reader should consult before continuing this doc. +- **Check out** is informal. + Use it in tutorials and step-by-step passages where the conversational register suits the content. + Do not use it in reference material. +- **Visit** is best when the destination is an external URL or another site, especially when the reader leaves the docs. + +Do not use **see** as a navigational verb. +Reserve **see** for the rare case where the prose describes what a reader observes in the product UI ("You see a list of templates on the Templates page"). +The plain-language alternatives carry register information that "see" does not, and reserving "see" for its observational meaning improves clarity for every reader. + +The same reservation covers "see" used to mean "understand" or "find out". +In a list of outcomes, "learn why the build fails" or "find out why the build fails" names what the reader gains. +"See why the build fails" borrows the observational sense of "see" for a comprehension outcome, so prefer "learn" or "find out". + +**Do**: + +> For the full command list, refer to the [Coder CLI reference](../../reference/cli/index.md). +> +> Check out the [Quickstart](../../tutorials/index.md) before you configure the production deployment. +> +> Visit the [Terraform Registry](https://registry.terraform.io/) for the latest provider versions. +> +> Add a Ruby option, then learn why the option alone does not install the toolchain. + +**Don't**: + +> For the full command list, see the [Coder CLI reference](../../reference/cli/index.md). +> +> See the [Quickstart](../../tutorials/index.md) before you configure the production deployment. +> +> See the [Terraform Registry](https://registry.terraform.io/) for the latest provider versions. +> +> Add a Ruby option, then see why the option alone does not install the toolchain. + +*Enforced by `Coder.SeeAlternatives` (planned).* + +## Learn more, not Next steps + +End-of-page navigation that points the reader at related material uses the heading **Learn more**, not **Next steps**. +Two rationales apply: + +- **Sequencing**: "Next steps" implies the reader must follow a specific sequence. + "Learn more" frames the section as optional related reading, which matches the DiĆ”taxis distinction between a tutorial (sequenced) and a how-to or reference (independent). +- **Inclusive language**: "steps" reads as a physical-mobility metaphor. + Readers who cannot walk through steps still consume technical documentation. + Neutral alternatives like "Learn more" do not encode that assumption. + +**Do**: + +```markdown +## Learn more + +- [Configure SSH access](./ssh.md) +- [Set workspace autostart](./autostart.md) +``` + +**Don't**: + +```markdown +## Next steps + +- [Configure SSH access](./ssh.md) +- [Set workspace autostart](./autostart.md) +``` + +*Enforced by `Coder.LearnMore` (planned).* + +## Tutorial, not walkthrough + +`Tutorial` is the standard term in technical documentation and matches the DiĆ”taxis category. +`Walkthrough` is colloquial, and the metaphor assumes the reader can walk. +Neutral alternatives like "tutorial" do not encode that assumption. + +**Do**: + +> This tutorial shows you how to deploy Coder on AWS. + +**Don't**: + +> This walkthrough shows you how to deploy Coder on AWS. + +*Enforced by `Coder.Tutorial` (planned).* + +## Select, not click + +Use "select" for actions on UI elements, regardless of input device. +"Click" assumes a mouse. +Touch devices tap, keyboard users press Enter, and assistive-technology users activate. +"Select" covers every case and matches the Microsoft style guide convention. + +Reserve "click" for code or configuration that literally fires on a click event, like a `onClick` handler or a DOM `click` event. + +**Do**: + +> Select **Save** to apply the changes. +> +> Select **Templates** > **Settings** > **Schedule**. + +**Don't**: + +> Click **Save** to apply the changes. +> +> Click on the **Templates** tab, then click **Settings**. + +*Enforced by `Coder.SelectClick` (planned).* + +## Don't assume simplicity or difficulty + +Words that minimize the difficulty of an action ("simply", "just", "easy", "easily", "obviously", "of course", "clearly") assume the reader's experience matches the author's. +If something is "obvious" to the author and not to the reader, the reader may feel the document is confusing or condescending. +Cut the simplicity-assuming word or restructure the sentence. + +The reverse pattern, exaggerating difficulty ("complex", "intricate", "non-trivial"), is also banned. +Both patterns predict the reader's reaction instead of describing the work. + +**Do**: + +> Run `coder login` to authenticate. + +**Don't**: + +> Simply run `coder login` to authenticate. +> It's easy! +> +> The non-trivial process of authenticating with Coder requires running `coder login`. + +*Enforced by `Coder.AssumeDifficulty` (planned).* + +## Avoid weasel words + +Vague attributions ("many believe", "some say", "experts agree", "studies show", "it is widely accepted that", "most people") let the prose claim something without naming a source. +Either name the source or remove the claim. + +Vague qualifiers ("often", "usually", "sometimes", "in most cases") tell the reader the statement is sometimes false but do not say when. +Replace with the specific condition, or remove the qualifier and accept the statement as a default. + +**Do**: + +> The Coder agent reconnects within 30 seconds of a network drop. +> +> The [Coder benchmarks](../../about/why-coder.md) show a 40% reduction in onboarding time for new developers. +> +> The provisioner runs `terraform plan` before `terraform apply`. + +**Don't**: + +> The Coder agent usually reconnects within a reasonable time. +> +> Many developers believe Coder reduces onboarding time. +> +> Experts agree that running `terraform plan` first is best practice. + +*Enforced by `Coder.WeaselWords` (planned).* + +## Stop, not kill; turn off, not disable + +In product-facing prose, prefer "stop" over "kill" and "turn off" over "disable". +The plain-language forms read better for a non-technical audience and do not carry violent or ableist connotations. + +The rule has scoped exceptions for unavoidable industry-specific terms. +When the prose names a specific technical command or a real state label, the original term is the only correct one. +Wrap the term in backticks to signal that the prose is naming a tool or a state, not using the violent verb. + +The exceptions are: + +- The Linux `kill` command (process control) and the `SIGKILL` signal. + When the prose tells the reader to terminate a process from a shell, the literal command is `kill `. + In prose, write "stop the process" or "end the process" instead. + Use `kill` in backticks only when the prose names the command itself. +- The `disabled` state of a feature flag in configuration. + Configuration values keep their literal name (`disabled: true`), and prose describing the flag also uses the state name in backticks. +- The `killed` status of a process in a log file or in CLI output. + The log line preserves the original wording. + +The Coder docs team is aware that the most natural verb for software (`run`) carries similar connotations. +A dedicated rule for `run` is out of scope for this revision. + +**Do**: + +> To stop a workspace, select **Stop** in the workspace dashboard. +> +> You can turn off auto-update in the template settings. +> +> If the provisioner hangs, end the process from the shell. +> The literal command is `kill ` or `pkill provisionerd`. +> +> The agent reports a `killed` status when the supervisor terminated the process. + +**Don't**: + +> To kill a workspace, select **Kill** in the workspace dashboard. +> +> You can disable auto-update in the template settings. +> +> If the provisioner hangs, kill the process from the shell. +> (Plain-text `kill` used where backticks are required, and the verb reads as violent.) + +*Enforced by `Coder.PlainLanguage` (planned), with the industry-term exception scoped in the rule.* + +## Keep internal-only references out of published docs + +The published documentation, including the contribution guides, is public. +Every reader and every contributor, whether a community contributor or a Coder employee, must be able to open every resource linked from the docs. +A link that only employees can open excludes community contributors, so it does not belong on a published page. + +Keep these out of published pages: + +- Issue-tracker identifiers and URLs (for example, an `ABC-123` identifier or a `linear.app` link). +- Private or internal-only repositories and their URLs. +- Internal-only chat threads, design docs, dashboards, runbooks, and wikis. +- Any link gated behind employee-only access. + +Track the work in the surfaces built for it. +A pull request description, a commit message, or a code-review comment is the right place to cite an internal issue ID or a private link, because every contributor on that change can read it there. +The published page stays the same for everyone. + +**Do**: + +> The provisioner retries the build 3 times before it fails. + +**Don't**: + +> The provisioner retries the build 3 times before it fails. +> For the backstory, refer to [ABC-123](https://linear.app/acme/issue/ABC-123). + +*Documentation-only. +Planned Vale rule `Coder.InternalReferences`.* + +## Related + +- [Style guide landing page](./README.md) +- [Voice and tone](./voice-and-tone.md) +- [Accessibility and inclusion](./accessibility-and-inclusion.md) diff --git a/docs/.style/styles/Coder/README.md b/docs/.style/styles/Coder/README.md index 2a70166eeb..4023b22f53 100644 --- a/docs/.style/styles/Coder/README.md +++ b/docs/.style/styles/Coder/README.md @@ -22,9 +22,7 @@ incrementally. Planned starter rules: 1. Write a YAML file under this directory. Name it after the rule's intent, for example `InclusiveLanguage.yml` or `ProductVoice.yml`. -2. Each rule's `message:` should link to the matching section in - `docs/.style/style-guide.md`, ideally with a deep-link anchor, so a - contributor reading a Vale warning can jump straight to the guidance. +2. Each rule's `message:` should link to the matching section in the appropriate subpage of `docs/.style/style-guide/`, ideally with a deep-link anchor, so a contributor reading a Vale warning can jump straight to the guidance. 3. Land at `level: warning` first. Promote to `level: error` only after both conditions hold: - The rule is objectively correct (typo, brand-name casing, banned diff --git a/docs/about/contributing/documentation.md b/docs/about/contributing/documentation.md index cab9bf1362..7f511d9b58 100644 --- a/docs/about/contributing/documentation.md +++ b/docs/about/contributing/documentation.md @@ -32,13 +32,10 @@ following third-party references: ## Tools -This repository runs [Vale](https://vale.sh/) on `docs/` as part of CI to -enforce prose style. The configuration is the repo-root `.vale.ini`, and -the curated rule set is documented in -[`docs/.style/style-guide.md`](../../.style/style-guide.md). Run the same -checks locally with `make lint/prose`. Vale runs in advisory mode in -this iteration: warnings surface as inline PR annotations, but they don't -block the build. +This repository runs [Vale](https://vale.sh/) on `docs/` as part of CI to enforce prose style. +The configuration is the repo-root `.vale.ini`, and the curated rule set is documented in [`docs/.style/style-guide/`](../../.style/style-guide/README.md). +Run the same checks locally with `make lint/prose`. +Vale runs in advisory mode in this iteration: warnings surface as inline PR annotations, but they don't block the build. The following external tools can also help when drafting. Take their suggestions with a grain of salt because they aren't tuned for Coder's diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index c30b7d9413..27556bca61 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -15,7 +15,7 @@ We track the following resources: | Resource | | | |-----------------------------------------------------------------|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| AIGatewayKey
create, delete | |
FieldTracked
created_atfalse
hashed_secrettrue
idtrue
last_used_atfalse
nametrue
secret_prefixtrue
| +| AIGatewayKey
create, delete | |
FieldTracked
created_atfalse
hashed_secrettrue
idtrue
last_heartbeat_atfalse
nametrue
secret_prefixtrue
| | AIProvider
create, write, delete | |
FieldTracked
base_urltrue
created_atfalse
deletedtrue
display_nametrue
enabledtrue
idtrue
nametrue
settingstrue
settings_key_idfalse
typetrue
updated_atfalse
| | AIProviderKey
create, delete | |
FieldTracked
api_keytrue
api_key_key_idfalse
created_atfalse
idtrue
provider_idtrue
updated_atfalse
| | AISeatState
create | |
FieldTracked
first_used_attrue
last_event_descriptiontrue
last_event_typetrue
last_used_atfalse
updated_atfalse
user_idtrue
| diff --git a/docs/ai-coder/agents/architecture.md b/docs/ai-coder/agents/architecture.md index 9d8c8e6ecf..1360faf0af 100644 --- a/docs/ai-coder/agents/architecture.md +++ b/docs/ai-coder/agents/architecture.md @@ -178,8 +178,9 @@ parallel. | `spawn_agent` (`type=general` or `explore`) | Delegates a task to a sub-agent with its own context window. | | `wait_agent` | Waits for a sub-agent to finish and collects its result. | | `message_agent` | Sends a follow-up message to a running sub-agent. | -| `close_agent` | Stops a running sub-agent. | +| `interrupt_agent` | Halts a sub-agent's current turn; it transitions to waiting or running if there are queued messages. | | `spawn_agent` (`type=computer_use`) | Spawns a sub-agent with desktop interaction capabilities (screenshot, mouse, keyboard). Requires an administrator-configured computer-use provider (Anthropic or OpenAI) and the [virtual desktop experiment](./platform-controls/experiments.md#virtual-desktop) to be enabled. | +| `list_agents` | Lists spawned child agents, most recently active first. | ### Provider tools diff --git a/docs/ai-coder/agents/index.md b/docs/ai-coder/agents/index.md index 886e7c7835..1e464a63b4 100644 --- a/docs/ai-coder/agents/index.md +++ b/docs/ai-coder/agents/index.md @@ -229,30 +229,31 @@ model. Developers select from enabled models when starting a chat. The agent has access to a set of workspace tools that it uses to accomplish tasks: -| Tool | Description | -|---------------------------------------------|--------------------------------------------------------------------------| -| `list_templates` | Browse available workspace templates | -| `read_template` | Get template details and configurable parameters | -| `create_workspace` | Create a workspace from a template | -| `start_workspace` | Start a stopped workspace for the current chat | -| `propose_plan` | Present a Markdown plan file for user review | -| `ask_user_question` | Ask the user structured clarification questions during plan mode | -| `read_file` | Read file contents from the workspace | -| `write_file` | Write a file to the workspace | -| `edit_files` | Perform search-and-replace edits across files | -| `execute` | Run shell commands in the workspace | -| `process_output` | Retrieve output from a background process | -| `process_list` | List all tracked processes in the workspace | -| `process_signal` | Send a signal (terminate/kill) to a tracked process | -| `attach_file` | Attach a workspace file to the chat as a durable downloadable attachment | -| `spawn_agent` (`type=general` or `explore`) | Delegate a task to a sub-agent running in parallel | -| `wait_agent` | Wait for a sub-agent to complete and collect its result | -| `message_agent` | Send a follow-up message to a running sub-agent | -| `close_agent` | Stop a running sub-agent | -| `spawn_agent` (`type=computer_use`) | Spawn a sub-agent with desktop interaction (screenshot, mouse, keyboard) | -| `read_skill` | Read the instructions for a workspace skill by name | -| `read_skill_file` | Read a supporting file from a skill's directory | -| `web_search` | Search the internet (provider-native, when enabled) | +| Tool | Description | +|---------------------------------------------|----------------------------------------------------------------------------------------------------| +| `list_templates` | Browse available workspace templates | +| `read_template` | Get template details and configurable parameters | +| `create_workspace` | Create a workspace from a template | +| `start_workspace` | Start a stopped workspace for the current chat | +| `propose_plan` | Present a Markdown plan file for user review | +| `ask_user_question` | Ask the user structured clarification questions during plan mode | +| `read_file` | Read file contents from the workspace | +| `write_file` | Write a file to the workspace | +| `edit_files` | Perform search-and-replace edits across files | +| `execute` | Run shell commands in the workspace | +| `process_output` | Retrieve output from a background process | +| `process_list` | List all tracked processes in the workspace | +| `process_signal` | Send a signal (terminate/kill) to a tracked process | +| `attach_file` | Attach a workspace file to the chat as a durable downloadable attachment | +| `spawn_agent` (`type=general` or `explore`) | Delegate a task to a sub-agent running in parallel | +| `wait_agent` | Wait for a sub-agent to complete and collect its result | +| `message_agent` | Send a follow-up message to a running sub-agent | +| `interrupt_agent` | Halt a sub-agent's current turn; it transitions to waiting or running if there are queued messages | +| `spawn_agent` (`type=computer_use`) | Spawn a sub-agent with desktop interaction (screenshot, mouse, keyboard) | +| `list_agents` | List spawned child agents, most recently active first | +| `read_skill` | Read the instructions for a workspace skill by name | +| `read_skill_file` | Read a supporting file from a skill's directory | +| `web_search` | Search the internet (provider-native, when enabled) | These tools connect to the workspace over the same secure connection used for web terminals and IDE access. No additional ports or services are required in @@ -260,7 +261,7 @@ the workspace. Platform tools (`list_templates`, `read_template`, `create_workspace`, `start_workspace`, `propose_plan`, `ask_user_question`) and orchestration tools (`spawn_agent`, -`wait_agent`, `message_agent`, `close_agent`) +`wait_agent`, `message_agent`, `interrupt_agent`, `list_agents`) are only available to root chats. Sub-agents do not have access to these tools and cannot create workspaces or spawn further sub-agents. diff --git a/docs/ai-coder/mcp-server.md b/docs/ai-coder/mcp-server.md index 3a3ea42b98..8b0b9194be 100644 --- a/docs/ai-coder/mcp-server.md +++ b/docs/ai-coder/mcp-server.md @@ -1,58 +1,217 @@ # MCP Server -Power users can configure [claude.ai](https://claude.ai), Claude Desktop, Cursor, or other external agents to interact with Coder in order to: +Coder includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) +(MCP) server that provides AI assistants with tools and context about your Coder +deployment. This enables AI-powered workflows for managing workspaces, +templates, and development environments. -- List workspaces -- Create/start/stop workspaces -- Run commands on workspaces -- Check in on agent activity +Coder supports two MCP server modes: -> [!NOTE] -> See our [toolsdk](https://pkg.go.dev/github.com/coder/coder/v2/codersdk/toolsdk#pkg-variables) documentation for a full list of tools included in the MCP server +- **[Local MCP Server](#local-mcp-server)**: Runs via the Coder CLI using stdio + transport. Ideal for local AI tools and IDE integrations. +- **[Remote MCP Server](#remote-mcp-server)**: HTTP-based server exposed by your + Coder deployment. Supports OAuth2 authentication and is published to the MCP + Registry. -In this model, any custom agent could interact with a remote Coder workspace, or Coder can be used in a remote pipeline or a larger workflow. +## Local MCP Server -## Local MCP server +The local MCP server runs via the Coder CLI and uses stdio transport to +communicate with AI tools. -The Coder CLI has options to automatically configure MCP servers for you. On your local machine, run the following command: +### Setup -```sh -# First log in to Coder. -coder login - -# Configure your client with the Coder MCP -coder exp mcp configure claude-desktop # Configure Claude Desktop to interact with Coder -coder exp mcp configure cursor # Configure Cursor to interact with Coder -``` - -For other agents, run the MCP server with this command: +Run the MCP server using the Coder CLI: ```sh coder exp mcp server ``` -> [!NOTE] -> The MCP server is authenticated with the same identity as your Coder CLI and can perform any action on the user's behalf. Fine-grained permissions are in development. [Contact us](https://coder.com/contact) if this use case is important to you. +### Client Configuration -## Remote MCP server +Configure your MCP client to spawn the Coder CLI: -Coder can expose an MCP server via HTTP. This is useful for connecting web-based agents, like https://claude.ai/, to Coder. This is an experimental feature and is subject to change. +```json +{ + "mcpServers": { + "coder": { + "command": "coder", + "args": ["exp", "mcp", "server"] + } + } +} +``` -To enable this feature, activate the `oauth2` and `mcp-server-http` experiments using an environment variable or a CLI flag: +The CLI automatically uses your existing Coder authentication (from `coder login`). + +### Claude Desktop Example + +Add to your Claude Desktop configuration file: + +
+ +#### macOS + +Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "coder": { + "command": "coder", + "args": ["exp", "mcp", "server"] + } + } +} +``` + +#### Windows + +Edit `%APPDATA%\Claude\claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "coder": { + "command": "coder.exe", + "args": ["exp", "mcp", "server"] + } + } +} +``` + +
+ +## Remote MCP Server + +The remote MCP server is an HTTP endpoint exposed by your Coder deployment at +`/api/experimental/mcp/http`. This enables MCP clients to connect to Coder +without running the CLI locally. + +### Prerequisites + +The remote MCP HTTP endpoint requires both the `oauth2` and `mcp-server-http` +experiments enabled on your Coder deployment: ```sh -CODER_EXPERIMENTS="oauth2,mcp-server-http" coder server -# or coder server --experiments=oauth2,mcp-server-http ``` -The Coder server will expose the MCP server at: +Or set the environment variable: -```txt -https://coder.example.com/api/experimental/mcp/http +```sh +CODER_EXPERIMENTS=oauth2,mcp-server-http ``` -> [!NOTE] -> At this time, the remote MCP server is not compatible with web-based ChatGPT. +### MCP Registry -Users can authenticate applications to use the remote MCP server with [OAuth2](../admin/integrations/oauth2-provider.md). An authenticated application can perform any action on the user's behalf. Fine-grained permissions are in development. +Coder is published to the official [MCP Registry](https://github.com/modelcontextprotocol/registry) +as `io.github.coder/coder`, enabling easy installation in supported MCP clients. + +#### VS Code / GitHub Copilot + +1. Open VS Code Command Palette and run **MCP: Add Server...** +1. Select **From MCP Registry** +1. Search for "Coder" and select it +1. Enter your Coder deployment hostname when prompted (e.g., `coder.example.com`) +1. VS Code will automatically handle OAuth2 authentication + +#### Claude Desktop (Remote) + +Add to your Claude Desktop configuration file (`claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "coder": { + "url": "https://coder.example.com/api/experimental/mcp/http" + } + } +} +``` + +Claude Desktop will automatically discover OAuth2 endpoints and prompt you to +authenticate through your browser. + +### Manual Configuration + +For MCP clients that don't support the registry or OAuth2 discovery, configure +the server manually with a session token: + +```json +{ + "mcpServers": { + "coder": { + "url": "https://coder.example.com/api/experimental/mcp/http", + "headers": { + "Coder-Session-Token": "" + } + } + } +} +``` + +To create a session token: + +1. Navigate to your Coder deployment +1. Go to **Settings > Tokens** +1. Create a new token +1. Add the token to your MCP client configuration + +## Authentication + +The MCP server supports two authentication methods: + +### OAuth2 (Recommended for Interactive Clients) + +MCP clients that support [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) +(Protected Resource Metadata) can authenticate automatically using OAuth2. The +server advertises its OAuth2 capabilities via the `WWW-Authenticate` header and +`/.well-known/oauth-protected-resource` endpoint. + +This enables a seamless "click-to-connect" experience where users authenticate +through their browser without manually managing tokens. + +> [!NOTE] +> OAuth2 requires the `oauth2` experiment to be enabled on your Coder deployment. + +### Session Token (For Programmatic Access) + +For clients that don't support OAuth2 discovery, or for programmatic access, use +a session token as shown in the [Manual Configuration](#manual-configuration) +section. + +## Available Tools + +The MCP server exposes tools across several areas: + +- **Workspace management**: list, inspect, create, and build workspaces +- **Template operations**: list, inspect, create, and manage templates and versions +- **File operations**: read, write, and edit files in a workspace +- **Workspace interaction**: run commands, forward ports, list apps, and read logs +- **Task management**: create, list, inspect, and control tasks +- **User and system**: authenticated user details, tar uploads, and task reporting + +The full, authoritative set of tools, including their names, descriptions, and +arguments, is defined in Coder's +[`toolsdk` package](../../codersdk/toolsdk/toolsdk.go). Refer to it for the +current list, since the available tools can change between releases. + +## Troubleshooting + +### "Unauthorized" errors + +- Verify your session token is valid and not expired +- Check that the MCP server experiment is enabled on your deployment +- Ensure your user has appropriate permissions for the requested operations + +### Connection timeouts + +- Verify your Coder deployment URL is correct and accessible +- Check network connectivity between your MCP client and the Coder server +- Review Coder server logs for any errors + +### OAuth2 authentication not working + +- Ensure your Coder deployment has the `oauth2` experiment enabled +- Verify your MCP client supports RFC 9728 Protected Resource Metadata +- Check that your browser can reach the Coder authorization endpoint diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index a12c3247b2..f631241229 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -201,7 +201,7 @@ curl -X GET http://coder-server:8080/api/v2/ai-gateway/keys \ "created_at": "2019-08-24T14:15:22Z", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "key_prefix": "string", - "last_used_at": "2019-08-24T14:15:22Z", + "last_heartbeat_at": "2019-08-24T14:15:22Z", "name": "string" } ] @@ -217,14 +217,14 @@ curl -X GET http://coder-server:8080/api/v2/ai-gateway/keys \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -|------------------|-------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `Ā» created_at` | string(date-time) | false | | | -| `Ā» id` | string(uuid) | false | | | -| `Ā» key_prefix` | string | false | | | -| `Ā» last_used_at` | string(date-time) | false | | | -| `Ā» name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------|-------------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `Ā» created_at` | string(date-time) | false | | | +| `Ā» id` | string(uuid) | false | | | +| `Ā» key_prefix` | string | false | | | +| `Ā» last_heartbeat_at` | string(date-time) | false | | | +| `Ā» name` | string | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -304,6 +304,26 @@ curl -X DELETE http://coder-server:8080/api/v2/ai-gateway/keys/{key} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## AI Gateway serve + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/ai-gateway/serve \ + -H 'X-AI-Governance-Gateway-Key: API_KEY' +``` + +`GET /api/v2/ai-gateway/serve` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|--------------------------------------------------------------------------|---------------------|--------| +| 101 | [Switching Protocols](https://tools.ietf.org/html/rfc7231#section-6.2.2) | Switching Protocols | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get appearance ### Code samples diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 4ae0f8d73b..fa3c245d80 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -233,6 +233,9 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "browser_only": true, "cache_directory": "string", "cli_upgrade_message": "string", + "cluster": { + "host": "string" + }, "config": "string", "config_ssh": { "deploymentName": "string", @@ -432,6 +435,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index af90fe3da3..d640ba77df 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -990,20 +990,20 @@ "created_at": "2019-08-24T14:15:22Z", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "key_prefix": "string", - "last_used_at": "2019-08-24T14:15:22Z", + "last_heartbeat_at": "2019-08-24T14:15:22Z", "name": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------|--------|----------|--------------|-------------| -| `created_at` | string | false | | | -| `id` | string | false | | | -| `key_prefix` | string | false | | | -| `last_used_at` | string | false | | | -| `name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------------|--------|----------|--------------|-------------| +| `created_at` | string | false | | | +| `id` | string | false | | | +| `key_prefix` | string | false | | | +| `last_heartbeat_at` | string | false | | | +| `name` | string | false | | | ## codersdk.AIProvider @@ -1201,9 +1201,9 @@ None #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_gateway_key:*`, `ai_gateway_key:create`, `ai_gateway_key:delete`, `ai_gateway_key:read`, `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_gateway_key:*`, `ai_gateway_key:create`, `ai_gateway_key:delete`, `ai_gateway_key:read`, `ai_gateway_key:update`, `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest @@ -3987,6 +3987,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in |-----------------------------------------------------------------------------------------------------------------------------------| | `action_required`, `context_dirty`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` | +## codersdk.ClusterConfig + +```json +{ + "host": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------|--------|----------|--------------|-------------| +| `host` | string | false | | | + ## codersdk.ConnectionLatency ```json @@ -5578,6 +5592,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "browser_only": true, "cache_directory": "string", "cli_upgrade_message": "string", + "cluster": { + "host": "string" + }, "config": "string", "config_ssh": { "deploymentName": "string", @@ -5777,6 +5794,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -6180,6 +6198,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "browser_only": true, "cache_directory": "string", "cli_upgrade_message": "string", + "cluster": { + "host": "string" + }, "config": "string", "config_ssh": { "deploymentName": "string", @@ -6379,6 +6400,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -6607,6 +6629,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `browser_only` | boolean | false | | | | `cache_directory` | string | false | | | | `cli_upgrade_message` | string | false | | | +| `cluster` | [codersdk.ClusterConfig](#codersdkclusterconfig) | false | | | | `config` | string | false | | | | `config_ssh` | [codersdk.SSHConfig](#codersdksshconfig) | false | | | | `dangerous` | [codersdk.DangerousConfig](#codersdkdangerousconfig) | false | | | @@ -8860,6 +8883,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "email_domain": [ "string" ], + "email_fallback": true, "email_field": "string", "group_allow_list": [ "string" @@ -8929,6 +8953,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. | | `client_secret` | string | false | | | | `email_domain` | array of string | false | | | +| `email_fallback` | boolean | false | | Email fallback allows OIDC logins to fall back to email-based matching when the `linked_id` (issuer+subject) does not match an existing user link. INSECURE: weakens the linked_id check. It exists for IdP brokers that do not issue a stable `sub` for the same user across connections. | | `email_field` | string | false | | | | `group_allow_list` | array of string | false | | | | `group_auto_create` | boolean | false | | | diff --git a/docs/reference/cli/ai-gateway_keys_list.md b/docs/reference/cli/ai-gateway_keys_list.md index 39f1ffcebe..babe66b416 100644 --- a/docs/reference/cli/ai-gateway_keys_list.md +++ b/docs/reference/cli/ai-gateway_keys_list.md @@ -17,10 +17,10 @@ coder ai-gateway keys list [flags] ### -c, --column -| | | -|---------|---------------------------------------------------------------| -| Type | [id\|name\|key prefix\|created at\|last used at] | -| Default | id,name,key prefix,last used at,created at | +| | | +|---------|--------------------------------------------------------------------| +| Type | [id\|name\|key prefix\|created at\|last heartbeat at] | +| Default | id,name,key prefix,last heartbeat at,created at | Columns to display in table output. diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 33b3cec7d2..367fb0519b 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1122,6 +1122,16 @@ The algorithm to use for generating ssh keys. Accepted values are "ed25519", "ec Whether Coder only allows connections to workspaces via the browser. +### --cluster-host + +| | | +|-------------|---------------------------------------------| +| Type | string | +| Environment | $CODER_CLUSTER_HOST | +| YAML | networking.cluster.clusterHost | + +Hostname or (more commonly) IP to reach this replica for clustering. + ### --scim-auth-header | | | diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf index 838ade7713..04d08c5bc1 100644 --- a/dogfood/coder/main.tf +++ b/dogfood/coder/main.tf @@ -34,7 +34,7 @@ locals { "za-cpt" = "tcp://schonkopf-cpt-cdr-dev.tailscale.svc.cluster.local:2375" } - repo_base_dir = "/home/coder" + repo_base_dir = data.coder_parameter.repo_base_dir.value == "~" ? "/home/coder" : replace(data.coder_parameter.repo_base_dir.value, "/^~\\//", "/home/coder/") repo_dir = replace(try(module.git-clone[0].repo_dir, ""), "/^~\\//", "/home/coder/") container_name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" @@ -50,8 +50,9 @@ data "coder_workspace_preset" "pittsburgh" { description = "Development workspace hosted in United States with 2 prebuild instances" icon = "/emojis/1f1fa-1f1f8.png" parameters = { - (data.coder_parameter.region.name) = "us-pittsburgh" - (data.coder_parameter.image_type.name) = data.coder_parameter.image_type.default + (data.coder_parameter.region.name) = "us-pittsburgh" + (data.coder_parameter.image_type.name) = data.coder_parameter.image_type.default + (data.coder_parameter.repo_base_dir.name) = "~" } prebuilds { instances = 2 @@ -63,8 +64,9 @@ data "coder_workspace_preset" "cpt" { description = "Development workspace hosted in South Africa with 1 prebuild instance" icon = "/emojis/1f1ff-1f1e6.png" parameters = { - (data.coder_parameter.region.name) = "za-cpt" - (data.coder_parameter.image_type.name) = data.coder_parameter.image_type.default + (data.coder_parameter.region.name) = "za-cpt" + (data.coder_parameter.image_type.name) = data.coder_parameter.image_type.default + (data.coder_parameter.repo_base_dir.name) = "~" } prebuilds { instances = 1 @@ -76,8 +78,9 @@ data "coder_workspace_preset" "falkenstein" { description = "Development workspace hosted in Europe with 1 prebuild instance" icon = "/emojis/1f1ea-1f1fa.png" parameters = { - (data.coder_parameter.region.name) = "eu-helsinki" - (data.coder_parameter.image_type.name) = data.coder_parameter.image_type.default + (data.coder_parameter.region.name) = "eu-helsinki" + (data.coder_parameter.image_type.name) = data.coder_parameter.image_type.default + (data.coder_parameter.repo_base_dir.name) = "~" } prebuilds { instances = 1 @@ -89,8 +92,9 @@ data "coder_workspace_preset" "sydney" { description = "Development workspace hosted in Australia with 1 prebuild instance" icon = "/emojis/1f1e6-1f1fa.png" parameters = { - (data.coder_parameter.region.name) = "ap-sydney" - (data.coder_parameter.image_type.name) = data.coder_parameter.image_type.default + (data.coder_parameter.region.name) = "ap-sydney" + (data.coder_parameter.image_type.name) = data.coder_parameter.image_type.default + (data.coder_parameter.repo_base_dir.name) = "~" } prebuilds { instances = 1 @@ -108,6 +112,14 @@ locals { } } +data "coder_parameter" "repo_base_dir" { + type = "string" + name = "Coder Repository Base Directory" + default = "~" + description = "The directory specified will be created (if missing) and [coder/coder](https://github.com/coder/coder) will be automatically cloned into [base directory]/coder šŸŖ„." + mutable = true +} + data "coder_parameter" "image_type" { type = "string" name = "Coder Image" diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index e412a2c2eb..e197a7782b 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -417,12 +417,12 @@ var auditableResourcesTypes = map[any]map[string]Action{ "updated_at": ActionIgnore, // Changes; not useful in a diff. }, &database.AIGatewayKey{}: { - "id": ActionTrack, - "name": ActionTrack, - "secret_prefix": ActionTrack, - "hashed_secret": ActionSecret, // Bearer token hash, never expose. - "created_at": ActionIgnore, // Implicit; not useful in a diff. - "last_used_at": ActionIgnore, // Bumped on every use. + "id": ActionTrack, + "name": ActionTrack, + "secret_prefix": ActionTrack, + "hashed_secret": ActionSecret, // Bearer token hash, never expose. + "created_at": ActionIgnore, // Implicit; not useful in a diff. + "last_heartbeat_at": ActionIgnore, // Bumped on every heartbeat. }, &database.TaskTable{}: { "id": ActionTrack, diff --git a/enterprise/cli/aigateway.go b/enterprise/cli/aigateway.go index 408391456b..2844ef43dc 100644 --- a/enterprise/cli/aigateway.go +++ b/enterprise/cli/aigateway.go @@ -75,7 +75,7 @@ func (r *RootCmd) aiGatewayKeysCreate() *serpent.Command { func (r *RootCmd) aiGatewayKeysList() *serpent.Command { formatter := cliui.NewOutputFormatter( - cliui.TableFormat([]codersdk.AIGatewayKey{}, []string{"id", "name", "key prefix", "last used at", "created at"}), + cliui.TableFormat([]codersdk.AIGatewayKey{}, []string{"id", "name", "key prefix", "last heartbeat at", "created at"}), cliui.JSONFormat(), ) diff --git a/enterprise/cli/create_test.go b/enterprise/cli/create_test.go index 94a04a5501..213d8f12f1 100644 --- a/enterprise/cli/create_test.go +++ b/enterprise/cli/create_test.go @@ -76,11 +76,9 @@ func TestEnterpriseCreate(t *testing.T) { createTemplate := func(tplName string, orgID uuid.UUID) { version := coderdtest.CreateTemplateVersion(t, ownerClient, orgID, nil) - wg.Add(1) - go func() { + wg.Go(func() { coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version.ID) - wg.Done() - }() + }) coderdtest.CreateTemplate(t, ownerClient, orgID, version.ID, func(request *codersdk.CreateTemplateRequest) { request.Name = tplName diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 37febd028b..22b3ddbf18 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -32,18 +32,28 @@ import ( func (r *RootCmd) Server(_ func()) *serpent.Command { cmd := r.RootCmd.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, io.Closer, error) { + var ( + derpURL *url.URL + err error + ) if options.DeploymentValues.DERP.Server.RelayURL.String() != "" { - _, err := url.Parse(options.DeploymentValues.DERP.Server.RelayURL.String()) + derpURL, err = url.Parse(options.DeploymentValues.DERP.Server.RelayURL.String()) if err != nil { return nil, nil, xerrors.Errorf("derp-server-relay-address must be a valid HTTP URL: %w", err) } } + clusterHost := options.DeploymentValues.Cluster.Host.String() + if clusterHost == "" && derpURL != nil { + // Use the DERP host if the operator didn't specify an explicit cluster host, since this is an older setting + // and more likely to be configured by longtime HA customers. + clusterHost = derpURL.Hostname() + } // Always generate a mesh key, even if the built-in DERP server is // disabled. This mesh key is still used by workspace proxies running // HA. var meshKey string - err := options.Database.InTx(func(tx database.Store) error { + err = options.Database.InTx(func(tx database.Store) error { // This will block until the lock is acquired, and will be // automatically released when the transaction ends. err := tx.AcquireLock(ctx, database.LockIDEnterpriseDeploymentSetup) @@ -97,6 +107,7 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { SCIMAPIKey: []byte(options.DeploymentValues.SCIMAPIKey.Value()), UseLegacySCIM: options.DeploymentValues.UseLegacySCIM.Value(), RBAC: true, + ClusterHost: clusterHost, DERPServerRelayAddress: options.DeploymentValues.DERP.Server.RelayURL.String(), DERPServerRegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()), ProxyHealthInterval: options.DeploymentValues.ProxyHealthStatusInterval.Value(), diff --git a/enterprise/cli/testdata/coder_ai-gateway_keys_list_--help.golden b/enterprise/cli/testdata/coder_ai-gateway_keys_list_--help.golden index 043ac660f9..f922806ded 100644 --- a/enterprise/cli/testdata/coder_ai-gateway_keys_list_--help.golden +++ b/enterprise/cli/testdata/coder_ai-gateway_keys_list_--help.golden @@ -8,7 +8,7 @@ USAGE: Aliases: ls OPTIONS: - -c, --column [id|name|key prefix|created at|last used at] (default: id,name,key prefix,last used at,created at) + -c, --column [id|name|key prefix|created at|last heartbeat at] (default: id,name,key prefix,last heartbeat at,created at) Columns to display in table output. -o, --output table|json (default: table) diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 9d2d5b6cc5..bc0822640d 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -965,6 +965,9 @@ These options are only available in the Enterprise Edition. --browser-only bool, $CODER_BROWSER_ONLY Whether Coder only allows connections to workspaces via the browser. + --cluster-host string, $CODER_CLUSTER_HOST + Hostname or (more commonly) IP to reach this replica for clustering. + --derp-server-relay-url url, $CODER_DERP_SERVER_RELAY_URL An HTTP URL that is accessible by other replicas to relay DERP traffic. Required for high availability. diff --git a/enterprise/coderd/aibridge_reload_test.go b/enterprise/coderd/aibridge_reload_test.go index d911d3c05a..b4e605dede 100644 --- a/enterprise/coderd/aibridge_reload_test.go +++ b/enterprise/coderd/aibridge_reload_test.go @@ -1,7 +1,6 @@ package coderd_test import ( - "context" "encoding/json" "io" "net/http" @@ -13,15 +12,10 @@ import ( promtest "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" - "cdr.dev/slog/v3" - "cdr.dev/slog/v3/sloggers/slogtest" - "github.com/coder/coder/v2/cli" - "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/aibridged" + "github.com/coder/coder/v2/coderd/aibridgedtest" "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" @@ -52,60 +46,6 @@ func newMockUpstream(t *testing.T, name string) *mockUpstream { return m } -// startTestAIBridgeDaemon wires an in-process aibridged daemon onto -// the supplied API and subscribes it to ai_providers change events. -// This mirrors what cli/server.go does in production so /api/v2/ai-gateway -// requests dispatch through the real pool and reloader. -func startTestAIBridgeDaemon(t *testing.T, api *coderd.API) *aibridged.Metrics { - t.Helper() - - ctx := context.Background() - logger := slogtest.Make(t, nil).Named("aibridged").Leveled(slog.LevelDebug) - cfg := api.DeploymentValues.AI.BridgeConfig - tracer := otel.Tracer("aibridge-reload-test") - - providers, _, err := cli.BuildProviders(ctx, api.Database, cfg, logger, nil) - require.NoError(t, err) - - pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, providers, logger.Named("pool"), nil, tracer) - require.NoError(t, err) - t.Cleanup(func() { _ = pool.Shutdown(context.Background()) }) - - metrics := aibridged.NewMetrics(prometheus.NewRegistry()) - reloader := &testPoolReloader{pool: pool, db: api.Database, cfg: cfg, logger: logger.Named("reloader"), metrics: metrics} - unsubscribe, err := aibridged.SubscribeProviderReload(ctx, api.Pubsub, reloader, logger.Named("subscriber")) - require.NoError(t, err) - t.Cleanup(unsubscribe) - - srv, err := aibridged.New(ctx, pool, func(dialCtx context.Context) (aibridged.DRPCClient, error) { - return api.CreateInMemoryAIBridgeServer(dialCtx) - }, logger, tracer) - require.NoError(t, err) - t.Cleanup(func() { _ = srv.Close() }) - - api.RegisterInMemoryAIBridgedHTTPHandler(srv) - return metrics -} - -type testPoolReloader struct { - pool *aibridged.CachedBridgePool - db database.Store - cfg codersdk.AIBridgeConfig - logger slog.Logger - metrics *aibridged.Metrics -} - -func (r *testPoolReloader) Reload(ctx context.Context) error { - defer r.metrics.RecordReloadAttempt() - providers, outcomes, err := cli.BuildProviders(ctx, r.db, r.cfg, r.logger, nil) - if err != nil { - return err - } - r.pool.ReplaceProviders(providers) - r.metrics.RecordReloadSuccess(outcomes) - return nil -} - // TestAIBridgeProviderHotReload exercises the end-to-end CRUD -> // reload -> routing path: every provider mutation made through codersdk // must, within a short window, change the routing observed at @@ -131,7 +71,8 @@ func TestAIBridgeProviderHotReload(t *testing.T) { }, }) - metrics := startTestAIBridgeDaemon(t, api.AGPL) + metrics := aibridged.NewMetrics(prometheus.NewRegistry()) + aibridgedtest.StartTestAIBridgeDaemon(testutil.Context(t, testutil.WaitLong), t, api.AGPL, metrics) // requireProviderStatus polls until the provider_info series for // (name, status) settles to value 1. Reloads happen via pubsub, so diff --git a/enterprise/coderd/aibridgeserve.go b/enterprise/coderd/aibridgeserve.go new file mode 100644 index 0000000000..1bc639b058 --- /dev/null +++ b/enterprise/coderd/aibridgeserve.go @@ -0,0 +1,239 @@ +package coderd + +import ( + "context" + "io" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/hashicorp/yamux" + "golang.org/x/xerrors" + "storj.io/drpc/drpcmux" + "storj.io/drpc/drpcserver" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/buildinfo" + aibridgedproto "github.com/coder/coder/v2/coderd/aibridged/proto" + "github.com/coder/coder/v2/coderd/aibridgedserver" + "github.com/coder/coder/v2/coderd/apikey" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw/loggermw" + "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/drpcsdk" + "github.com/coder/websocket" +) + +// aiGatewayKeyHeartbeatInterval defines how often an active DRPC session refreshes +// last_heartbeat_at for its authenticating key. +const aiGatewayKeyHeartbeatInterval = 60 * time.Second + +// aiGatewayServe upgrades the connection to a WebSocket and serves the DRPC +// services (Recorder, MCPConfigurator, Authorizer) to a remote standalone AI +// Gateway replica, mirroring the embedded case. AI Gateway key authentication is +// enforced before the WebSocket upgrade. License entitlement is enforced by +// middleware on the route. +// +// @Summary AI Gateway serve +// @ID ai-gateway-serve +// @Security AIGatewayKey +// @Tags Enterprise +// @Success 101 +// @Router /api/v2/ai-gateway/serve [get] +func (api *API) aiGatewayServe(rw http.ResponseWriter, r *http.Request) { + key := r.Header.Get(codersdk.AIGatewayKeyHeader) + if key == "" { + httpapi.Write(r.Context(), rw, http.StatusUnauthorized, codersdk.Response{ + Message: "AI Gateway key required.", + }) + return + } + + // nolint:gocritic // AI Gateway doesn't have Coder identity.System must look up the AI Gateway key to authenticate the request. + gatewayKey, err := api.Database.GetAIGatewayKeyByHashedSecret(dbauthz.AsSystemRestricted(r.Context()), apikey.HashSecret(key)) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.Write(r.Context(), rw, http.StatusUnauthorized, codersdk.Response{ + Message: "AI Gateway key invalid.", + }) + return + } + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to look up AI Gateway key.", + }) + return + } + + clientAPIVersion := r.URL.Query().Get("version") + clientCoderVersion := r.Header.Get(codersdk.BuildVersionHeader) + logger := api.Logger.Named("aigateway-serve").With( + slog.F("remote_addr", r.RemoteAddr), + slog.F("client_api_version", clientAPIVersion), + slog.F("client_build_version", clientCoderVersion), + slog.F("server_api_version", aibridgedproto.CurrentVersion.String()), + slog.F("server_build_version", buildinfo.Version), + slog.F("ai_gateway_key_id", gatewayKey.ID), + slog.F("ai_gateway_key_name", gatewayKey.Name), + slog.F("ai_gateway_key_prefix", gatewayKey.SecretPrefix), + ) + + // keyCtx bounds all work for this authenticated key. Canceling it terminates + // the websocket session and related background work. + keyCtx, keyCtxCancel := context.WithCancel(r.Context()) + defer keyCtxCancel() + + if err := aibridgedproto.CurrentVersion.Validate(clientAPIVersion); err != nil { + httpapi.Write(keyCtx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Incompatible or unparsable version", + Validations: []codersdk.ValidationError{ + {Field: "version", Detail: err.Error()}, + {Field: "client_api_version", Detail: clientAPIVersion}, + {Field: "server_api_version", Detail: aibridgedproto.CurrentVersion.String()}, + }, + }) + return + } + + // Track the websocket so API shutdown waits for it to close. + api.AGPL.WebsocketWaitMutex.Lock() + api.AGPL.WebsocketWaitGroup.Add(1) + api.AGPL.WebsocketWaitMutex.Unlock() + defer api.AGPL.WebsocketWaitGroup.Done() + + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + // Need to disable compression to avoid a data-race, yamux reads and writes concurrently. + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + if !xerrors.Is(err, context.Canceled) { + logger.Error(keyCtx, "websocket upgrade failed", slog.Error(err)) + } + httpapi.Write(keyCtx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to accept websocket connection.", + Detail: err.Error(), + }) + return + } + + config := yamux.DefaultConfig() + config.LogOutput = io.Discard + connCtx, wsNetConn := codersdk.WebsocketNetConn(keyCtx, conn, websocket.MessageBinary) + conn.SetReadLimit(drpcsdk.YamuxDefaultStreamWindowSize) + defer wsNetConn.Close() + session, err := yamux.Server(wsNetConn, config) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("multiplex server: %s", err)) + return + } + + if _, err := aiGatewayUpdateKeyLastHeartbeat(connCtx, api, gatewayKey.ID); err != nil { + logger.Warn(connCtx, "update ai gateway key last heartbeat", slog.Error(err)) + } + go aiGatewayTrackKeyUsage(connCtx, keyCtxCancel, api, gatewayKey.ID, logger) + + mux := drpcmux.New() + srv, err := aibridgedserver.NewServer( + connCtx, + api.Database, + logger, + api.AccessURL.String(), + api.DeploymentValues.AI.BridgeConfig, + api.ExternalAuthConfigs, + api.AGPL.Experiments, + api.AGPL.AISeatTracker, + ) + if err != nil { + if !xerrors.Is(err, context.Canceled) { + logger.Error(connCtx, "server creation failed", slog.Error(err)) + } + _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("create ai gateway server: %s", err)) + return + } + if err := aibridgedserver.Register(mux, srv); err != nil { + _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("register ai gateway services: %s", err)) + return + } + + server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + drpcserver.Options{ + Manager: drpcsdk.DefaultDRPCOptions(nil), + Log: func(err error) { + if xerrors.Is(err, io.EOF) { + return + } + logger.Debug(connCtx, "drpc server error", slog.Error(err)) + }, + }, + ) + + // Log the request immediately instead of after it completes. + if rl := loggermw.RequestLoggerFromContext(connCtx); rl != nil { + rl.WriteLog(connCtx, http.StatusAccepted) + } + + logger.Info(connCtx, "opened connection") + err = server.Serve(connCtx, session) + logger.Info(connCtx, "closed connection", slog.Error(err)) + if err != nil && !xerrors.Is(err, io.EOF) { + _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("serve: %s", err)) + return + } + _ = conn.Close(websocket.StatusGoingAway, "") +} + +// aiGatewayUpdateKeyLastHeartbeat records liveness for keyID and returns whether +// the key is still active. On error key is assumed to not be active. +func aiGatewayUpdateKeyLastHeartbeat(ctx context.Context, api *API, keyID uuid.UUID) (bool, error) { + // nolint:gocritic // Recording AI Gateway key liveness is an internal system write. + rows, err := api.Database.UpdateAIGatewayKeyLastHeartbeatAt(dbauthz.AsSystemRestricted(ctx), keyID) + if err != nil { + return false, err + } + return rows > 0, nil +} + +// aiGatewayTrackKeyUsage refreshes last_heartbeat_at for keyID on a fixed interval until ctx is canceled. +func aiGatewayTrackKeyUsage(ctx context.Context, ctxCancel context.CancelFunc, api *API, keyID uuid.UUID, logger slog.Logger) { + ticker, done := api.NewTicker(aiGatewayKeyHeartbeatInterval) + defer done() + + consecutiveFailures := 0 + for { + select { + case <-ctx.Done(): + return + case <-ticker: + } + + active, err := aiGatewayUpdateKeyLastHeartbeat(ctx, api, keyID) + if err == nil && !active { + logger.Info(ctx, "ai gateway key no longer exists, closing connection") + ctxCancel() + return + } + + if err != nil { + if xerrors.Is(err, context.Canceled) { + return + } + consecutiveFailures++ + // Log failures with exponential backoff (1, 2, 4, 8...). + // First failure logged at Debug, next failures escalate to Warn. + if consecutiveFailures&(consecutiveFailures-1) == 0 { + if consecutiveFailures == 1 { + logger.Debug(ctx, "update ai gateway key last heartbeat", slog.Error(err), slog.F("consecutive_failures", consecutiveFailures)) + } else { + logger.Warn(ctx, "update ai gateway key last heartbeat", slog.Error(err), slog.F("consecutive_failures", consecutiveFailures)) + } + } + continue + } + if consecutiveFailures > 1 { + logger.Info(ctx, "ai gateway key last heartbeat update recovered", + slog.F("consecutive_failures", consecutiveFailures)) + } + consecutiveFailures = 0 + } +} diff --git a/enterprise/coderd/aibridgeserve_test.go b/enterprise/coderd/aibridgeserve_test.go new file mode 100644 index 0000000000..76f3ffe178 --- /dev/null +++ b/enterprise/coderd/aibridgeserve_test.go @@ -0,0 +1,223 @@ +package coderd_test + +import ( + "context" + "io" + "net/http" + "testing" + "time" + + "github.com/hashicorp/yamux" + "github.com/stretchr/testify/require" + + aibridgedproto "github.com/coder/coder/v2/coderd/aibridged/proto" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/drpcsdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" + "github.com/coder/websocket" +) + +// dialAIGatewayServe dials /api/v2/ai-gateway/serve, authenticating with the given +// gateway key and API version. On a successful WebSocket upgrade it returns a +// yamux session and http.StatusSwitchingProtocols. Otherwise it returns a nil +// session and the HTTP status code coderd responded with. +func dialAIGatewayServe(ctx context.Context, t *testing.T, client *codersdk.Client, key string, version string) (*yamux.Session, int) { + t.Helper() + + serverURL, err := client.URL.Parse("/api/v2/ai-gateway/serve") + require.NoError(t, err) + query := serverURL.Query() + if version != "" { + query.Set("version", version) + } + serverURL.RawQuery = query.Encode() + + headers := http.Header{} + if key != "" { + headers.Set(codersdk.AIGatewayKeyHeader, key) + } + + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ + HTTPClient: &http.Client{Transport: client.HTTPClient.Transport}, + CompressionMode: websocket.CompressionDisabled, + HTTPHeader: headers, + }) + if err != nil { + statusCode := 0 + if res != nil { + statusCode = res.StatusCode + _ = res.Body.Close() + } + return nil, statusCode + } + cfg := yamux.DefaultConfig() + cfg.LogOutput = io.Discard + _, wsNetConn := codersdk.WebsocketNetConn(context.Background(), conn, websocket.MessageBinary) + conn.SetReadLimit(drpcsdk.YamuxDefaultStreamWindowSize) + session, err := yamux.Client(wsNetConn, cfg) + require.NoError(t, err) + t.Cleanup(func() { + _ = session.Close() + _ = wsNetConn.Close() + _ = conn.Close(websocket.StatusNormalClosure, "") + }) + return session, http.StatusSwitchingProtocols +} + +func TestAIGatewayServeSuccess(t *testing.T) { + t.Parallel() + + client, firstUser := coderdenttest.New(t, aibridgeOpts(t)) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is needed for gateway key management. + created, err := client.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: "serve-success"}) + require.NoError(t, err) + + session, status := dialAIGatewayServe(ctx, t, client, created.Key, aibridgedproto.CurrentVersion.String()) + require.Equal(t, http.StatusSwitchingProtocols, status) + require.NotNil(t, session) + + // The Authorizer service should be served and authorize the owner's + // session token, exercising a full DRPC round trip over the WebSocket. + authorizer := aibridgedproto.NewDRPCAuthorizerClient(drpcsdk.MultiplexedConn(session)) + resp, err := authorizer.IsAuthorized(ctx, &aibridgedproto.IsAuthorizedRequest{ + Key: client.SessionToken(), + }) + require.NoError(t, err) + require.Equal(t, firstUser.UserID.String(), resp.GetOwnerId()) + + // The session records liveness for the authenticating key. + require.Eventually(t, func() bool { + //nolint:gocritic // Owner role is needed for gateway key management. + keys, err := client.ListAIGatewayKeys(ctx) + if err != nil { + return false + } + for _, k := range keys { + if k.ID == created.ID { + return k.LastHeartbeatAt != nil + } + } + return false + }, testutil.WaitMedium, testutil.IntervalFast) +} + +func TestAIGatewayServeKeyAndVersionValidationErr(t *testing.T) { + t.Parallel() + + client, _ := coderdenttest.New(t, aibridgeOpts(t)) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is needed for gateway key management. + created, err := client.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: "serve-quick-failures"}) + require.NoError(t, err) + validKey := created.Key + + //nolint:gocritic // Owner role is needed for gateway key management. + revoked, err := client.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: "serve-revoked"}) + require.NoError(t, err) + require.NoError(t, client.DeleteAIGatewayKey(ctx, revoked.ID)) + + tests := []struct { + name string + key string + version string + wantStatus int + }{ + { + name: "MissingKey", + key: "", + version: aibridgedproto.CurrentVersion.String(), + wantStatus: http.StatusUnauthorized, + }, + { + name: "InvalidKey", + key: "not-a-real-key", + version: aibridgedproto.CurrentVersion.String(), + wantStatus: http.StatusUnauthorized, + }, + { + name: "RevokedKey", + key: revoked.Key, + version: aibridgedproto.CurrentVersion.String(), + wantStatus: http.StatusUnauthorized, + }, + { + name: "IncompatibleVersion", + key: validKey, + version: "999.0", + wantStatus: http.StatusBadRequest, + }, + { + name: "MissingVersion", + key: validKey, + version: "", + wantStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, status := dialAIGatewayServe(t.Context(), t, client, tc.key, tc.version) + require.Equal(t, tc.wantStatus, status) + }) + } +} + +func TestAIGatewayServeMissingEntitlement(t *testing.T) { + t.Parallel() + + // Enable the bridge config but do not grant the FeatureAIBridge license. + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{DeploymentValues: dv}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{}, + }, + }) + ctx := testutil.Context(t, testutil.WaitLong) + + _, status := dialAIGatewayServe(ctx, t, client, "any-key", aibridgedproto.CurrentVersion.String()) + require.Equal(t, http.StatusForbidden, status) +} + +func TestAIGatewayServeDeletedKeyClosesActiveSession(t *testing.T) { + t.Parallel() + + tick := make(chan time.Time, 1) + opts := aibridgeOpts(t) + opts.Options.NewTicker = func(time.Duration) (<-chan time.Time, func()) { + return tick, func() {} + } + + client, _ := coderdenttest.New(t, opts) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is needed for gateway key management. + created, err := client.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: "serve-delete-active"}) + require.NoError(t, err) + + session, status := dialAIGatewayServe(ctx, t, client, created.Key, aibridgedproto.CurrentVersion.String()) + require.Equal(t, http.StatusSwitchingProtocols, status) + require.NotNil(t, session) + + //nolint:gocritic // Owner role is needed for gateway key management. + require.NoError(t, client.DeleteAIGatewayKey(ctx, created.ID)) + + tick <- time.Now() // trigger aiGatewayTrackKeyUsage. + require.Eventually(t, func() bool { + select { + case <-session.CloseChan(): + return true + default: + return false + } + }, testutil.WaitShort, testutil.IntervalFast) +} diff --git a/enterprise/coderd/aigatewaykeys.go b/enterprise/coderd/aigatewaykeys.go index 227d0930d1..30815f9bdc 100644 --- a/enterprise/coderd/aigatewaykeys.go +++ b/enterprise/coderd/aigatewaykeys.go @@ -186,27 +186,27 @@ func (api *API) deleteAIGatewayKey(rw http.ResponseWriter, r *http.Request) { } aReq.Old = database.AIGatewayKey{ - ID: deleted.ID, - Name: deleted.Name, - SecretPrefix: deleted.SecretPrefix, - CreatedAt: deleted.CreatedAt, - LastUsedAt: deleted.LastUsedAt, + ID: deleted.ID, + Name: deleted.Name, + SecretPrefix: deleted.SecretPrefix, + CreatedAt: deleted.CreatedAt, + LastHeartbeatAt: deleted.LastHeartbeatAt, } rw.WriteHeader(http.StatusNoContent) } func convertAIGatewayKey(row database.ListAIGatewayKeysRow) codersdk.AIGatewayKey { - var lastUsed *time.Time - if row.LastUsedAt.Valid { - t := row.LastUsedAt.Time - lastUsed = &t + var lastHeartbeat *time.Time + if row.LastHeartbeatAt.Valid { + t := row.LastHeartbeatAt.Time + lastHeartbeat = &t } return codersdk.AIGatewayKey{ - ID: row.ID, - Name: row.Name, - KeyPrefix: row.SecretPrefix, - CreatedAt: row.CreatedAt, - LastUsedAt: lastUsed, + ID: row.ID, + Name: row.Name, + KeyPrefix: row.SecretPrefix, + CreatedAt: row.CreatedAt, + LastHeartbeatAt: lastHeartbeat, } } diff --git a/enterprise/coderd/aigatewaykeys_test.go b/enterprise/coderd/aigatewaykeys_test.go index cc11ed271a..e2fc326f76 100644 --- a/enterprise/coderd/aigatewaykeys_test.go +++ b/enterprise/coderd/aigatewaykeys_test.go @@ -59,7 +59,7 @@ func TestAIGatewayKeys(t *testing.T) { require.Equal(t, created.ID, keys[0].ID) require.Equal(t, created.Name, keys[0].Name) require.Equal(t, created.KeyPrefix, keys[0].KeyPrefix) - require.Nil(t, keys[0].LastUsedAt) + require.Nil(t, keys[0].LastHeartbeatAt) require.NoError(t, ownerClient.DeleteAIGatewayKey(ctx, created.ID)) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 1c68c556b2..9f3860f3ca 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -322,6 +322,17 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { }) }) + // /ai-gateway/serve provides the DRPC-over-WebSocket that standalone AI Gateway + // replicas connect to. It authenticates with a gateway key instead of a user session. + api.AGPL.APIHandler.Group(func(r chi.Router) { + r.Route("/ai-gateway/serve", func(r chi.Router) { + r.Use( + api.RequireFeatureMW(codersdk.FeatureAIBridge), + ) + r.Get("/", api.aiGatewayServe) + }) + }) + api.AGPL.APIHandler.Group(func(r chi.Router) { r.Get("/entitlements", api.serveEntitlements) // /regions overrides the AGPL /regions endpoint @@ -678,7 +689,8 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { } // We always want to run the replica manager even if we don't have DERP - // enabled, since it's used to detect other coder servers for licensing. + // enabled, since it's used to detect other coder servers for licensing, + // and NATS clustering for HA pubsub. api.replicaManager, err = replicasync.New(ctx, options.Logger, options.Database, options.ReplicaSyncPubsub, &replicasync.Options{ ID: api.AGPL.ID, RelayAddress: options.DERPServerRelayAddress, @@ -686,6 +698,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { RegionID: int32(options.DERPServerRegionID), TLSConfig: meshTLSConfig, UpdateInterval: options.ReplicaSyncUpdateInterval, + ClusterHost: options.ClusterHost, }) if err != nil { return nil, xerrors.Errorf("initialize replica: %w", err) @@ -788,6 +801,7 @@ type Options struct { // Used for high availability. ReplicaSyncUpdateInterval time.Duration ReplicaErrorGracePeriod time.Duration + ClusterHost string // IP or hostname to reach this specific replica DERPServerRelayAddress string DERPServerRegionID int diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index d0956057fc..32292b211a 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -631,7 +631,7 @@ func TestMultiReplica_NATSPubsubPeers(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) db, pgPubsub := dbtestutil.NewDB(t) clusterToken := "shared-token" @@ -671,13 +671,19 @@ func TestMultiReplica_NATSPubsubPeers(t *testing.T) { t.Cleanup(func() { _ = natsB.Close() }) mgr, err := replicasync.New(ctx, logger.Named("replica-b"), db, pgPubsub, &replicasync.Options{ - ID: uuid.New(), - RelayAddress: fmt.Sprintf("nats://127.0.0.1:%d", natsB.Server.ClusterAddr().Port), + ID: uuid.New(), + // port doesn't matter because we don't have an API up, but replicasync will refuse peers that don't set + // RelayAddress at all. + RelayAddress: "https://127.0.0.1", + ClusterHost: "127.0.0.1", RegionID: 12345, UpdateInterval: testutil.IntervalFast, }) require.NoError(t, err) t.Cleanup(func() { _ = mgr.Close() }) + require.NotNil(t, natsB.Server.ClusterAddr()) + // nolint: gosec // nats listens on TCP ports + mgr.SetSelfNATSPort(int32(natsB.Server.ClusterAddr().Port)) subject := "nats.replica" messages := make(chan []byte, 1) diff --git a/enterprise/coderd/prebuilds/reconcile_test.go b/enterprise/coderd/prebuilds/reconcile_test.go index 1fb67fd2d4..40fdae42aa 100644 --- a/enterprise/coderd/prebuilds/reconcile_test.go +++ b/enterprise/coderd/prebuilds/reconcile_test.go @@ -1974,9 +1974,7 @@ func TestReconciliationLock(t *testing.T) { wg := sync.WaitGroup{} mutex := sync.Mutex{} for i := 0; i < 5; i++ { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) reconciler := prebuilds.NewStoreReconciler( db, @@ -2002,7 +2000,7 @@ func TestReconciliationLock(t *testing.T) { defer mutex.Unlock() return nil }) - }() + }) } wg.Wait() } diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 17a00d2242..c2ba568b96 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -313,15 +313,13 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) }) return } - // Align with the frame size of yamux. - conn.SetReadLimit(256 * 1024) - // Multiplexes the incoming connection using yamux. // This allows multiple function calls to occur over // the same connection. config := yamux.DefaultConfig() config.LogOutput = io.Discard ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary) + conn.SetReadLimit(drpcsdk.YamuxDefaultStreamWindowSize) defer wsNetConn.Close() session, err := yamux.Server(wsNetConn, config) if err != nil { diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go index 530aa89e09..c70d02e9cf 100644 --- a/enterprise/coderd/workspaceproxy.go +++ b/enterprise/coderd/workspaceproxy.go @@ -667,8 +667,8 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request) Error: req.ReplicaError, DatabaseLatency: 0, Primary: false, - ClusterHost: "", // TODO - NATSPort: 0, // TODO + ClusterHost: "", + NATSPort: 0, // proxies do not run NATS }) if err != nil { return xerrors.Errorf("update replica: %w", err) @@ -686,8 +686,8 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request) Version: req.Version, DatabaseLatency: 0, Primary: false, - ClusterHost: "", // TODO - NATSPort: 0, // TODO + ClusterHost: "", + NATSPort: 0, // proxies do not run NATS }) if err != nil { return xerrors.Errorf("insert replica: %w", err) @@ -830,8 +830,8 @@ func (api *API) workspaceProxyDeregister(rw http.ResponseWriter, r *http.Request Error: replica.Error, DatabaseLatency: replica.DatabaseLatency, Primary: replica.Primary, - ClusterHost: "", // TODO - NATSPort: 0, // TODO + ClusterHost: "", + NATSPort: 0, // proxies do not run NATS }) if err != nil { return xerrors.Errorf("update replica: %w", err) diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go index 241b832e71..b73563727c 100644 --- a/enterprise/coderd/workspacequota_test.go +++ b/enterprise/coderd/workspacequota_test.go @@ -152,13 +152,11 @@ func TestWorkspaceQuota(t *testing.T) { // Spin up three workspaces fine var wg sync.WaitGroup for i := 0; i < 4; i++ { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { workspace := coderdtest.CreateWorkspace(t, client, template.ID) build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) assert.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) - }() + }) } wg.Wait() verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4) diff --git a/enterprise/replicasync/replicasync.go b/enterprise/replicasync/replicasync.go index 7ea6dd5818..f1acececfe 100644 --- a/enterprise/replicasync/replicasync.go +++ b/enterprise/replicasync/replicasync.go @@ -36,6 +36,7 @@ type Options struct { RelayAddress string RegionID int32 TLSConfig *tls.Config + ClusterHost string } // New registers the replica with the database and periodically updates to @@ -77,8 +78,8 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, ps pubsub.P // #nosec G115 - Safe conversion for microseconds latency which is expected to be within int32 range DatabaseLatency: int32(databaseLatency.Microseconds()), Primary: true, - ClusterHost: "", // TODO - NATSPort: 0, // TODO + ClusterHost: options.ClusterHost, + NATSPort: 0, // set later via SetSelfNATSPort }) if err != nil { return nil, xerrors.Errorf("insert replica: %w", err) @@ -329,8 +330,8 @@ func (m *Manager) syncReplicas(ctx context.Context) error { // #nosec G115 - Safe conversion for microseconds latency which is expected to be within int32 range DatabaseLatency: int32(databaseLatency.Microseconds()), Primary: m.self.Primary, - ClusterHost: "", // TODO - NATSPort: 0, // TODO + ClusterHost: m.self.ClusterHost, + NATSPort: m.self.NATSPort, }) if err != nil { if !errors.Is(err, sql.ErrNoRows) { @@ -350,8 +351,8 @@ func (m *Manager) syncReplicas(ctx context.Context) error { // #nosec G115 - Safe conversion for microseconds latency which is expected to be within int32 range DatabaseLatency: int32(databaseLatency.Microseconds()), Primary: m.self.Primary, - ClusterHost: "", // TODO - NATSPort: 0, // TODO + ClusterHost: m.self.ClusterHost, + NATSPort: m.self.NATSPort, }) if err != nil { return xerrors.Errorf("update replica: %w", err) @@ -420,14 +421,27 @@ func (m *Manager) AllPrimary() []database.Replica { return replicas } -func (m *Manager) PrimaryPeerAddresses() []string { +func (m *Manager) FetchNATSPeers() []string { addresses := make([]string, 0, len(m.AllPrimary())) for _, replica := range m.AllPrimary() { - addresses = append(addresses, replica.RelayAddress) + if replica.ClusterHost == "" || replica.NATSPort == 0 { + continue + } + natsAddr := fmt.Sprintf("nats://%s:%d", replica.ClusterHost, replica.NATSPort) + addresses = append(addresses, natsAddr) } return addresses } +func (m *Manager) SetSelfNATSPort(port int32) { + m.mutex.Lock() + defer m.mutex.Unlock() + m.self.NATSPort = port + m.logger.Debug(context.Background(), "nats port updated", slog.F("port", port)) + // We're not really in a rush here, since it will take some time for our peers to dial and establish connections + // to us. So, we're not going to trigger a synchronous update. We'll just wait for the periodic update ticker. +} + // InRegion returns every replica in the given DERP region excluding itself. func (m *Manager) InRegion(regionID int32) []database.Replica { m.mutex.Lock() @@ -503,8 +517,8 @@ func (m *Manager) Close() error { Error: m.self.Error, DatabaseLatency: 0, // A stopped replica has no latency. Primary: false, // A stopped replica cannot be primary. - ClusterHost: "", // TODO - NATSPort: 0, // TODO + ClusterHost: m.self.ClusterHost, + NATSPort: 0, // A stopped replica cannot cluster with NATS }) if err != nil { return xerrors.Errorf("update replica: %w", err) diff --git a/enterprise/replicasync/replicasync_test.go b/enterprise/replicasync/replicasync_test.go index dfbd2fa2b1..6b2fc517d2 100644 --- a/enterprise/replicasync/replicasync_test.go +++ b/enterprise/replicasync/replicasync_test.go @@ -279,17 +279,19 @@ func TestReplica(t *testing.T) { require.NoError(t, server.UpdateNow(ctx)) requireNoCallback(t, called) }) - t.Run("PrimaryPeerAddresses", func(t *testing.T) { + t.Run("FetchNATSPeers", func(t *testing.T) { t.Parallel() db, pubsub := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) - primary, err := db.InsertReplica(ctx, database.InsertReplicaParams{ + _, err := db.InsertReplica(ctx, database.InsertReplicaParams{ ID: uuid.New(), CreatedAt: dbtime.Now(), StartedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), - RelayAddress: "nats://primary.example:6222", + RelayAddress: "https://primary-relay.example", Primary: true, + ClusterHost: "primary.example", + NATSPort: 6222, }) require.NoError(t, err) _, err = db.InsertReplica(ctx, database.InsertReplicaParams{ @@ -297,7 +299,7 @@ func TestReplica(t *testing.T) { CreatedAt: dbtime.Now(), StartedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), - RelayAddress: "nats://proxy.example:6222", + RelayAddress: "https://proxy-relay.example", Primary: false, }) require.NoError(t, err) @@ -310,15 +312,24 @@ func TestReplica(t *testing.T) { }) require.NoError(t, err) server, err := replicasync.New(ctx, testutil.Logger(t), db, pubsub, &replicasync.Options{ - RelayAddress: "nats://self.example:6222", + RelayAddress: "https://self-relay.example", + ClusterHost: "self.example", + UpdateInterval: time.Hour, // we'll explicitly trigger this }) require.NoError(t, err) defer server.Close() - require.Contains(t, server.PrimaryPeerAddresses(), primary.RelayAddress) require.ElementsMatch(t, []string{ "nats://primary.example:6222", - "nats://self.example:6222", - }, server.PrimaryPeerAddresses()) + }, server.FetchNATSPeers()) + + server.SetSelfNATSPort(6223) + err = server.UpdateNow(ctx) + require.NoError(t, err) + + require.ElementsMatch(t, []string{ + "nats://primary.example:6222", + "nats://self.example:6223", + }, server.FetchNATSPeers()) }) t.Run("TwentyConcurrent", func(t *testing.T) { // Ensures that twenty concurrent replicas can spawn and all diff --git a/enterprise/wsproxy/wsproxy_test.go b/enterprise/wsproxy/wsproxy_test.go index 8115e4ae15..ec6ecc01d3 100644 --- a/enterprise/wsproxy/wsproxy_test.go +++ b/enterprise/wsproxy/wsproxy_test.go @@ -1201,12 +1201,10 @@ func createProxyReplicas(ctx context.Context, t *testing.T, opts *createProxyRep ok = false // Retry registration on this proxy. - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { err := proxy.RegisterNow(ctx) t.Logf("replica %d re-registered: err=%v", i, err) - }() + }) } } wg.Wait() diff --git a/helm/coder/templates/_coder.tpl b/helm/coder/templates/_coder.tpl index f344239f19..32d899c5a0 100644 --- a/helm/coder/templates/_coder.tpl +++ b/helm/coder/templates/_coder.tpl @@ -94,6 +94,10 @@ env: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: "http://$(KUBE_POD_IP):8080" +- name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP {{- include "coder.tlsEnv" . }} {{- with .Values.coder.env }} {{ toYaml . }} diff --git a/helm/coder/tests/testdata/auto_access_url_1.golden b/helm/coder/tests/testdata/auto_access_url_1.golden index a6a064e535..1574869803 100644 --- a/helm/coder/tests/testdata/auto_access_url_1.golden +++ b/helm/coder/tests/testdata/auto_access_url_1.golden @@ -162,6 +162,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: SOME_ENV value: some value - name: CODER_ACCESS_URL diff --git a/helm/coder/tests/testdata/auto_access_url_1_coder.golden b/helm/coder/tests/testdata/auto_access_url_1_coder.golden index be09066fb1..745cb0087e 100644 --- a/helm/coder/tests/testdata/auto_access_url_1_coder.golden +++ b/helm/coder/tests/testdata/auto_access_url_1_coder.golden @@ -162,6 +162,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: SOME_ENV value: some value - name: CODER_ACCESS_URL diff --git a/helm/coder/tests/testdata/auto_access_url_2.golden b/helm/coder/tests/testdata/auto_access_url_2.golden index ae96db6fce..d31665bbe2 100644 --- a/helm/coder/tests/testdata/auto_access_url_2.golden +++ b/helm/coder/tests/testdata/auto_access_url_2.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: SOME_ENV value: some value image: ghcr.io/coder/coder:latest diff --git a/helm/coder/tests/testdata/auto_access_url_2_coder.golden b/helm/coder/tests/testdata/auto_access_url_2_coder.golden index c9da24feeb..60c870e22e 100644 --- a/helm/coder/tests/testdata/auto_access_url_2_coder.golden +++ b/helm/coder/tests/testdata/auto_access_url_2_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: SOME_ENV value: some value image: ghcr.io/coder/coder:latest diff --git a/helm/coder/tests/testdata/auto_access_url_3.golden b/helm/coder/tests/testdata/auto_access_url_3.golden index a0fc740b18..9f86f92bc8 100644 --- a/helm/coder/tests/testdata/auto_access_url_3.golden +++ b/helm/coder/tests/testdata/auto_access_url_3.golden @@ -162,6 +162,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: SOME_ENV value: some value image: ghcr.io/coder/coder:latest diff --git a/helm/coder/tests/testdata/auto_access_url_3_coder.golden b/helm/coder/tests/testdata/auto_access_url_3_coder.golden index 00f8bb0029..7c15331829 100644 --- a/helm/coder/tests/testdata/auto_access_url_3_coder.golden +++ b/helm/coder/tests/testdata/auto_access_url_3_coder.golden @@ -162,6 +162,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: SOME_ENV value: some value image: ghcr.io/coder/coder:latest diff --git a/helm/coder/tests/testdata/command.golden b/helm/coder/tests/testdata/command.golden index f6e9eb63c8..d89ad775a7 100644 --- a/helm/coder/tests/testdata/command.golden +++ b/helm/coder/tests/testdata/command.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/command_args.golden b/helm/coder/tests/testdata/command_args.golden index e42faf81b1..92d69f1aa2 100644 --- a/helm/coder/tests/testdata/command_args.golden +++ b/helm/coder/tests/testdata/command_args.golden @@ -165,6 +165,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/command_args_coder.golden b/helm/coder/tests/testdata/command_args_coder.golden index e1763bad38..cc512aa864 100644 --- a/helm/coder/tests/testdata/command_args_coder.golden +++ b/helm/coder/tests/testdata/command_args_coder.golden @@ -165,6 +165,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/command_coder.golden b/helm/coder/tests/testdata/command_coder.golden index 23fc7b94c5..1335be61c5 100644 --- a/helm/coder/tests/testdata/command_coder.golden +++ b/helm/coder/tests/testdata/command_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/custom_resources.golden b/helm/coder/tests/testdata/custom_resources.golden index 97b5410a8f..b06d97c694 100644 --- a/helm/coder/tests/testdata/custom_resources.golden +++ b/helm/coder/tests/testdata/custom_resources.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/custom_resources_coder.golden b/helm/coder/tests/testdata/custom_resources_coder.golden index eab1973a47..cb7f781d87 100644 --- a/helm/coder/tests/testdata/custom_resources_coder.golden +++ b/helm/coder/tests/testdata/custom_resources_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/default_values.golden b/helm/coder/tests/testdata/default_values.golden index 8c8576c659..6b729d33d6 100644 --- a/helm/coder/tests/testdata/default_values.golden +++ b/helm/coder/tests/testdata/default_values.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/default_values_coder.golden b/helm/coder/tests/testdata/default_values_coder.golden index 130172a653..af57c53ca5 100644 --- a/helm/coder/tests/testdata/default_values_coder.golden +++ b/helm/coder/tests/testdata/default_values_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/env_from.golden b/helm/coder/tests/testdata/env_from.golden index ba03d2ad1a..130153bfdd 100644 --- a/helm/coder/tests/testdata/env_from.golden +++ b/helm/coder/tests/testdata/env_from.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: COOL_ENV valueFrom: configMapKeyRef: diff --git a/helm/coder/tests/testdata/env_from_coder.golden b/helm/coder/tests/testdata/env_from_coder.golden index 43c3c3b41f..b54746b093 100644 --- a/helm/coder/tests/testdata/env_from_coder.golden +++ b/helm/coder/tests/testdata/env_from_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: COOL_ENV valueFrom: configMapKeyRef: diff --git a/helm/coder/tests/testdata/extra_templates.golden b/helm/coder/tests/testdata/extra_templates.golden index 35ede023c6..b95e7f476f 100644 --- a/helm/coder/tests/testdata/extra_templates.golden +++ b/helm/coder/tests/testdata/extra_templates.golden @@ -173,6 +173,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/extra_templates_coder.golden b/helm/coder/tests/testdata/extra_templates_coder.golden index 38eddb2aa2..89ac8b5a65 100644 --- a/helm/coder/tests/testdata/extra_templates_coder.golden +++ b/helm/coder/tests/testdata/extra_templates_coder.golden @@ -173,6 +173,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/host_aliases.golden b/helm/coder/tests/testdata/host_aliases.golden index 5aba404cd9..77bd4d024c 100644 --- a/helm/coder/tests/testdata/host_aliases.golden +++ b/helm/coder/tests/testdata/host_aliases.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/host_aliases_coder.golden b/helm/coder/tests/testdata/host_aliases_coder.golden index ebaa8f0fe4..c8b84af20f 100644 --- a/helm/coder/tests/testdata/host_aliases_coder.golden +++ b/helm/coder/tests/testdata/host_aliases_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/labels_annotations.golden b/helm/coder/tests/testdata/labels_annotations.golden index cd601d77e9..988f2c5c44 100644 --- a/helm/coder/tests/testdata/labels_annotations.golden +++ b/helm/coder/tests/testdata/labels_annotations.golden @@ -172,6 +172,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/labels_annotations_coder.golden b/helm/coder/tests/testdata/labels_annotations_coder.golden index 38190f0b30..d6abc7f8f2 100644 --- a/helm/coder/tests/testdata/labels_annotations_coder.golden +++ b/helm/coder/tests/testdata/labels_annotations_coder.golden @@ -172,6 +172,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/namespace_rbac.golden b/helm/coder/tests/testdata/namespace_rbac.golden index 0cbfce4d98..80f4b64f8a 100644 --- a/helm/coder/tests/testdata/namespace_rbac.golden +++ b/helm/coder/tests/testdata/namespace_rbac.golden @@ -354,6 +354,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/namespace_rbac_coder.golden b/helm/coder/tests/testdata/namespace_rbac_coder.golden index 56ce5c9e9d..9bee753a6c 100644 --- a/helm/coder/tests/testdata/namespace_rbac_coder.golden +++ b/helm/coder/tests/testdata/namespace_rbac_coder.golden @@ -354,6 +354,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/partial_resources.golden b/helm/coder/tests/testdata/partial_resources.golden index aa66c2e523..88e38a49a3 100644 --- a/helm/coder/tests/testdata/partial_resources.golden +++ b/helm/coder/tests/testdata/partial_resources.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/partial_resources_coder.golden b/helm/coder/tests/testdata/partial_resources_coder.golden index baae3bd305..92dff99bb9 100644 --- a/helm/coder/tests/testdata/partial_resources_coder.golden +++ b/helm/coder/tests/testdata/partial_resources_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/pod_securitycontext.golden b/helm/coder/tests/testdata/pod_securitycontext.golden index 56660bcb8a..36da47548b 100644 --- a/helm/coder/tests/testdata/pod_securitycontext.golden +++ b/helm/coder/tests/testdata/pod_securitycontext.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/pod_securitycontext_coder.golden b/helm/coder/tests/testdata/pod_securitycontext_coder.golden index 91ab6d32ae..f947e22ca8 100644 --- a/helm/coder/tests/testdata/pod_securitycontext_coder.golden +++ b/helm/coder/tests/testdata/pod_securitycontext_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/pprof_address_override.golden b/helm/coder/tests/testdata/pprof_address_override.golden index 42e9655dce..3d6a3c5938 100644 --- a/helm/coder/tests/testdata/pprof_address_override.golden +++ b/helm/coder/tests/testdata/pprof_address_override.golden @@ -162,6 +162,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: CODER_PPROF_ADDRESS value: 127.0.0.1:6060 - name: CODER_PPROF_ENABLE diff --git a/helm/coder/tests/testdata/pprof_address_override_coder.golden b/helm/coder/tests/testdata/pprof_address_override_coder.golden index c69afab593..d9d475edb9 100644 --- a/helm/coder/tests/testdata/pprof_address_override_coder.golden +++ b/helm/coder/tests/testdata/pprof_address_override_coder.golden @@ -162,6 +162,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: CODER_PPROF_ADDRESS value: 127.0.0.1:6060 - name: CODER_PPROF_ENABLE diff --git a/helm/coder/tests/testdata/priority_class_name.golden b/helm/coder/tests/testdata/priority_class_name.golden index 841cd8afee..c17333ad67 100644 --- a/helm/coder/tests/testdata/priority_class_name.golden +++ b/helm/coder/tests/testdata/priority_class_name.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/priority_class_name_coder.golden b/helm/coder/tests/testdata/priority_class_name_coder.golden index c1bf856d8f..31b3f1ac05 100644 --- a/helm/coder/tests/testdata/priority_class_name_coder.golden +++ b/helm/coder/tests/testdata/priority_class_name_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/probes_custom.golden b/helm/coder/tests/testdata/probes_custom.golden index 559ee18357..0d79e99628 100644 --- a/helm/coder/tests/testdata/probes_custom.golden +++ b/helm/coder/tests/testdata/probes_custom.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/probes_custom_coder.golden b/helm/coder/tests/testdata/probes_custom_coder.golden index 3c60278d8d..1ad922aa7f 100644 --- a/helm/coder/tests/testdata/probes_custom_coder.golden +++ b/helm/coder/tests/testdata/probes_custom_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/probes_disabled.golden b/helm/coder/tests/testdata/probes_disabled.golden index a6cc68568c..6de6dc54ba 100644 --- a/helm/coder/tests/testdata/probes_disabled.golden +++ b/helm/coder/tests/testdata/probes_disabled.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/probes_disabled_coder.golden b/helm/coder/tests/testdata/probes_disabled_coder.golden index 714c166e86..5b917e63f1 100644 --- a/helm/coder/tests/testdata/probes_disabled_coder.golden +++ b/helm/coder/tests/testdata/probes_disabled_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/prometheus.golden b/helm/coder/tests/testdata/prometheus.golden index 1bf94c5a10..74eace57a2 100644 --- a/helm/coder/tests/testdata/prometheus.golden +++ b/helm/coder/tests/testdata/prometheus.golden @@ -163,6 +163,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: CODER_PROMETHEUS_ENABLE value: "true" image: ghcr.io/coder/coder:latest diff --git a/helm/coder/tests/testdata/prometheus_address_override.golden b/helm/coder/tests/testdata/prometheus_address_override.golden index 30d65a6c81..8090fa7cac 100644 --- a/helm/coder/tests/testdata/prometheus_address_override.golden +++ b/helm/coder/tests/testdata/prometheus_address_override.golden @@ -162,6 +162,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: CODER_PROMETHEUS_ADDRESS value: 127.0.0.1:2112 - name: CODER_PROMETHEUS_ENABLE diff --git a/helm/coder/tests/testdata/prometheus_address_override_coder.golden b/helm/coder/tests/testdata/prometheus_address_override_coder.golden index 0c258d0a35..b42631df11 100644 --- a/helm/coder/tests/testdata/prometheus_address_override_coder.golden +++ b/helm/coder/tests/testdata/prometheus_address_override_coder.golden @@ -162,6 +162,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: CODER_PROMETHEUS_ADDRESS value: 127.0.0.1:2112 - name: CODER_PROMETHEUS_ENABLE diff --git a/helm/coder/tests/testdata/prometheus_coder.golden b/helm/coder/tests/testdata/prometheus_coder.golden index 95f132f249..5ed1647842 100644 --- a/helm/coder/tests/testdata/prometheus_coder.golden +++ b/helm/coder/tests/testdata/prometheus_coder.golden @@ -163,6 +163,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: CODER_PROMETHEUS_ENABLE value: "true" image: ghcr.io/coder/coder:latest diff --git a/helm/coder/tests/testdata/provisionerd_psk.golden b/helm/coder/tests/testdata/provisionerd_psk.golden index 27b66ad255..5ab5d8a075 100644 --- a/helm/coder/tests/testdata/provisionerd_psk.golden +++ b/helm/coder/tests/testdata/provisionerd_psk.golden @@ -169,6 +169,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/provisionerd_psk_coder.golden b/helm/coder/tests/testdata/provisionerd_psk_coder.golden index c6e1d4ded3..b6a35af757 100644 --- a/helm/coder/tests/testdata/provisionerd_psk_coder.golden +++ b/helm/coder/tests/testdata/provisionerd_psk_coder.golden @@ -169,6 +169,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/sa.golden b/helm/coder/tests/testdata/sa.golden index f81b0cc59a..9f0032dd7b 100644 --- a/helm/coder/tests/testdata/sa.golden +++ b/helm/coder/tests/testdata/sa.golden @@ -166,6 +166,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/sa_coder.golden b/helm/coder/tests/testdata/sa_coder.golden index 5cc6d2bf3f..a7922b0c7f 100644 --- a/helm/coder/tests/testdata/sa_coder.golden +++ b/helm/coder/tests/testdata/sa_coder.golden @@ -166,6 +166,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/sa_disabled.golden b/helm/coder/tests/testdata/sa_disabled.golden index 74a805f277..aacf2ea394 100644 --- a/helm/coder/tests/testdata/sa_disabled.golden +++ b/helm/coder/tests/testdata/sa_disabled.golden @@ -150,6 +150,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/sa_disabled_coder.golden b/helm/coder/tests/testdata/sa_disabled_coder.golden index 3c346af36a..6f84eac797 100644 --- a/helm/coder/tests/testdata/sa_disabled_coder.golden +++ b/helm/coder/tests/testdata/sa_disabled_coder.golden @@ -150,6 +150,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/sa_extra_rules.golden b/helm/coder/tests/testdata/sa_extra_rules.golden index f6fbfe8052..339c2d5170 100644 --- a/helm/coder/tests/testdata/sa_extra_rules.golden +++ b/helm/coder/tests/testdata/sa_extra_rules.golden @@ -177,6 +177,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/sa_extra_rules_coder.golden b/helm/coder/tests/testdata/sa_extra_rules_coder.golden index 559eabdfa9..8ccd61f997 100644 --- a/helm/coder/tests/testdata/sa_extra_rules_coder.golden +++ b/helm/coder/tests/testdata/sa_extra_rules_coder.golden @@ -177,6 +177,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/securitycontext.golden b/helm/coder/tests/testdata/securitycontext.golden index 7c2025da97..28426d277b 100644 --- a/helm/coder/tests/testdata/securitycontext.golden +++ b/helm/coder/tests/testdata/securitycontext.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/securitycontext_coder.golden b/helm/coder/tests/testdata/securitycontext_coder.golden index e204e30d74..366fcccbd1 100644 --- a/helm/coder/tests/testdata/securitycontext_coder.golden +++ b/helm/coder/tests/testdata/securitycontext_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/svc_loadbalancer.golden b/helm/coder/tests/testdata/svc_loadbalancer.golden index fb786e4e15..c4bc6d000d 100644 --- a/helm/coder/tests/testdata/svc_loadbalancer.golden +++ b/helm/coder/tests/testdata/svc_loadbalancer.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/svc_loadbalancer_class.golden b/helm/coder/tests/testdata/svc_loadbalancer_class.golden index bf2080defe..13daff1635 100644 --- a/helm/coder/tests/testdata/svc_loadbalancer_class.golden +++ b/helm/coder/tests/testdata/svc_loadbalancer_class.golden @@ -165,6 +165,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/svc_loadbalancer_class_coder.golden b/helm/coder/tests/testdata/svc_loadbalancer_class_coder.golden index eb20497c8b..380f119969 100644 --- a/helm/coder/tests/testdata/svc_loadbalancer_class_coder.golden +++ b/helm/coder/tests/testdata/svc_loadbalancer_class_coder.golden @@ -165,6 +165,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/svc_loadbalancer_coder.golden b/helm/coder/tests/testdata/svc_loadbalancer_coder.golden index 625f64e6ab..17e419b6c7 100644 --- a/helm/coder/tests/testdata/svc_loadbalancer_coder.golden +++ b/helm/coder/tests/testdata/svc_loadbalancer_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/svc_nodeport.golden b/helm/coder/tests/testdata/svc_nodeport.golden index 4fd5a6440c..e51b1d5d53 100644 --- a/helm/coder/tests/testdata/svc_nodeport.golden +++ b/helm/coder/tests/testdata/svc_nodeport.golden @@ -163,6 +163,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/svc_nodeport_coder.golden b/helm/coder/tests/testdata/svc_nodeport_coder.golden index 4b12a2f135..0c1738ccf2 100644 --- a/helm/coder/tests/testdata/svc_nodeport_coder.golden +++ b/helm/coder/tests/testdata/svc_nodeport_coder.golden @@ -163,6 +163,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/tls.golden b/helm/coder/tests/testdata/tls.golden index 68e9ee3be6..30d1b05d24 100644 --- a/helm/coder/tests/testdata/tls.golden +++ b/helm/coder/tests/testdata/tls.golden @@ -169,6 +169,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: CODER_TLS_ENABLE value: "true" - name: CODER_TLS_ADDRESS diff --git a/helm/coder/tests/testdata/tls_coder.golden b/helm/coder/tests/testdata/tls_coder.golden index 3363f80695..f50ad616a0 100644 --- a/helm/coder/tests/testdata/tls_coder.golden +++ b/helm/coder/tests/testdata/tls_coder.golden @@ -169,6 +169,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: CODER_TLS_ENABLE value: "true" - name: CODER_TLS_ADDRESS diff --git a/helm/coder/tests/testdata/topology.golden b/helm/coder/tests/testdata/topology.golden index 45f21d3828..803a11ed48 100644 --- a/helm/coder/tests/testdata/topology.golden +++ b/helm/coder/tests/testdata/topology.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/topology_coder.golden b/helm/coder/tests/testdata/topology_coder.golden index 4446d2b084..611d7ef904 100644 --- a/helm/coder/tests/testdata/topology_coder.golden +++ b/helm/coder/tests/testdata/topology_coder.golden @@ -164,6 +164,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP image: ghcr.io/coder/coder:latest imagePullPolicy: IfNotPresent lifecycle: {} diff --git a/helm/coder/tests/testdata/workspace_proxy.golden b/helm/coder/tests/testdata/workspace_proxy.golden index 2b5de38f75..103d1cd720 100644 --- a/helm/coder/tests/testdata/workspace_proxy.golden +++ b/helm/coder/tests/testdata/workspace_proxy.golden @@ -165,6 +165,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: CODER_PRIMARY_ACCESS_URL value: https://dev.coder.com - name: CODER_PROXY_SESSION_TOKEN diff --git a/helm/coder/tests/testdata/workspace_proxy_coder.golden b/helm/coder/tests/testdata/workspace_proxy_coder.golden index ba1a5ea0fe..fc4d6c6ec1 100644 --- a/helm/coder/tests/testdata/workspace_proxy_coder.golden +++ b/helm/coder/tests/testdata/workspace_proxy_coder.golden @@ -165,6 +165,10 @@ spec: fieldPath: status.podIP - name: CODER_DERP_SERVER_RELAY_URL value: http://$(KUBE_POD_IP):8080 + - name: CODER_CLUSTER_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP - name: CODER_PRIMARY_ACCESS_URL value: https://dev.coder.com - name: CODER_PROXY_SESSION_TOKEN diff --git a/helm/coder/values.yaml b/helm/coder/values.yaml index 10f5fb583f..366ec5dd38 100644 --- a/helm/coder/values.yaml +++ b/helm/coder/values.yaml @@ -12,6 +12,7 @@ coder: # - CODER_TLS_KEY_FILE: set if tls.secretName is not empty. # - KUBE_POD_IP # - CODER_DERP_SERVER_RELAY_URL + # - CODER_CLUSTER_HOST: set to the pod IP # # The following environment variables have defaults but CAN be overridden: # - CODER_PROMETHEUS_ADDRESS: defaults to 0.0.0.0:2112. Override to restrict diff --git a/provisioner/terraform/install_test.go b/provisioner/terraform/install_test.go index aedd3fe7b3..7f87969a68 100644 --- a/provisioner/terraform/install_test.go +++ b/provisioner/terraform/install_test.go @@ -140,13 +140,11 @@ func TestInstall(t *testing.T) { var wg sync.WaitGroup paths := make(chan string, 8) for i := 0; i < 8; i++ { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { p, err := terraform.Install(ctx, log, false, dir, version, "http://"+proxy.listener.Addr().String()) assert.NoError(t, err) paths <- p - }() + }) } go func() { wg.Wait() diff --git a/provisionerd/provisionerd_test.go b/provisionerd/provisionerd_test.go index c35e23608f..991712e720 100644 --- a/provisionerd/provisionerd_test.go +++ b/provisionerd/provisionerd_test.go @@ -47,6 +47,20 @@ func closedWithin(c chan struct{}, d time.Duration) func() bool { } } +// assertNoErrorOrCanceled asserts that a send or receive on the AcquireJobWithCancel +// stream succeeded, but tolerates context.Canceled. dRPC is racy and will +// sometimes return context.Canceled even after it has successfully sent the +// message, when the stream is canceled right away, e.g. a test that closes the +// daemon immediately after acquisition. Swallowing it here is safe: a job that +// was genuinely never delivered surfaces as a downstream failure when the test +// waits on its completion signal. Any other error fails the test. +func assertNoErrorOrCanceled(t *testing.T, err error) { + t.Helper() + if !xerrors.Is(err, context.Canceled) { + assert.NoError(t, err) + } +} + func TestProvisionerd(t *testing.T) { t.Parallel() @@ -110,7 +124,7 @@ func TestProvisionerd(t *testing.T) { }, }, }) - assert.NoError(t, err) + assertNoErrorOrCanceled(t, err) return nil }, updateJob: noopUpdateJob, @@ -256,7 +270,7 @@ func TestProvisionerd(t *testing.T) { }, }, }) - assert.NoError(t, err) + assertNoErrorOrCanceled(t, err) return nil }, updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { @@ -734,7 +748,7 @@ func TestProvisionerd(t *testing.T) { }, }, }) - assert.NoError(t, err) + assertNoErrorOrCanceled(t, err) return nil }, updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { @@ -817,7 +831,7 @@ func TestProvisionerd(t *testing.T) { }, }, }) - assert.NoError(t, err) + assertNoErrorOrCanceled(t, err) return nil }, updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { @@ -917,10 +931,10 @@ func TestProvisionerd(t *testing.T) { if second.Load() { job = &proto.AcquiredJob{} _, err := stream.Recv() - assert.NoError(t, err) + assertNoErrorOrCanceled(t, err) } err := stream.Send(job) - assert.NoError(t, err) + assertNoErrorOrCanceled(t, err) return nil }, updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { @@ -999,7 +1013,7 @@ func TestProvisionerd(t *testing.T) { if second.Load() { completeOnce.Do(func() { close(completeChan) }) _, err := stream.Recv() - assert.NoError(t, err) + assertNoErrorOrCanceled(t, err) return nil } job := &proto.AcquiredJob{ @@ -1015,7 +1029,7 @@ func TestProvisionerd(t *testing.T) { }, } err := stream.Send(job) - assert.NoError(t, err) + assertNoErrorOrCanceled(t, err) return nil }, failJob: func(ctx context.Context, job *proto.FailedJob) (*proto.Empty, error) { @@ -1095,9 +1109,9 @@ func TestProvisionerd(t *testing.T) { logger.Info(ctx, "provisioner stage: AcquiredJob") if len(ops) > 0 { _, err := stream.Recv() - assert.NoError(t, err) + assertNoErrorOrCanceled(t, err) err = stream.Send(&proto.AcquiredJob{}) - assert.NoError(t, err) + assertNoErrorOrCanceled(t, err) return nil } ops = append(ops, "AcquireJob") @@ -1114,7 +1128,7 @@ func TestProvisionerd(t *testing.T) { }, }, }) - assert.NoError(t, err) + assertNoErrorOrCanceled(t, err) return nil }, updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { @@ -1394,12 +1408,7 @@ func (a *acquireOne) acquireWithCancel(stream proto.DRPCProvisionerDaemon_Acquir return nil } err := stream.Send(a.job) - // dRPC is racy, and sometimes will return context.Canceled after it has successfully sent the message if we cancel - // right away, e.g. in unit tests that complete. So, just swallow the error in that case. If we are canceled before - // the job was acquired, presumably something else in the test will have failed. - if !xerrors.Is(err, context.Canceled) { - assert.NoError(a.t, err) - } + assertNoErrorOrCanceled(a.t, err) return nil } diff --git a/provisionersdk/agent_test.go b/provisionersdk/agent_test.go index 3101959fe0..01b06233c2 100644 --- a/provisionersdk/agent_test.go +++ b/provisionersdk/agent_test.go @@ -94,15 +94,12 @@ func TestAgentScript(t *testing.T) { done := make(chan error, 1) var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - + wg.Go(func() { // The bootstrap scripts trap exit codes to allow operators to view the script logs and debug the process // while it is still running. We do not expect Wait() to complete. err := cmd.Wait() done <- err - }() + }) select { case <-ctx.Done(): diff --git a/scaletest/bridge/config.go b/scaletest/bridge/config.go index 39f7d1171b..0f40992c89 100644 --- a/scaletest/bridge/config.go +++ b/scaletest/bridge/config.go @@ -20,7 +20,7 @@ const ( type Config struct { // Mode determines how requests are made. - // "bridge": Create users in Coder and use their session tokens to make requests through AI Bridge. + // "bridge": Create users in Coder and use their session tokens to make requests through AI Gateway. // "direct": Make requests directly to UpstreamURL without user creation. Mode RequestMode `json:"mode"` diff --git a/scaletest/bridge/strategy.go b/scaletest/bridge/strategy.go index 4c5015ea6c..75ed034d8d 100644 --- a/scaletest/bridge/strategy.go +++ b/scaletest/bridge/strategy.go @@ -18,7 +18,7 @@ type requestModeStrategy interface { Cleanup(ctx context.Context, id string, logs io.Writer) error } -// bridgeStrategy creates users via Coder and routes requests through AI Bridge. +// bridgeStrategy creates users via Coder and routes requests through AI Gateway. type bridgeStrategy struct { client *codersdk.Client provider string @@ -66,11 +66,11 @@ func (s *bridgeStrategy) Setup(ctx context.Context, id string, logs io.Writer) ( switch s.provider { case "messages": - requestURL = fmt.Sprintf("%s/api/v2/aibridge/anthropic/v1/messages", s.client.URL) + requestURL = fmt.Sprintf("%s/api/v2/ai-gateway/anthropic/v1/messages", s.client.URL) case "responses": - requestURL = fmt.Sprintf("%s/api/v2/aibridge/openai/v1/responses", s.client.URL) + requestURL = fmt.Sprintf("%s/api/v2/ai-gateway/openai/v1/responses", s.client.URL) case "completions": - requestURL = fmt.Sprintf("%s/api/v2/aibridge/openai/v1/chat/completions", s.client.URL) + requestURL = fmt.Sprintf("%s/api/v2/ai-gateway/openai/v1/chat/completions", s.client.URL) } logger.Info(ctx, "bridge runner in bridge mode", slog.F("url", requestURL), diff --git a/scripts/check_emdash.sh b/scripts/check_emdash.sh index 71d1728637..4433a6d6b9 100755 --- a/scripts/check_emdash.sh +++ b/scripts/check_emdash.sh @@ -39,87 +39,24 @@ scan_all_files() { fi } -# resolve_merge_base finds the merge-base between HEAD and the given ref. -# In shallow CI clones the merge-base is not directly reachable, so we -# query the PR commit count via `gh`, deepen HEAD by count+1, and -# resolve HEAD~N which is the parent of the first PR commit. -resolve_merge_base() { - local base_ref="$1" - - # Fast path: merge-base already reachable (full clone or sufficient depth). - local mb - mb=$(git merge-base HEAD "$base_ref" 2>/dev/null || true) - if [[ -n "$mb" ]]; then - echo "$mb" - return - fi - - if ! command -v gh >/dev/null 2>&1; then - echo "gh CLI not found, cannot determine PR commit count." >&2 - return - fi - - # Use the PR commit count to deepen HEAD past the PR commits. - # HEAD~N is the parent of the oldest PR commit, i.e. the merge-base. - local count - count=$(gh pr view --json commits --jq '.commits | length' 2>/dev/null || true) - if [[ -z "$count" || "$count" -le 0 ]]; then - echo "Could not determine PR commit count from gh." >&2 - return - fi - - echo "Deepening HEAD by $((count + 1)) to reach PR base..." >&2 - git fetch --deepen="$((count + 1))" 2>/dev/null || true - - # Retry merge-base now that we have more history. - mb=$(git merge-base HEAD "$base_ref" 2>/dev/null || true) - if [[ -n "$mb" ]]; then - echo "$mb" - return - fi - - # Last resort: walk first-parent history. This is correct for - # linear PRs but may traverse the wrong branch for merge-commit - # checkouts. - git rev-parse --verify "HEAD~${count}" 2>/dev/null || true -} - -# fetch_base_ref ensures origin/$GITHUB_BASE_REF is available locally. -# CI shallow clones (fetch-depth: 1) typically omit the base branch. -fetch_base_ref() { - local base_ref="$1" - - if git rev-parse --verify "$base_ref" >/dev/null 2>&1; then - return 0 - fi - - local ref="${base_ref#origin/}" - echo "Base ref $base_ref not found locally, fetching $ref..." >&2 - git fetch origin "$ref" --depth=1 2>/dev/null || true - - if ! git rev-parse --verify "$base_ref" >/dev/null 2>&1; then - echo "ERROR: could not fetch base ref $base_ref." >&2 - return 1 - fi -} - -# resolve_diff_base determines the base ref to diff against. +# resolve_diff_base determines the base commit to diff against. resolve_diff_base() { - # CI pull requests: use merge-base against the target branch. + # CI pull requests: actions/checkout checks out the PR merge commit + # (refs/pull//merge). Its first parent (HEAD^1) is the exact base + # commit GitHub merged against, so diffing HEAD^1 against the checkout + # yields every change the PR makes against its base branch. We rely on + # this commit rather than fetching the base branch by name: branch + # names are mutable and Graphite stacks target an ephemeral + # graphite-base/ ref that may not exist on origin. Requires the + # checkout to use fetch-depth >= 2 so HEAD^1 is present. if [[ -n "${GITHUB_BASE_REF:-}" ]]; then - local base_ref="origin/${GITHUB_BASE_REF}" - fetch_base_ref "$base_ref" || return 1 - - local base - base=$(resolve_merge_base "$base_ref") - if [[ -n "$base" ]]; then - echo "$base" - return + if ! git rev-parse --verify --quiet "HEAD^1" >/dev/null; then + echo "ERROR: the PR base commit (HEAD^1) is missing. Check out" >&2 + echo " the PR with fetch-depth >= 2 so the merge commit's" >&2 + echo " base parent is available." >&2 + return 1 fi - - # Could not determine merge-base; fall back to branch tip. - echo "WARNING: could not find merge-base with $base_ref, using branch tip (diff may include non-PR changes)." >&2 - echo "$base_ref" + git rev-parse "HEAD^1" return fi diff --git a/scripts/develop/main_test.go b/scripts/develop/main_test.go index 2491d52b4c..6d3df9728e 100644 --- a/scripts/develop/main_test.go +++ b/scripts/develop/main_test.go @@ -101,13 +101,11 @@ func TestLogWriter(t *testing.T) { var wg sync.WaitGroup for range 10 { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { for range 50 { _, _ = w.Write([]byte("x\n")) } - }() + }) } wg.Wait() diff --git a/server.json b/server.json new file mode 100644 index 0000000000..8dbe1a0ecc --- /dev/null +++ b/server.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.coder/coder", + "title": "Coder", + "description": "Manage Coder workspaces, templates, and cloud development environments", + "repository": { + "url": "https://github.com/coder/coder", + "source": "github" + }, + "version": "0.0.0-dev", + "websiteUrl": "https://coder.com/docs", + "icons": [ + { + "src": "https://raw.githubusercontent.com/coder/coder/main/docs/images/logo-black.png", + "mimeType": "image/png" + } + ], + "remotes": [ + { + "type": "streamable-http", + "url": "https://{coder_hostname}/api/experimental/mcp/http", + "variables": { + "coder_hostname": { + "description": "Hostname of your Coder deployment (e.g., coder.example.com)", + "isRequired": true, + "format": "string", + "placeholder": "coder.example.com" + } + } + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "documentation": "https://coder.com/docs/ai-coder/mcp-server", + "keywords": [ + "workspaces", + "cloud-development", + "templates", + "devcontainers", + "terraform", + "self-hosted" + ], + "license": "AGPL-3.0", + "publisher": "Coder" + } + } +} diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 15fd4a0f43..261abb90bd 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -12,6 +12,7 @@ export const RBACResourceActions: Partial< create: "create an AI Gateway key", delete: "delete an AI Gateway key", read: "read AI Gateway keys", + update: "update an AI Gateway key", }, ai_model_price: { read: "read AI model prices", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6ecb349f45..f7a2c67b74 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -263,9 +263,15 @@ export interface AIGatewayKey { readonly name: string; readonly key_prefix: string; readonly created_at: string; - readonly last_used_at?: string; + readonly last_heartbeat_at?: string; } +// From codersdk/client.go +/** + * AIGatewayKeyHeader contains the authentication key for a standalone AI Gateway replica. + */ +export const AIGatewayKeyHeader = "X-Coder-AI-Governance-Gateway-Key"; + // From codersdk/aiproviders.go /** * AIProvider represents an AI provider configuration row as returned @@ -491,6 +497,7 @@ export type APIKeyScope = | "ai_gateway_key:create" | "ai_gateway_key:delete" | "ai_gateway_key:read" + | "ai_gateway_key:update" | "ai_model_price:*" | "ai_model_price:read" | "ai_model_price:update" @@ -725,6 +732,7 @@ export const APIKeyScopes: APIKeyScope[] = [ "ai_gateway_key:create", "ai_gateway_key:delete", "ai_gateway_key:read", + "ai_gateway_key:update", "ai_model_price:*", "ai_model_price:read", "ai_model_price:update", @@ -3267,6 +3275,11 @@ export interface ChatWorkspaceTTLResponse { readonly workspace_ttl_ms: number; } +// From codersdk/deployment.go +export interface ClusterConfig { + readonly host: string; +} + // From codersdk/client.go /** * CoderDesktopTelemetryHeader contains a JSON-encoded representation of Desktop telemetry @@ -4282,6 +4295,7 @@ export interface DeploymentValues { readonly http_address?: string; readonly autobuild_poll_interval?: number; readonly job_hang_detector_interval?: number; + readonly cluster?: ClusterConfig; readonly derp?: DERP; readonly prometheus?: PrometheusConfig; readonly pprof?: PprofConfig; @@ -6256,6 +6270,14 @@ export interface OIDCConfig { */ readonly redirect_url: string; readonly auto_repair_links: boolean; + /** + * EmailFallback allows OIDC logins to fall back to email-based matching + * when the `linked_id` (issuer+subject) does not match an existing user + * link. INSECURE: weakens the linked_id check. It exists for IdP + * brokers that do not issue a stable `sub` for the same user across + * connections. + */ + readonly email_fallback: boolean; } // From codersdk/parameters.go diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx index fbc184c483..8d26234b1d 100644 --- a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx @@ -80,7 +80,7 @@ export const GatewayKeysPageView: FC = ({ Name Key prefix - Last used + Last heartbeat Created @@ -111,9 +111,9 @@ export const GatewayKeysPageView: FC = ({ - {key.last_used_at ? ( + {key.last_heartbeat_at ? ( - {relativeTime(new Date(key.last_used_at))} + {relativeTime(new Date(key.last_heartbeat_at))} ) : ( Never diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 86bf90598c..ac6a4c6c07 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -755,21 +755,22 @@ const EVERY_TOOL_ASSISTANT_TURN = { }, }, - // close_agent -- terminate a subagent + // interrupt_agent: interrupt a subagent { type: "tool-call", - tool_call_id: "every-close-agent", - tool_name: "close_agent", + tool_call_id: "every-interrupt-agent", + tool_name: "interrupt_agent", args: { chat_id: "every-explore-child" }, }, { type: "tool-result", - tool_call_id: "every-close-agent", - tool_name: "close_agent", + tool_call_id: "every-interrupt-agent", + tool_name: "interrupt_agent", result: { chat_id: "every-explore-child", type: "explore", status: "completed", + interrupted: true, }, }, @@ -1693,18 +1694,19 @@ export const WithMixedSubagentTranscript: Story = { }, { type: "tool-call", - tool_call_id: "legacy-close", - tool_name: "close_agent", + tool_call_id: "legacy-interrupt", + tool_name: "interrupt_agent", args: { chat_id: "legacy-child" }, }, { type: "tool-result", - tool_call_id: "legacy-close", - tool_name: "close_agent", + tool_call_id: "legacy-interrupt", + tool_name: "interrupt_agent", result: { chat_id: "legacy-child", type: "general", status: "completed", + interrupted: "true", }, }, ], diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index f8411e752c..29a302d3dd 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -1380,6 +1380,133 @@ export const StickyUserMessagePinsOnScroll: Story = { }, }; +// Tall user messages interleaved with verbose assistant replies. The height +// gives the sticky clip room to shrink as the transcript grows, and the +// volume overflows the 600px scroll decorator. +const buildTallStickyConversation = (count: number): TypesGen.ChatMessage[] => { + const messages: TypesGen.ChatMessage[] = []; + for (let i = 1; i <= count; i++) { + const role: TypesGen.ChatMessageRole = i % 2 === 1 ? "user" : "assistant"; + const text = + role === "user" + ? Array.from( + { length: 6 }, + (_, line) => + `Question ${Math.ceil(i / 2)} paragraph ${line + 1}: keep this user message tall enough to clip.`, + ).join("\n\n") + : `Detailed answer ${Math.floor(i / 2)}. `.repeat(12); + messages.push(buildMessage(i, role, text)); + } + return messages; +}; + +const stickyClipUpdateStore = buildStoreWithMessages( + buildTallStickyConversation(30), +); + +/** + * Regression guard: the sticky truncation must stay in sync as the + * transcript grows while the user is pinned to the bottom. + * + * The clip height is recomputed by a scroll handler, a window-resize + * handler, and a ResizeObserver on the transcript. The observer used to + * watch `scroller.firstElementChild`, which is the aria-hidden flex spacer + * that pins content to the bottom. That spacer collapses to 0px once the + * transcript overflows and then stops emitting resize callbacks, so several + * messages arriving while pinned left the clip stale until the next manual + * scroll and the bubble overflowed. The fix observes the real content + * wrapper tagged with `data-chat-scroll-content`. + * + * This story grows the transcript while pinned and asserts the clip tracks + * the new geometry without any scroll event. + */ +export const StickyUserMessageClipUpdatesWhilePinned: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + decorators: scrollStoryDecorators, + render: () => , + play: async ({ canvasElement }) => { + stickyClipUpdateStore.replaceMessages(buildTallStickyConversation(30)); + stickyClipUpdateStore.setChatStatus("completed"); + const canvas = within(canvasElement); + const scrollContainer = canvas.getByTestId("scroll-container"); + + await waitForScrollOverflow(scrollContainer); + + // The observed transcript node must be the real content wrapper, not + // the aria-hidden flex spacer that collapses to 0px on overflow. + const contentMarker = scrollContainer.querySelector( + "[data-chat-scroll-content]", + ); + expect(contentMarker).not.toBeNull(); + const spacer = scrollContainer.firstElementChild; + expect(spacer).not.toBe(contentMarker); + expect(spacer?.getAttribute("aria-hidden")).toBe("true"); + + // Every sticky sentinel lives inside the observed content node, so a + // resize of that node reflects transcript growth. + const sentinels = scrollContainer.querySelectorAll("[data-user-sentinel]"); + expect(sentinels.length).toBeGreaterThan(0); + for (const sentinel of sentinels) { + expect(contentMarker?.contains(sentinel)).toBe(true); + } + + // At scrollTop 0 the newest message is pinned to the bottom. The most + // recent user message whose sentinel sits just above the top edge is + // the bubble pinned at the top and actively clipped. + const scrollerRect = scrollContainer.getBoundingClientRect(); + const pinnedSentinel = Array.from(sentinels) + .reverse() + .find( + (sentinel) => + sentinel.getBoundingClientRect().top < scrollerRect.top - 4, + ) as HTMLElement | undefined; + expect(pinnedSentinel).toBeDefined(); + if (!pinnedSentinel) { + return; + } + const pinnedContainer = pinnedSentinel.nextElementSibling as HTMLElement; + + const MIN_CLIP_HEIGHT = 72; + const readClip = () => + Number.parseFloat(pinnedContainer.style.getPropertyValue("--clip-h")) || + 0; + const measureScrolledPast = () => + scrollContainer.getBoundingClientRect().top - + pinnedSentinel.getBoundingClientRect().top; + const expectedClip = () => + Math.max( + pinnedContainer.offsetHeight - measureScrolledPast(), + MIN_CLIP_HEIGHT, + ); + + const scrolledPastBefore = measureScrolledPast(); + expect(scrolledPastBefore).toBeGreaterThan(4); + // Stay in the clipping regime (not a near-full-height bubble). + expect(pinnedContainer.offsetHeight).toBeLessThanOrEqual( + scrollContainer.clientHeight * 0.75, + ); + expect(scrollContainer.scrollTop).toBe(0); + + // Grow the transcript at the newest end. While pinned, scrollTop stays + // at 0 so no scroll event fires; only the content ResizeObserver can + // drive the recompute. + stickyClipUpdateStore.replaceMessages([ + ...getStoreMessages(stickyClipUpdateStore), + buildMessage(31, "assistant", "Freshly streamed reply. ".repeat(80)), + buildMessage(32, "assistant", "More freshly streamed reply. ".repeat(80)), + ]); + + // The pinned bubble is now further above the top edge. Its clip must + // follow the new geometry. Before the fix it stayed stale (matching + // the pre-growth scrolledPast) until a manual scroll. + await waitFor(() => { + expect(scrollContainer.scrollTop).toBe(0); + expect(measureScrolledPast()).toBeGreaterThan(scrolledPastBefore + 10); + expect(Math.abs(readClip() - expectedClip())).toBeLessThanOrEqual(2); + }); + }, +}; + /** * Selecting the Terminal tab in the sidebar must move keyboard focus into * the terminal so typing goes there, not the chat input. diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 3013ff0d24..bcfa3d4a7a 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -894,7 +894,7 @@ export const AgentChatPageView: FC = ({ onFetchMoreMessages={onFetchMoreMessages} messageCount={messageCount} > -
+
{ + // Read the scroller geometry on each tick. Caching it goes + // stale when the scroller moves or resizes without a window + // resize (for example the composer growing), which skews the + // clip height and push-up math. + const scrollerTop = scroller.getBoundingClientRect().top; + const scrollerHeight = scroller.clientHeight; const fullHeight = container.offsetHeight; // Skip sticky behavior for messages that take up @@ -904,12 +907,6 @@ const StickyUserMessage = memo<{ }; updateFnRef.current = update; - const onResize = () => { - scrollerTop = scroller.getBoundingClientRect().top; - scrollerHeight = scroller.clientHeight; - update(); - }; - // Throttle to one update per animation frame so we don't // do redundant work on high-refresh-rate displays. let rafId: number | null = null; @@ -921,12 +918,21 @@ const StickyUserMessage = memo<{ }); }; - // Re-run the visual update when the scrollable content height - // changes (e.g. streaming responses growing the transcript). - // In flex-col-reverse, scrollTop stays at 0 when pinned to - // bottom so no scroll event fires — but the content wrapper - // resizes and this observer catches that. - const contentEl = scroller.firstElementChild as HTMLElement | null; + // Re-run the visual update when the transcript height changes, + // for example a streaming response or several messages arriving + // at once. In flex-col-reverse the scrollTop stays at 0 while + // pinned to the bottom, so no scroll event fires; observing the + // content wrapper catches that growth instead. + // + // The scroller's firstElementChild is the flex spacer that pins + // content to the bottom. It collapses to 0px once the transcript + // overflows and then stops emitting resize callbacks, which is + // exactly when truncation is active, so observe the real content + // node (an ancestor of the sentinel) and fall back to the spacer + // only when the marker is absent. + const contentEl = + sentinel.closest("[data-chat-scroll-content]") ?? + (scroller.firstElementChild as HTMLElement | null); let contentRafId: number | null = null; const contentObserver = contentEl ? new ResizeObserver(() => { @@ -940,7 +946,7 @@ const StickyUserMessage = memo<{ contentObserver?.observe(contentEl!); scroller.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", onResize); + window.addEventListener("resize", update); update(); // Set immediately — both --clip-h and --overlay-ready are // applied before the browser paints since we're in a @@ -948,7 +954,7 @@ const StickyUserMessage = memo<{ container.style.setProperty("--overlay-ready", "1"); return () => { scroller.removeEventListener("scroll", onScroll); - window.removeEventListener("resize", onResize); + window.removeEventListener("resize", update); contentObserver?.disconnect(); container.style.removeProperty("--overlay-ready"); if (rafId !== null) cancelAnimationFrame(rafId); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts index 5e0a259fb4..7d3fa92bd8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts @@ -960,7 +960,7 @@ describe("subagent transcript parsing", () => { expect(variants.get("unified-child")).toBe("explore"); }); - it("includes close_agent in the shared subagent parsing path", () => { + it("includes close_agent (legacy alias) in the shared subagent parsing path", () => { const { variants } = parseSubagents([ msg(1, [ toolCall("close-tool", "close_agent", { chat_id: "closing-child" }), @@ -975,6 +975,24 @@ describe("subagent transcript parsing", () => { expect(variants.get("closing-child")).toBe("explore"); }); + it("includes interrupt_agent in the shared subagent parsing path", () => { + const { variants } = parseSubagents([ + msg(1, [ + toolCall("interrupt-tool", "interrupt_agent", { + chat_id: "interrupting-child", + }), + toolResult("interrupt-tool", "interrupt_agent", { + chat_id: "interrupting-child", + type: "explore", + status: "completed", + interrupted: true, + }), + ]), + ]); + + expect(variants.get("interrupting-child")).toBe("explore"); + }); + it("tracks computer-use variants for legacy and spawn_agent tools", () => { const { variants } = parseSubagents([ msg(1, [ @@ -1022,8 +1040,10 @@ describe("subagent transcript parsing", () => { }), ]), msg(3, [ - toolCall("close-tool", "close_agent", { chat_id: "close-child" }), - toolResult("close-tool", "close_agent", { + toolCall("interrupt-tool", "interrupt_agent", { + chat_id: "close-child", + }), + toolResult("interrupt-tool", "interrupt_agent", { chat_id: "close-child", type: "general", status: "completed", @@ -1068,8 +1088,10 @@ describe("subagent transcript parsing", () => { }), ]), msg(4, [ - toolCall("close-tool", "close_agent", { chat_id: "history-child" }), - toolResult("close-tool", "close_agent", { + toolCall("interrupt-tool", "interrupt_agent", { + chat_id: "history-child", + }), + toolResult("interrupt-tool", "interrupt_agent", { chat_id: "history-child", status: "completed", }), @@ -1086,7 +1108,8 @@ describe("getSubagentDescriptor", () => { const lifecycleTools = [ { name: "wait_agent", action: "wait" }, { name: "message_agent", action: "message" }, - { name: "close_agent", action: "close" }, + { name: "close_agent", action: "interrupt" }, + { name: "interrupt_agent", action: "interrupt" }, ] as const; for (const tool of lifecycleTools) { @@ -1111,6 +1134,7 @@ describe("getSubagentDescriptor", () => { "wait_agent", "message_agent", "close_agent", + "interrupt_agent", ] as const; for (const name of lifecycleToolNames) { @@ -1127,4 +1151,30 @@ describe("getSubagentDescriptor", () => { }); } }); + + it("renders list_agents with a fixed generic affordance", () => { + const descriptor = getSubagentDescriptor({ + name: "list_agents", + args: {}, + result: { + agents: [ + { chat_id: "agent-1", type: "explore", status: "completed" }, + { chat_id: "agent-2", type: "computer_use", status: "running" }, + ], + total: 2, + returned: 2, + offset: 0, + has_more: false, + }, + }); + + // The list result has no single top-level type, so the descriptor + // must not derive a variant from per-agent types. + expect(descriptor).toMatchObject({ + action: "list", + variant: "general", + iconKind: "bot", + supportsDesktopAffordance: false, + }); + }); }); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx new file mode 100644 index 0000000000..05ae1e431c --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx @@ -0,0 +1,87 @@ +import { ExternalLinkIcon } from "lucide-react"; +import type React from "react"; +import { Link, useLocation } from "react-router"; +import { safeBuildAgentChatPath } from "../../../utils/navigation"; +import { ToolCall } from "./ToolCall"; +import { asRecord, asString, type ToolStatus } from "./utils"; + +/** + * Collapsed-by-default rendering for `list_agents` tool calls. Shows + * "Listed N of M agents" with a chevron; expanding reveals the agent + * list with links to each agent's chat. + */ +export const ListAgentsTool: React.FC<{ + agents: unknown[]; + total: number; + status: ToolStatus; + isError: boolean; + errorMessage?: string; +}> = ({ agents, total, status, isError, errorMessage }) => { + const location = useLocation(); + const hasContent = agents.length > 0; + const isRunning = status === "running"; + + const label = isRunning + ? "Listing agents" + : hasContent + ? `Listed ${agents.length} of ${total} agents` + : "Listed 0 agents"; + + return ( + + + +
+ {agents.map((agent, index) => { + const rec = asRecord(agent); + if (!rec) { + return null; + } + const title = asString(rec.title) || "untitled"; + const chatStatus = asString(rec.status) || "unknown"; + const type = asString(rec.type) || "general"; + const chatId = asString(rec.chat_id); + const agentChatPath = chatId + ? safeBuildAgentChatPath({ chatId }) + : null; + + const row = ( + + {title} ({type}, {chatStatus}) + + ); + + if (!agentChatPath) { + return ( +
+ {row} +
+ ); + } + + return ( +
+ + {row} + + +
+ ); + })} +
+
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx index c64f39fb56..9fc4aaa26f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx @@ -41,11 +41,17 @@ const SUBAGENT_VERBS: Record< error: "Failed to message ", timeout: "Timed out messaging ", }, - close: { - completed: "Terminated ", - running: "Terminating ", - error: "Failed to terminate ", - timeout: "Timed out terminating ", + interrupt: { + completed: "Interrupted ", + running: "Interrupting ", + error: "Failed to interrupt ", + timeout: "Timed out interrupting ", + }, + list: { + completed: "Listed ", + running: "Listing ", + error: "Failed to list ", + timeout: "Timed out listing ", }, }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 32c5a5853f..eb5321c939 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -224,7 +224,7 @@ const allToolShowcaseItems: ToolShowcaseItem[] = [ result: { chat_id: "bot-child", status: "completed" }, }, { - name: "close_agent", + name: "interrupt_agent", args: { chat_id: "bot-child" }, result: { chat_id: "bot-child", status: "completed" }, }, @@ -1015,9 +1015,9 @@ export const MessageAgentExploreStreamingFromResult: Story = { }, }; -export const CloseAgentRunningWithoutChatId: Story = { +export const InterruptAgentRunningWithoutChatId: Story = { args: { - name: "close_agent", + name: "interrupt_agent", status: "running", args: {}, result: { status: "running" }, @@ -1033,25 +1033,127 @@ export const CloseAgentRunningWithoutChatId: Story = { }, }; -export const CloseAgentExploreCompleted: Story = { +// interrupt_agent is the post-rename name for close_agent. The response +// carries `interrupted: true`. +export const InterruptAgentExploreCompleted: Story = { args: { - name: "close_agent", + name: "interrupt_agent", status: "completed", - args: { chat_id: "close-child" }, + args: { chat_id: "interrupt-child" }, result: { - chat_id: "close-child", + chat_id: "interrupt-child", type: "explore", status: "completed", + interrupted: true, }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect( - canvas.getByRole("button", { name: /Terminated Explore agent/ }), + canvas.getByRole("button", { name: /Interrupted Explore agent/ }), ).toBeInTheDocument(); }, }; +// list_agents renders through ListAgentsTool, showing a count in the +// header and an expandable list of agents with links. +export const ListAgentsCompleted: Story = { + args: { + name: "list_agents", + status: "completed", + args: {}, + result: { + agents: [ + { + chat_id: "agent-1", + title: "Repository review", + type: "general", + status: "completed", + created_at: "2026-04-21T00:00:00.000Z", + updated_at: "2026-04-21T00:05:00.000Z", + }, + { + chat_id: "agent-2", + title: "Inspect repository", + type: "explore", + status: "running", + created_at: "2026-04-21T00:01:00.000Z", + updated_at: "2026-04-21T00:06:00.000Z", + }, + { + chat_id: "agent-3", + title: "Drive the desktop", + type: "computer_use", + status: "pending", + created_at: "2026-04-21T00:02:00.000Z", + updated_at: "2026-04-21T00:07:00.000Z", + }, + ], + total: 3, + returned: 3, + offset: 0, + has_more: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const header = canvas.getByRole("button", { name: /Listed 3 of 3 agents/ }); + expect(header).toBeInTheDocument(); + // Expand to verify agent rows and links render. + await userEvent.click(header); + expect( + canvas.getByText("Repository review (general, completed)"), + ).toBeInTheDocument(); + expect( + canvas.getByText("Inspect repository (explore, running)"), + ).toBeInTheDocument(); + }, +}; + +export const ListAgentsRunning: Story = { + args: { + name: "list_agents", + status: "running", + args: {}, + result: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listing agents")).toBeInTheDocument(); + }, +}; + +export const ListAgentsEmpty: Story = { + args: { + name: "list_agents", + status: "completed", + args: {}, + result: { + agents: [], + total: 0, + has_more: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listed 0 agents")).toBeInTheDocument(); + }, +}; + +export const ListAgentsError: Story = { + args: { + name: "list_agents", + status: "error", + isError: true, + args: {}, + result: "list_agents is only available on root chats", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listed 0 agents")).toBeInTheDocument(); + }, +}; + // --------------------------------------------------------------------------- // ListTemplates stories // --------------------------------------------------------------------------- @@ -1161,17 +1263,17 @@ export const ChatSummarized: Story = { }; // --------------------------------------------------------------------------- -// SubagentTerminate stories +// SubagentInterrupt stories // --------------------------------------------------------------------------- -export const SubagentTerminate: Story = { +export const SubagentInterrupt: Story = { args: { - name: "close_agent", + name: "interrupt_agent", args: undefined, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText(/Terminated/)).toBeInTheDocument(); + expect(canvas.getByText(/Interrupted/)).toBeInTheDocument(); expect(canvas.getByText("Sub-agent")).toBeInTheDocument(); }, }; @@ -2169,6 +2271,31 @@ export const SubagentWaitTimedOutTitleFromMap: Story = { }, }; +export const SubagentWaitTimedOutStructured: Story = { + args: { + name: "wait_agent", + status: "completed", + isError: false, + args: { chat_id: "timed-out-child" }, + result: { + chat_id: "timed-out-child", + title: "Fix login bug", + status: "running", + timed_out: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Should show clock icon for timeout. + expect(canvasElement.querySelector(".lucide-clock")).not.toBeNull(); + // Should NOT show red alert icon. + expect(canvasElement.querySelector(".lucide-circle-alert")).toBeNull(); + // Should show timeout verb. + expect(canvas.getByText(/Timed out waiting for/)).toBeInTheDocument(); + expect(canvas.getByText("Fix login bug")).toBeInTheDocument(); + }, +}; + export const SubagentSpawnError: Story = { args: { name: "spawn_agent", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 619a4b1929..a1bfcc2207 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -19,6 +19,7 @@ import { ExecuteTool as ExecuteToolComponent, WaitForExternalAuthTool, } from "./ExecuteTool"; +import { ListAgentsTool } from "./ListAgentsTool"; import { ListTemplatesTool } from "./ListTemplatesTool"; import { ProcessOutputTool } from "./ProcessOutputTool"; import { ProposePlanTool } from "./ProposePlanTool"; @@ -528,12 +529,14 @@ const SubagentRenderer: FC = ({ } // Detect timeout from the result. A timed-out wait_agent - // typically returns an error string or an object with an - // error field containing "timed out". + // returns a structured payload with timed_out: true + // (IsError=false), or an error string containing "timed out". const resultStr = typeof result === "string" ? result : ""; const errorStr = rec ? asString(rec.error) : ""; let isTimeout = false; - if (subagentIsError) { + if (rec && rec.timed_out === true) { + isTimeout = true; + } else if (subagentIsError) { const timedOutInResult = resultStr.toLowerCase().includes("timed out"); const timedOutInError = errorStr.toLowerCase().includes("timed out"); if (timedOutInResult || timedOutInError) { @@ -586,6 +589,34 @@ const ListTemplatesRenderer: FC = ({ ); }; +const ListAgentsRenderer: FC = ({ + status, + result, + isError, +}) => { + const rec = asRecord(result); + const agents = rec && Array.isArray(rec.agents) ? rec.agents : []; + const total = rec + ? (asNumber(rec.total, { parseString: true }) ?? agents.length) + : 0; + + return ( + + ); +}; + const ReadTemplateRenderer: FC = ({ status, result, @@ -1033,6 +1064,7 @@ const toolRenderers: Record> = { create_workspace: CreateWorkspaceRenderer, start_workspace: StartWorkspaceRenderer, list_templates: ListTemplatesRenderer, + list_agents: ListAgentsRenderer, read_template: ReadTemplateRenderer, read_skill: ReadSkillRenderer, read_skill_file: ReadSkillFileRenderer, @@ -1074,9 +1106,10 @@ export const Tool = memo( ref, ...props }: ToolProps) => { - const Renderer = isSubagentToolName(name) - ? SubagentRenderer - : (toolRenderers[name] ?? GenericToolRenderer); + const Renderer = + isSubagentToolName(name) && name !== "list_agents" + ? SubagentRenderer + : (toolRenderers[name] ?? GenericToolRenderer); const isShellTool = name === "execute" || name === "process_output"; if (!shouldRenderTool({ name, status, args, result })) { return null; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx index 4e0ff7d54c..b8babecf7a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx @@ -39,10 +39,14 @@ const renderSubagentLabel = ( return providedTitle ? `Messaging ${providedTitle}` : `Messaging ${fallbackTitle}…`; - case "close": + case "interrupt": return providedTitle - ? `Terminating ${providedTitle}` - : `Terminating ${fallbackTitle}`; + ? `Interrupting ${providedTitle}` + : `Interrupting ${fallbackTitle}`; + case "list": + return providedTitle + ? `Listing ${providedTitle}` + : `Listing ${fallbackTitle}`; } })(); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts index fe75680818..0d7503b0e6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts @@ -1,7 +1,12 @@ import { asString } from "../runtimeTypeUtils"; import { parseArgs } from "./utils"; -export type SubagentAction = "spawn" | "wait" | "message" | "close"; +export type SubagentAction = + | "spawn" + | "wait" + | "message" + | "interrupt" + | "list"; export type SubagentVariant = "general" | "explore" | "computer_use"; export type SubagentIconKind = "bot" | "monitor"; @@ -47,7 +52,14 @@ const actionByToolName: Record = { spawn_subagent: "spawn", wait_agent: "wait", message_agent: "message", - close_agent: "close", + // Legacy persisted tool name kept so old chat histories still render. + close_agent: "interrupt", + interrupt_agent: "interrupt", + // list_agents is a subagent tool but renders through + // ListAgentsRenderer, not SubagentRenderer. The "list" action + // exists for isSubagentToolName classification and ToolIcon + // dispatch, not for the SubagentRenderer label machinery. + list_agents: "list", }; const variantBySpawnToolName: Record = { diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts index e6aa89b3a0..31f68826e0 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts @@ -114,7 +114,7 @@ describe("toolVisibility", () => { ).toBe(false); }); - it("hides running close_agent rows until chat_id is available", () => { + it("hides running close_agent (legacy alias) rows until chat_id is available", () => { expect( shouldRenderTool({ name: "close_agent", @@ -125,6 +125,28 @@ describe("toolVisibility", () => { ).toBe(false); }); + it("hides running interrupt_agent rows until chat_id is available", () => { + expect( + shouldRenderTool({ + name: "interrupt_agent", + status: "running", + args: {}, + result: { status: "running" }, + }), + ).toBe(false); + }); + + it("renders list_agents rows even without a chat_id", () => { + expect( + shouldRenderTool({ + name: "list_agents", + status: "running", + args: {}, + result: undefined, + }), + ).toBe(true); + }); + it("renders running lifecycle rows once args provide the chat_id", () => { expect( shouldRenderTool({ @@ -136,7 +158,7 @@ describe("toolVisibility", () => { ).toBe(true); }); - it("renders completed lifecycle rows even if chat_id is absent", () => { + it("renders completed close_agent (legacy alias) rows even if chat_id is absent", () => { expect( shouldRenderTool({ name: "close_agent", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts index 32c17772fd..58089f02c6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts @@ -93,13 +93,13 @@ const shouldRenderSubagentLifecycleTool = ({ if ( descriptor.action !== "wait" && descriptor.action !== "message" && - descriptor.action !== "close" + descriptor.action !== "interrupt" ) { return true; } - // Wait, message, and close rows can stream before their target chat_id - // arrives. Hiding them until that id exists avoids flashing generic + // Wait, message, and interrupt rows can stream before their target + // chat_id arrives. Hiding them until that id exists avoids flashing generic // lifecycle copy before the transcript can resolve the real title. return Boolean(getSubagentChatId({ args, result })); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts index 44ff5c75df..609773d550 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts @@ -221,6 +221,17 @@ describe("mapSubagentStatusToToolStatus", () => { ); }); + it("treats interrupted as an unknown status, not a chat status", () => { + // The interrupt_agent rename returns an `interrupted: true` response + // boolean, which is not a chat status. Status mapping only handles + // chat status strings, so "interrupted" falls back like any unknown + // value and "terminated" keeps mapping to completed. + expect(mapSubagentStatusToToolStatus("interrupted", "running")).toBe( + "running", + ); + expect(mapSubagentStatusToToolStatus("interrupted", "error")).toBe("error"); + }); + it("maps error to error", () => { expect(mapSubagentStatusToToolStatus("error", "running")).toBe("error"); }); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 3559e702eb..af0eb797c2 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -5622,7 +5622,7 @@ export const MockAIGatewayKeys: TypesGen.AIGatewayKey[] = [ name: "primary-gateway", key_prefix: "a1B2c3D4e5F", created_at: "2024-05-01T14:00:00Z", - last_used_at: "2024-05-20T09:30:00Z", + last_heartbeat_at: "2024-05-20T09:30:00Z", }, { id: "2d3f7a5b-9c4e-4a2b-8d6f-3b6c9e7f1a22",