diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4199b64f6b683..acd6a58f53cbb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -181,7 +181,7 @@ jobs: echo "LINT_CACHE_DIR=$dir" >> "$GITHUB_ENV" - name: golangci-lint cache - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ${{ env.LINT_CACHE_DIR }} @@ -191,7 +191,7 @@ jobs: # Check for any typos - name: Check for typos - uses: crate-ci/typos@85f62a8a84f939ae994ab3763f01a0296d61a7ee # v1.36.2 + uses: crate-ci/typos@80c8a4945eec0f6d464eaf9e65ed98ef085283d1 # v1.38.1 with: config: .github/workflows/typos.toml @@ -806,7 +806,7 @@ jobs: # the check to pass. This is desired in PRs, but not in mainline. - name: Publish to Chromatic (non-mainline) if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder' - uses: chromaui/action@20c7e42e1b2f6becd5d188df9acb02f3e2f51519 # v13.2.0 + uses: chromaui/action@4ffe736a2a8262ea28067ff05a13b635ba31ec05 # v13.3.0 env: NODE_OPTIONS: "--max_old_space_size=4096" STORYBOOK: true @@ -838,7 +838,7 @@ jobs: # infinitely "in progress" in mainline unless we re-review each build. - name: Publish to Chromatic (mainline) if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder' - uses: chromaui/action@20c7e42e1b2f6becd5d188df9acb02f3e2f51519 # v13.2.0 + uses: chromaui/action@4ffe736a2a8262ea28067ff05a13b635ba31ec05 # v13.3.0 env: NODE_OPTIONS: "--max_old_space_size=4096" STORYBOOK: true @@ -1123,7 +1123,7 @@ jobs: persist-credentials: false - name: GHCR Login - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 38936826261f5..30d9e384149fa 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -76,7 +76,7 @@ jobs: persist-credentials: false - name: GHCR Login - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -92,7 +92,7 @@ jobs: uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 - name: Set up Flux CLI - uses: fluxcd/flux2/action@6bf37f6a560fd84982d67f853162e4b3c2235edb # v2.6.4 + uses: fluxcd/flux2/action@4a15fa6a023259353ef750acf1c98fe88407d4d0 # v2.7.2 with: # Keep this and the github action up to date with the version of flux installed in dogfood cluster version: "2.7.0" diff --git a/.github/workflows/docker-base.yaml b/.github/workflows/docker-base.yaml index a62d43d0b6a6c..2998aae1b5a79 100644 --- a/.github/workflows/docker-base.yaml +++ b/.github/workflows/docker-base.yaml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - name: Docker login - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/docs-ci.yaml b/.github/workflows/docs-ci.yaml index eb64a35ffa3f4..a4f593d425f8a 100644 --- a/.github/workflows/docs-ci.yaml +++ b/.github/workflows/docs-ci.yaml @@ -30,7 +30,7 @@ jobs: - name: Setup Node uses: ./.github/actions/setup-node - - uses: tj-actions/changed-files@4563c729c555b4141fac99c80f699f571219b836 # v45.0.7 + - uses: tj-actions/changed-files@d03a93c0dbfac6d6dd6a0d8a5e7daff992b07449 # v45.0.7 id: changed-files with: files: | diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml index 5793b64616703..780cef6e0cc6d 100644 --- a/.github/workflows/dogfood.yaml +++ b/.github/workflows/dogfood.yaml @@ -82,7 +82,7 @@ jobs: - name: Login to DockerHub if: github.ref == 'refs/heads/main' - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} diff --git a/.github/workflows/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml index 1fd4351503b9c..eb0eb296923c3 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -189,7 +189,7 @@ jobs: egress-policy: audit - name: Find Comment - uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0 + uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 id: fc with: issue-number: ${{ needs.get_info.outputs.PR_NUMBER }} @@ -199,7 +199,7 @@ jobs: - name: Comment on PR id: comment_id - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: comment-id: ${{ steps.fc.outputs.comment-id }} issue-number: ${{ needs.get_info.outputs.PR_NUMBER }} @@ -248,7 +248,7 @@ jobs: uses: ./.github/actions/setup-sqlc - name: GHCR Login - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -491,7 +491,7 @@ jobs: PASSWORD: ${{ steps.setup_deployment.outputs.password }} - name: Find Comment - uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0 + uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 id: fc with: issue-number: ${{ env.PR_NUMBER }} @@ -500,7 +500,7 @@ jobs: direction: last - name: Comment on PR - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 env: STATUS: ${{ needs.get_info.outputs.NEW == 'true' && 'Created' || 'Updated' }} with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cdd2ae96ffcb4..a91b9b8c6519f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -239,7 +239,7 @@ jobs: cat "$CODER_RELEASE_NOTES_FILE" - name: Docker Login - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -785,7 +785,7 @@ jobs: - name: Send repository-dispatch event if: ${{ !inputs.dry_run }} - uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0 + uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 with: token: ${{ secrets.CDRCI_GITHUB_TOKEN }} repository: coder/packages diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index ba366fb72428c..279556bce6ff7 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -30,7 +30,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif @@ -47,6 +47,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5 + uses: github/codeql-action/upload-sarif@16140ae1a102900babc80a33c44059580f687047 # v3.29.5 with: sarif_file: results.sarif diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index c9debc6c87f66..927d7a161151f 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -40,7 +40,7 @@ jobs: uses: ./.github/actions/setup-go - name: Initialize CodeQL - uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5 + uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v3.29.5 with: languages: go, javascript @@ -50,7 +50,7 @@ jobs: rm Makefile - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5 + uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v3.29.5 - name: Send Slack notification on failure if: ${{ failure() }} @@ -154,7 +154,7 @@ jobs: severity: "CRITICAL,HIGH" - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5 + uses: github/codeql-action/upload-sarif@16140ae1a102900babc80a33c44059580f687047 # v3.29.5 with: sarif_file: trivy-results.sarif category: "Trivy" diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 15b3996ab61eb..e7555523dd6bb 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -23,7 +23,7 @@ jobs: egress-policy: audit - name: stale - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: stale-issue-label: "stale" stale-pr-label: "stale" diff --git a/.github/workflows/traiage.yaml b/.github/workflows/traiage.yaml index 2749e07a4f63f..8560af091d348 100644 --- a/.github/workflows/traiage.yaml +++ b/.github/workflows/traiage.yaml @@ -13,12 +13,12 @@ on: template_name: description: "Coder template to use for workspace" required: true - default: "traiage" + default: "coder" type: string template_preset: description: "Template preset to use" required: true - default: "Default" + default: "none" type: string prefix: description: "Prefix for workspace name" @@ -66,8 +66,8 @@ jobs: GITHUB_EVENT_USER_ID: ${{ github.event.sender.id }} GITHUB_EVENT_USER_LOGIN: ${{ github.event.sender.login }} INPUTS_ISSUE_URL: ${{ inputs.issue_url }} - INPUTS_TEMPLATE_NAME: ${{ inputs.template_name || 'traiage' }} - INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || 'Default'}} + INPUTS_TEMPLATE_NAME: ${{ inputs.template_name || 'coder' }} + INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || 'none'}} INPUTS_PREFIX: ${{ inputs.prefix || 'traiage' }} GH_TOKEN: ${{ github.token }} run: | @@ -168,7 +168,7 @@ jobs: echo "coder_username=${coder_username}" >> "${GITHUB_OUTPUT}" - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/weekly-docs.yaml b/.github/workflows/weekly-docs.yaml index 85e5ce58e0ee0..a7ae448902d0c 100644 --- a/.github/workflows/weekly-docs.yaml +++ b/.github/workflows/weekly-docs.yaml @@ -31,7 +31,7 @@ jobs: persist-credentials: false - name: Check Markdown links - uses: umbrelladocs/action-linkspector@874d01cae9fd488e3077b08952093235bd626977 # v1.3.7 + uses: umbrelladocs/action-linkspector@652f85bc57bb1e7d4327260decc10aa68f7694c3 # v1.4.0 id: markdown-link-check # checks all markdown files from /docs including all subfolders with: diff --git a/.gitignore b/.gitignore index 5aa08b2512527..86f70cc3afd3e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ node_modules/ vendor/ yarn-error.log +# Test output files +test-output/ + # VSCode settings. **/.vscode/* # Allow VSCode recommendations and default settings in project root. diff --git a/Makefile b/Makefile index 8b17b88e2069e..93b0245a8a01f 100644 --- a/Makefile +++ b/Makefile @@ -676,6 +676,7 @@ gen/db: $(DB_GEN_FILES) .PHONY: gen/db gen/golden-files: \ + agent/unit/testdata/.gen-golden \ cli/testdata/.gen-golden \ coderd/.gen-golden \ coderd/notifications/.gen-golden \ @@ -952,6 +953,10 @@ clean/golden-files: -type f -name '*.golden' -delete .PHONY: clean/golden-files +agent/unit/testdata/.gen-golden: $(wildcard agent/unit/testdata/*.golden) $(GO_SRC_FILES) $(wildcard agent/unit/*_test.go) + TZ=UTC go test ./agent/unit -run="TestGraph" -update + touch "$@" + cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go) TZ=UTC go test ./cli -run="Test(CommandHelp|ServerYAML|ErrorExamples|.*Golden)" -update touch "$@" diff --git a/agent/agent_test.go b/agent/agent_test.go index ca0018076728e..d4d40b56bb92e 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -3462,11 +3462,7 @@ func TestAgent_Metrics_SSH(t *testing.T) { registry := prometheus.NewRegistry() //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{ - // Make sure we always get a DERP connection for - // currently_reachable_peers. - DisableDirectConnections: true, - }, 0, func(_ *agenttest.Client, o *agent.Options) { + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) { o.PrometheusRegistry = registry }) @@ -3481,16 +3477,31 @@ func TestAgent_Metrics_SSH(t *testing.T) { err = session.Shell() require.NoError(t, err) - expected := []*proto.Stats_Metric{ + expected := []struct { + Name string + Type proto.Stats_Metric_Type + CheckFn func(float64) error + Labels []*proto.Stats_Metric_Label + }{ { - Name: "agent_reconnecting_pty_connections_total", - Type: proto.Stats_Metric_COUNTER, - Value: 0, + Name: "agent_reconnecting_pty_connections_total", + Type: proto.Stats_Metric_COUNTER, + CheckFn: func(v float64) error { + if v == 0 { + return nil + } + return xerrors.Errorf("expected 0, got %f", v) + }, }, { - Name: "agent_sessions_total", - Type: proto.Stats_Metric_COUNTER, - Value: 1, + Name: "agent_sessions_total", + Type: proto.Stats_Metric_COUNTER, + CheckFn: func(v float64) error { + if v == 1 { + return nil + } + return xerrors.Errorf("expected 1, got %f", v) + }, Labels: []*proto.Stats_Metric_Label{ { Name: "magic_type", @@ -3503,24 +3514,44 @@ func TestAgent_Metrics_SSH(t *testing.T) { }, }, { - Name: "agent_ssh_server_failed_connections_total", - Type: proto.Stats_Metric_COUNTER, - Value: 0, + Name: "agent_ssh_server_failed_connections_total", + Type: proto.Stats_Metric_COUNTER, + CheckFn: func(v float64) error { + if v == 0 { + return nil + } + return xerrors.Errorf("expected 0, got %f", v) + }, }, { - Name: "agent_ssh_server_sftp_connections_total", - Type: proto.Stats_Metric_COUNTER, - Value: 0, + Name: "agent_ssh_server_sftp_connections_total", + Type: proto.Stats_Metric_COUNTER, + CheckFn: func(v float64) error { + if v == 0 { + return nil + } + return xerrors.Errorf("expected 0, got %f", v) + }, }, { - Name: "agent_ssh_server_sftp_server_errors_total", - Type: proto.Stats_Metric_COUNTER, - Value: 0, + Name: "agent_ssh_server_sftp_server_errors_total", + Type: proto.Stats_Metric_COUNTER, + CheckFn: func(v float64) error { + if v == 0 { + return nil + } + return xerrors.Errorf("expected 0, got %f", v) + }, }, { - Name: "coderd_agentstats_currently_reachable_peers", - Type: proto.Stats_Metric_GAUGE, - Value: 1, + Name: "coderd_agentstats_currently_reachable_peers", + Type: proto.Stats_Metric_GAUGE, + CheckFn: func(float64) error { + // We can't reliably ping a peer here, and networking is out of + // scope of this test, so we just test that the metric exists + // with the correct labels. + return nil + }, Labels: []*proto.Stats_Metric_Label{ { Name: "connection_type", @@ -3529,9 +3560,11 @@ func TestAgent_Metrics_SSH(t *testing.T) { }, }, { - Name: "coderd_agentstats_currently_reachable_peers", - Type: proto.Stats_Metric_GAUGE, - Value: 0, + Name: "coderd_agentstats_currently_reachable_peers", + Type: proto.Stats_Metric_GAUGE, + CheckFn: func(float64) error { + return nil + }, Labels: []*proto.Stats_Metric_Label{ { Name: "connection_type", @@ -3540,9 +3573,20 @@ func TestAgent_Metrics_SSH(t *testing.T) { }, }, { - Name: "coderd_agentstats_startup_script_seconds", - Type: proto.Stats_Metric_GAUGE, - Value: 1, + Name: "coderd_agentstats_startup_script_seconds", + Type: proto.Stats_Metric_GAUGE, + CheckFn: func(f float64) error { + if f >= 0 { + return nil + } + return xerrors.Errorf("expected >= 0, got %f", f) + }, + Labels: []*proto.Stats_Metric_Label{ + { + Name: "success", + Value: "true", + }, + }, }, } @@ -3564,11 +3608,10 @@ func TestAgent_Metrics_SSH(t *testing.T) { for _, m := range mf.GetMetric() { assert.Equal(t, expected[i].Name, mf.GetName()) assert.Equal(t, expected[i].Type.String(), mf.GetType().String()) - // Value is max expected if expected[i].Type == proto.Stats_Metric_GAUGE { - assert.GreaterOrEqualf(t, expected[i].Value, m.GetGauge().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetGauge().GetValue()) + assert.NoError(t, expected[i].CheckFn(m.GetGauge().GetValue()), "check fn for %s failed", expected[i].Name) } else if expected[i].Type == proto.Stats_Metric_COUNTER { - assert.GreaterOrEqualf(t, expected[i].Value, m.GetCounter().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetCounter().GetValue()) + assert.NoError(t, expected[i].CheckFn(m.GetCounter().GetValue()), "check fn for %s failed", expected[i].Name) } for j, lbl := range expected[i].Labels { assert.Equal(t, m.GetLabel()[j], &promgo.LabelPair{ diff --git a/agent/unit/graph.go b/agent/unit/graph.go new file mode 100644 index 0000000000000..3d8a6703addf2 --- /dev/null +++ b/agent/unit/graph.go @@ -0,0 +1,174 @@ +package unit + +import ( + "fmt" + "sync" + + "golang.org/x/xerrors" + "gonum.org/v1/gonum/graph/encoding/dot" + "gonum.org/v1/gonum/graph/simple" + "gonum.org/v1/gonum/graph/topo" +) + +// Graph provides a bidirectional interface over gonum's directed graph implementation. +// While the underlying gonum graph is directed, we overlay bidirectional semantics +// by distinguishing between forward and reverse edges. Wanting and being wanted by +// other units are related but different concepts that have different graph traversal +// implications when Units update their status. +// +// The graph stores edge types to represent different relationships between units, +// allowing for domain-specific semantics beyond simple connectivity. +type Graph[EdgeType, VertexType comparable] struct { + mu sync.RWMutex + // The underlying gonum graph. It stores vertices and edges without knowing about the types of the vertices and edges. + gonumGraph *simple.DirectedGraph + // Maps vertices to their IDs so that a gonum vertex ID can be used to lookup the vertex type. + vertexToID map[VertexType]int64 + // Maps vertex IDs to their types so that a vertex type can be used to lookup the gonum vertex ID. + idToVertex map[int64]VertexType + // The next ID to assign to a vertex. + nextID int64 + // Store edge types by "fromID->toID" key. This is used to lookup the edge type for a given edge. + edgeTypes map[string]EdgeType +} + +// Edge is a convenience type for representing an edge in the graph. +// It encapsulates the from and to vertices and the edge type itself. +type Edge[EdgeType, VertexType comparable] struct { + From VertexType + To VertexType + Edge EdgeType +} + +// AddEdge adds an edge to the graph. It initializes the graph and metadata on first use, +// checks for cycles, and adds the edge to the gonum graph. +func (g *Graph[EdgeType, VertexType]) AddEdge(from, to VertexType, edge EdgeType) error { + g.mu.Lock() + defer g.mu.Unlock() + + if g.gonumGraph == nil { + g.gonumGraph = simple.NewDirectedGraph() + g.vertexToID = make(map[VertexType]int64) + g.idToVertex = make(map[int64]VertexType) + g.edgeTypes = make(map[string]EdgeType) + g.nextID = 1 + } + + fromID := g.getOrCreateVertexID(from) + toID := g.getOrCreateVertexID(to) + + if g.canReach(to, from) { + return xerrors.Errorf("adding edge (%v -> %v) would create a cycle", from, to) + } + + g.gonumGraph.SetEdge(simple.Edge{F: simple.Node(fromID), T: simple.Node(toID)}) + + edgeKey := fmt.Sprintf("%d->%d", fromID, toID) + g.edgeTypes[edgeKey] = edge + + return nil +} + +// GetForwardAdjacentVertices returns all the edges that originate from the given vertex. +func (g *Graph[EdgeType, VertexType]) GetForwardAdjacentVertices(from VertexType) []Edge[EdgeType, VertexType] { + g.mu.RLock() + defer g.mu.RUnlock() + + fromID, exists := g.vertexToID[from] + if !exists { + return []Edge[EdgeType, VertexType]{} + } + + edges := []Edge[EdgeType, VertexType]{} + toNodes := g.gonumGraph.From(fromID) + for toNodes.Next() { + toID := toNodes.Node().ID() + to := g.idToVertex[toID] + + // Get the edge type + edgeKey := fmt.Sprintf("%d->%d", fromID, toID) + edgeType := g.edgeTypes[edgeKey] + + edges = append(edges, Edge[EdgeType, VertexType]{From: from, To: to, Edge: edgeType}) + } + + return edges +} + +// GetReverseAdjacentVertices returns all the edges that terminate at the given vertex. +func (g *Graph[EdgeType, VertexType]) GetReverseAdjacentVertices(to VertexType) []Edge[EdgeType, VertexType] { + g.mu.RLock() + defer g.mu.RUnlock() + + toID, exists := g.vertexToID[to] + if !exists { + return []Edge[EdgeType, VertexType]{} + } + + edges := []Edge[EdgeType, VertexType]{} + fromNodes := g.gonumGraph.To(toID) + for fromNodes.Next() { + fromID := fromNodes.Node().ID() + from := g.idToVertex[fromID] + + // Get the edge type + edgeKey := fmt.Sprintf("%d->%d", fromID, toID) + edgeType := g.edgeTypes[edgeKey] + + edges = append(edges, Edge[EdgeType, VertexType]{From: from, To: to, Edge: edgeType}) + } + + return edges +} + +// getOrCreateVertexID returns the ID for a vertex, creating it if it doesn't exist. +func (g *Graph[EdgeType, VertexType]) getOrCreateVertexID(vertex VertexType) int64 { + if id, exists := g.vertexToID[vertex]; exists { + return id + } + + id := g.nextID + g.nextID++ + g.vertexToID[vertex] = id + g.idToVertex[id] = vertex + + // Add the node to the gonum graph + g.gonumGraph.AddNode(simple.Node(id)) + + return id +} + +// canReach checks if there is a path from the start vertex to the end vertex. +func (g *Graph[EdgeType, VertexType]) canReach(start, end VertexType) bool { + if start == end { + return true + } + + startID, startExists := g.vertexToID[start] + endID, endExists := g.vertexToID[end] + + if !startExists || !endExists { + return false + } + + // Use gonum's built-in path existence check + return topo.PathExistsIn(g.gonumGraph, simple.Node(startID), simple.Node(endID)) +} + +// ToDOT exports the graph to DOT format for visualization +func (g *Graph[EdgeType, VertexType]) ToDOT(name string) (string, error) { + g.mu.RLock() + defer g.mu.RUnlock() + + if g.gonumGraph == nil { + return "", xerrors.New("graph is not initialized") + } + + // Marshal the graph to DOT format + dotBytes, err := dot.Marshal(g.gonumGraph, name, "", " ") + if err != nil { + return "", xerrors.Errorf("failed to marshal graph to DOT: %w", err) + } + + return string(dotBytes), nil +} diff --git a/agent/unit/graph_test.go b/agent/unit/graph_test.go new file mode 100644 index 0000000000000..3c76756aee88c --- /dev/null +++ b/agent/unit/graph_test.go @@ -0,0 +1,454 @@ +// Package unit_test provides tests for the unit package. +// +// DOT Graph Testing: +// The graph tests use golden files for DOT representation verification. +// To update the golden files: +// make gen/golden-files +// +// The golden files contain the expected DOT representation and can be easily +// inspected, version controlled, and updated when the graph structure changes. +package unit_test + +import ( + "bytes" + "flag" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/agent/unit" + "github.com/coder/coder/v2/cryptorand" +) + +type testGraphEdge string + +const ( + testEdgeStarted testGraphEdge = "started" + testEdgeCompleted testGraphEdge = "completed" +) + +type testGraphVertex struct { + Name string +} + +type ( + testGraph = unit.Graph[testGraphEdge, *testGraphVertex] + testEdge = unit.Edge[testGraphEdge, *testGraphVertex] +) + +// randInt generates a random integer in the range [0, limit). +func randInt(limit int) int { + if limit <= 0 { + return 0 + } + n, err := cryptorand.Int63n(int64(limit)) + if err != nil { + return 0 + } + return int(n) +} + +// UpdateGoldenFiles indicates golden files should be updated. +// To update the golden files: +// make gen/golden-files +var UpdateGoldenFiles = flag.Bool("update", false, "update .golden files") + +// assertDOTGraph requires that the graph's DOT representation matches the golden file +func assertDOTGraph(t *testing.T, graph *testGraph, goldenName string) { + t.Helper() + + dot, err := graph.ToDOT(goldenName) + require.NoError(t, err) + + goldenFile := filepath.Join("testdata", goldenName+".golden") + if *UpdateGoldenFiles { + t.Logf("update golden file for: %q: %s", goldenName, goldenFile) + err := os.MkdirAll(filepath.Dir(goldenFile), 0o755) + require.NoError(t, err, "want no error creating golden file directory") + err = os.WriteFile(goldenFile, []byte(dot), 0o600) + require.NoError(t, err, "update golden file") + } + + expected, err := os.ReadFile(goldenFile) + require.NoError(t, err, "read golden file, run \"make gen/golden-files\" and commit the changes") + + // Normalize line endings for cross-platform compatibility + expected = normalizeLineEndings(expected) + normalizedDot := normalizeLineEndings([]byte(dot)) + + assert.Empty(t, cmp.Diff(string(expected), string(normalizedDot)), "golden file mismatch (-want +got): %s, run \"make gen/golden-files\", verify and commit the changes", goldenFile) +} + +// normalizeLineEndings ensures that all line endings are normalized to \n. +// Required for Windows compatibility. +func normalizeLineEndings(content []byte) []byte { + content = bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n")) + content = bytes.ReplaceAll(content, []byte("\r"), []byte("\n")) + return content +} + +func TestGraph(t *testing.T) { + t.Parallel() + + testFuncs := map[string]func(t *testing.T) *unit.Graph[testGraphEdge, *testGraphVertex]{ + "ForwardAndReverseEdges": func(t *testing.T) *unit.Graph[testGraphEdge, *testGraphVertex] { + graph := &unit.Graph[testGraphEdge, *testGraphVertex]{} + unit1 := &testGraphVertex{Name: "unit1"} + unit2 := &testGraphVertex{Name: "unit2"} + unit3 := &testGraphVertex{Name: "unit3"} + err := graph.AddEdge(unit1, unit2, testEdgeCompleted) + require.NoError(t, err) + err = graph.AddEdge(unit1, unit3, testEdgeStarted) + require.NoError(t, err) + + // Check for forward edge + vertices := graph.GetForwardAdjacentVertices(unit1) + require.Len(t, vertices, 2) + // Unit 1 depends on the completion of Unit2 + require.Contains(t, vertices, testEdge{ + From: unit1, + To: unit2, + Edge: testEdgeCompleted, + }) + // Unit 1 depends on the start of Unit3 + require.Contains(t, vertices, testEdge{ + From: unit1, + To: unit3, + Edge: testEdgeStarted, + }) + + // Check for reverse edges + unit2ReverseEdges := graph.GetReverseAdjacentVertices(unit2) + require.Len(t, unit2ReverseEdges, 1) + // Unit 2 must be completed before Unit 1 can start + require.Contains(t, unit2ReverseEdges, testEdge{ + From: unit1, + To: unit2, + Edge: testEdgeCompleted, + }) + + unit3ReverseEdges := graph.GetReverseAdjacentVertices(unit3) + require.Len(t, unit3ReverseEdges, 1) + // Unit 3 must be started before Unit 1 can complete + require.Contains(t, unit3ReverseEdges, testEdge{ + From: unit1, + To: unit3, + Edge: testEdgeStarted, + }) + + return graph + }, + "SelfReference": func(t *testing.T) *testGraph { + graph := &testGraph{} + unit1 := &testGraphVertex{Name: "unit1"} + err := graph.AddEdge(unit1, unit1, testEdgeCompleted) + require.Error(t, err) + require.ErrorContains(t, err, fmt.Sprintf("adding edge (%v -> %v) would create a cycle", unit1, unit1)) + + return graph + }, + "Cycle": func(t *testing.T) *testGraph { + graph := &testGraph{} + unit1 := &testGraphVertex{Name: "unit1"} + unit2 := &testGraphVertex{Name: "unit2"} + err := graph.AddEdge(unit1, unit2, testEdgeCompleted) + require.NoError(t, err) + err = graph.AddEdge(unit2, unit1, testEdgeStarted) + require.Error(t, err) + require.ErrorContains(t, err, fmt.Sprintf("adding edge (%v -> %v) would create a cycle", unit2, unit1)) + + return graph + }, + "MultipleDependenciesSameStatus": func(t *testing.T) *testGraph { + graph := &testGraph{} + unit1 := &testGraphVertex{Name: "unit1"} + unit2 := &testGraphVertex{Name: "unit2"} + unit3 := &testGraphVertex{Name: "unit3"} + unit4 := &testGraphVertex{Name: "unit4"} + + // Unit1 depends on completion of both unit2 and unit3 (same status type) + err := graph.AddEdge(unit1, unit2, testEdgeCompleted) + require.NoError(t, err) + err = graph.AddEdge(unit1, unit3, testEdgeCompleted) + require.NoError(t, err) + + // Unit1 also depends on starting of unit4 (different status type) + err = graph.AddEdge(unit1, unit4, testEdgeStarted) + require.NoError(t, err) + + // Check that unit1 has 3 forward dependencies + forwardEdges := graph.GetForwardAdjacentVertices(unit1) + require.Len(t, forwardEdges, 3) + + // Verify all expected dependencies exist + expectedDependencies := []testEdge{ + {From: unit1, To: unit2, Edge: testEdgeCompleted}, + {From: unit1, To: unit3, Edge: testEdgeCompleted}, + {From: unit1, To: unit4, Edge: testEdgeStarted}, + } + + for _, expected := range expectedDependencies { + require.Contains(t, forwardEdges, expected) + } + + // Check reverse dependencies + unit2ReverseEdges := graph.GetReverseAdjacentVertices(unit2) + require.Len(t, unit2ReverseEdges, 1) + require.Contains(t, unit2ReverseEdges, testEdge{ + From: unit1, To: unit2, Edge: testEdgeCompleted, + }) + + unit3ReverseEdges := graph.GetReverseAdjacentVertices(unit3) + require.Len(t, unit3ReverseEdges, 1) + require.Contains(t, unit3ReverseEdges, testEdge{ + From: unit1, To: unit3, Edge: testEdgeCompleted, + }) + + unit4ReverseEdges := graph.GetReverseAdjacentVertices(unit4) + require.Len(t, unit4ReverseEdges, 1) + require.Contains(t, unit4ReverseEdges, testEdge{ + From: unit1, To: unit4, Edge: testEdgeStarted, + }) + + return graph + }, + } + + for testName, testFunc := range testFuncs { + var graph *testGraph + t.Run(testName, func(t *testing.T) { + t.Parallel() + graph = testFunc(t) + assertDOTGraph(t, graph, testName) + }) + } +} + +func TestGraphThreadSafety(t *testing.T) { + t.Parallel() + + t.Run("ConcurrentReadWrite", func(t *testing.T) { + t.Parallel() + + graph := &testGraph{} + var wg sync.WaitGroup + const numWriters = 50 + const numReaders = 100 + const operationsPerWriter = 1000 + const operationsPerReader = 2000 + + barrier := make(chan struct{}) + // Launch writers + for i := 0; i < numWriters; i++ { + wg.Add(1) + go func(writerID int) { + defer wg.Done() + <-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)} + graph.AddEdge(from, to, testEdgeCompleted) + } + }(i) + } + + // Launch readers + readerResults := make([]struct { + panicked bool + readCount int + }, numReaders) + + for i := 0; i < numReaders; i++ { + wg.Add(1) + go func(readerID int) { + defer wg.Done() + <-barrier + defer func() { + if r := recover(); r != nil { + readerResults[readerID].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)} + forwardEdges := graph.GetForwardAdjacentVertices(testUnit) + reverseEdges := graph.GetReverseAdjacentVertices(testUnit) + + // Just verify no panics (results may be nil for non-existent vertices) + _ = forwardEdges + _ = reverseEdges + readCount++ + } + readerResults[readerID].readCount = readCount + }(i) + } + + close(barrier) + wg.Wait() + + // Verify no panics occurred in readers + for i, result := range readerResults { + require.False(t, result.panicked, "reader %d panicked", i) + require.Equal(t, operationsPerReader, result.readCount, "reader %d should have performed expected reads", i) + } + }) + + t.Run("ConcurrentCycleDetection", func(t *testing.T) { + t.Parallel() + + graph := &testGraph{} + + // Pre-create chain: A→B→C→D + unitA := &testGraphVertex{Name: "A"} + unitB := &testGraphVertex{Name: "B"} + unitC := &testGraphVertex{Name: "C"} + unitD := &testGraphVertex{Name: "D"} + + err := graph.AddEdge(unitA, unitB, testEdgeCompleted) + require.NoError(t, err) + err = graph.AddEdge(unitB, unitC, testEdgeCompleted) + require.NoError(t, err) + err = graph.AddEdge(unitC, unitD, testEdgeCompleted) + require.NoError(t, err) + + barrier := make(chan struct{}) + var wg sync.WaitGroup + const numGoroutines = 50 + cycleErrors := make([]error, numGoroutines) + + // 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() + <-barrier + err := graph.AddEdge(unitD, unitA, testEdgeCompleted) + cycleErrors[goroutineID] = err + }(i) + } + + close(barrier) + wg.Wait() + + // Verify all attempts correctly returned cycle error + for i, err := range cycleErrors { + require.Error(t, err, "goroutine %d should have detected cycle", i) + require.Contains(t, err.Error(), "would create a cycle") + } + + // Verify graph remains valid (original chain intact) + dot, err := graph.ToDOT("test") + require.NoError(t, err) + require.NotEmpty(t, dot) + }) + + t.Run("ConcurrentToDOT", func(t *testing.T) { + t.Parallel() + + graph := &testGraph{} + + // Pre-populate graph + for i := 0; i < 20; i++ { + from := &testGraphVertex{Name: fmt.Sprintf("dot-unit-%d", i)} + to := &testGraphVertex{Name: fmt.Sprintf("dot-unit-%d", i+1)} + err := graph.AddEdge(from, to, testEdgeCompleted) + require.NoError(t, err) + } + + barrier := make(chan struct{}) + var wg sync.WaitGroup + const numReaders = 100 + const numWriters = 20 + dotResults := make([]string, numReaders) + + // Launch readers calling ToDOT + dotErrors := make([]error, numReaders) + for i := 0; i < numReaders; i++ { + wg.Add(1) + go func(readerID int) { + defer wg.Done() + <-barrier + dot, err := graph.ToDOT(fmt.Sprintf("test-%d", readerID)) + dotErrors[readerID] = err + if err == nil { + dotResults[readerID] = dot + } + }(i) + } + + // Launch writers adding edges + for i := 0; i < numWriters; i++ { + wg.Add(1) + go func(writerID int) { + defer wg.Done() + <-barrier + from := &testGraphVertex{Name: fmt.Sprintf("writer-dot-%d", writerID)} + to := &testGraphVertex{Name: fmt.Sprintf("writer-dot-target-%d", writerID)} + graph.AddEdge(from, to, testEdgeCompleted) + }(i) + } + + close(barrier) + wg.Wait() + + // Verify no errors occurred during DOT generation + for i, err := range dotErrors { + require.NoError(t, err, "DOT generation error at index %d", i) + } + + // Verify all DOT results are valid + for i, dot := range dotResults { + require.NotEmpty(t, dot, "DOT result %d should not be empty", i) + } + }) +} + +func BenchmarkGraph_ConcurrentMixedOperations(b *testing.B) { + graph := &testGraph{} + var wg sync.WaitGroup + const numGoroutines = 200 + + b.ResetTimer() + 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() + operationCount := 0 + + for operationCount < 50 { + operation := float32(randInt(100)) / 100.0 + + if operation < 0.6 { // 60% reads + // Read operation + testUnit := &testGraphVertex{Name: fmt.Sprintf("bench-read-%d-%d", goroutineID, operationCount)} + forwardEdges := graph.GetForwardAdjacentVertices(testUnit) + reverseEdges := graph.GetReverseAdjacentVertices(testUnit) + + // Just verify no panics (results may be nil for non-existent vertices) + _ = forwardEdges + _ = 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)} + graph.AddEdge(from, to, testEdgeCompleted) + } + + operationCount++ + } + }(j) + } + + wg.Wait() + } +} diff --git a/agent/unit/testdata/Cycle.golden b/agent/unit/testdata/Cycle.golden new file mode 100644 index 0000000000000..6fb842460101c --- /dev/null +++ b/agent/unit/testdata/Cycle.golden @@ -0,0 +1,8 @@ +strict digraph Cycle { + // Node definitions. + 1; + 2; + + // Edge definitions. + 1 -> 2; +} \ No newline at end of file diff --git a/agent/unit/testdata/ForwardAndReverseEdges.golden b/agent/unit/testdata/ForwardAndReverseEdges.golden new file mode 100644 index 0000000000000..36cf2218fbbc2 --- /dev/null +++ b/agent/unit/testdata/ForwardAndReverseEdges.golden @@ -0,0 +1,10 @@ +strict digraph ForwardAndReverseEdges { + // Node definitions. + 1; + 2; + 3; + + // Edge definitions. + 1 -> 2; + 1 -> 3; +} \ No newline at end of file diff --git a/agent/unit/testdata/MultipleDependenciesSameStatus.golden b/agent/unit/testdata/MultipleDependenciesSameStatus.golden new file mode 100644 index 0000000000000..af7cbb71e0e22 --- /dev/null +++ b/agent/unit/testdata/MultipleDependenciesSameStatus.golden @@ -0,0 +1,12 @@ +strict digraph MultipleDependenciesSameStatus { + // Node definitions. + 1; + 2; + 3; + 4; + + // Edge definitions. + 1 -> 2; + 1 -> 3; + 1 -> 4; +} \ No newline at end of file diff --git a/agent/unit/testdata/SelfReference.golden b/agent/unit/testdata/SelfReference.golden new file mode 100644 index 0000000000000..d0d036d6fb66a --- /dev/null +++ b/agent/unit/testdata/SelfReference.golden @@ -0,0 +1,4 @@ +strict digraph SelfReference { + // Node definitions. + 1; +} \ No newline at end of file diff --git a/cli/allowlistflag.go b/cli/allowlistflag.go new file mode 100644 index 0000000000000..8885dcceb9d22 --- /dev/null +++ b/cli/allowlistflag.go @@ -0,0 +1,78 @@ +package cli + +import ( + "encoding/csv" + "strings" + + "github.com/spf13/pflag" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" +) + +var ( + _ pflag.SliceValue = &allowListFlag{} + _ pflag.Value = &allowListFlag{} +) + +// allowListFlag implements pflag.SliceValue for codersdk.APIAllowListTarget entries. +type allowListFlag []codersdk.APIAllowListTarget + +func AllowListFlagOf(al *[]codersdk.APIAllowListTarget) *allowListFlag { + return (*allowListFlag)(al) +} + +func (a allowListFlag) String() string { + return strings.Join(a.GetSlice(), ",") +} + +func (a allowListFlag) Value() []codersdk.APIAllowListTarget { + return []codersdk.APIAllowListTarget(a) +} + +func (allowListFlag) Type() string { return "allow-list" } + +func (a *allowListFlag) Set(set string) error { + values, err := csv.NewReader(strings.NewReader(set)).Read() + if err != nil { + return xerrors.Errorf("parse allow list entries as csv: %w", err) + } + for _, v := range values { + if err := a.Append(v); err != nil { + return err + } + } + return nil +} + +func (a *allowListFlag) Append(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return xerrors.New("allow list entry cannot be empty") + } + var target codersdk.APIAllowListTarget + if err := target.UnmarshalText([]byte(value)); err != nil { + return err + } + + *a = append(*a, target) + return nil +} + +func (a *allowListFlag) Replace(items []string) error { + *a = []codersdk.APIAllowListTarget{} + for _, item := range items { + if err := a.Append(item); err != nil { + return err + } + } + return nil +} + +func (a *allowListFlag) GetSlice() []string { + out := make([]string, len(*a)) + for i, entry := range *a { + out[i] = entry.String() + } + return out +} diff --git a/cli/cliui/table.go b/cli/cliui/table.go index 478bbe2260f91..c82854802224d 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -296,22 +296,23 @@ func renderTable(out any, sort string, headers table.Row, filterColumns []string // returned. If the table tag is malformed, an error is returned. // // The returned name is transformed from "snake_case" to "normal text". -func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName bool, err error) { +func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName, emptyNil bool, err error) { tags, err := structtag.Parse(string(field.Tag)) if err != nil { - return "", false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) + return "", false, false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) } tag, err := tags.Get("table") if err != nil || tag.Name == "-" { // tags.Get only returns an error if the tag is not found. - return "", false, false, false, false, nil + return "", false, false, false, false, false, nil } defaultSortOpt := false noSortOpt = false recursiveOpt := false skipParentNameOpt := false + emptyNilOpt := false for _, opt := range tag.Options { switch opt { case "default_sort": @@ -326,12 +327,14 @@ func parseTableStructTag(field reflect.StructField) (name string, defaultSort, n // make sure the child name is unique across all nested structs in the parent. recursiveOpt = true skipParentNameOpt = true + case "empty_nil": + emptyNilOpt = true default: - return "", false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt) + return "", false, false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt) } } - return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, nil + return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, emptyNilOpt, nil } func isStructOrStructPointer(t reflect.Type) bool { @@ -358,7 +361,7 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string, noSortOpt := false for i := 0; i < t.NumField(); i++ { field := t.Field(i) - name, defaultSort, noSort, recursive, skip, err := parseTableStructTag(field) + name, defaultSort, noSort, recursive, skip, _, err := parseTableStructTag(field) if err != nil { return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err) } @@ -435,7 +438,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) { for i := 0; i < val.NumField(); i++ { field := val.Type().Field(i) fieldVal := val.Field(i) - name, _, _, recursive, skip, err := parseTableStructTag(field) + name, _, _, recursive, skip, emptyNil, err := parseTableStructTag(field) if err != nil { return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err) } @@ -443,8 +446,14 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) { continue } - // Recurse if it's a struct. fieldType := field.Type + + // If empty_nil is set and this is a nil pointer, use a zero value. + if emptyNil && fieldVal.Kind() == reflect.Pointer && fieldVal.IsNil() { + fieldVal = reflect.New(fieldType.Elem()) + } + + // Recurse if it's a struct. if recursive { if !isStructOrStructPointer(fieldType) { return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, fieldType.String()) @@ -467,7 +476,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) { } // Otherwise, we just use the field value. - row[name] = val.Field(i).Interface() + row[name] = fieldVal.Interface() } return row, nil diff --git a/cli/cliui/table_test.go b/cli/cliui/table_test.go index 4e82707f3fec8..424b9c9a7d6f3 100644 --- a/cli/cliui/table_test.go +++ b/cli/cliui/table_test.go @@ -400,6 +400,78 @@ foo 10 [a, b, c] foo1 11 foo2 12 fo }) }) }) + + t.Run("EmptyNil", func(t *testing.T) { + t.Parallel() + + type emptyNilTest struct { + Name string `table:"name,default_sort"` + EmptyOnNil *string `table:"empty_on_nil,empty_nil"` + NormalBehavior *string `table:"normal_behavior"` + } + + value := "value" + in := []emptyNilTest{ + { + Name: "has_value", + EmptyOnNil: &value, + NormalBehavior: &value, + }, + { + Name: "has_nil", + EmptyOnNil: nil, + NormalBehavior: nil, + }, + } + + expected := ` +NAME EMPTY ON NIL NORMAL BEHAVIOR +has_nil +has_value value value + ` + + out, err := cliui.DisplayTable(in, "", nil) + log.Println("rendered table:\n" + out) + require.NoError(t, err) + compareTables(t, expected, out) + }) + + t.Run("EmptyNilWithRecursiveInline", func(t *testing.T) { + t.Parallel() + + type nestedData struct { + Name string `table:"name"` + } + + type inlineTest struct { + Nested *nestedData `table:"ignored,recursive_inline,empty_nil"` + Count int `table:"count,default_sort"` + } + + in := []inlineTest{ + { + Nested: &nestedData{ + Name: "alice", + }, + Count: 1, + }, + { + Nested: nil, + Count: 2, + }, + } + + expected := ` +NAME COUNT +alice 1 + 2 + ` + + out, err := cliui.DisplayTable(in, "", nil) + log.Println("rendered table:\n" + out) + require.NoError(t, err) + compareTables(t, expected, out) + }) } // compareTables normalizes the incoming table lines diff --git a/cli/delete_test.go b/cli/delete_test.go index 2e550d74849ab..271f5342ea91c 100644 --- a/cli/delete_test.go +++ b/cli/delete_test.go @@ -185,9 +185,6 @@ func TestDelete(t *testing.T) { t.Run("WarnNoProvisioners", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } store, ps, db := dbtestutil.NewDBWithSQLDB(t) client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ @@ -228,9 +225,6 @@ func TestDelete(t *testing.T) { t.Run("Prebuilt workspace delete permissions", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } // Setup db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) diff --git a/cli/exp_scaletest_dynamicparameters.go b/cli/exp_scaletest_dynamicparameters.go index 3c6922a8d4dd6..31b6766ac6acf 100644 --- a/cli/exp_scaletest_dynamicparameters.go +++ b/cli/exp_scaletest_dynamicparameters.go @@ -27,6 +27,7 @@ const ( func (r *RootCmd) scaletestDynamicParameters() *serpent.Command { var ( templateName string + provisionerTags []string numEvals int64 tracingFlags = &scaletestTracingFlags{} prometheusFlags = &scaletestPrometheusFlags{} @@ -56,6 +57,11 @@ func (r *RootCmd) scaletestDynamicParameters() *serpent.Command { return xerrors.Errorf("template cannot be empty") } + tags, err := ParseProvisionerTags(provisionerTags) + if err != nil { + return err + } + org, err := orgContext.Selected(inv, client) if err != nil { return err @@ -99,7 +105,7 @@ func (r *RootCmd) scaletestDynamicParameters() *serpent.Command { }() tracer := tracerProvider.Tracer(scaletestTracerName) - partitions, err := dynamicparameters.SetupPartitions(ctx, client, org.ID, templateName, numEvals, logger) + partitions, err := dynamicparameters.SetupPartitions(ctx, client, org.ID, templateName, tags, numEvals, logger) if err != nil { return xerrors.Errorf("setup dynamic parameters partitions: %w", err) } @@ -160,6 +166,11 @@ func (r *RootCmd) scaletestDynamicParameters() *serpent.Command { Default: "100", Value: serpent.Int64Of(&numEvals), }, + { + Flag: "provisioner-tag", + Description: "Specify a set of tags to target provisioner daemons.", + Value: serpent.StringArrayOf(&provisionerTags), + }, } orgContext.AttachOptions(cmd) output.attach(&cmd.Options) diff --git a/cli/exp_task_delete.go b/cli/exp_task_delete.go index 43675057bde79..1611e4196e6c0 100644 --- a/cli/exp_task_delete.go +++ b/cli/exp_task_delete.go @@ -5,7 +5,6 @@ import ( "strings" "time" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/pretty" @@ -47,43 +46,19 @@ func (r *RootCmd) taskDelete() *serpent.Command { } exp := codersdk.NewExperimentalClient(client) - type toDelete struct { - ID uuid.UUID - Owner string - Display string - } - - var items []toDelete + var tasks []codersdk.Task for _, identifier := range inv.Args { - identifier = strings.TrimSpace(identifier) - if identifier == "" { - return xerrors.New("task identifier cannot be empty or whitespace") - } - - // Check task identifier, try UUID first. - if id, err := uuid.Parse(identifier); err == nil { - task, err := exp.TaskByID(ctx, id) - if err != nil { - return xerrors.Errorf("resolve task %q: %w", identifier, err) - } - display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) - items = append(items, toDelete{ID: id, Display: display, Owner: task.OwnerName}) - continue - } - - // Non-UUID, treat as a workspace identifier (name or owner/name). - ws, err := namedWorkspace(ctx, client, identifier) + task, err := exp.TaskByIdentifier(ctx, identifier) if err != nil { return xerrors.Errorf("resolve task %q: %w", identifier, err) } - display := ws.FullName() - items = append(items, toDelete{ID: ws.ID, Display: display, Owner: ws.OwnerName}) + tasks = append(tasks, task) } // Confirm deletion of the tasks. var displayList []string - for _, it := range items { - displayList = append(displayList, it.Display) + for _, task := range tasks { + displayList = append(displayList, fmt.Sprintf("%s/%s", task.OwnerName, task.Name)) } _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: fmt.Sprintf("Delete these tasks: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(displayList, ", "))), @@ -94,12 +69,13 @@ func (r *RootCmd) taskDelete() *serpent.Command { return err } - for _, item := range items { - if err := exp.DeleteTask(ctx, item.Owner, item.ID); err != nil { - return xerrors.Errorf("delete task %q: %w", item.Display, err) + for i, task := range tasks { + display := displayList[i] + if err := exp.DeleteTask(ctx, task.OwnerName, task.ID); err != nil { + return xerrors.Errorf("delete task %q: %w", display, err) } _, _ = fmt.Fprintln( - inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, item.Display)+" at "+cliui.Timestamp(time.Now()), + inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, display)+" at "+cliui.Timestamp(time.Now()), ) } diff --git a/cli/exp_task_delete_test.go b/cli/exp_task_delete_test.go index 0b288c4ca3379..e90ee8c5b19ba 100644 --- a/cli/exp_task_delete_test.go +++ b/cli/exp_task_delete_test.go @@ -56,12 +56,18 @@ func TestExpTaskDelete(t *testing.T) { taskID := uuid.MustParse(id1) return func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/exists": + case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"": c.nameResolves.Add(1) - httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{ - ID: taskID, - Name: "exists", - OwnerName: "me", + httpapi.Write(r.Context(), w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: taskID, + Name: "exists", + OwnerName: "me", + }}, + Count: 1, }) case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id1: c.deleteCalls.Add(1) @@ -104,12 +110,18 @@ func TestExpTaskDelete(t *testing.T) { firstID := uuid.MustParse(id3) return func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/first": + case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"": c.nameResolves.Add(1) - httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{ - ID: firstID, - Name: "first", - OwnerName: "me", + httpapi.Write(r.Context(), w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: firstID, + Name: "first", + OwnerName: "me", + }}, + Count: 1, }) case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id4: httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ @@ -139,8 +151,14 @@ func TestExpTaskDelete(t *testing.T) { buildHandler: func(_ *testCounters) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/doesnotexist": - httpapi.ResourceNotFound(w) + case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"": + httpapi.Write(r.Context(), w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{}, + Count: 0, + }) default: httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) } @@ -156,12 +174,18 @@ func TestExpTaskDelete(t *testing.T) { taskID := uuid.MustParse(id5) return func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/bad": + case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks" && r.URL.Query().Get("q") == "owner:\"me\"": c.nameResolves.Add(1) - httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{ - ID: taskID, - Name: "bad", - OwnerName: "me", + httpapi.Write(r.Context(), w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: taskID, + Name: "bad", + OwnerName: "me", + }}, + Count: 1, }) case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id5: httpapi.InternalServerError(w, xerrors.New("boom")) diff --git a/cli/exp_task_list.go b/cli/exp_task_list.go index e4d558e35d611..89b313a1f49c5 100644 --- a/cli/exp_task_list.go +++ b/cli/exp_task_list.go @@ -8,6 +8,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -98,10 +99,10 @@ func (r *RootCmd) taskList() *serpent.Command { Options: serpent.OptionSet{ { Name: "status", - Description: "Filter by task status (e.g. running, failed, etc).", + Description: "Filter by task status.", Flag: "status", Default: "", - Value: serpent.StringOf(&statusFilter), + Value: serpent.EnumOf(&statusFilter, slice.ToStrings(codersdk.AllTaskStatuses())...), }, { Name: "all", @@ -143,7 +144,7 @@ func (r *RootCmd) taskList() *serpent.Command { tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{ Owner: targetUser, - Status: statusFilter, + Status: codersdk.TaskStatus(statusFilter), }) if err != nil { return xerrors.Errorf("list tasks: %w", err) diff --git a/cli/exp_task_list_test.go b/cli/exp_task_list_test.go index 2761588a3859e..d297310dc4fc3 100644 --- a/cli/exp_task_list_test.go +++ b/cli/exp_task_list_test.go @@ -22,6 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" @@ -29,7 +30,7 @@ import ( ) // makeAITask creates an AI-task workspace. -func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) (workspace database.WorkspaceTable) { +func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) database.Task { t.Helper() tv := dbfake.TemplateVersion(t, db). @@ -91,7 +92,27 @@ func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UU ) require.NoError(t, err) - return build.Workspace + // Create a task record in the tasks table for the new data model. + task := dbgen.Task(t, db, database.TaskTable{ + OrganizationID: orgID, + OwnerID: ownerID, + Name: build.Workspace.Name, + WorkspaceID: uuid.NullUUID{UUID: build.Workspace.ID, Valid: true}, + TemplateVersionID: tv.TemplateVersion.ID, + TemplateParameters: []byte("{}"), + Prompt: prompt, + CreatedAt: dbtime.Now(), + }) + + // Link the task to the workspace app. + dbgen.TaskWorkspaceApp(t, db, database.TaskWorkspaceApp{ + TaskID: task.ID, + WorkspaceBuildNumber: build.Build.BuildNumber, + WorkspaceAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + }) + + return task } func TestExpTaskList(t *testing.T) { @@ -128,7 +149,7 @@ func TestExpTaskList(t *testing.T) { memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) wantPrompt := "build me a web app" - ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt) + task := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt) inv, root := clitest.New(t, "exp", "task", "list", "--column", "id,name,status,initial prompt") clitest.SetupConfig(t, memberClient, root) @@ -140,8 +161,8 @@ func TestExpTaskList(t *testing.T) { require.NoError(t, err) // Validate the table includes the task and status. - pty.ExpectMatch(ws.Name) - pty.ExpectMatch("running") + pty.ExpectMatch(task.Name) + pty.ExpectMatch("initializing") pty.ExpectMatch(wantPrompt) }) @@ -154,12 +175,12 @@ func TestExpTaskList(t *testing.T) { owner := coderdtest.CreateFirstUser(t, client) memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - // Create two AI tasks: one running, one stopped. - running := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running") - stopped := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please") + // Create two AI tasks: one initializing, one paused. + initializingTask := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me initializing") + pausedTask := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please") // Use JSON output to reliably validate filtering. - inv, root := clitest.New(t, "exp", "task", "list", "--status=stopped", "--output=json") + inv, root := clitest.New(t, "exp", "task", "list", "--status=paused", "--output=json") clitest.SetupConfig(t, memberClient, root) ctx := testutil.Context(t, testutil.WaitShort) @@ -173,10 +194,10 @@ func TestExpTaskList(t *testing.T) { var tasks []codersdk.Task require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks)) - // Only the stopped task is returned. + // Only the paused task is returned. require.Len(t, tasks, 1, "expected one task after filtering") - require.Equal(t, stopped.ID, tasks[0].ID) - require.NotEqual(t, running.ID, tasks[0].ID) + require.Equal(t, pausedTask.ID, tasks[0].ID) + require.NotEqual(t, initializingTask.ID, tasks[0].ID) }) t.Run("UserFlag_Me_Table", func(t *testing.T) { @@ -188,7 +209,7 @@ func TestExpTaskList(t *testing.T) { _, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) _ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "other-task") - ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task") + task := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task") inv, root := clitest.New(t, "exp", "task", "list", "--user", "me") //nolint:gocritic // Owner client is intended here smoke test the member task not showing up. @@ -200,7 +221,7 @@ func TestExpTaskList(t *testing.T) { err := inv.WithContext(ctx).Run() require.NoError(t, err) - pty.ExpectMatch(ws.Name) + pty.ExpectMatch(task.Name) }) t.Run("Quiet", func(t *testing.T) { @@ -213,7 +234,7 @@ func TestExpTaskList(t *testing.T) { memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) // Given: We have two tasks - task1 := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running") + task1 := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me active") task2 := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please") // Given: We add the `--quiet` flag diff --git a/cli/exp_task_logs.go b/cli/exp_task_logs.go index 6c99df3edf7aa..d1d4a826cd9ce 100644 --- a/cli/exp_task_logs.go +++ b/cli/exp_task_logs.go @@ -3,7 +3,6 @@ package cli import ( "fmt" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" @@ -41,24 +40,17 @@ func (r *RootCmd) taskLogs() *serpent.Command { } var ( - ctx = inv.Context() - exp = codersdk.NewExperimentalClient(client) - task = inv.Args[0] - taskID uuid.UUID + ctx = inv.Context() + exp = codersdk.NewExperimentalClient(client) + identifier = inv.Args[0] ) - if id, err := uuid.Parse(task); err == nil { - taskID = id - } else { - ws, err := namedWorkspace(ctx, client, task) - if err != nil { - return xerrors.Errorf("resolve task %q: %w", task, err) - } - - taskID = ws.ID + task, err := exp.TaskByIdentifier(ctx, identifier) + if err != nil { + return xerrors.Errorf("resolve task %q: %w", identifier, err) } - logs, err := exp.TaskLogs(ctx, codersdk.Me, taskID) + logs, err := exp.TaskLogs(ctx, codersdk.Me, task.ID) if err != nil { return xerrors.Errorf("get task logs: %w", err) } diff --git a/cli/exp_task_logs_test.go b/cli/exp_task_logs_test.go index 69905aa434af0..859ff135d0d63 100644 --- a/cli/exp_task_logs_test.go +++ b/cli/exp_task_logs_test.go @@ -38,15 +38,15 @@ func Test_TaskLogs(t *testing.T) { }, } - t.Run("ByWorkspaceName_JSON", func(t *testing.T) { + t.Run("ByTaskName_JSON", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) userClient := client // user already has access to their own workspace var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "logs", workspace.Name, "--output", "json") + inv, root := clitest.New(t, "exp", "task", "logs", task.Name, "--output", "json") inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) @@ -64,15 +64,15 @@ func Test_TaskLogs(t *testing.T) { require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type) }) - t.Run("ByWorkspaceID_JSON", func(t *testing.T) { + t.Run("ByTaskID_JSON", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) userClient := client var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "logs", workspace.ID.String(), "--output", "json") + inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String(), "--output", "json") inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) @@ -90,15 +90,15 @@ func Test_TaskLogs(t *testing.T) { require.Equal(t, codersdk.TaskLogTypeOutput, logs[1].Type) }) - t.Run("ByWorkspaceID_Table", func(t *testing.T) { + t.Run("ByTaskID_Table", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsOK(testMessages)) userClient := client var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "logs", workspace.ID.String()) + inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String()) inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) @@ -112,7 +112,7 @@ func Test_TaskLogs(t *testing.T) { require.Contains(t, output, "output") }) - t.Run("WorkspaceNotFound_ByName", func(t *testing.T) { + t.Run("TaskNotFound_ByName", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -130,7 +130,7 @@ func Test_TaskLogs(t *testing.T) { require.ErrorContains(t, err, httpapi.ResourceNotFoundResponse.Message) }) - t.Run("WorkspaceNotFound_ByID", func(t *testing.T) { + t.Run("TaskNotFound_ByID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -152,10 +152,10 @@ func Test_TaskLogs(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsErr(assert.AnError)) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskLogsErr(assert.AnError)) userClient := client - inv, root := clitest.New(t, "exp", "task", "logs", workspace.ID.String()) + inv, root := clitest.New(t, "exp", "task", "logs", task.ID.String()) clitest.SetupConfig(t, userClient, root) err := inv.WithContext(ctx).Run() diff --git a/cli/exp_task_send.go b/cli/exp_task_send.go index 4d9e10bbddae5..e8985d55d97da 100644 --- a/cli/exp_task_send.go +++ b/cli/exp_task_send.go @@ -3,7 +3,6 @@ package cli import ( "io" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/codersdk" @@ -39,12 +38,11 @@ func (r *RootCmd) taskSend() *serpent.Command { } var ( - ctx = inv.Context() - exp = codersdk.NewExperimentalClient(client) - task = inv.Args[0] + ctx = inv.Context() + exp = codersdk.NewExperimentalClient(client) + identifier = inv.Args[0] taskInput string - taskID uuid.UUID ) if stdin { @@ -62,18 +60,12 @@ func (r *RootCmd) taskSend() *serpent.Command { taskInput = inv.Args[1] } - if id, err := uuid.Parse(task); err == nil { - taskID = id - } else { - ws, err := namedWorkspace(ctx, client, task) - if err != nil { - return xerrors.Errorf("resolve task: %w", err) - } - - taskID = ws.ID + task, err := exp.TaskByIdentifier(ctx, identifier) + if err != nil { + return xerrors.Errorf("resolve task: %w", err) } - if err = exp.TaskSend(ctx, codersdk.Me, taskID, codersdk.TaskSendRequest{Input: taskInput}); err != nil { + if err = exp.TaskSend(ctx, codersdk.Me, task.ID, codersdk.TaskSendRequest{Input: taskInput}); err != nil { return xerrors.Errorf("send input to task: %w", err) } diff --git a/cli/exp_task_send_test.go b/cli/exp_task_send_test.go index cb8ee74d06398..3529cf2e0b9b5 100644 --- a/cli/exp_task_send_test.go +++ b/cli/exp_task_send_test.go @@ -22,15 +22,15 @@ import ( func Test_TaskSend(t *testing.T) { t.Parallel() - t.Run("ByWorkspaceName_WithArgument", func(t *testing.T) { + t.Run("ByTaskName_WithArgument", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) userClient := client var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "send", workspace.Name, "carry on with the task") + inv, root := clitest.New(t, "exp", "task", "send", task.Name, "carry on with the task") inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) @@ -38,15 +38,15 @@ func Test_TaskSend(t *testing.T) { require.NoError(t, err) }) - t.Run("ByWorkspaceID_WithArgument", func(t *testing.T) { + t.Run("ByTaskID_WithArgument", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) userClient := client var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "send", workspace.ID.String(), "carry on with the task") + inv, root := clitest.New(t, "exp", "task", "send", task.ID.String(), "carry on with the task") inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) @@ -54,15 +54,15 @@ func Test_TaskSend(t *testing.T) { require.NoError(t, err) }) - t.Run("ByWorkspaceName_WithStdin", func(t *testing.T) { + t.Run("ByTaskName_WithStdin", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - client, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) + client, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) userClient := client var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "send", workspace.Name, "--stdin") + inv, root := clitest.New(t, "exp", "task", "send", task.Name, "--stdin") inv.Stdout = &stdout inv.Stdin = strings.NewReader("carry on with the task") clitest.SetupConfig(t, userClient, root) @@ -71,7 +71,7 @@ func Test_TaskSend(t *testing.T) { require.NoError(t, err) }) - t.Run("WorkspaceNotFound_ByName", func(t *testing.T) { + t.Run("TaskNotFound_ByName", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -89,7 +89,7 @@ func Test_TaskSend(t *testing.T) { require.ErrorContains(t, err, httpapi.ResourceNotFoundResponse.Message) }) - t.Run("WorkspaceNotFound_ByID", func(t *testing.T) { + t.Run("TaskNotFound_ByID", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -111,10 +111,10 @@ func Test_TaskSend(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - userClient, workspace := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendErr(t, assert.AnError)) + userClient, task := setupCLITaskTest(ctx, t, fakeAgentAPITaskSendErr(t, assert.AnError)) var stdout strings.Builder - inv, root := clitest.New(t, "exp", "task", "send", workspace.Name, "some task input") + inv, root := clitest.New(t, "exp", "task", "send", task.Name, "some task input") inv.Stdout = &stdout clitest.SetupConfig(t, userClient, root) diff --git a/cli/exp_task_status.go b/cli/exp_task_status.go index a43fd4feedfe2..1bd77f5f7f5b3 100644 --- a/cli/exp_task_status.go +++ b/cli/exp_task_status.go @@ -5,7 +5,6 @@ import ( "strings" "time" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" @@ -84,21 +83,10 @@ func (r *RootCmd) taskStatus() *serpent.Command { } ctx := i.Context() - ec := codersdk.NewExperimentalClient(client) + exp := codersdk.NewExperimentalClient(client) identifier := i.Args[0] - taskID, err := uuid.Parse(identifier) - if err != nil { - // Try to resolve the task as a named workspace - // TODO: right now tasks are still "workspaces" under the hood. - // We should update this once we have a proper task model. - ws, err := namedWorkspace(ctx, client, identifier) - if err != nil { - return err - } - taskID = ws.ID - } - task, err := ec.TaskByID(ctx, taskID) + task, err := exp.TaskByIdentifier(ctx, identifier) if err != nil { return err } @@ -119,7 +107,7 @@ func (r *RootCmd) taskStatus() *serpent.Command { // TODO: implement streaming updates instead of polling lastStatusRow := tsr for range t.C { - task, err := ec.TaskByID(ctx, taskID) + task, err := exp.TaskByID(ctx, task.ID) if err != nil { return err } @@ -152,7 +140,7 @@ func (r *RootCmd) taskStatus() *serpent.Command { } func taskWatchIsEnded(task codersdk.Task) bool { - if task.Status == codersdk.WorkspaceStatusStopped { + if task.WorkspaceStatus == codersdk.WorkspaceStatusStopped { return true } if task.WorkspaceAgentHealth == nil || !task.WorkspaceAgentHealth.Healthy { @@ -168,28 +156,21 @@ func taskWatchIsEnded(task codersdk.Task) bool { } type taskStatusRow struct { - codersdk.Task `table:"-"` - ChangedAgo string `json:"-" table:"state changed,default_sort"` - Timestamp time.Time `json:"-" table:"-"` - TaskStatus string `json:"-" table:"status"` - Healthy bool `json:"-" table:"healthy"` - TaskState string `json:"-" table:"state"` - Message string `json:"-" table:"message"` + codersdk.Task `table:"r,recursive_inline"` + ChangedAgo string `json:"-" table:"state changed"` + Healthy bool `json:"-" table:"healthy"` } func taskStatusRowEqual(r1, r2 taskStatusRow) bool { - return r1.TaskStatus == r2.TaskStatus && + return r1.Status == r2.Status && r1.Healthy == r2.Healthy && - r1.TaskState == r2.TaskState && - r1.Message == r2.Message + taskStateEqual(r1.CurrentState, r2.CurrentState) } func toStatusRow(task codersdk.Task) taskStatusRow { tsr := taskStatusRow{ Task: task, ChangedAgo: time.Since(task.UpdatedAt).Truncate(time.Second).String() + " ago", - Timestamp: task.UpdatedAt, - TaskStatus: string(task.Status), } tsr.Healthy = task.WorkspaceAgentHealth != nil && task.WorkspaceAgentHealth.Healthy && @@ -199,9 +180,19 @@ func toStatusRow(task codersdk.Task) taskStatusRow { if task.CurrentState != nil { tsr.ChangedAgo = time.Since(task.CurrentState.Timestamp).Truncate(time.Second).String() + " ago" - tsr.Timestamp = task.CurrentState.Timestamp - tsr.TaskState = string(task.CurrentState.State) - tsr.Message = task.CurrentState.Message } return tsr } + +func taskStateEqual(se1, se2 *codersdk.TaskStateEntry) bool { + var s1, m1, s2, m2 string + if se1 != nil { + s1 = string(se1.State) + m1 = se1.Message + } + if se2 != nil { + s2 = string(se2.State) + m2 = se2.Message + } + return s1 == s2 && m1 == m2 +} diff --git a/cli/exp_task_status_test.go b/cli/exp_task_status_test.go index 1e255c05b95bc..f15222d51b0fb 100644 --- a/cli/exp_task_status_test.go +++ b/cli/exp_task_status_test.go @@ -36,26 +36,17 @@ func Test_TaskStatus(t *testing.T) { hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/v2/users/me/workspace/doesnotexist": - httpapi.ResourceNotFound(w) - default: - t.Errorf("unexpected path: %s", r.URL.Path) - } - } - }, - }, - { - args: []string{"err-fetching-workspace"}, - expectError: assert.AnError.Error(), - hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/users/me/workspace/err-fetching-workspace": - httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - }) - case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": - httpapi.InternalServerError(w, assert.AnError) + case "/api/experimental/tasks": + if r.URL.Query().Get("q") == "owner:\"me\"" { + httpapi.Write(ctx, w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{}, + Count: 0, + }) + return + } default: t.Errorf("unexpected path: %s", r.URL.Path) } @@ -64,21 +55,45 @@ func Test_TaskStatus(t *testing.T) { }, { args: []string{"exists"}, - expectOutput: `STATE CHANGED STATUS HEALTHY STATE MESSAGE -0s ago running true working Thinking furiously...`, + expectOutput: `STATE CHANGED STATUS HEALTHY STATE MESSAGE +0s ago active true working Thinking furiously...`, hf: func(ctx context.Context, now time.Time) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/v2/users/me/workspace/exists": - httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - }) + case "/api/experimental/tasks": + if r.URL.Query().Get("q") == "owner:\"me\"" { + httpapi.Write(ctx, w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Name: "exists", + OwnerName: "me", + WorkspaceStatus: codersdk.WorkspaceStatusRunning, + CreatedAt: now, + UpdatedAt: now, + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateWorking, + Timestamp: now, + Message: "Thinking furiously...", + }, + WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ + Healthy: true, + }, + WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), + Status: codersdk.TaskStatusActive, + }}, + Count: 1, + }) + return + } case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - Status: codersdk.WorkspaceStatusRunning, - CreatedAt: now, - UpdatedAt: now, + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + WorkspaceStatus: codersdk.WorkspaceStatusRunning, + CreatedAt: now, + UpdatedAt: now, CurrentState: &codersdk.TaskStateEntry{ State: codersdk.TaskStateWorking, Timestamp: now, @@ -88,7 +103,9 @@ func Test_TaskStatus(t *testing.T) { Healthy: true, }, WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), + Status: codersdk.TaskStatusActive, }) + return default: t.Errorf("unexpected path: %s", r.URL.Path) } @@ -97,50 +114,77 @@ func Test_TaskStatus(t *testing.T) { }, { args: []string{"exists", "--watch"}, - expectOutput: ` -STATE CHANGED STATUS HEALTHY STATE MESSAGE -4s ago running true -3s ago running true working Reticulating splines... -2s ago running true complete Splines reticulated successfully!`, + expectOutput: `STATE CHANGED STATUS HEALTHY STATE MESSAGE +5s ago pending true +4s ago initializing true +4s ago active true +3s ago active true working Reticulating splines... +2s ago active true complete Splines reticulated successfully!`, hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) { var calls atomic.Int64 return func(w http.ResponseWriter, r *http.Request) { - defer calls.Add(1) switch r.URL.Path { - case "/api/v2/users/me/workspace/exists": - httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - }) + case "/api/experimental/tasks": + if r.URL.Query().Get("q") == "owner:\"me\"" { + // Return initial task state for --watch test + httpapi.Write(ctx, w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Name: "exists", + OwnerName: "me", + WorkspaceStatus: codersdk.WorkspaceStatusPending, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-5 * time.Second), + WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ + Healthy: true, + }, + WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), + Status: codersdk.TaskStatusPending, + }}, + Count: 1, + }) + return + } case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": + defer calls.Add(1) switch calls.Load() { case 0: httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - Status: codersdk.WorkspaceStatusPending, - CreatedAt: now.Add(-5 * time.Second), - UpdatedAt: now.Add(-5 * time.Second), + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Name: "exists", + OwnerName: "me", + WorkspaceStatus: codersdk.WorkspaceStatusRunning, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-4 * time.Second), WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ Healthy: true, }, WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), + Status: codersdk.TaskStatusInitializing, }) + return case 1: httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - Status: codersdk.WorkspaceStatusRunning, - CreatedAt: now.Add(-5 * time.Second), + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + WorkspaceStatus: codersdk.WorkspaceStatusRunning, + CreatedAt: now.Add(-5 * time.Second), WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ Healthy: true, }, WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), UpdatedAt: now.Add(-4 * time.Second), + Status: codersdk.TaskStatusActive, }) + return case 2: httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - Status: codersdk.WorkspaceStatusRunning, - CreatedAt: now.Add(-5 * time.Second), - UpdatedAt: now.Add(-4 * time.Second), + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + WorkspaceStatus: codersdk.WorkspaceStatusRunning, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-4 * time.Second), WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ Healthy: true, }, @@ -150,13 +194,15 @@ STATE CHANGED STATUS HEALTHY STATE MESSAGE Timestamp: now.Add(-3 * time.Second), Message: "Reticulating splines...", }, + Status: codersdk.TaskStatusActive, }) + return case 3: httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - Status: codersdk.WorkspaceStatusRunning, - CreatedAt: now.Add(-5 * time.Second), - UpdatedAt: now.Add(-4 * time.Second), + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + WorkspaceStatus: codersdk.WorkspaceStatusRunning, + CreatedAt: now.Add(-5 * time.Second), + UpdatedAt: now.Add(-4 * time.Second), WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ Healthy: true, }, @@ -166,13 +212,16 @@ STATE CHANGED STATUS HEALTHY STATE MESSAGE Timestamp: now.Add(-2 * time.Second), Message: "Splines reticulated successfully!", }, + Status: codersdk.TaskStatusActive, }) + return default: httpapi.InternalServerError(w, xerrors.New("too many calls!")) return } default: httpapi.InternalServerError(w, xerrors.Errorf("unexpected path: %q", r.URL.Path)) + return } } }, @@ -183,19 +232,24 @@ STATE CHANGED STATUS HEALTHY STATE MESSAGE "id": "11111111-1111-1111-1111-111111111111", "organization_id": "00000000-0000-0000-0000-000000000000", "owner_id": "00000000-0000-0000-0000-000000000000", - "owner_name": "", - "name": "", + "owner_name": "me", + "name": "exists", "template_id": "00000000-0000-0000-0000-000000000000", + "template_version_id": "00000000-0000-0000-0000-000000000000", "template_name": "", "template_display_name": "", "template_icon": "", "workspace_id": null, + "workspace_name": "", + "workspace_status": "running", "workspace_agent_id": null, - "workspace_agent_lifecycle": null, - "workspace_agent_health": null, + "workspace_agent_lifecycle": "ready", + "workspace_agent_health": { + "healthy": true + }, "workspace_app_id": null, "initial_prompt": "", - "status": "running", + "status": "active", "current_state": { "timestamp": "2025-08-26T12:34:57Z", "state": "working", @@ -205,26 +259,52 @@ STATE CHANGED STATUS HEALTHY STATE MESSAGE "created_at": "2025-08-26T12:34:56Z", "updated_at": "2025-08-26T12:34:56Z" }`, - hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { + hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) { ts := time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC) return func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/v2/users/me/workspace/exists": - httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - }) + case "/api/experimental/tasks": + if r.URL.Query().Get("q") == "owner:\"me\"" { + httpapi.Write(ctx, w, http.StatusOK, struct { + Tasks []codersdk.Task `json:"tasks"` + Count int `json:"count"` + }{ + Tasks: []codersdk.Task{{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Name: "exists", + OwnerName: "me", + WorkspaceStatus: codersdk.WorkspaceStatusRunning, + CreatedAt: ts, + UpdatedAt: ts, + CurrentState: &codersdk.TaskStateEntry{ + State: codersdk.TaskStateWorking, + Timestamp: ts.Add(time.Second), + Message: "Thinking furiously...", + }, + WorkspaceAgentHealth: &codersdk.WorkspaceAgentHealth{ + Healthy: true, + }, + WorkspaceAgentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady), + Status: codersdk.TaskStatusActive, + }}, + Count: 1, + }) + return + } case "/api/experimental/tasks/me/11111111-1111-1111-1111-111111111111": httpapi.Write(ctx, w, http.StatusOK, codersdk.Task{ - ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - Status: codersdk.WorkspaceStatusRunning, - CreatedAt: ts, - UpdatedAt: ts, + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + WorkspaceStatus: codersdk.WorkspaceStatusRunning, + CreatedAt: ts, + UpdatedAt: ts, CurrentState: &codersdk.TaskStateEntry{ State: codersdk.TaskStateWorking, Timestamp: ts.Add(time.Second), Message: "Thinking furiously...", }, + Status: codersdk.TaskStatusActive, }) + return default: t.Errorf("unexpected path: %s", r.URL.Path) } diff --git a/cli/exp_task_test.go b/cli/exp_task_test.go index 4cf99e1dd45ad..d2d3728aeb280 100644 --- a/cli/exp_task_test.go +++ b/cli/exp_task_test.go @@ -2,26 +2,242 @@ package cli_test import ( "context" + "encoding/json" "net/http" "net/http/httptest" + "slices" + "strings" "sync" "testing" + "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + agentapisdk "github.com/coder/agentapi-sdk-go" "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" ) +// This test performs an integration-style test for tasks functionality. +// +//nolint:tparallel // The sub-tests of this test must be run sequentially. +func Test_Tasks(t *testing.T) { + t.Parallel() + + // Given: a template configured for tasks + var ( + ctx = testutil.Context(t, testutil.WaitLong) + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner = coderdtest.CreateFirstUser(t, client) + userClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + initMsg = agentapisdk.Message{ + Content: "test task input for " + t.Name(), + Id: 0, + Role: "user", + Time: time.Now().UTC(), + } + authToken = uuid.NewString() + echoAgentAPI = startFakeAgentAPI(t, fakeAgentAPIEcho(ctx, t, initMsg, "hello")) + taskTpl = createAITaskTemplate(t, client, owner.OrganizationID, withAgentToken(authToken), withSidebarURL(echoAgentAPI.URL())) + taskName = strings.ReplaceAll(testutil.GetRandomName(t), "_", "-") + ) + + //nolint:paralleltest // The sub-tests of this test must be run sequentially. + for _, tc := range []struct { + name string + cmdArgs []string + assertFn func(stdout string, userClient *codersdk.Client) + }{ + { + name: "create task", + cmdArgs: []string{"exp", "task", "create", "test task input for " + t.Name(), "--name", taskName, "--template", taskTpl.Name}, + assertFn: func(stdout string, userClient *codersdk.Client) { + require.Contains(t, stdout, taskName, "task name should be in output") + }, + }, + { + name: "list tasks after create", + cmdArgs: []string{"exp", "task", "list", "--output", "json"}, + assertFn: func(stdout string, userClient *codersdk.Client) { + var tasks []codersdk.Task + err := json.NewDecoder(strings.NewReader(stdout)).Decode(&tasks) + require.NoError(t, err, "list output should unmarshal properly") + require.Len(t, tasks, 1, "expected one task") + require.Equal(t, taskName, tasks[0].Name, "task name should match") + require.Equal(t, initMsg.Content, tasks[0].InitialPrompt, "initial prompt should match") + require.True(t, tasks[0].WorkspaceID.Valid, "workspace should be created") + // For the next test, we need to wait for the workspace to be healthy + ws := coderdtest.MustWorkspace(t, userClient, tasks[0].WorkspaceID.UUID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) + _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { + o.Client = agentClient + }) + coderdtest.NewWorkspaceAgentWaiter(t, userClient, tasks[0].WorkspaceID.UUID).WithContext(ctx).WaitFor(coderdtest.AgentsReady) + }, + }, + { + name: "get task status after create", + cmdArgs: []string{"exp", "task", "status", taskName, "--output", "json"}, + assertFn: func(stdout string, userClient *codersdk.Client) { + var task codersdk.Task + require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status") + require.Equal(t, task.Name, taskName, "task name should match") + require.Equal(t, codersdk.TaskStatusActive, task.Status, "task should be active") + }, + }, + { + name: "send task message", + cmdArgs: []string{"exp", "task", "send", taskName, "hello"}, + // Assertions for this happen in the fake agent API handler. + }, + { + name: "read task logs", + cmdArgs: []string{"exp", "task", "logs", taskName, "--output", "json"}, + assertFn: func(stdout string, userClient *codersdk.Client) { + var logs []codersdk.TaskLogEntry + require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&logs), "should unmarshal task logs") + require.Len(t, logs, 3, "should have 3 logs") + require.Equal(t, logs[0].Content, initMsg.Content, "first message should be the init message") + require.Equal(t, logs[0].Type, codersdk.TaskLogTypeInput, "first message should be an input") + require.Equal(t, logs[1].Content, "hello", "second message should be the sent message") + require.Equal(t, logs[1].Type, codersdk.TaskLogTypeInput, "second message should be an input") + require.Equal(t, logs[2].Content, "hello", "third message should be the echoed message") + require.Equal(t, logs[2].Type, codersdk.TaskLogTypeOutput, "third message should be an output") + }, + }, + { + name: "delete task", + cmdArgs: []string{"exp", "task", "delete", taskName, "--yes"}, + assertFn: func(stdout string, userClient *codersdk.Client) { + // The task should eventually no longer show up in the list of tasks + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + expClient := codersdk.NewExperimentalClient(userClient) + tasks, err := expClient.Tasks(ctx, &codersdk.TasksFilter{}) + if !assert.NoError(t, err) { + return false + } + return slices.IndexFunc(tasks, func(task codersdk.Task) bool { + return task.Name == taskName + }) == -1 + }, testutil.IntervalMedium) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var stdout strings.Builder + inv, root := clitest.New(t, tc.cmdArgs...) + inv.Stdout = &stdout + clitest.SetupConfig(t, userClient, root) + require.NoError(t, inv.WithContext(ctx).Run()) + if tc.assertFn != nil { + tc.assertFn(stdout.String(), userClient) + } + }) + } +} + +func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Message, want ...string) map[string]http.HandlerFunc { + t.Helper() + var mmu sync.RWMutex + msgs := []agentapisdk.Message{initMsg} + wantCpy := make([]string, len(want)) + copy(wantCpy, want) + t.Cleanup(func() { + mmu.Lock() + defer mmu.Unlock() + if !t.Failed() { + assert.Empty(t, wantCpy, "not all expected messages received: missing %v", wantCpy) + } + }) + writeAgentAPIError := func(w http.ResponseWriter, err error, status int) { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(agentapisdk.ErrorModel{ + Errors: ptr.Ref([]agentapisdk.ErrorDetail{ + { + Message: ptr.Ref(err.Error()), + }, + }), + }) + } + return map[string]http.HandlerFunc{ + "/status": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(agentapisdk.GetStatusResponse{ + Status: "stable", + }) + }, + "/messages": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + mmu.RLock() + defer mmu.RUnlock() + bs, err := json.Marshal(agentapisdk.GetMessagesResponse{ + Messages: msgs, + }) + if err != nil { + writeAgentAPIError(w, err, http.StatusBadRequest) + return + } + _, _ = w.Write(bs) + }, + "/message": func(w http.ResponseWriter, r *http.Request) { + mmu.Lock() + defer mmu.Unlock() + var params agentapisdk.PostMessageParams + w.Header().Set("Content-Type", "application/json") + err := json.NewDecoder(r.Body).Decode(¶ms) + if !assert.NoError(t, err, "decode message") { + writeAgentAPIError(w, err, http.StatusBadRequest) + return + } + + if len(wantCpy) == 0 { + assert.Fail(t, "unexpected message", "received message %v, but no more expected messages", params) + writeAgentAPIError(w, xerrors.New("no more expected messages"), http.StatusBadRequest) + return + } + exp := wantCpy[0] + wantCpy = wantCpy[1:] + + if !assert.Equal(t, exp, params.Content, "message content mismatch") { + writeAgentAPIError(w, xerrors.New("unexpected message content: expected "+exp+", got "+params.Content), http.StatusBadRequest) + return + } + + msgs = append(msgs, agentapisdk.Message{ + Id: int64(len(msgs) + 1), + Content: params.Content, + Role: agentapisdk.RoleUser, + Time: time.Now().UTC(), + }) + msgs = append(msgs, agentapisdk.Message{ + Id: int64(len(msgs) + 1), + Content: params.Content, + Role: agentapisdk.RoleAgent, + Time: time.Now().UTC(), + }) + assert.NoError(t, json.NewEncoder(w).Encode(agentapisdk.PostMessageResponse{ + Ok: true, + })) + }, + } +} + // setupCLITaskTest creates a test workspace with an AI task template and agent, // with a fake agent API configured with the provided set of handlers. // Returns the user client and workspace. -func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (*codersdk.Client, codersdk.Workspace) { +func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (*codersdk.Client, codersdk.Task) { t.Helper() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) @@ -34,11 +250,18 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st template := createAITaskTemplate(t, client, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken)) wantPrompt := "test prompt" - workspace := coderdtest.CreateWorkspace(t, userClient, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: wantPrompt}, - } + exp := codersdk.NewExperimentalClient(userClient) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: wantPrompt, + Name: "test-task", }) + require.NoError(t, err) + + // Wait for the task's underlying workspace to be built + require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") + workspace, err := userClient.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) @@ -49,7 +272,7 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID). WaitFor(coderdtest.AgentsReady) - return userClient, workspace + return userClient, task } // createAITaskTemplate creates a template configured for AI tasks with a sidebar app. diff --git a/cli/schedule.go b/cli/schedule.go index 15f837bc16779..a4b02d6d8be9e 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -176,6 +176,22 @@ func (r *RootCmd) scheduleStart() *serpent.Command { } schedStr = ptr.Ref(sched.String()) + + // Check if the template has autostart requirements that may conflict + // with the user's schedule. + template, err := client.Template(inv.Context(), workspace.TemplateID) + if err != nil { + return xerrors.Errorf("get template: %w", err) + } + + if len(template.AutostartRequirement.DaysOfWeek) > 0 { + _, _ = fmt.Fprintf( + inv.Stderr, + "Warning: your workspace template restricts autostart to the following days: %s.\n"+ + "Your workspace may only autostart on these days.\n", + strings.Join(template.AutostartRequirement.DaysOfWeek, ", "), + ) + } } err = client.UpdateWorkspaceAutostart(inv.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ diff --git a/cli/schedule_test.go b/cli/schedule_test.go index b161f41cbcebc..bc473279f7ca4 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -373,3 +373,67 @@ func TestScheduleOverride(t *testing.T) { }) } } + +//nolint:paralleltest // t.Setenv +func TestScheduleStart_TemplateAutostartRequirement(t *testing.T) { + t.Setenv("TZ", "UTC") + loc, err := tz.TimezoneIANA() + require.NoError(t, err) + require.Equal(t, "UTC", loc.String()) + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + 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) + + // Update template to have autostart requirement + // Note: In AGPL, this will be ignored and all days will be allowed (enterprise feature). + template, err = client.UpdateTemplateMeta(context.Background(), template.ID, codersdk.UpdateTemplateMeta{ + AutostartRequirement: &codersdk.TemplateAutostartRequirement{ + DaysOfWeek: []string{"monday", "wednesday", "friday"}, + }, + }) + require.NoError(t, err) + + // Verify the template - in AGPL, AutostartRequirement will have all days (enterprise feature) + template, err = client.Template(context.Background(), template.ID) + require.NoError(t, err) + require.NotEmpty(t, template.AutostartRequirement.DaysOfWeek, "template should have autostart requirement days") + + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + t.Run("ShowsWarning", func(t *testing.T) { + // When: user sets autostart schedule + inv, root := clitest.New(t, + "schedule", "start", workspace.Name, "9:30AM", "Mon-Fri", + ) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: warning should be shown + // In AGPL, this will show all days (enterprise feature defaults to all days allowed) + pty.ExpectMatch("Warning") + pty.ExpectMatch("may only autostart") + }) + + t.Run("NoWarningWhenManual", func(t *testing.T) { + // When: user sets manual schedule + inv, root := clitest.New(t, + "schedule", "start", workspace.Name, "manual", + ) + clitest.SetupConfig(t, client, root) + + var stderrBuf bytes.Buffer + inv.Stderr = &stderrBuf + + require.NoError(t, inv.Run()) + + // Then: no warning should be shown on stderr + stderrOutput := stderrBuf.String() + require.NotContains(t, stderrOutput, "Warning") + }) +} diff --git a/cli/server_regenerate_vapid_keypair_test.go b/cli/server_regenerate_vapid_keypair_test.go index cbaff3681df11..6c9603e00929c 100644 --- a/cli/server_regenerate_vapid_keypair_test.go +++ b/cli/server_regenerate_vapid_keypair_test.go @@ -17,9 +17,6 @@ import ( func TestRegenerateVapidKeypair(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test is only supported on postgres") - } t.Run("NoExistingVAPIDKeys", func(t *testing.T) { t.Parallel() diff --git a/cli/server_test.go b/cli/server_test.go index 0748786765cb8..d6278fc7669c0 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -348,9 +348,6 @@ func TestServer(t *testing.T) { runGitHubProviderTest := func(t *testing.T, tc testCase) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test requires postgres") - } ctx, cancelFunc := context.WithCancel(testutil.Context(t, testutil.WaitLong)) defer cancelFunc() @@ -2142,10 +2139,6 @@ func TestServerYAMLConfig(t *testing.T) { func TestConnectToPostgres(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test does not make sense without postgres") - } - t.Run("Migrate", func(t *testing.T) { t.Parallel() @@ -2256,10 +2249,6 @@ type runServerOpts struct { func TestServer_TelemetryDisabled_FinalReport(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } - telemetryServerURL, deployment, snapshot := mockTelemetryServer(t) dbConnURL, err := dbtestutil.Open(t) require.NoError(t, err) diff --git a/cli/testdata/coder_tokens_--help.golden b/cli/testdata/coder_tokens_--help.golden index 7247c42a4bd1d..fb58dab8b3e69 100644 --- a/cli/testdata/coder_tokens_--help.golden +++ b/cli/testdata/coder_tokens_--help.golden @@ -16,6 +16,10 @@ USAGE: $ coder tokens ls + - Create a scoped token: + + $ coder tokens create --scope workspace:read --allow workspace: + - Remove a token by ID: $ coder tokens rm WuoWs4ZsMX @@ -24,6 +28,7 @@ SUBCOMMANDS: create Create a token list List tokens remove Delete a token + view Display detailed information about a token ——— Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_tokens_create_--help.golden b/cli/testdata/coder_tokens_create_--help.golden index 9399635563a11..6db7a07a27920 100644 --- a/cli/testdata/coder_tokens_create_--help.golden +++ b/cli/testdata/coder_tokens_create_--help.golden @@ -6,12 +6,18 @@ USAGE: Create a token OPTIONS: + --allow allow-list + Repeatable allow-list entry (:, e.g. workspace:1234-...). + --lifetime string, $CODER_TOKEN_LIFETIME Specify a duration for the lifetime of the token. -n, --name string, $CODER_TOKEN_NAME Specify a human-readable name. + --scope string-array + Repeatable scope to attach to the token (e.g. workspace:read). + -u, --user string, $CODER_TOKEN_USER Specify the user to create the token for (Only works if logged in user is admin). diff --git a/cli/testdata/coder_tokens_list_--help.golden b/cli/testdata/coder_tokens_list_--help.golden index 9ad17fbafb8e6..a3c24bcd0fabe 100644 --- a/cli/testdata/coder_tokens_list_--help.golden +++ b/cli/testdata/coder_tokens_list_--help.golden @@ -12,7 +12,7 @@ OPTIONS: Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens). - -c, --column [id|name|last used|expires at|created at|owner] (default: id,name,last used,expires at,created at) + -c, --column [id|name|scopes|allow list|last used|expires at|created at|owner] (default: id,name,scopes,allow list,last used,expires at,created at) Columns to display in table output. -o, --output table|json (default: table) diff --git a/cli/testdata/coder_tokens_view_--help.golden b/cli/testdata/coder_tokens_view_--help.golden new file mode 100644 index 0000000000000..1bceac32ce52f --- /dev/null +++ b/cli/testdata/coder_tokens_view_--help.golden @@ -0,0 +1,16 @@ +coder v0.0.0-devel + +USAGE: + coder tokens view [flags] + + Display detailed information about a token + +OPTIONS: + -c, --column [id|name|scopes|allow list|last used|expires at|created at|owner] (default: id,name,scopes,allow list,last used,expires at,created at,owner) + Columns to display in table output. + + -o, --output table|json (default: table) + Output format. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/tokens.go b/cli/tokens.go index 5d63f2e1ae841..fb36ba25a3ec5 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -4,12 +4,14 @@ import ( "fmt" "os" "slices" + "sort" "strings" "time" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -27,6 +29,10 @@ func (r *RootCmd) tokens() *serpent.Command { Description: "List your tokens", Command: "coder tokens ls", }, + Example{ + Description: "Create a scoped token", + Command: "coder tokens create --scope workspace:read --allow workspace:", + }, Example{ Description: "Remove a token by ID", Command: "coder tokens rm WuoWs4ZsMX", @@ -39,6 +45,7 @@ func (r *RootCmd) tokens() *serpent.Command { Children: []*serpent.Command{ r.createToken(), r.listTokens(), + r.viewToken(), r.removeToken(), }, } @@ -50,6 +57,8 @@ func (r *RootCmd) createToken() *serpent.Command { tokenLifetime string name string user string + scopes []string + allowList []codersdk.APIAllowListTarget ) cmd := &serpent.Command{ Use: "create", @@ -88,10 +97,18 @@ func (r *RootCmd) createToken() *serpent.Command { } } - res, err := client.CreateToken(inv.Context(), userID, codersdk.CreateTokenRequest{ + req := codersdk.CreateTokenRequest{ Lifetime: parsedLifetime, TokenName: name, - }) + } + if len(req.Scopes) == 0 { + req.Scopes = slice.StringEnums[codersdk.APIKeyScope](scopes) + } + if len(allowList) > 0 { + req.AllowList = append([]codersdk.APIAllowListTarget(nil), allowList...) + } + + res, err := client.CreateToken(inv.Context(), userID, req) if err != nil { return xerrors.Errorf("create tokens: %w", err) } @@ -123,6 +140,16 @@ func (r *RootCmd) createToken() *serpent.Command { Description: "Specify the user to create the token for (Only works if logged in user is admin).", Value: serpent.StringOf(&user), }, + { + Flag: "scope", + Description: "Repeatable scope to attach to the token (e.g. workspace:read).", + Value: serpent.StringArrayOf(&scopes), + }, + { + Flag: "allow", + Description: "Repeatable allow-list entry (:, e.g. workspace:1234-...).", + Value: AllowListFlagOf(&allowList), + }, } return cmd @@ -136,6 +163,8 @@ type tokenListRow struct { // For table format: ID string `json:"-" table:"id,default_sort"` TokenName string `json:"token_name" table:"name"` + Scopes string `json:"-" table:"scopes"` + Allow string `json:"-" table:"allow list"` LastUsed time.Time `json:"-" table:"last used"` ExpiresAt time.Time `json:"-" table:"expires at"` CreatedAt time.Time `json:"-" table:"created at"` @@ -143,20 +172,50 @@ type tokenListRow struct { } func tokenListRowFromToken(token codersdk.APIKeyWithOwner) tokenListRow { + return tokenListRowFromKey(token.APIKey, token.Username) +} + +func tokenListRowFromKey(token codersdk.APIKey, owner string) tokenListRow { return tokenListRow{ - APIKey: token.APIKey, + APIKey: token, ID: token.ID, TokenName: token.TokenName, + Scopes: joinScopes(token.Scopes), + Allow: joinAllowList(token.AllowList), LastUsed: token.LastUsed, ExpiresAt: token.ExpiresAt, CreatedAt: token.CreatedAt, - Owner: token.Username, + Owner: owner, } } +func joinScopes(scopes []codersdk.APIKeyScope) string { + if len(scopes) == 0 { + return "" + } + vals := make([]string, len(scopes)) + for i, scope := range scopes { + vals[i] = string(scope) + } + sort.Strings(vals) + return strings.Join(vals, ", ") +} + +func joinAllowList(entries []codersdk.APIAllowListTarget) string { + if len(entries) == 0 { + return "" + } + vals := make([]string, len(entries)) + for i, entry := range entries { + vals[i] = entry.String() + } + sort.Strings(vals) + return strings.Join(vals, ", ") +} + func (r *RootCmd) listTokens() *serpent.Command { // we only display the 'owner' column if the --all argument is passed in - defaultCols := []string{"id", "name", "last used", "expires at", "created at"} + defaultCols := []string{"id", "name", "scopes", "allow list", "last used", "expires at", "created at"} if slices.Contains(os.Args, "-a") || slices.Contains(os.Args, "--all") { defaultCols = append(defaultCols, "owner") } @@ -226,6 +285,48 @@ func (r *RootCmd) listTokens() *serpent.Command { return cmd } +func (r *RootCmd) viewToken() *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]tokenListRow{}, []string{"id", "name", "scopes", "allow list", "last used", "expires at", "created at", "owner"}), + cliui.JSONFormat(), + ) + + cmd := &serpent.Command{ + Use: "view ", + Short: "Display detailed information about a token", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + tokenName := inv.Args[0] + token, err := client.APIKeyByName(inv.Context(), codersdk.Me, tokenName) + if err != nil { + maybeID := strings.Split(tokenName, "-")[0] + token, err = client.APIKeyByID(inv.Context(), codersdk.Me, maybeID) + if err != nil { + return xerrors.Errorf("fetch api key by name or id: %w", err) + } + } + + row := tokenListRowFromKey(*token, "") + out, err := formatter.Format(inv.Context(), []tokenListRow{row}) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} + func (r *RootCmd) removeToken() *serpent.Command { cmd := &serpent.Command{ Use: "remove ", diff --git a/cli/tokens_test.go b/cli/tokens_test.go index 0c717bb890f9e..990516aa9ba13 100644 --- a/cli/tokens_test.go +++ b/cli/tokens_test.go @@ -4,10 +4,13 @@ import ( "bytes" "context" "encoding/json" + "fmt" "testing" "github.com/stretchr/testify/require" + "github.com/google/uuid" + "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" @@ -46,6 +49,18 @@ func TestTokens(t *testing.T) { require.NotEmpty(t, res) id := res[:10] + allowWorkspaceID := uuid.New() + allowSpec := fmt.Sprintf("workspace:%s", allowWorkspaceID.String()) + inv, root = clitest.New(t, "tokens", "create", "--name", "scoped-token", "--scope", string(codersdk.APIKeyScopeWorkspaceRead), "--allow", allowSpec) + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + res = buf.String() + require.NotEmpty(t, res) + scopedTokenID := res[:10] + // Test creating a token for second user from first user's (admin) session inv, root = clitest.New(t, "tokens", "create", "--name", "token-two", "--user", secondUser.ID.String()) clitest.SetupConfig(t, client, root) @@ -67,7 +82,7 @@ func TestTokens(t *testing.T) { require.NoError(t, err) res = buf.String() require.NotEmpty(t, res) - // Result should only contain the token created for the admin user + // Result should only contain the tokens created for the admin user require.Contains(t, res, "ID") require.Contains(t, res, "EXPIRES AT") require.Contains(t, res, "CREATED AT") @@ -76,6 +91,16 @@ func TestTokens(t *testing.T) { // Result should not contain the token created for the second user require.NotContains(t, res, secondTokenID) + inv, root = clitest.New(t, "tokens", "view", "scoped-token") + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + res = buf.String() + require.Contains(t, res, string(codersdk.APIKeyScopeWorkspaceRead)) + require.Contains(t, res, allowSpec) + // Test listing tokens from the second user's session inv, root = clitest.New(t, "tokens", "ls") clitest.SetupConfig(t, secondUserClient, root) @@ -101,6 +126,14 @@ func TestTokens(t *testing.T) { // User (non-admin) should not be able to create a token for another user require.Error(t, err) + inv, root = clitest.New(t, "tokens", "create", "--name", "invalid-allow", "--allow", "badvalue") + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.Error(t, err) + require.Contains(t, err.Error(), "invalid allow_list entry") + inv, root = clitest.New(t, "tokens", "ls", "--output=json") clitest.SetupConfig(t, client, root) buf = new(bytes.Buffer) @@ -110,8 +143,17 @@ func TestTokens(t *testing.T) { var tokens []codersdk.APIKey require.NoError(t, json.Unmarshal(buf.Bytes(), &tokens)) - require.Len(t, tokens, 1) - require.Equal(t, id, tokens[0].ID) + require.Len(t, tokens, 2) + tokenByName := make(map[string]codersdk.APIKey, len(tokens)) + for _, tk := range tokens { + tokenByName[tk.TokenName] = tk + } + require.Contains(t, tokenByName, "token-one") + require.Contains(t, tokenByName, "scoped-token") + scopedToken := tokenByName["scoped-token"] + require.Contains(t, scopedToken.Scopes, codersdk.APIKeyScopeWorkspaceRead) + require.Len(t, scopedToken.AllowList, 1) + require.Equal(t, allowSpec, scopedToken.AllowList[0].String()) // Delete by name inv, root = clitest.New(t, "tokens", "rm", "token-one") @@ -135,6 +177,17 @@ func TestTokens(t *testing.T) { require.NotEmpty(t, res) require.Contains(t, res, "deleted") + // Delete scoped token by ID + inv, root = clitest.New(t, "tokens", "rm", scopedTokenID) + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + inv.Stdout = buf + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + res = buf.String() + require.NotEmpty(t, res) + require.Contains(t, res, "deleted") + // Create third token inv, root = clitest.New(t, "tokens", "create", "--name", "token-three") clitest.SetupConfig(t, client, root) diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 95ad8008e88ca..1d06daeae96c0 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -2,8 +2,6 @@ package coderd import ( "context" - "database/sql" - "errors" "fmt" "net" "net/http" @@ -12,12 +10,13 @@ import ( "strings" "time" - "github.com/go-chi/chi/v5" "github.com/google/uuid" + "golang.org/x/xerrors" "cdr.dev/slog" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpapi/httperror" "github.com/coder/coder/v2/coderd/httpmw" @@ -25,7 +24,6 @@ import ( "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/taskname" - "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" aiagentapi "github.com/coder/agentapi-sdk-go" @@ -96,31 +94,54 @@ func (api *API) aiTasksPrompts(rw http.ResponseWriter, r *http.Request) { // This endpoint creates a new task for the given user. func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { var ( - ctx = r.Context() - apiKey = httpmw.APIKey(r) - auditor = api.Auditor.Load() - mems = httpmw.OrganizationMembersParam(r) + ctx = r.Context() + apiKey = httpmw.APIKey(r) + auditor = api.Auditor.Load() + mems = httpmw.OrganizationMembersParam(r) + taskResourceInfo = audit.AdditionalFields{} ) + if mems.User != nil { + taskResourceInfo.WorkspaceOwner = mems.User.Username + } + + aReq, commitAudit := audit.InitRequest[database.TaskTable](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + AdditionalFields: taskResourceInfo, + }) + + defer commitAudit() + var req codersdk.CreateTaskRequest if !httpapi.Read(ctx, rw, r, &req) { return } - hasAITask, err := api.Database.GetTemplateVersionHasAITask(ctx, req.TemplateVersionID) + // Fetch the template version to verify access and whether or not it has an + // AI task. + templateVersion, err := api.Database.GetTemplateVersionByID(ctx, req.TemplateVersionID) if err != nil { - if errors.Is(err, sql.ErrNoRows) || rbac.IsUnauthorizedError(err) { - httpapi.ResourceNotFound(rw) + if httpapi.Is404Error(err) { + // Avoid using httpapi.ResourceNotFound() here because this is an + // input error and 404 would be confusing. + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Template version not found or you do not have access to this resource", + }) return } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching whether the template version has an AI task.", + Message: "Internal error fetching template version.", Detail: err.Error(), }) return } - if !hasAITask { + + aReq.UpdateOrganizationID(templateVersion.OrganizationID) + + if !templateVersion.HasAITask.Valid || !templateVersion.HasAITask.Bool { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf(`Template does not have required parameter %q`, codersdk.AITaskPromptParameterName), }) @@ -177,23 +198,12 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { } else { // A task can still be created if the caller can read the organization // member. The organization is required, which can be sourced from the - // template. + // templateVersion. // - // TODO: This code gets called twice for each workspace build request. - // This is inefficient and costs at most 2 extra RTTs to the DB. - // This can be optimized. It exists as it is now for code simplicity. - // The most common case is to create a workspace for 'Me'. Which does - // not enter this code branch. - template, err := requestTemplate(ctx, createReq, api.Database) - if err != nil { - httperror.WriteResponseError(ctx, rw, err) - return - } - // If the caller can find the organization membership in the same org // as the template, then they can continue. orgIndex := slices.IndexFunc(mems.Memberships, func(mem httpmw.OrganizationMember) bool { - return mem.OrganizationID == template.OrganizationID + return mem.OrganizationID == templateVersion.OrganizationID }) if orgIndex == -1 { httpapi.ResourceNotFound(rw) @@ -206,56 +216,112 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) { Username: member.Username, AvatarURL: member.AvatarURL, } + + // Update workspace owner information for audit in case it changed. + taskResourceInfo.WorkspaceOwner = owner.Username } - aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{ - Audit: *auditor, - Log: api.Logger, - Request: r, - Action: database.AuditActionCreate, - AdditionalFields: audit.AdditionalFields{ - WorkspaceOwner: owner.Username, + // Track insert from preCreateInTX. + var dbTaskTable database.TaskTable + + // Ensure an audit log is created for the workspace creation event. + aReqWS, commitAuditWS := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + AdditionalFields: taskResourceInfo, + OrganizationID: templateVersion.OrganizationID, + }) + defer commitAuditWS() + + workspace, err := createWorkspace(ctx, aReqWS, apiKey.UserID, api, owner, createReq, r, &createWorkspaceOptions{ + // Before creating the workspace, ensure that this task can be created. + preCreateInTX: func(ctx context.Context, tx database.Store) error { + // Create task record in the database before creating the workspace so that + // we can request that the workspace be linked to it after creation. + dbTaskTable, err = tx.InsertTask(ctx, database.InsertTaskParams{ + OrganizationID: templateVersion.OrganizationID, + OwnerID: owner.ID, + Name: taskName, + WorkspaceID: uuid.NullUUID{}, // Will be set after workspace creation. + TemplateVersionID: templateVersion.ID, + TemplateParameters: []byte("{}"), + Prompt: req.Input, + CreatedAt: dbtime.Time(api.Clock.Now()), + }) + if err != nil { + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating task.", + Detail: err.Error(), + }) + } + return nil + }, + // After the workspace is created, ensure that the task is linked to it. + postCreateInTX: func(ctx context.Context, tx database.Store, workspace database.Workspace) error { + // Update the task record with the workspace ID after creation. + dbTaskTable, err = tx.UpdateTaskWorkspaceID(ctx, database.UpdateTaskWorkspaceIDParams{ + ID: dbTaskTable.ID, + WorkspaceID: uuid.NullUUID{ + UUID: workspace.ID, + Valid: true, + }, + }) + if err != nil { + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating task.", + Detail: err.Error(), + }) + } + return nil }, }) - defer commitAudit() - w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, createReq, r) if err != nil { httperror.WriteResponseError(ctx, rw, err) return } - task := taskFromWorkspace(w, req.Input) - httpapi.Write(ctx, rw, http.StatusCreated, task) + aReq.New = dbTaskTable + + // Fetch the task to get the additional columns from the view. + dbTask, err := api.Database.GetTaskByID(ctx, dbTaskTable.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusCreated, taskFromDBTaskAndWorkspace(dbTask, workspace)) } -func taskFromWorkspace(ws codersdk.Workspace, initialPrompt string) codersdk.Task { - // TODO(DanielleMaywood): - // This just picks up the first agent it discovers. - // This approach _might_ break when a task has multiple agents, - // depending on which agent was found first. - // - // We explicitly do not have support for running tasks - // inside of a sub agent at the moment, so we can be sure - // that any sub agents are not the agent we're looking for. - var taskAgentID uuid.NullUUID +// taskFromDBTaskAndWorkspace creates a codersdk.Task response from the task +// database record and workspace. +func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) codersdk.Task { var taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle var taskAgentHealth *codersdk.WorkspaceAgentHealth - for _, resource := range ws.LatestBuild.Resources { - for _, agent := range resource.Agents { - if agent.ParentID.Valid { - continue - } - taskAgentID = uuid.NullUUID{Valid: true, UUID: agent.ID} - taskAgentLifecycle = &agent.LifecycleState - taskAgentHealth = &agent.Health - break + // If we have an agent ID from the task, find the agent details in the + // workspace. + if dbTask.WorkspaceAgentID.Valid { + findTaskAgentLoop: + for _, resource := range ws.LatestBuild.Resources { + for _, agent := range resource.Agents { + if agent.ID == dbTask.WorkspaceAgentID.UUID { + taskAgentLifecycle = &agent.LifecycleState + taskAgentHealth = &agent.Health + break findTaskAgentLoop + } + } } } - // Ignore 'latest app status' if it is older than the latest build and the latest build is a 'start' transition. - // This ensures that you don't show a stale app status from a previous build. - // For stop transitions, there is still value in showing the latest app status. + // Ignore 'latest app status' if it is older than the latest build and the + // latest build is a 'start' transition. This ensures that you don't show a + // stale app status from a previous build. For stop transitions, there is + // still value in showing the latest app status. var currentState *codersdk.TaskStateEntry if ws.LatestAppStatus != nil { if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) { @@ -268,188 +334,135 @@ func taskFromWorkspace(ws codersdk.Workspace, initialPrompt string) codersdk.Tas } } - var appID uuid.NullUUID - if ws.LatestBuild.AITaskSidebarAppID != nil { - appID = uuid.NullUUID{ - Valid: true, - UUID: *ws.LatestBuild.AITaskSidebarAppID, - } - } - return codersdk.Task{ - ID: ws.ID, - OrganizationID: ws.OrganizationID, - OwnerID: ws.OwnerID, + ID: dbTask.ID, + OrganizationID: dbTask.OrganizationID, + OwnerID: dbTask.OwnerID, OwnerName: ws.OwnerName, - Name: ws.Name, + OwnerAvatarURL: ws.OwnerAvatarURL, + Name: dbTask.Name, TemplateID: ws.TemplateID, + TemplateVersionID: dbTask.TemplateVersionID, TemplateName: ws.TemplateName, TemplateDisplayName: ws.TemplateDisplayName, TemplateIcon: ws.TemplateIcon, - WorkspaceID: uuid.NullUUID{Valid: true, UUID: ws.ID}, - WorkspaceBuildNumber: ws.LatestBuild.BuildNumber, - WorkspaceAgentID: taskAgentID, + WorkspaceID: dbTask.WorkspaceID, + WorkspaceName: ws.Name, + WorkspaceBuildNumber: dbTask.WorkspaceBuildNumber.Int32, + WorkspaceStatus: ws.LatestBuild.Status, + WorkspaceAgentID: dbTask.WorkspaceAgentID, WorkspaceAgentLifecycle: taskAgentLifecycle, WorkspaceAgentHealth: taskAgentHealth, - WorkspaceAppID: appID, - CreatedAt: ws.CreatedAt, - UpdatedAt: ws.UpdatedAt, - InitialPrompt: initialPrompt, - Status: ws.LatestBuild.Status, + WorkspaceAppID: dbTask.WorkspaceAppID, + InitialPrompt: dbTask.Prompt, + Status: codersdk.TaskStatus(dbTask.Status), CurrentState: currentState, + CreatedAt: dbTask.CreatedAt, + UpdatedAt: ws.UpdatedAt, } } -// tasksFromWorkspaces converts a slice of API workspaces into tasks, fetching -// prompts and mapping status/state. This method enforces that only AI task -// workspaces are given. -func (api *API) tasksFromWorkspaces(ctx context.Context, apiWorkspaces []codersdk.Workspace) ([]codersdk.Task, error) { - // Fetch prompts for each workspace build and map by build ID. - buildIDs := make([]uuid.UUID, 0, len(apiWorkspaces)) - for _, ws := range apiWorkspaces { - buildIDs = append(buildIDs, ws.LatestBuild.ID) - } - parameters, err := api.Database.GetWorkspaceBuildParametersByBuildIDs(ctx, buildIDs) - if err != nil { - return nil, err - } - promptsByBuildID := make(map[uuid.UUID]string, len(parameters)) - for _, p := range parameters { - if p.Name == codersdk.AITaskPromptParameterName { - promptsByBuildID[p.WorkspaceBuildID] = p.Value - } - } - - tasks := make([]codersdk.Task, 0, len(apiWorkspaces)) - for _, ws := range apiWorkspaces { - tasks = append(tasks, taskFromWorkspace(ws, promptsByBuildID[ws.LatestBuild.ID])) - } - - return tasks, nil -} - -// tasksListResponse wraps a list of experimental tasks. -// -// Experimental: Response shape is experimental and may change. -type tasksListResponse struct { - Tasks []codersdk.Task `json:"tasks"` - Count int `json:"count"` -} - // @Summary List AI tasks // @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable. // @ID list-tasks // @Security CoderSessionToken // @Tags Experimental -// @Param q query string false "Search query for filtering tasks" -// @Param after_id query string false "Return tasks after this ID for pagination" -// @Param limit query int false "Maximum number of tasks to return" minimum(1) maximum(100) default(25) -// @Param offset query int false "Offset for pagination" minimum(0) default(0) -// @Success 200 {object} coderd.tasksListResponse +// @Param q query string false "Search query for filtering tasks. Supports: owner:, organization:, status:" +// @Success 200 {object} codersdk.TasksListResponse // @Router /api/experimental/tasks [get] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. -// tasksList is an experimental endpoint to list AI tasks by mapping -// workspaces to a task-shaped response. +// tasksList is an experimental endpoint to list tasks. func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - // Support standard pagination/filters for workspaces. - page, ok := ParsePagination(rw, r) - if !ok { - return - } + // Parse query parameters for filtering tasks. queryStr := r.URL.Query().Get("q") - filter, errs := searchquery.Workspaces(ctx, api.Database, queryStr, page, api.AgentInactiveDisconnectTimeout) + filter, errs := searchquery.Tasks(ctx, api.Database, queryStr, apiKey.UserID) if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid workspace search query.", + Message: "Invalid task search query.", Validations: errs, }) return } - // Ensure that we only include AI task workspaces in the results. - filter.HasAITask = sql.NullBool{Valid: true, Bool: true} - - if filter.OwnerUsername == "me" { - filter.OwnerID = apiKey.UserID - filter.OwnerUsername = "" - } - - prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type) + // Fetch all tasks matching the filters from the database. + dbTasks, err := api.Database.ListTasks(ctx, filter) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error preparing sql filter.", + Message: "Internal error fetching tasks.", Detail: err.Error(), }) return } - // Order with requester's favorites first, include summary row. - filter.RequesterID = apiKey.UserID - filter.WithSummary = true - - workspaceRows, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, prepared) + tasks, err := api.convertTasks(ctx, apiKey.UserID, dbTasks) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspaces.", + Message: "Internal error converting tasks.", Detail: err.Error(), }) return } - if len(workspaceRows) == 0 { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspaces.", - Detail: "Workspace summary row is missing.", - }) - return + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.TasksListResponse{ + Tasks: tasks, + Count: len(tasks), + }) +} + +// convertTasks converts database tasks to API tasks, enriching them with +// workspace information. +func (api *API) convertTasks(ctx context.Context, requesterID uuid.UUID, dbTasks []database.Task) ([]codersdk.Task, error) { + if len(dbTasks) == 0 { + return []codersdk.Task{}, nil } - if len(workspaceRows) == 1 { - httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{ - Tasks: []codersdk.Task{}, - Count: 0, - }) - return + + // Prepare to batch fetch workspaces. + workspaceIDs := make([]uuid.UUID, 0, len(dbTasks)) + for _, task := range dbTasks { + if !task.WorkspaceID.Valid { + return nil, xerrors.New("task has no workspace ID") + } + workspaceIDs = append(workspaceIDs, task.WorkspaceID.UUID) } - // Skip summary row. - workspaceRows = workspaceRows[:len(workspaceRows)-1] + // Fetch workspaces for tasks that have workspaces. + workspaceRows, err := api.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{ + WorkspaceIds: workspaceIDs, + }) + if err != nil { + return nil, xerrors.Errorf("fetch workspaces: %w", err) + } workspaces := database.ConvertWorkspaceRows(workspaceRows) // Gather associated data and convert to API workspaces. data, err := api.workspaceData(ctx, workspaces) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace resources.", - Detail: err.Error(), - }) - return + return nil, xerrors.Errorf("fetch workspace data: %w", err) } - apiWorkspaces, err := convertWorkspaces(apiKey.UserID, workspaces, data) + + apiWorkspaces, err := convertWorkspaces(requesterID, workspaces, data) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error converting workspaces.", - Detail: err.Error(), - }) - return + return nil, xerrors.Errorf("convert workspaces: %w", err) } - tasks, err := api.tasksFromWorkspaces(ctx, apiWorkspaces) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching task prompts and states.", - Detail: err.Error(), - }) - return + workspacesByID := make(map[uuid.UUID]codersdk.Workspace) + for _, ws := range apiWorkspaces { + workspacesByID[ws.ID] = ws } - httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{ - Tasks: tasks, - Count: len(tasks), - }) + // Convert tasks to SDK format. + result := make([]codersdk.Task, 0, len(dbTasks)) + for _, dbTask := range dbTasks { + task := taskFromDBTaskAndWorkspace(dbTask, workspacesByID[dbTask.WorkspaceID.UUID]) + result = append(result, task) + } + + return result, nil } // @Summary Get AI task by ID @@ -458,9 +471,9 @@ func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param id path string true "Task ID" format(uuid) +// @Param task path string true "Task ID" format(uuid) // @Success 200 {object} codersdk.Task -// @Router /api/experimental/tasks/{user}/{id} [get] +// @Router /api/experimental/tasks/{user}/{task} [get] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. // taskGet is an experimental endpoint to fetch a single AI task by ID @@ -469,25 +482,22 @@ func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) + task := httpmw.TaskParam(r) - idStr := chi.URLParam(r, "id") - taskID, err := uuid.Parse(idStr) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr), + if !task.WorkspaceID.Valid { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task.", + Detail: "Task workspace ID is invalid.", }) return } - // For now, taskID = workspaceID, once we have a task data model in - // the DB, we can change this lookup. - workspaceID := taskID - workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID) - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } + workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID) if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace.", Detail: err.Error(), @@ -507,34 +517,6 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { httpapi.ResourceNotFound(rw) return } - if data.builds[0].HasAITask == nil || !*data.builds[0].HasAITask { - // TODO(DanielleMaywood): - // This is a temporary workaround. When a task has just been created, but - // not yet provisioned, the workspace build will not have `HasAITask` set. - // - // When we reach this code flow, it is _either_ because the workspace is - // not a task, or it is a task that has not yet been provisioned. This - // endpoint should rarely be called with a non-task workspace so we - // should be fine with this extra database call to check if it has the - // special "AI Task" parameter. - parameters, err := api.Database.GetWorkspaceBuildParameters(ctx, data.builds[0].ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace build parameters.", - Detail: err.Error(), - }) - return - } - - _, hasAITask := slice.Find(parameters, func(t database.WorkspaceBuildParameter) bool { - return t.Name == codersdk.AITaskPromptParameterName - }) - - if !hasAITask { - httpapi.ResourceNotFound(rw) - return - } - } appStatus := codersdk.WorkspaceAppStatus{} if len(data.appStatuses) > 0 { @@ -557,16 +539,8 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { return } - tasks, err := api.tasksFromWorkspaces(ctx, []codersdk.Workspace{ws}) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching task prompt and state.", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, tasks[0]) + taskResp := taskFromDBTaskAndWorkspace(task, ws) + httpapi.Write(ctx, rw, http.StatusOK, taskResp) } // @Summary Delete AI task by ID @@ -575,83 +549,71 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param id path string true "Task ID" format(uuid) +// @Param task path string true "Task ID" format(uuid) // @Success 202 "Task deletion initiated" -// @Router /api/experimental/tasks/{user}/{id} [delete] +// @Router /api/experimental/tasks/{user}/{task} [delete] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. -// taskDelete is an experimental endpoint to delete a task by ID (workspace ID). +// taskDelete is an experimental endpoint to delete a task by ID. // It creates a delete workspace build and returns 202 Accepted if the build was // created. func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) + task := httpmw.TaskParam(r) - idStr := chi.URLParam(r, "id") - taskID, err := uuid.Parse(idStr) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr), - }) - return - } + now := api.Clock.Now() - // For now, taskID = workspaceID, once we have a task data model in - // the DB, we can change this lookup. - workspaceID := taskID - workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID) - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace.", - Detail: err.Error(), - }) - return + if task.WorkspaceID.Valid { + workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task workspace before deleting task.", + Detail: err.Error(), + }) + return + } + + // Construct a request to the workspace build creation handler to + // initiate deletion. + buildReq := codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionDelete, + Reason: "Deleted via tasks API", + } + + _, err = api.postWorkspaceBuildsInternal( + ctx, + apiKey, + workspace, + buildReq, + func(action policy.Action, object rbac.Objecter) bool { + return api.Authorize(r, action, object) + }, + audit.WorkspaceBuildBaggageFromRequest(r), + ) + if err != nil { + httperror.WriteWorkspaceBuildError(ctx, rw, err) + return + } } - data, err := api.workspaceData(ctx, []database.Workspace{workspace}) + _, err := api.Database.DeleteTask(ctx, database.DeleteTaskParams{ + ID: task.ID, + DeletedAt: dbtime.Time(now), + }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace resources.", + Message: "Failed to delete task", Detail: err.Error(), }) return } - if len(data.builds) == 0 || len(data.templates) == 0 { - httpapi.ResourceNotFound(rw) - return - } - if data.builds[0].HasAITask == nil || !*data.builds[0].HasAITask { - httpapi.ResourceNotFound(rw) - return - } - - // Construct a request to the workspace build creation handler to - // initiate deletion. - buildReq := codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionDelete, - Reason: "Deleted via tasks API", - } - - _, err = api.postWorkspaceBuildsInternal( - ctx, - apiKey, - workspace, - buildReq, - func(action policy.Action, object rbac.Objecter) bool { - return api.Authorize(r, action, object) - }, - audit.WorkspaceBuildBaggageFromRequest(r), - ) - if err != nil { - httperror.WriteWorkspaceBuildError(ctx, rw, err) - return - } - // Delete build created successfully. + // Task deleted and delete build created successfully. rw.WriteHeader(http.StatusAccepted) } @@ -661,26 +623,18 @@ func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param id path string true "Task ID" format(uuid) +// @Param task path string true "Task ID" format(uuid) // @Param request body codersdk.TaskSendRequest true "Task input request" // @Success 204 "Input sent successfully" -// @Router /api/experimental/tasks/{user}/{id}/send [post] +// @Router /api/experimental/tasks/{user}/{task}/send [post] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. -// taskSend submits task input to the tasks sidebar app by dialing the agent +// taskSend submits task input to the task app by dialing the agent // directly over the tailnet. We enforce ApplicationConnect RBAC on the -// workspace and validate the sidebar app health. +// workspace and validate the task app health. func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - - idStr := chi.URLParam(r, "id") - taskID, err := uuid.Parse(idStr) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr), - }) - return - } + task := httpmw.TaskParam(r) var req codersdk.TaskSendRequest if !httpapi.Read(ctx, rw, r, &req) { @@ -693,7 +647,7 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { return } - if err = api.authAndDoWithTaskSidebarAppClient(r, taskID, func(ctx context.Context, client *http.Client, appURL *url.URL) error { + if err := api.authAndDoWithTaskAppClient(r, task, func(ctx context.Context, client *http.Client, appURL *url.URL) error { agentAPIClient, err := aiagentapi.NewClient(appURL.String(), aiagentapi.WithHTTPClient(client)) if err != nil { return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ @@ -743,27 +697,19 @@ func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Experimental // @Param user path string true "Username, user ID, or 'me' for the authenticated user" -// @Param id path string true "Task ID" format(uuid) +// @Param task path string true "Task ID" format(uuid) // @Success 200 {object} codersdk.TaskLogsResponse -// @Router /api/experimental/tasks/{user}/{id}/logs [get] +// @Router /api/experimental/tasks/{user}/{task}/logs [get] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. // taskLogs reads task output by dialing the agent directly over the tailnet. -// We enforce ApplicationConnect RBAC on the workspace and validate the sidebar app health. +// We enforce ApplicationConnect RBAC on the workspace and validate the task app health. func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - - idStr := chi.URLParam(r, "id") - taskID, err := uuid.Parse(idStr) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr), - }) - return - } + task := httpmw.TaskParam(r) var out codersdk.TaskLogsResponse - if err := api.authAndDoWithTaskSidebarAppClient(r, taskID, func(ctx context.Context, client *http.Client, appURL *url.URL) error { + if err := api.authAndDoWithTaskAppClient(r, task, func(ctx context.Context, client *http.Client, appURL *url.URL) error { agentAPIClient, err := aiagentapi.NewClient(appURL.String(), aiagentapi.WithHTTPClient(client)) if err != nil { return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ @@ -811,24 +757,40 @@ func (api *API) taskLogs(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, out) } -// authAndDoWithTaskSidebarAppClient centralizes the shared logic to: +// authAndDoWithTaskAppClient centralizes the shared logic to: // // - Fetch the task workspace // - Authorize ApplicationConnect on the workspace -// - Validate the AI task and sidebar app health +// - Validate the AI task and task app health // - Dial the agent and construct an HTTP client to the apps loopback URL // // The provided callback receives the context, an HTTP client that dials via the // agent, and the base app URL (as a value URL) to perform any request. -func (api *API) authAndDoWithTaskSidebarAppClient( +func (api *API) authAndDoWithTaskAppClient( r *http.Request, - taskID uuid.UUID, + task database.Task, do func(ctx context.Context, client *http.Client, appURL *url.URL) error, ) error { ctx := r.Context() - workspaceID := taskID - workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID) + if task.Status != database.TaskStatusActive { + return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ + Message: "Task status must be active.", + Detail: fmt.Sprintf("Task status is %q, it must be %q to interact with the task.", task.Status, codersdk.TaskStatusActive), + }) + } + if !task.WorkspaceID.Valid { + return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ + Message: "Task does not have a workspace.", + }) + } + if !task.WorkspaceAppID.Valid { + return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ + Message: "Task does not have a workspace app.", + }) + } + + workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID) if err != nil { if httpapi.Is404Error(err) { return httperror.ErrResourceNotFound @@ -844,65 +806,30 @@ func (api *API) authAndDoWithTaskSidebarAppClient( return httperror.ErrResourceNotFound } - data, err := api.workspaceData(ctx, []database.Workspace{workspace}) + apps, err := api.Database.GetWorkspaceAppsByAgentID(ctx, task.WorkspaceAgentID.UUID) if err != nil { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching workspace resources.", Detail: err.Error(), }) } - if len(data.builds) == 0 || len(data.templates) == 0 { - return httperror.ErrResourceNotFound - } - build := data.builds[0] - if build.HasAITask == nil || !*build.HasAITask || build.AITaskSidebarAppID == nil || *build.AITaskSidebarAppID == uuid.Nil { - return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ - Message: "Task is not configured with a sidebar app.", - }) - } - // Find the sidebar app details to get the URL and validate app health. - sidebarAppID := *build.AITaskSidebarAppID - agentID, sidebarApp, ok := func() (uuid.UUID, codersdk.WorkspaceApp, bool) { - for _, res := range build.Resources { - for _, agent := range res.Agents { - for _, app := range agent.Apps { - if app.ID == sidebarAppID { - return agent.ID, app, true - } - } - } + var app *database.WorkspaceApp + for _, a := range apps { + if a.ID == task.WorkspaceAppID.UUID { + app = &a + break } - return uuid.Nil, codersdk.WorkspaceApp{}, false - }() - if !ok { - return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ - Message: "Task sidebar app not found in latest build.", - }) - } - - // Return an informative error if the app isn't healthy rather than trying - // and failing. - switch sidebarApp.Health { - case codersdk.WorkspaceAppHealthDisabled: - // No health check, pass through. - case codersdk.WorkspaceAppHealthInitializing: - return httperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{ - Message: "Task sidebar app is initializing. Try again shortly.", - }) - case codersdk.WorkspaceAppHealthUnhealthy: - return httperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{ - Message: "Task sidebar app is unhealthy.", - }) } // Build the direct app URL and dial the agent. - if sidebarApp.URL == "" { + appURL := app.Url.String + if appURL == "" { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ - Message: "Task sidebar app URL is not configured.", + Message: "Task app URL is not configured.", }) } - parsedURL, err := url.Parse(sidebarApp.URL) + parsedURL, err := url.Parse(appURL) if err != nil { return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ Message: "Internal error parsing task app URL.", @@ -917,7 +844,7 @@ func (api *API) authAndDoWithTaskSidebarAppClient( dialCtx, dialCancel := context.WithTimeout(ctx, time.Second*30) defer dialCancel() - agentConn, release, err := api.agentProvider.AgentConn(dialCtx, agentID) + agentConn, release, err := api.agentProvider.AgentConn(dialCtx, task.WorkspaceAgentID.UUID) if err != nil { return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ Message: "Failed to reach task app endpoint.", diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 28d444014bdd5..491194ffd9483 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -2,7 +2,7 @@ package coderd_test import ( "database/sql" - "fmt" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + agentapisdk "github.com/coder/agentapi-sdk-go" "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/coderd/coderdtest" @@ -22,7 +23,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" "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/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" @@ -54,10 +54,6 @@ func TestAITasksPrompts(t *testing.T) { t.Run("MultipleBuilds", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test checks RBAC, which is not supported in the in-memory database") - } - adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) first := coderdtest.CreateFirstUser(t, adminClient) memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, first.OrganizationID) @@ -215,8 +211,8 @@ func TestTasks(t *testing.T) { Apps: []*proto.App{ { Id: taskAppID.String(), - Slug: "task-sidebar", - DisplayName: "Task Sidebar", + Slug: "task-app", + DisplayName: "Task App", Url: opt.appURL, }, }, @@ -226,9 +222,7 @@ func TestTasks(t *testing.T) { }, AiTasks: []*proto.AITask{ { - SidebarApp: &proto.AITaskSidebarApp{ - Id: taskAppID.String(), - }, + AppId: taskAppID.String(), }, }, }, @@ -251,27 +245,33 @@ func TestTasks(t *testing.T) { template := createAITemplate(t, client, user) - // Create a workspace (task) with a specific prompt. + // Create a task with a specific prompt using the new data model. wantPrompt := "build me a web app" - workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: wantPrompt}, - } + exp := codersdk.NewExperimentalClient(client) + task, err := exp.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: wantPrompt, }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") + + // Wait for the workspace to be built. + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) // List tasks via experimental API and verify the prompt and status mapping. - exp := codersdk.NewExperimentalClient(client) tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{Owner: codersdk.Me}) require.NoError(t, err) - got, ok := slice.Find(tasks, func(task codersdk.Task) bool { return task.ID == workspace.ID }) + got, ok := slice.Find(tasks, func(t codersdk.Task) bool { return t.ID == task.ID }) require.True(t, ok, "task should be found in the list") assert.Equal(t, wantPrompt, got.InitialPrompt, "task prompt should match the AI Prompt parameter") - assert.Equal(t, workspace.Name, got.Name, "task name should map from workspace name") - assert.Equal(t, workspace.ID, got.WorkspaceID.UUID, "workspace id should match") - // Status should be populated via app status or workspace status mapping. + assert.Equal(t, task.WorkspaceID.UUID, got.WorkspaceID.UUID, "workspace id should match") + assert.Equal(t, task.WorkspaceName, got.WorkspaceName, "workspace name should match") + // Status should be populated via the tasks_with_status view. assert.NotEmpty(t, got.Status, "task status should not be empty") + assert.NotEmpty(t, got.WorkspaceStatus, "workspace status should not be empty") }) t.Run("Get", func(t *testing.T) { @@ -282,17 +282,22 @@ func TestTasks(t *testing.T) { ctx = testutil.Context(t, testutil.WaitLong) user = coderdtest.CreateFirstUser(t, client) template = createAITemplate(t, client, user) - // Create a workspace (task) with a specific prompt. wantPrompt = "review my code" - workspace = coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: wantPrompt}, - } - }) + exp = codersdk.NewExperimentalClient(client) ) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - ws := coderdtest.MustWorkspace(t, client, workspace.ID) + task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: wantPrompt, + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + // Get the workspace and wait for it to be ready. + ws, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + ws = coderdtest.MustWorkspace(t, client, task.WorkspaceID.UUID) // Assert invariant: the workspace has exactly one resource with one agent with one app. require.Len(t, ws.LatestBuild.Resources, 1) require.Len(t, ws.LatestBuild.Resources[0].Agents, 1) @@ -300,9 +305,9 @@ func TestTasks(t *testing.T) { taskAppID := ws.LatestBuild.Resources[0].Agents[0].Apps[0].ID // Insert an app status for the workspace - _, err := db.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ + _, err = db.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ ID: uuid.New(), - WorkspaceID: workspace.ID, + WorkspaceID: task.WorkspaceID.UUID, CreatedAt: dbtime.Now(), AgentID: agentID, AppID: taskAppID, @@ -312,31 +317,34 @@ func TestTasks(t *testing.T) { require.NoError(t, err) // Fetch the task by ID via experimental API and verify fields. - exp := codersdk.NewExperimentalClient(client) - task, err := exp.TaskByID(ctx, workspace.ID) + updated, err := exp.TaskByID(ctx, task.ID) require.NoError(t, err) - assert.Equal(t, workspace.ID, task.ID, "task ID should match workspace ID") - assert.Equal(t, workspace.Name, task.Name, "task name should map from workspace name") - assert.Equal(t, wantPrompt, task.InitialPrompt, "task prompt should match the AI Prompt parameter") - assert.Equal(t, workspace.ID, task.WorkspaceID.UUID, "workspace id should match") - assert.NotEmpty(t, task.Status, "task status should not be empty") + assert.Equal(t, task.ID, updated.ID, "task ID should match") + assert.Equal(t, task.Name, updated.Name, "task name should match") + assert.Equal(t, wantPrompt, updated.InitialPrompt, "task prompt should match the AI Prompt parameter") + assert.Equal(t, task.WorkspaceID.UUID, updated.WorkspaceID.UUID, "workspace id should match") + assert.Equal(t, task.WorkspaceName, updated.WorkspaceName, "workspace name should match") + assert.Equal(t, ws.LatestBuild.BuildNumber, updated.WorkspaceBuildNumber, "workspace build number should match") + assert.Equal(t, agentID, updated.WorkspaceAgentID.UUID, "workspace agent id should match") + assert.Equal(t, taskAppID, updated.WorkspaceAppID.UUID, "workspace app id should match") + assert.NotEmpty(t, updated.WorkspaceStatus, "task status should not be empty") // Stop the workspace - coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, client, task.WorkspaceID.UUID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Verify that the previous status still remains - updated, err := exp.TaskByID(ctx, workspace.ID) + updated, err = exp.TaskByID(ctx, task.ID) require.NoError(t, err) assert.NotNil(t, updated.CurrentState, "current state should not be nil") assert.Equal(t, "all done", updated.CurrentState.Message) assert.Equal(t, codersdk.TaskStateComplete, updated.CurrentState.State) // Start the workspace again - coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart) + coderdtest.MustTransitionWorkspace(t, client, task.WorkspaceID.UUID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart) // Verify that the status from the previous build is no longer present - updated, err = exp.TaskByID(ctx, workspace.ID) + updated, err = exp.TaskByID(ctx, task.ID) require.NoError(t, err) assert.Nil(t, updated.CurrentState, "current state should be nil") }) @@ -359,7 +367,8 @@ func TestTasks(t *testing.T) { Input: "delete me", }) require.NoError(t, err) - ws, err := client.Workspace(ctx, task.ID) + require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") + ws, err := client.Workspace(ctx, task.WorkspaceID.UUID) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) @@ -368,7 +377,7 @@ func TestTasks(t *testing.T) { // Poll until the workspace is deleted. for { - dws, derr := client.DeletedWorkspace(ctx, task.ID) + dws, derr := client.DeletedWorkspace(ctx, task.WorkspaceID.UUID) if derr == nil && dws.LatestBuild.Status == codersdk.WorkspaceStatusDeleted { break } @@ -439,7 +448,8 @@ func TestTasks(t *testing.T) { Input: "delete me not", }) require.NoError(t, err) - ws, err := client.Workspace(ctx, task.ID) + require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") + ws, err := client.Workspace(ctx, task.WorkspaceID.UUID) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) @@ -466,36 +476,37 @@ func TestTasks(t *testing.T) { t.Run("IntegrationOK", func(t *testing.T) { t.Parallel() - client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) - userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - - createStatusResponse := func(status string) string { - return ` - { - "$schema": "http://localhost:3284/schemas/StatusResponseBody.json", - "status": "` + status + `" - } - ` - } - statusResponse := createStatusResponse("stable") + statusResponse := agentapisdk.StatusStable // Start a fake AgentAPI that accepts GET /status and POST /message. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && r.URL.Path == "/status" { w.Header().Set("Content-Type", "application/json") + resp := agentapisdk.GetStatusResponse{ + Status: statusResponse, + } + respBytes, err := json.Marshal(resp) + assert.NoError(t, err) w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprint(w, statusResponse) + w.Write(respBytes) return } if r.Method == http.MethodPost && r.URL.Path == "/message" { w.Header().Set("Content-Type", "application/json") b, _ := io.ReadAll(r.Body) - assert.Equal(t, `{"content":"Hello, Agent!","type":"user"}`, string(b), "expected message content") + expectedReq := agentapisdk.PostMessageParams{ + Content: "Hello, Agent!", + Type: agentapisdk.MessageTypeUser, + } + expectedBytes, _ := json.Marshal(expectedReq) + assert.Equal(t, string(expectedBytes), string(b), "expected message content") + resp := agentapisdk.PostMessageResponse{Ok: true} + respBytes, err := json.Marshal(resp) + assert.NoError(t, err) w.WriteHeader(http.StatusOK) - io.WriteString(w, `{"ok": true}`) + w.Write(respBytes) return } w.WriteHeader(http.StatusInternalServerError) @@ -503,103 +514,105 @@ func TestTasks(t *testing.T) { defer srv.Close() // Create an AI-capable template whose sidebar app points to our fake AgentAPI. - authToken := uuid.NewString() - template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken)) + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + ctx = testutil.Context(t, testutil.WaitLong) + owner = coderdtest.CreateFirstUser(t, client) + userClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + agentAuthToken = uuid.NewString() + template = createAITemplate(t, client, owner, withAgentToken(agentAuthToken), withSidebarURL(srv.URL)) + exp = codersdk.NewExperimentalClient(userClient) + ) - // Create a workspace (task) from the AI-capable template. - ws := coderdtest.CreateWorkspace(t, userClient, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: "send a message"}, - } + task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "send me food", }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + // Get the workspace and wait for it to be ready. + ws, err := userClient.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, ws.LatestBuild.ID) + + // Fetch the task by ID via experimental API and verify fields. + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.NotZero(t, task.WorkspaceBuildNumber) + require.True(t, task.WorkspaceAgentID.Valid) + require.True(t, task.WorkspaceAppID.Valid) + + // Insert an app status for the workspace + _, err = db.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ + ID: uuid.New(), + WorkspaceID: task.WorkspaceID.UUID, + CreatedAt: dbtime.Now(), + AgentID: task.WorkspaceAgentID.UUID, + AppID: task.WorkspaceAppID.UUID, + State: database.WorkspaceAppStatusStateComplete, + Message: "all done", + }) + require.NoError(t, err) // Start a fake agent so the workspace agent is connected before sending the message. - agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) - _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { + agentClient := agentsdk.New(userClient.URL, agentsdk.WithFixedToken(agentAuthToken)) + _ = agenttest.New(t, userClient.URL, agentAuthToken, func(o *agent.Options) { o.Client = agentClient }) + coderdtest.NewWorkspaceAgentWaiter(t, userClient, ws.ID).WaitFor(coderdtest.AgentsReady) - ctx := testutil.Context(t, testutil.WaitMedium) - coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WithContext(ctx).WaitFor(coderdtest.AgentsReady) - - // Lookup the sidebar app ID. - w, err := client.Workspace(ctx, ws.ID) + // Fetch the task by ID via experimental API and verify fields. + task, err = exp.TaskByID(ctx, task.ID) require.NoError(t, err) - var sidebarAppID uuid.UUID - for _, res := range w.LatestBuild.Resources { - for _, ag := range res.Agents { - for _, app := range ag.Apps { - if app.Slug == "task-sidebar" { - sidebarAppID = app.ID - } - } - } - } - require.NotEqual(t, uuid.Nil, sidebarAppID) // Make the sidebar app unhealthy initially. - err = api.Database.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{ - ID: sidebarAppID, + err = db.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{ + ID: task.WorkspaceAppID.UUID, Health: database.WorkspaceAppHealthUnhealthy, }) require.NoError(t, err) - exp := codersdk.NewExperimentalClient(userClient) - err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ + err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ Input: "Hello, Agent!", }) require.Error(t, err, "wanted error due to unhealthy sidebar app") // Make the sidebar app healthy. - err = api.Database.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{ - ID: sidebarAppID, + err = db.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{ + ID: task.WorkspaceAppID.UUID, Health: database.WorkspaceAppHealthHealthy, }) require.NoError(t, err) - statusResponse = createStatusResponse("bad") + statusResponse = agentapisdk.AgentStatus("bad") - err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ + err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ Input: "Hello, Agent!", }) require.Error(t, err, "wanted error due to bad status") - statusResponse = createStatusResponse("stable") + statusResponse = agentapisdk.StatusStable - // Send task input to the tasks sidebar app and expect 204.e - err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ - Input: "Hello, Agent!", + //nolint:tparallel // Not intended to run in parallel. + t.Run("SendOK", func(t *testing.T) { + err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ + Input: "Hello, Agent!", + }) + require.NoError(t, err, "wanted no error due to healthy sidebar app and stable status") }) - require.NoError(t, err, "wanted no error due to healthy sidebar app and stable status") - }) - t.Run("MissingContent", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - - template := createAITemplate(t, client, user) - - // Create a workspace (task). - ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: "do work"}, - } - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + //nolint:tparallel // Not intended to run in parallel. + t.Run("MissingContent", func(t *testing.T) { + err = exp.TaskSend(ctx, "me", task.ID, codersdk.TaskSendRequest{ + Input: "", + }) + require.Error(t, err, "wanted error due to missing content") - exp := codersdk.NewExperimentalClient(client) - err := exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ - Input: "", + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) }) - - var sdkErr *codersdk.Error - require.Error(t, err) - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) }) t.Run("TaskNotFound", func(t *testing.T) { @@ -619,106 +632,112 @@ func TestTasks(t *testing.T) { require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) - - t.Run("NotATask", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitShort) - - // Create a template without AI tasks. - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - - ws := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - - exp := codersdk.NewExperimentalClient(client) - err := exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ - Input: "hello", - }) - - var sdkErr *codersdk.Error - require.Error(t, err) - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) - }) }) t.Run("Logs", func(t *testing.T) { t.Parallel() - t.Run("OK", func(t *testing.T) { - t.Parallel() + messageResponseData := agentapisdk.GetMessagesResponse{ + Messages: []agentapisdk.Message{ + { + Id: 0, + Content: "Welcome, user!", + Role: agentapisdk.RoleAgent, + Time: time.Date(2025, 9, 25, 10, 42, 48, 0, time.UTC), + }, + { + Id: 1, + Content: "Hello, agent!", + Role: agentapisdk.RoleUser, + Time: time.Date(2025, 9, 25, 10, 46, 42, 0, time.UTC), + }, + { + Id: 2, + Content: "What would you like to work on today?", + Role: agentapisdk.RoleAgent, + Time: time.Date(2025, 9, 25, 10, 46, 50, 0, time.UTC), + }, + }, + } + messageResponseBytes, err := json.Marshal(messageResponseData) + require.NoError(t, err) + messageResponse := string(messageResponseBytes) - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) + var shouldReturnError bool - messageResponse := ` - { - "$schema": "http://localhost:3284/schemas/MessagesResponseBody.json", - "messages": [ - { - "id": 0, - "content": "Welcome, user!", - "role": "agent", - "time": "2025-09-25T10:42:48.751774125Z" - }, - { - "id": 1, - "content": "Hello, agent!", - "role": "user", - "time": "2025-09-25T10:46:42.880996296Z" - }, - { - "id": 2, - "content": "What would you like to work on today?", - "role": "agent", - "time": "2025-09-25T10:46:50.747761102Z" - } - ] - } - ` + // Fake AgentAPI that returns a couple of messages or an error. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if shouldReturnError { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, "boom") + return + } + if r.Method == http.MethodGet && r.URL.Path == "/messages" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + io.WriteString(w, messageResponse) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() - // Fake AgentAPI that returns a couple of messages. - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/messages" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - io.WriteString(w, messageResponse) - return - } - w.WriteHeader(http.StatusNotFound) - })) - t.Cleanup(srv.Close) + // Create an AI-capable template whose sidebar app points to our fake AgentAPI. + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + ctx = testutil.Context(t, testutil.WaitLong) + owner = coderdtest.CreateFirstUser(t, client) + agentAuthToken = uuid.NewString() + template = createAITemplate(t, client, owner, withAgentToken(agentAuthToken), withSidebarURL(srv.URL)) + exp = codersdk.NewExperimentalClient(client) + ) - // Template pointing sidebar app to our fake AgentAPI. - authToken := uuid.NewString() - template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken)) + task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "show logs", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) - // Create task workspace. - ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: "show logs"}, - } - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + // Get the workspace and wait for it to be ready. + ws, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - // Start a fake agent. - agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) - _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { - o.Client = agentClient - }) - coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WithContext(ctx).WaitFor(coderdtest.AgentsReady) + // Fetch the task by ID via experimental API and verify fields. + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + require.NotZero(t, task.WorkspaceBuildNumber) + require.True(t, task.WorkspaceAgentID.Valid) + require.True(t, task.WorkspaceAppID.Valid) - // Omit sidebar app health as undefined is OK. + // Insert an app status for the workspace + _, err = db.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ + ID: uuid.New(), + WorkspaceID: task.WorkspaceID.UUID, + CreatedAt: dbtime.Now(), + AgentID: task.WorkspaceAgentID.UUID, + AppID: task.WorkspaceAppID.UUID, + State: database.WorkspaceAppStatusStateComplete, + Message: "all done", + }) + require.NoError(t, err) + + // Start a fake agent so the workspace agent is connected before fetching logs. + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(agentAuthToken)) + _ = agenttest.New(t, client.URL, agentAuthToken, func(o *agent.Options) { + o.Client = agentClient + }) + coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WaitFor(coderdtest.AgentsReady) + // Fetch the task by ID via experimental API and verify fields. + task, err = exp.TaskByID(ctx, task.ID) + require.NoError(t, err) + + //nolint:tparallel // Not intended to run in parallel. + t.Run("OK", func(t *testing.T) { // Fetch logs. - exp := codersdk.NewExperimentalClient(client) - resp, err := exp.TaskLogs(ctx, "me", ws.ID) + resp, err := exp.TaskLogs(ctx, "me", task.ID) require.NoError(t, err) require.Len(t, resp.Logs, 3) assert.Equal(t, 0, resp.Logs[0].ID) @@ -734,38 +753,11 @@ func TestTasks(t *testing.T) { assert.Equal(t, "What would you like to work on today?", resp.Logs[2].Content) }) + //nolint:tparallel // Not intended to run in parallel. t.Run("UpstreamError", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitShort) - - // Fake AgentAPI that returns 500 for messages. - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = io.WriteString(w, "boom") - })) - t.Cleanup(srv.Close) - - authToken := uuid.NewString() - template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken)) - ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { - req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ - {Name: codersdk.AITaskPromptParameterName, Value: "show logs"}, - } - }) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - - // Start fake agent. - agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) - _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { - o.Client = agentClient - }) - coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WithContext(ctx).WaitFor(coderdtest.AgentsReady) - - exp := codersdk.NewExperimentalClient(client) - _, err := exp.TaskLogs(ctx, "me", ws.ID) + shouldReturnError = true + t.Cleanup(func() { shouldReturnError = false }) + _, err := exp.TaskLogs(ctx, "me", task.ID) var sdkErr *codersdk.Error require.Error(t, err) @@ -796,7 +788,7 @@ func TestTasksCreate(t *testing.T) { ProvisionApply: echo.ApplyComplete, ProvisionPlan: []*proto.Response{ {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{{Name: "AI Prompt", Type: "string"}}, + Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, HasAiTasks: true, }}}, }, @@ -869,7 +861,7 @@ func TestTasksCreate(t *testing.T) { ProvisionApply: echo.ApplyComplete, ProvisionPlan: []*proto.Response{ {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{{Name: "AI Prompt", Type: "string"}}, + Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, HasAiTasks: true, }}}, }, @@ -965,7 +957,212 @@ func TestTasksCreate(t *testing.T) { var sdkErr *codersdk.Error require.Error(t, err) require.ErrorAsf(t, err, &sdkErr, "error should be of type *codersdk.Error") - assert.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("TaskTableCreatedAndLinked", func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + taskPrompt = "Create a REST API" + ) + + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + // Create a template with AI task support to test the new task data model. + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: []*proto.Response{ + {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, + HasAiTasks: true, + }}}, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + expClient := codersdk.NewExperimentalClient(client) + + task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: taskPrompt, + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid) + + ws, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + // Verify that the task was created in the tasks table with the correct + // fields. This ensures the data model properly separates task records + // from workspace records. + dbCtx := dbauthz.AsSystemRestricted(ctx) + dbTask, err := db.GetTaskByID(dbCtx, task.ID) + require.NoError(t, err) + assert.Equal(t, user.OrganizationID, dbTask.OrganizationID) + assert.Equal(t, user.UserID, dbTask.OwnerID) + assert.Equal(t, task.Name, dbTask.Name) + assert.True(t, dbTask.WorkspaceID.Valid) + assert.Equal(t, ws.ID, dbTask.WorkspaceID.UUID) + assert.Equal(t, version.ID, dbTask.TemplateVersionID) + assert.Equal(t, taskPrompt, dbTask.Prompt) + assert.False(t, dbTask.DeletedAt.Valid) + + // Verify the bidirectional relationship works by looking up the task + // via workspace ID. + dbTaskByWs, err := db.GetTaskByWorkspaceID(dbCtx, ws.ID) + require.NoError(t, err) + assert.Equal(t, dbTask.ID, dbTaskByWs.ID) + }) + + t.Run("TaskWithCustomName", func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + taskPrompt = "Build a dashboard" + taskName = "my-custom-task" + ) + + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: []*proto.Response{ + {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, + HasAiTasks: true, + }}}, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + expClient := codersdk.NewExperimentalClient(client) + + task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: taskPrompt, + Name: taskName, + }) + require.NoError(t, err) + require.Equal(t, taskName, task.Name) + + // Verify the custom name is preserved in the database record. + dbCtx := dbauthz.AsSystemRestricted(ctx) + dbTask, err := db.GetTaskByID(dbCtx, task.ID) + require.NoError(t, err) + assert.Equal(t, taskName, dbTask.Name) + }) + + t.Run("MultipleTasksForSameUser", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: []*proto.Response{ + {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, + HasAiTasks: true, + }}}, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + expClient := codersdk.NewExperimentalClient(client) + + task1, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "First task", + Name: "task-1", + }) + require.NoError(t, err) + + task2, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "Second task", + Name: "task-2", + }) + require.NoError(t, err) + + // Verify both tasks are stored independently and can be listed together. + dbCtx := dbauthz.AsSystemRestricted(ctx) + tasks, err := db.ListTasks(dbCtx, database.ListTasksParams{ + OwnerID: user.UserID, + OrganizationID: uuid.Nil, + }) + require.NoError(t, err) + require.GreaterOrEqual(t, len(tasks), 2) + + taskIDs := make(map[uuid.UUID]bool) + for _, task := range tasks { + taskIDs[task.ID] = true + } + assert.True(t, taskIDs[task1.ID], "task1 should be in the list") + assert.True(t, taskIDs[task2.ID], "task2 should be in the list") + }) + + t.Run("TaskLinkedToCorrectTemplateVersion", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: []*proto.Response{ + {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, + HasAiTasks: true, + }}}, + }, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + + version2 := coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: []*proto.Response{ + {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{{Name: codersdk.AITaskPromptParameterName, Type: "string"}}, + HasAiTasks: true, + }}}, + }, + }, template.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID) + + expClient := codersdk.NewExperimentalClient(client) + + // Create a task using version 2 to verify the template_version_id is + // stored correctly. + task, err := expClient.CreateTask(ctx, "me", codersdk.CreateTaskRequest{ + TemplateVersionID: version2.ID, + Input: "Use version 2", + }) + require.NoError(t, err) + + // Verify the task references the correct template version, not just the + // active one. + dbCtx := dbauthz.AsSystemRestricted(ctx) + dbTask, err := db.GetTaskByID(dbCtx, task.ID) + require.NoError(t, err) + assert.Equal(t, version2.ID, dbTask.TemplateVersionID, "task should be linked to version 2") }) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 585cc22710b61..5a7904d689998 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -151,39 +151,16 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Search query for filtering tasks", + "description": "Search query for filtering tasks. Supports: owner:\u003cusername/uuid/me\u003e, organization:\u003corg-name/uuid\u003e, status:\u003cstatus\u003e", "name": "q", "in": "query" - }, - { - "type": "string", - "description": "Return tasks after this ID for pagination", - "name": "after_id", - "in": "query" - }, - { - "maximum": 100, - "minimum": 1, - "type": "integer", - "default": 25, - "description": "Maximum number of tasks to return", - "name": "limit", - "in": "query" - }, - { - "minimum": 0, - "type": "integer", - "default": 0, - "description": "Offset for pagination", - "name": "offset", - "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/coderd.tasksListResponse" + "$ref": "#/definitions/codersdk.TasksListResponse" } } } @@ -229,7 +206,7 @@ const docTemplate = `{ } } }, - "/api/experimental/tasks/{user}/{id}": { + "/api/experimental/tasks/{user}/{task}": { "get": { "security": [ { @@ -253,7 +230,7 @@ const docTemplate = `{ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -290,7 +267,7 @@ const docTemplate = `{ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -302,7 +279,7 @@ const docTemplate = `{ } } }, - "/api/experimental/tasks/{user}/{id}/logs": { + "/api/experimental/tasks/{user}/{task}/logs": { "get": { "security": [ { @@ -326,7 +303,7 @@ const docTemplate = `{ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -341,7 +318,7 @@ const docTemplate = `{ } } }, - "/api/experimental/tasks/{user}/{id}/send": { + "/api/experimental/tasks/{user}/{task}/send": { "post": { "security": [ { @@ -365,7 +342,7 @@ const docTemplate = `{ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true }, @@ -3082,6 +3059,45 @@ const docTemplate = `{ } } }, + "/oauth2/revoke": { + "post": { + "consumes": [ + "application/x-www-form-urlencoded" + ], + "tags": [ + "Enterprise" + ], + "summary": "Revoke OAuth2 tokens (RFC 7009).", + "operationId": "oauth2-token-revocation", + "parameters": [ + { + "type": "string", + "description": "Client ID for authentication", + "name": "client_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "The token to revoke", + "name": "token", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Hint about token type (access_token or refresh_token)", + "name": "token_type_hint", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "Token successfully revoked" + } + } + } + }, "/oauth2/tokens": { "post": { "produces": [ @@ -7961,6 +7977,58 @@ const docTemplate = `{ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update token API key", + "operationId": "update-token-api-key", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Key Name", + "name": "keyname", + "in": "path", + "required": true + }, + { + "description": "Update token request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.APIKey" + } + } + } } }, "/users/{user}/keys/{keyid}": { @@ -11624,20 +11692,6 @@ const docTemplate = `{ } } }, - "coderd.tasksListResponse": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "tasks": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Task" - } - } - } - }, "codersdk.ACLAvailable": { "type": "object", "properties": { @@ -11727,14 +11781,14 @@ const docTemplate = `{ "codersdk.AIBridgeListInterceptionsResponse": { "type": "object", "properties": { + "count": { + "type": "integer" + }, "results": { "type": "array", "items": { "$ref": "#/definitions/codersdk.AIBridgeInterception" } - }, - "total": { - "type": "integer" } } }, @@ -11878,6 +11932,12 @@ const docTemplate = `{ "user_id" ], "properties": { + "allow_list": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIAllowListTarget" + } + }, "created_at": { "type": "string", "format": "date-time" @@ -14897,7 +14957,15 @@ const docTemplate = `{ "enum": [ "bug", "chat", - "docs" + "docs", + "star" + ] + }, + "location": { + "type": "string", + "enum": [ + "navbar", + "dropdown" ] }, "name": { @@ -15345,6 +15413,9 @@ const docTemplate = `{ }, "token": { "type": "string" + }, + "token_revoke": { + "type": "string" } } }, @@ -15444,7 +15515,10 @@ const docTemplate = `{ } }, "registration_access_token": { - "type": "string" + "type": "array", + "items": { + "type": "integer" + } }, "registration_client_uri": { "type": "string" @@ -17708,6 +17782,9 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "owner_avatar_url": { + "type": "string" + }, "owner_id": { "type": "string", "format": "uuid" @@ -17718,19 +17795,15 @@ const docTemplate = `{ "status": { "enum": [ "pending", - "starting", - "running", - "stopping", - "stopped", - "failed", - "canceling", - "canceled", - "deleting", - "deleted" + "initializing", + "active", + "paused", + "unknown", + "error" ], "allOf": [ { - "$ref": "#/definitions/codersdk.WorkspaceStatus" + "$ref": "#/definitions/codersdk.TaskStatus" } ] }, @@ -17747,6 +17820,10 @@ const docTemplate = `{ "template_name": { "type": "string" }, + "template_version_id": { + "type": "string", + "format": "uuid" + }, "updated_at": { "type": "string", "format": "date-time" @@ -17783,6 +17860,28 @@ const docTemplate = `{ "$ref": "#/definitions/uuid.NullUUID" } ] + }, + "workspace_name": { + "type": "string" + }, + "workspace_status": { + "enum": [ + "pending", + "starting", + "running", + "stopping", + "stopped", + "failed", + "canceling", + "canceled", + "deleting", + "deleted" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceStatus" + } + ] } } }, @@ -17867,6 +17966,39 @@ const docTemplate = `{ } } }, + "codersdk.TaskStatus": { + "type": "string", + "enum": [ + "pending", + "initializing", + "active", + "paused", + "unknown", + "error" + ], + "x-enum-varnames": [ + "TaskStatusPending", + "TaskStatusInitializing", + "TaskStatusActive", + "TaskStatusPaused", + "TaskStatusUnknown", + "TaskStatusError" + ] + }, + "codersdk.TasksListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Task" + } + } + } + }, "codersdk.TelemetryConfig": { "type": "object", "properties": { @@ -18932,6 +19064,29 @@ const docTemplate = `{ } } }, + "codersdk.UpdateTokenRequest": { + "type": "object", + "properties": { + "allow_list": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIAllowListTarget" + } + }, + "lifetime": { + "type": "integer" + }, + "scope": { + "$ref": "#/definitions/codersdk.APIKeyScope" + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } + } + } + }, "codersdk.UpdateUserAppearanceSettingsRequest": { "type": "object", "required": [ @@ -20394,6 +20549,7 @@ const docTemplate = `{ "type": "object", "properties": { "ai_task_sidebar_app_id": { + "description": "Deprecated: This field has been replaced with ` + "`" + `TaskAppID` + "`" + `", "type": "string", "format": "uuid" }, @@ -20475,6 +20631,10 @@ const docTemplate = `{ } ] }, + "task_app_id": { + "type": "string", + "format": "uuid" + }, "template_version_id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ac25626a03b2b..0983642b26362 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -125,39 +125,16 @@ "parameters": [ { "type": "string", - "description": "Search query for filtering tasks", + "description": "Search query for filtering tasks. Supports: owner:\u003cusername/uuid/me\u003e, organization:\u003corg-name/uuid\u003e, status:\u003cstatus\u003e", "name": "q", "in": "query" - }, - { - "type": "string", - "description": "Return tasks after this ID for pagination", - "name": "after_id", - "in": "query" - }, - { - "maximum": 100, - "minimum": 1, - "type": "integer", - "default": 25, - "description": "Maximum number of tasks to return", - "name": "limit", - "in": "query" - }, - { - "minimum": 0, - "type": "integer", - "default": 0, - "description": "Offset for pagination", - "name": "offset", - "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/coderd.tasksListResponse" + "$ref": "#/definitions/codersdk.TasksListResponse" } } } @@ -201,7 +178,7 @@ } } }, - "/api/experimental/tasks/{user}/{id}": { + "/api/experimental/tasks/{user}/{task}": { "get": { "security": [ { @@ -223,7 +200,7 @@ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -258,7 +235,7 @@ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -270,7 +247,7 @@ } } }, - "/api/experimental/tasks/{user}/{id}/logs": { + "/api/experimental/tasks/{user}/{task}/logs": { "get": { "security": [ { @@ -292,7 +269,7 @@ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true } @@ -307,7 +284,7 @@ } } }, - "/api/experimental/tasks/{user}/{id}/send": { + "/api/experimental/tasks/{user}/{task}/send": { "post": { "security": [ { @@ -329,7 +306,7 @@ "type": "string", "format": "uuid", "description": "Task ID", - "name": "id", + "name": "task", "in": "path", "required": true }, @@ -2720,6 +2697,41 @@ } } }, + "/oauth2/revoke": { + "post": { + "consumes": ["application/x-www-form-urlencoded"], + "tags": ["Enterprise"], + "summary": "Revoke OAuth2 tokens (RFC 7009).", + "operationId": "oauth2-token-revocation", + "parameters": [ + { + "type": "string", + "description": "Client ID for authentication", + "name": "client_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "The token to revoke", + "name": "token", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Hint about token type (access_token or refresh_token)", + "name": "token_type_hint", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "Token successfully revoked" + } + } + } + }, "/oauth2/tokens": { "post": { "produces": ["application/json"], @@ -7048,6 +7060,52 @@ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Update token API key", + "operationId": "update-token-api-key", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Key Name", + "name": "keyname", + "in": "path", + "required": true + }, + { + "description": "Update token request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.APIKey" + } + } + } } }, "/users/{user}/keys/{keyid}": { @@ -10324,20 +10382,6 @@ } } }, - "coderd.tasksListResponse": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "tasks": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Task" - } - } - } - }, "codersdk.ACLAvailable": { "type": "object", "properties": { @@ -10427,14 +10471,14 @@ "codersdk.AIBridgeListInterceptionsResponse": { "type": "object", "properties": { + "count": { + "type": "integer" + }, "results": { "type": "array", "items": { "$ref": "#/definitions/codersdk.AIBridgeInterception" } - }, - "total": { - "type": "integer" } } }, @@ -10578,6 +10622,12 @@ "user_id" ], "properties": { + "allow_list": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIAllowListTarget" + } + }, "created_at": { "type": "string", "format": "date-time" @@ -13489,7 +13539,11 @@ "properties": { "icon": { "type": "string", - "enum": ["bug", "chat", "docs"] + "enum": ["bug", "chat", "docs", "star"] + }, + "location": { + "type": "string", + "enum": ["navbar", "dropdown"] }, "name": { "type": "string" @@ -13907,6 +13961,9 @@ }, "token": { "type": "string" + }, + "token_revoke": { + "type": "string" } } }, @@ -14006,7 +14063,10 @@ } }, "registration_access_token": { - "type": "string" + "type": "array", + "items": { + "type": "integer" + } }, "registration_client_uri": { "type": "string" @@ -16204,6 +16264,9 @@ "type": "string", "format": "uuid" }, + "owner_avatar_url": { + "type": "string" + }, "owner_id": { "type": "string", "format": "uuid" @@ -16214,19 +16277,15 @@ "status": { "enum": [ "pending", - "starting", - "running", - "stopping", - "stopped", - "failed", - "canceling", - "canceled", - "deleting", - "deleted" + "initializing", + "active", + "paused", + "unknown", + "error" ], "allOf": [ { - "$ref": "#/definitions/codersdk.WorkspaceStatus" + "$ref": "#/definitions/codersdk.TaskStatus" } ] }, @@ -16243,6 +16302,10 @@ "template_name": { "type": "string" }, + "template_version_id": { + "type": "string", + "format": "uuid" + }, "updated_at": { "type": "string", "format": "date-time" @@ -16279,6 +16342,28 @@ "$ref": "#/definitions/uuid.NullUUID" } ] + }, + "workspace_name": { + "type": "string" + }, + "workspace_status": { + "enum": [ + "pending", + "starting", + "running", + "stopping", + "stopped", + "failed", + "canceling", + "canceled", + "deleting", + "deleted" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.WorkspaceStatus" + } + ] } } }, @@ -16352,6 +16437,39 @@ } } }, + "codersdk.TaskStatus": { + "type": "string", + "enum": [ + "pending", + "initializing", + "active", + "paused", + "unknown", + "error" + ], + "x-enum-varnames": [ + "TaskStatusPending", + "TaskStatusInitializing", + "TaskStatusActive", + "TaskStatusPaused", + "TaskStatusUnknown", + "TaskStatusError" + ] + }, + "codersdk.TasksListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Task" + } + } + } + }, "codersdk.TelemetryConfig": { "type": "object", "properties": { @@ -17371,6 +17489,29 @@ } } }, + "codersdk.UpdateTokenRequest": { + "type": "object", + "properties": { + "allow_list": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIAllowListTarget" + } + }, + "lifetime": { + "type": "integer" + }, + "scope": { + "$ref": "#/definitions/codersdk.APIKeyScope" + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } + } + } + }, "codersdk.UpdateUserAppearanceSettingsRequest": { "type": "object", "required": ["terminal_font", "theme_preference"], @@ -18736,6 +18877,7 @@ "type": "object", "properties": { "ai_task_sidebar_app_id": { + "description": "Deprecated: This field has been replaced with `TaskAppID`", "type": "string", "format": "uuid" }, @@ -18813,6 +18955,10 @@ } ] }, + "task_app_id": { + "type": "string", + "format": "uuid" + }, "template_version_id": { "type": "string", "format": "uuid" diff --git a/coderd/apikey.go b/coderd/apikey.go index f2aec89e5709e..e4eb1f3140a35 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -66,40 +66,13 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { return } - // Map and validate requested scope. - // Accept legacy special scopes (all, application_connect) and external scopes. - // Default to coder:all scopes for backward compatibility. - scopes := database.APIKeyScopes{database.ApiKeyScopeCoderAll} - if len(createToken.Scopes) > 0 { - scopes = make(database.APIKeyScopes, 0, len(createToken.Scopes)) - for _, s := range createToken.Scopes { - name := string(s) - if !rbac.IsExternalScope(rbac.ScopeName(name)) { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to create API key.", - Detail: fmt.Sprintf("invalid or unsupported API key scope: %q", name), - }) - return - } - scopes = append(scopes, database.APIKeyScope(name)) - } - } else if string(createToken.Scope) != "" { - name := string(createToken.Scope) - if !rbac.IsExternalScope(rbac.ScopeName(name)) { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to create API key.", - Detail: fmt.Sprintf("invalid or unsupported API key scope: %q", name), - }) - return - } - switch name { - case "all": - scopes = database.APIKeyScopes{database.ApiKeyScopeCoderAll} - case "application_connect": - scopes = database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect} - default: - scopes = database.APIKeyScopes{database.APIKeyScope(name)} - } + scopes, err := normalizeTokenScopes(createToken.Scope, createToken.Scopes) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to create API key.", + Detail: err.Error(), + }) + return } tokenName := namesgenerator.GetRandomName(1) @@ -116,35 +89,13 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { TokenName: tokenName, } - if len(createToken.AllowList) > 0 { - rbacAllowListElements := make([]rbac.AllowListElement, 0, len(createToken.AllowList)) - for _, t := range createToken.AllowList { - entry, err := rbac.NewAllowListElement(string(t.Type), t.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to create API key.", - Detail: err.Error(), - }) - return - } - rbacAllowListElements = append(rbacAllowListElements, entry) - } - - rbacAllowList, err := rbac.NormalizeAllowList(rbacAllowListElements) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to create API key.", - Detail: err.Error(), - }) - return - } - - dbAllowList := make(database.AllowList, 0, len(rbacAllowList)) - for _, e := range rbacAllowList { - dbAllowList = append(dbAllowList, rbac.AllowListElement{Type: e.Type, ID: e.ID}) - } - - params.AllowList = dbAllowList + params.AllowList, err = normalizeTokenAllowList(createToken.AllowList) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to create API key.", + Detail: err.Error(), + }) + return } if createToken.Lifetime != 0 { @@ -301,6 +252,142 @@ func (api *API) apiKeyByName(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(token)) } +// @Summary Update token API key +// @ID update-token-api-key +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Users +// @Param user path string true "User ID, name, or me" +// @Param keyname path string true "Key Name" format(string) +// @Param request body codersdk.UpdateTokenRequest true "Update token request" +// @Success 200 {object} codersdk.APIKey +// @Router /users/{user}/keys/tokens/{keyname} [patch] +func (api *API) patchToken(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + tokenName = chi.URLParam(r, "keyname") + auditor = api.Auditor.Load() + ) + + var updateReq codersdk.UpdateTokenRequest + if !httpapi.Read(ctx, rw, r, &updateReq) { + return + } + + if updateReq.Scope != nil && updateReq.Scopes != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update API key.", + Detail: "provide either scope or scopes, not both", + }) + return + } + + token, err := api.Database.GetAPIKeyByName(ctx, database.GetAPIKeyByNameParams{ + TokenName: tokenName, + UserID: user.ID, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching API key.", + Detail: err.Error(), + }) + return + } + + aReq, commitAudit := audit.InitRequest[database.APIKey](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + aReq.Old = token + defer commitAudit() + + if !api.Authorize(r, policy.ActionUpdate, token) { + httpapi.Forbidden(rw) + return + } + + updatedScopes := token.Scopes + if updateReq.Scopes != nil { + normalized, err := normalizeTokenScopes(codersdk.APIKeyScope(""), *updateReq.Scopes) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update API key.", + Detail: err.Error(), + }) + return + } + updatedScopes = normalized + } else if updateReq.Scope != nil { + normalized, err := normalizeTokenScopes(*updateReq.Scope, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update API key.", + Detail: err.Error(), + }) + return + } + updatedScopes = normalized + } + + updatedAllowList := token.AllowList + if updateReq.AllowList != nil { + if len(*updateReq.AllowList) == 0 { + updatedAllowList = database.AllowList{rbac.AllowListAll()} + } else { + normalized, err := normalizeTokenAllowList(*updateReq.AllowList) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update API key.", + Detail: err.Error(), + }) + return + } + updatedAllowList = normalized + } + } + + expiresAt := token.ExpiresAt + lifetimeSeconds := token.LifetimeSeconds + if updateReq.Lifetime != nil { + if err := api.validateAPIKeyLifetime(ctx, user.ID, *updateReq.Lifetime); err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update API key.", + Detail: err.Error(), + }) + return + } + expiresAt = dbtime.Now().Add(*updateReq.Lifetime) + lifetimeSeconds = int64(updateReq.Lifetime.Seconds()) + } + + updatedToken, err := api.Database.UpdateAPIKeySettings(ctx, database.UpdateAPIKeySettingsParams{ + ID: token.ID, + Scopes: updatedScopes, + AllowList: updatedAllowList, + LifetimeSeconds: lifetimeSeconds, + ExpiresAt: expiresAt, + UpdatedAt: dbtime.Now(), + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update API key.", + Detail: err.Error(), + }) + return + } + + aReq.New = updatedToken + httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(updatedToken)) +} + // @Summary Get user tokens // @ID get-user-tokens // @Security CoderSessionToken @@ -516,3 +603,67 @@ func (api *API) createAPIKey(ctx context.Context, params apikey.CreateParams) (* HttpOnly: true, }), &newkey, nil } + +func normalizeTokenScopes(scope codersdk.APIKeyScope, scopes []codersdk.APIKeyScope) (database.APIKeyScopes, error) { + // Default to coder:all for backward compatibility when nothing is provided. + if len(scopes) == 0 && string(scope) == "" { + return database.APIKeyScopes{database.ApiKeyScopeCoderAll}, nil + } + + if len(scopes) > 0 && string(scope) != "" { + return nil, xerrors.New("provide either scope or scopes, not both") + } + + if len(scopes) == 0 { + name := string(scope) + if name == "" { + return database.APIKeyScopes{database.ApiKeyScopeCoderAll}, nil + } + if !rbac.IsExternalScope(rbac.ScopeName(name)) { + return nil, xerrors.Errorf("invalid or unsupported API key scope: %q", name) + } + switch name { + case "all": + return database.APIKeyScopes{database.ApiKeyScopeCoderAll}, nil + case "application_connect": + return database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect}, nil + default: + return database.APIKeyScopes{database.APIKeyScope(name)}, nil + } + } + + out := make(database.APIKeyScopes, 0, len(scopes)) + for _, raw := range scopes { + name := string(raw) + if !rbac.IsExternalScope(rbac.ScopeName(name)) { + return nil, xerrors.Errorf("invalid or unsupported API key scope: %q", name) + } + out = append(out, database.APIKeyScope(name)) + } + return out, nil +} + +func normalizeTokenAllowList(entries []codersdk.APIAllowListTarget) (database.AllowList, error) { + if len(entries) == 0 { + return database.AllowList{}, nil + } + + rbacAllowList := make([]rbac.AllowListElement, 0, len(entries)) + for _, entry := range entries { + re, err := rbac.NewAllowListElement(string(entry.Type), entry.ID) + if err != nil { + return nil, err + } + rbacAllowList = append(rbacAllowList, re) + } + + normalized, err := rbac.NormalizeAllowList(rbacAllowList) + if err != nil { + return nil, err + } + if len(normalized) == 0 { + panic("normalizeTokenAllowList: developer error, normalized allow list empty") + } + + return database.AllowList(normalized), nil +} diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index 0dd874b340a90..89bbb7ca536d8 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -2,6 +2,7 @@ package apikey import ( "crypto/sha256" + "crypto/subtle" "fmt" "net" "time" @@ -44,12 +45,17 @@ type CreateParams struct { // database representation. It is the responsibility of the caller to insert it // into the database. func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) { - keyID, keySecret, err := generateKey() + // Length of an API Key ID. + keyID, err := cryptorand.String(10) if err != nil { - return database.InsertAPIKeyParams{}, "", xerrors.Errorf("generate API key: %w", err) + return database.InsertAPIKeyParams{}, "", xerrors.Errorf("generate API key ID: %w", err) } - hashed := sha256.Sum256([]byte(keySecret)) + // Length of an API Key secret. + keySecret, hashedSecret, err := GenerateSecret(22) + if err != nil { + return database.InsertAPIKeyParams{}, "", xerrors.Errorf("generate API key secret: %w", err) + } // Default expires at to now+lifetime, or use the configured value if not // set. @@ -120,7 +126,7 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) ExpiresAt: params.ExpiresAt.UTC(), CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), - HashedSecret: hashed[:], + HashedSecret: hashedSecret, LoginType: params.LoginType, Scopes: scopes, AllowList: params.AllowList, @@ -128,17 +134,24 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) }, token, nil } -// generateKey a new ID and secret for an API key. -func generateKey() (id string, secret string, err error) { - // Length of an API Key ID. - id, err = cryptorand.String(10) - if err != nil { - return "", "", err - } - // Length of an API Key secret. - secret, err = cryptorand.String(22) +func GenerateSecret(length int) (secret string, hashed []byte, err error) { + secret, err = cryptorand.String(length) if err != nil { - return "", "", err + return "", nil, err } - return id, secret, nil + hash := HashSecret(secret) + return secret, hash, nil +} + +// ValidateHash compares a secret against an expected hashed secret. +func ValidateHash(hashedSecret []byte, secret string) bool { + hash := HashSecret(secret) + return subtle.ConstantTimeCompare(hashedSecret, hash) == 1 +} + +// HashSecret is the single function used to hash API key secrets. +// Use this to ensure a consistent hashing algorithm. +func HashSecret(secret string) []byte { + hash := sha256.Sum256([]byte(secret)) + return hash[:] } diff --git a/coderd/apikey/apikey_test.go b/coderd/apikey/apikey_test.go index 1f5de3aa18a49..aa17a02561eeb 100644 --- a/coderd/apikey/apikey_test.go +++ b/coderd/apikey/apikey_test.go @@ -1,7 +1,6 @@ package apikey_test import ( - "crypto/sha256" "strings" "testing" "time" @@ -126,8 +125,8 @@ func TestGenerate(t *testing.T) { require.Equal(t, key.ID, keytokens[0]) // Assert that the hashed secret is correct. - hashed := sha256.Sum256([]byte(keytokens[1])) - assert.ElementsMatch(t, hashed, key.HashedSecret) + equal := apikey.ValidateHash(key.HashedSecret, keytokens[1]) + require.True(t, equal, "valid secret") assert.Equal(t, tc.params.UserID, key.UserID) assert.WithinDuration(t, dbtime.Now(), key.CreatedAt, time.Second*5) @@ -173,3 +172,17 @@ func TestGenerate(t *testing.T) { }) } } + +// TestInvalid just ensures the false case is asserted by some tests. +// Otherwise, a function that just `returns true` might pass all tests incorrectly. +func TestInvalid(t *testing.T) { + t.Parallel() + + require.Falsef(t, apikey.ValidateHash([]byte{}, "secret"), "empty hash") + + secret, hash, err := apikey.GenerateSecret(10) + require.NoError(t, err) + + require.Falsef(t, apikey.ValidateHash(hash, secret+"_"), "different secret") + require.Falsef(t, apikey.ValidateHash(hash[:len(hash)-1], secret), "different hash length") +} diff --git a/coderd/apikey_scopes_validation_test.go b/coderd/apikey_scopes_validation_test.go index 2a57f39a2fd5c..bfdee12ad3f08 100644 --- a/coderd/apikey_scopes_validation_test.go +++ b/coderd/apikey_scopes_validation_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" @@ -62,3 +63,105 @@ func TestTokenCreation_ScopeValidation(t *testing.T) { }) } } + +func TestTokenCreation_AllowListValidation(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitShort) + defer cancel() + + fetchToken := func(tokenName string) codersdk.APIKeyWithOwner { + t.Helper() + tokens, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) + require.NoError(t, err) + for _, token := range tokens { + if token.TokenName == tokenName { + return token + } + } + names := make([]string, 0, len(tokens)) + for _, token := range tokens { + names = append(names, token.TokenName) + } + t.Fatalf("token %q not found, available tokens: %v", tokenName, names) + return codersdk.APIKeyWithOwner{} + } + + // Invalid resource type should be rejected. + _, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead}, + AllowList: []codersdk.APIAllowListTarget{ + {Type: codersdk.RBACResource("unknown"), ID: uuid.New().String()}, + }, + }) + require.Error(t, err) + + // Valid typed allow list should succeed. + typedTarget := codersdk.AllowResourceTarget(codersdk.ResourceWorkspace, uuid.New()) + typedTokenName := "workspace-target" + resp, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead}, + TokenName: typedTokenName, + AllowList: []codersdk.APIAllowListTarget{typedTarget}, + }) + require.NoError(t, err) + require.NotEmpty(t, resp.Key) + token := fetchToken(typedTokenName) + require.Len(t, token.AllowList, 1) + require.Equal(t, typedTarget.String(), token.AllowList[0].String()) + + // Wildcard resource allow list should succeed. + workspaceWildcard := codersdk.AllowTypeTarget(codersdk.ResourceWorkspace) + workspaceWildcardName := "workspace-wildcard" + resp, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead}, + TokenName: workspaceWildcardName, + AllowList: []codersdk.APIAllowListTarget{workspaceWildcard}, + }) + require.NoError(t, err) + require.NotEmpty(t, resp.Key) + token = fetchToken(workspaceWildcardName) + require.Len(t, token.AllowList, 1) + require.Equal(t, workspaceWildcard.String(), token.AllowList[0].String(), "typed wildcard preserves resource type wildcard") + + // Full wildcard allow list should succeed. + fullWildcard := codersdk.AllowAllTarget() + fullWildcardName := "wildcard-all" + resp, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead}, + TokenName: fullWildcardName, + AllowList: []codersdk.APIAllowListTarget{fullWildcard}, + }) + require.NoError(t, err) + require.NotEmpty(t, resp.Key) + token = fetchToken(fullWildcardName) + require.Len(t, token.AllowList, 1) + require.Equal(t, fullWildcard.String(), token.AllowList[0].String()) +} + +func TestTokenCreationAllowsElevatedScopes(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + admin := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, admin) + + limitedClient, _ := coderdtest.CreateAnotherUser(t, admin, first.OrganizationID) + + resp, err := limitedClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderWorkspacesDelete}, + }) + require.NoError(t, err) + require.NotEmpty(t, resp.Key) + + resp, err = limitedClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderApikeysManageSelf}, + }) + require.NoError(t, err) + require.NotEmpty(t, resp.Key) +} diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index f980706d6ef6e..e7a5065d6d072 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -51,6 +52,8 @@ func TestTokenCRUD(t *testing.T) { require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*6)) require.Less(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*8)) require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope) + require.Len(t, keys[0].AllowList, 1) + require.Equal(t, "*:*", keys[0].AllowList[0].String()) // no update @@ -86,6 +89,8 @@ func TestTokenScoped(t *testing.T) { require.EqualValues(t, len(keys), 1) require.Contains(t, res.Key, keys[0].ID) require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect) + require.Len(t, keys[0].AllowList, 1) + require.Equal(t, "*:*", keys[0].AllowList[0].String()) } // Ensure backward-compat: when a token is created using the legacy singular @@ -132,10 +137,81 @@ func TestTokenLegacySingularScopeCompat(t *testing.T) { require.Len(t, keys, 1) require.Equal(t, tc.scope, keys[0].Scope) require.ElementsMatch(t, keys[0].Scopes, tc.scopes) + require.Len(t, keys[0].AllowList, 1) + require.Equal(t, "*:*", keys[0].AllowList[0].String()) }) } } +func TestTokenUpdate(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{TokenName: "updatable"}) + require.NoError(t, err) + + workspaceID := uuid.New() + allowList := []codersdk.APIAllowListTarget{codersdk.AllowResourceTarget(codersdk.ResourceWorkspace, workspaceID)} + scopes := []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead} + lifetime := 2 * time.Hour + + updated, err := client.UpdateToken(ctx, codersdk.Me, "updatable", codersdk.UpdateTokenRequest{ + Scopes: &scopes, + AllowList: &allowList, + Lifetime: &lifetime, + }) + require.NoError(t, err) + require.NotNil(t, updated) + require.ElementsMatch(t, scopes, updated.Scopes) + require.Len(t, updated.AllowList, 1) + require.Equal(t, allowList[0].String(), updated.AllowList[0].String()) + require.InDelta(t, lifetime.Seconds(), float64(updated.LifetimeSeconds), 1) + + keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) + require.NoError(t, err) + require.Len(t, keys, 1) + require.ElementsMatch(t, scopes, keys[0].Scopes) + require.Equal(t, allowList[0].String(), keys[0].AllowList[0].String()) +} + +func TestTokenUpdateMaxLifetime(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + dc := coderdtest.DeploymentValues(t) + dc.Sessions.MaximumTokenDuration = serpent.Duration(time.Hour) + dc.Sessions.MaximumAdminTokenDuration = serpent.Duration(2 * time.Hour) + client := coderdtest.New(t, &coderdtest.Options{DeploymentValues: dc}) + adminUser := coderdtest.CreateFirstUser(t, client) + nonAdminClient, _ := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID) + + _, err := nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{TokenName: "nonadmin-updatable"}) + require.NoError(t, err) + + tooLongNonAdmin := 90 * time.Minute + _, err = nonAdminClient.UpdateToken(ctx, codersdk.Me, "nonadmin-updatable", codersdk.UpdateTokenRequest{ + Lifetime: &tooLongNonAdmin, + }) + require.Error(t, err) + require.ErrorContains(t, err, "lifetime must be less than") + + _, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{TokenName: "admin-updatable"}) + require.NoError(t, err) + + tooLongAdmin := 3 * time.Hour + _, err = client.UpdateToken(ctx, codersdk.Me, "admin-updatable", codersdk.UpdateTokenRequest{ + Lifetime: &tooLongAdmin, + }) + require.Error(t, err) + require.ErrorContains(t, err, "lifetime must be less than") +} + func TestUserSetTokenDuration(t *testing.T) { t.Parallel() diff --git a/coderd/authorize.go b/coderd/authorize.go index 575bb5e98baf6..01293574e0b08 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -84,6 +84,7 @@ func (h *HTTPAuthorizer) Authorize(r *http.Request, action policy.Action, object slog.F("route", r.URL.Path), slog.F("action", action), slog.F("object", object), + slog.F("allow_list", roles.SafeAllowList()), ) return false diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 1bd50564b6b9b..263a9e7e13c77 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -776,10 +776,6 @@ func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) { } func TestExecutorAutostartMultipleOK(t *testing.T) { - if !dbtestutil.WillUsePostgres() { - t.Skip(`This test only really works when using a "real" database, similar to a HA setup`) - } - t.Parallel() var ( @@ -1259,10 +1255,6 @@ func TestNotifications(t *testing.T) { func TestExecutorPrebuilds(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } - // Prebuild workspaces should not be autostopped when the deadline is reached. // After being claimed, the workspace should stop at the deadline. t.Run("OnlyStopsAfterClaimed", func(t *testing.T) { diff --git a/coderd/coderd.go b/coderd/coderd.go index dd8d053624783..63e8306fd3a01 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -985,6 +985,16 @@ func New(options *Options) *API { r.Post("/", api.postOAuth2ProviderAppToken()) }) + // RFC 7009 Token Revocation Endpoint + r.Route("/revoke", func(r chi.Router) { + r.Use( + // RFC 7009 endpoint uses OAuth2 client authentication, not API key + httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(options.Database)), + ) + // POST /revoke is the standard OAuth2 token revocation endpoint per RFC 7009 + r.Post("/", api.revokeOAuth2Token()) + }) + // RFC 7591 Dynamic Client Registration - Public endpoint r.Post("/register", api.postOAuth2ClientRegistration()) @@ -1022,11 +1032,15 @@ func New(options *Options) *API { r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize)) - r.Get("/{id}", api.taskGet) - r.Delete("/{id}", api.taskDelete) - r.Post("/{id}/send", api.taskSend) - r.Get("/{id}/logs", api.taskLogs) r.Post("/", api.tasksCreate) + + r.Route("/{task}", func(r chi.Router) { + r.Use(httpmw.ExtractTaskParam(options.Database)) + r.Get("/", api.taskGet) + r.Delete("/", api.taskDelete) + r.Post("/send", api.taskSend) + r.Get("/logs", api.taskLogs) + }) }) }) r.Route("/mcp", func(r chi.Router) { @@ -1335,6 +1349,7 @@ func New(options *Options) *API { r.Get("/tokenconfig", api.tokenConfig) r.Route("/{keyname}", func(r chi.Router) { r.Get("/", api.apiKeyByName) + r.Patch("/", api.patchToken) }) }) r.Route("/{keyid}", func(r chi.Router) { diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index e80307ffc463c..d84802b9882af 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -6,6 +6,7 @@ type CheckConstraint string // CheckConstraint enums. const ( + CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users CheckUsersUsernameMinLength CheckConstraint = "users_username_min_length" // users CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 77ddcbbf4aecb..4c1e3da1a4471 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -51,6 +51,13 @@ func ListLazy[F any, T any](convert func(F) T) func(list []F) []T { } } +func APIAllowListTarget(entry rbac.AllowListElement) codersdk.APIAllowListTarget { + return codersdk.APIAllowListTarget{ + Type: codersdk.RBACResource(entry.Type), + ID: entry.ID, + } +} + type ExternalAuthMeta struct { Authenticated bool ValidateError string @@ -383,6 +390,9 @@ func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) cod }).String(), // We do not currently support DeviceAuth. DeviceAuth: "", + TokenRevoke: accessURL.ResolveReference(&url.URL{ + Path: "/oauth2/revoke", + }).String(), }, } } diff --git a/coderd/database/db_test.go b/coderd/database/db_test.go index f9442942e53e1..68b60a788fd3d 100644 --- a/coderd/database/db_test.go +++ b/coderd/database/db_test.go @@ -85,10 +85,6 @@ func TestNestedInTx(t *testing.T) { func testSQLDB(t testing.TB) *sql.DB { t.Helper() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } - connection, err := dbtestutil.Open(t) require.NoError(t, err) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f1b262047ab7b..47da820259671 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -446,6 +446,34 @@ var ( Scope: rbac.ScopeAll, }.WithCachedASTValue() + subjectSystemOAuth2 = rbac.Subject{ + Type: rbac.SubjectTypeSystemOAuth, + FriendlyName: "System OAuth2", + ID: uuid.Nil.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "system-oauth2"}, + DisplayName: "System OAuth2", + Site: rbac.Permissions(map[string][]policy.Action{ + // OAuth2 resources - full CRUD permissions + rbac.ResourceOauth2App.Type: rbac.ResourceOauth2App.AvailableActions(), + rbac.ResourceOauth2AppSecret.Type: rbac.ResourceOauth2AppSecret.AvailableActions(), + rbac.ResourceOauth2AppCodeToken.Type: rbac.ResourceOauth2AppCodeToken.AvailableActions(), + + // API key permissions needed for OAuth2 token revocation + rbac.ResourceApiKey.Type: {policy.ActionRead, policy.ActionDelete}, + + // Minimal read permissions that might be needed for OAuth2 operations + rbac.ResourceUser.Type: {policy.ActionRead}, + rbac.ResourceOrganization.Type: {policy.ActionRead}, + }), + User: []rbac.Permission{}, + ByOrgID: map[string]rbac.OrgPermissions{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() + subjectSystemReadProvisionerDaemons = rbac.Subject{ Type: rbac.SubjectTypeSystemReadProvisionerDaemons, FriendlyName: "Provisioner Daemons Reader", @@ -643,6 +671,12 @@ func AsSystemRestricted(ctx context.Context) context.Context { return As(ctx, subjectSystemRestricted) } +// AsSystemOAuth2 returns a context with an actor that has permissions +// required for OAuth2 provider operations (token revocation, device codes, registration). +func AsSystemOAuth2(ctx context.Context) context.Context { + return As(ctx, subjectSystemOAuth2) +} + // AsSystemReadProvisionerDaemons returns a context with an actor that has permissions // to read provisioner daemons. func AsSystemReadProvisionerDaemons(ctx context.Context) context.Context { @@ -1764,6 +1798,19 @@ func (q *querier) DeleteTailnetTunnel(ctx context.Context, arg database.DeleteTa return q.db.DeleteTailnetTunnel(ctx, arg) } +func (q *querier) DeleteTask(ctx context.Context, arg database.DeleteTaskParams) (database.TaskTable, error) { + task, err := q.db.GetTaskByID(ctx, arg.ID) + if err != nil { + return database.TaskTable{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionDelete, task.RBACObject()); err != nil { + return database.TaskTable{}, err + } + + return q.db.DeleteTask(ctx, arg) +} + func (q *querier) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { // First get the secret to check ownership secret, err := q.GetUserSecret(ctx, id) @@ -2428,7 +2475,7 @@ func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (d return q.db.GetOAuth2ProviderAppByID(ctx, id) } -func (q *querier) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken sql.NullString) (database.OAuth2ProviderApp, error) { +func (q *querier) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken []byte) (database.OAuth2ProviderApp, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2App); err != nil { return database.OAuth2ProviderApp{}, err } @@ -4664,6 +4711,13 @@ func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKe return update(q.log, q.auth, fetch, q.db.UpdateAPIKeyByID)(ctx, arg) } +func (q *querier) UpdateAPIKeySettings(ctx context.Context, arg database.UpdateAPIKeySettingsParams) (database.APIKey, error) { + fetch := func(ctx context.Context, arg database.UpdateAPIKeySettingsParams) (database.APIKey, error) { + return q.db.GetAPIKeyByID(ctx, arg.ID) + } + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateAPIKeySettings)(ctx, arg) +} + func (q *querier) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceCryptoKey); err != nil { return database.CryptoKey{}, err @@ -4975,6 +5029,30 @@ func (q *querier) UpdateTailnetPeerStatusByCoordinator(ctx context.Context, arg return q.db.UpdateTailnetPeerStatusByCoordinator(ctx, arg) } +func (q *querier) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) { + // An actor is allowed to update the workspace ID of a task if they are the + // owner of the task and workspace or have the appropriate permissions. + task, err := q.db.GetTaskByID(ctx, arg.ID) + if err != nil { + return database.TaskTable{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionUpdate, task.RBACObject()); err != nil { + return database.TaskTable{}, err + } + + ws, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID.UUID) + if err != nil { + return database.TaskTable{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionUpdate, ws.RBACObject()); err != nil { + return database.TaskTable{}, err + } + + return q.db.UpdateTaskWorkspaceID(ctx, arg) +} + func (q *querier) UpdateTemplateACLByID(ctx context.Context, arg database.UpdateTemplateACLByIDParams) error { fetch := func(ctx context.Context, arg database.UpdateTemplateACLByIDParams) (database.Template, error) { return q.db.GetTemplateByID(ctx, arg.ID) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index feb78215c5d29..13bf813b7ce54 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -256,6 +256,23 @@ func (s *MethodTestSuite) TestAPIKey() { dbm.EXPECT().InsertAPIKey(gomock.Any(), arg).Return(ret, nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceApiKey.WithOwner(u.ID.String()), policy.ActionCreate) })) + s.Run("UpdateAPIKeySettings", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + original := testutil.Fake(s.T(), faker, database.APIKey{}) + params := database.UpdateAPIKeySettingsParams{ + ID: original.ID, + Scopes: database.APIKeyScopes{database.APIKeyScope("workspace:read")}, + AllowList: database.AllowList{rbac.AllowListAll()}, + LifetimeSeconds: original.LifetimeSeconds, + ExpiresAt: time.Now().Add(time.Hour), + UpdatedAt: time.Now(), + } + updated := original + updated.Scopes = params.Scopes + updated.AllowList = params.AllowList + dbm.EXPECT().GetAPIKeyByID(gomock.Any(), original.ID).Return(original, nil).AnyTimes() + dbm.EXPECT().UpdateAPIKeySettings(gomock.Any(), params).Return(updated, nil).AnyTimes() + check.Args(params).Asserts(original, policy.ActionUpdate).Returns(updated) + })) s.Run("UpdateAPIKeyByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) a := testutil.Fake(s.T(), faker, database.APIKey{UserID: u.ID, IPAddress: defaultIPAddress()}) @@ -2362,6 +2379,16 @@ func (s *MethodTestSuite) TestTasks() { dbm.EXPECT().GetTaskByID(gomock.Any(), task.ID).Return(task, nil).AnyTimes() check.Args(task.ID).Asserts(task, policy.ActionRead).Returns(task) })) + s.Run("DeleteTask", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + task := testutil.Fake(s.T(), faker, database.Task{}) + arg := database.DeleteTaskParams{ + ID: task.ID, + DeletedAt: dbtime.Now(), + } + dbm.EXPECT().GetTaskByID(gomock.Any(), task.ID).Return(task, nil).AnyTimes() + dbm.EXPECT().DeleteTask(gomock.Any(), arg).Return(database.TaskTable{}, nil).AnyTimes() + check.Args(arg).Asserts(task, policy.ActionDelete).Returns(database.TaskTable{}) + })) s.Run("InsertTask", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { tpl := testutil.Fake(s.T(), faker, database.Template{}) tv := testutil.Fake(s.T(), faker, database.TemplateVersion{ @@ -2395,6 +2422,20 @@ func (s *MethodTestSuite) TestTasks() { check.Args(arg).Asserts(task, policy.ActionUpdate).Returns(database.TaskWorkspaceApp{}) })) + s.Run("UpdateTaskWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + task := testutil.Fake(s.T(), faker, database.Task{}) + ws := testutil.Fake(s.T(), faker, database.Workspace{}) + arg := database.UpdateTaskWorkspaceIDParams{ + ID: task.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + } + + dbm.EXPECT().GetTaskByID(gomock.Any(), task.ID).Return(task, nil).AnyTimes() + dbm.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes() + dbm.EXPECT().UpdateTaskWorkspaceID(gomock.Any(), arg).Return(database.TaskTable{}, nil).AnyTimes() + + check.Args(arg).Asserts(task, policy.ActionUpdate, ws, policy.ActionUpdate).Returns(database.TaskTable{}) + })) s.Run("GetTaskByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { task := testutil.Fake(s.T(), faker, database.Task{}) task.WorkspaceID = uuid.NullUUID{UUID: uuid.New(), Valid: true} @@ -2946,7 +2987,6 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().GetParameterSchemasByJobID(gomock.Any(), jobID).Return([]database.ParameterSchema{}, nil).AnyTimes() check.Args(jobID). Asserts(tpl, policy.ActionRead). - ErrorsWithInMemDB(sql.ErrNoRows). Returns([]database.ParameterSchema{}) })) s.Run("GetWorkspaceAppsByAgentIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { @@ -3189,7 +3229,7 @@ func (s *MethodTestSuite) TestSystemFunctions() { })) s.Run("GetAppSecurityKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetAppSecurityKey(gomock.Any()).Return("", sql.ErrNoRows).AnyTimes() - check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead).ErrorsWithPG(sql.ErrNoRows) + check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead).Errors(sql.ErrNoRows) })) s.Run("UpsertAppSecurityKey", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().UpsertAppSecurityKey(gomock.Any(), "foo").Return(nil).AnyTimes() @@ -3902,9 +3942,9 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { })) s.Run("GetOAuth2ProviderAppByRegistrationToken", s.Subtest(func(db database.Store, check *expects) { app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{ - RegistrationAccessToken: sql.NullString{String: "test-token", Valid: true}, + RegistrationAccessToken: []byte("test-token"), }) - check.Args(sql.NullString{String: "test-token", Valid: true}).Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(app) + check.Args([]byte("test-token")).Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(app) })) } diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index 6a3f1a2b3fe62..91fb68e1a1f3f 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -430,24 +430,6 @@ func (m *expects) Errors(err error) *expects { return m } -// ErrorsWithPG is optional. If it is never called, it will not be asserted. -// It will only be asserted if the test is running with a Postgres database. -func (m *expects) ErrorsWithPG(err error) *expects { - if dbtestutil.WillUsePostgres() { - return m.Errors(err) - } - return m -} - -// ErrorsWithInMemDB is optional. If it is never called, it will not be asserted. -// It will only be asserted if the test is running with an in-memory database. -func (m *expects) ErrorsWithInMemDB(err error) *expects { - if !dbtestutil.WillUsePostgres() { - return m.Errors(err) - } - return m -} - func (m *expects) FailSystemObjectChecks() *expects { return m.WithSuccessAuthorizer(func(ctx context.Context, subject rbac.Subject, action policy.Action, obj rbac.Object) error { if obj.Type == rbac.ResourceSystem.Type { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 1479dfb198944..9eaf1afb93a15 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -120,19 +120,23 @@ func (b WorkspaceBuildBuilder) WithAgent(mutations ...func([]*sdkproto.Agent) [] } func (b WorkspaceBuildBuilder) WithTask(seed *sdkproto.App) WorkspaceBuildBuilder { - //nolint: revive // returns modified struct - b.taskAppID = uuid.New() if seed == nil { seed = &sdkproto.App{} } + + var err error + //nolint: revive // returns modified struct + b.taskAppID, err = uuid.Parse(takeFirst(seed.Id, uuid.NewString())) + require.NoError(b.t, err) + return b.Params(database.WorkspaceBuildParameter{ Name: codersdk.AITaskPromptParameterName, Value: "list me", }).WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent { a[0].Apps = []*sdkproto.App{ { - Id: takeFirst(seed.Id, b.taskAppID.String()), - Slug: takeFirst(seed.Slug, "vcode"), + Id: b.taskAppID.String(), + Slug: takeFirst(seed.Slug, "task-app"), Url: takeFirst(seed.Url, ""), }, } @@ -195,11 +199,11 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { if b.ws.ID == uuid.Nil { // nolint: revive b.ws = dbgen.Workspace(b.t, b.db, b.ws) - resp.Workspace = b.ws b.logger.Debug(context.Background(), "created workspace", - slog.F("name", resp.Workspace.Name), - slog.F("workspace_id", resp.Workspace.ID)) + slog.F("name", b.ws.Name), + slog.F("workspace_id", b.ws.ID)) } + resp.Workspace = b.ws b.seed.WorkspaceID = b.ws.ID b.seed.InitiatorID = takeFirst(b.seed.InitiatorID, b.ws.OwnerID) @@ -273,6 +277,30 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse { slog.F("workspace_id", resp.Workspace.ID), slog.F("build_number", resp.Build.BuildNumber)) + // If this is a task workspace, link it to the workspace build. + task, err := b.db.GetTaskByWorkspaceID(ownerCtx, resp.Workspace.ID) + if err != nil { + if b.taskAppID != uuid.Nil { + require.Fail(b.t, "task app configured but failed to get task by workspace id", err) + } + } else { + if b.taskAppID == uuid.Nil { + require.Fail(b.t, "task app not configured but workspace is a task workspace") + } + + app := mustWorkspaceAppByWorkspaceAndBuildAndAppID(ownerCtx, b.t, b.db, resp.Workspace.ID, resp.Build.BuildNumber, b.taskAppID) + _, err = b.db.UpsertTaskWorkspaceApp(ownerCtx, database.UpsertTaskWorkspaceAppParams{ + TaskID: task.ID, + WorkspaceBuildNumber: resp.Build.BuildNumber, + WorkspaceAgentID: uuid.NullUUID{UUID: app.AgentID, Valid: true}, + WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + }) + require.NoError(b.t, err, "upsert task workspace app") + b.logger.Debug(context.Background(), "linked task to workspace build", + slog.F("task_id", task.ID), + slog.F("build_number", resp.Build.BuildNumber)) + } + for i := range b.params { b.params[i].WorkspaceBuildID = resp.Build.ID } @@ -623,3 +651,30 @@ func takeFirst[Value comparable](values ...Value) Value { return v != empty }) } + +// mustWorkspaceAppByWorkspaceAndBuildAndAppID finds a workspace app by +// workspace ID, build number, and app ID. It returns the workspace app +// if found, otherwise fails the test. +func mustWorkspaceAppByWorkspaceAndBuildAndAppID(ctx context.Context, t testing.TB, db database.Store, workspaceID uuid.UUID, buildNumber int32, appID uuid.UUID) database.WorkspaceApp { + t.Helper() + + agents, err := db.GetWorkspaceAgentsByWorkspaceAndBuildNumber(ctx, database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ + WorkspaceID: workspaceID, + BuildNumber: buildNumber, + }) + require.NoError(t, err, "get workspace agents") + require.NotEmpty(t, agents, "no agents found for workspace") + + for _, agent := range agents { + apps, err := db.GetWorkspaceAppsByAgentID(ctx, agent.ID) + require.NoError(t, err, "get workspace apps") + for _, app := range apps { + if app.ID == appID { + return app + } + } + } + + require.FailNow(t, "could not find workspace app", "workspaceID=%s buildNumber=%d appID=%s", workspaceID, buildNumber, appID) + return database.WorkspaceApp{} // Unreachable. +} diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 713cb53b6d296..0f38dd1f93105 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -3,7 +3,6 @@ package dbgen import ( "context" "crypto/rand" - "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" @@ -20,6 +19,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" @@ -161,8 +161,8 @@ func Template(t testing.TB, db database.Store, seed database.Template) database. func APIKey(t testing.TB, db database.Store, seed database.APIKey, munge ...func(*database.InsertAPIKeyParams)) (key database.APIKey, token string) { id, _ := cryptorand.String(10) - secret, _ := cryptorand.String(22) - hashed := sha256.Sum256([]byte(secret)) + secret, hashed, err := apikey.GenerateSecret(22) + require.NoError(t, err) ip := seed.IPAddress if !ip.Valid { @@ -179,7 +179,7 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey, munge ...func ID: takeFirst(seed.ID, id), // 0 defaults to 86400 at the db layer LifetimeSeconds: takeFirst(seed.LifetimeSeconds, 0), - HashedSecret: takeFirstSlice(seed.HashedSecret, hashed[:]), + HashedSecret: takeFirstSlice(seed.HashedSecret, hashed), IPAddress: ip, UserID: takeFirst(seed.UserID, uuid.New()), LastUsed: takeFirst(seed.LastUsed, dbtime.Now()), @@ -194,7 +194,7 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey, munge ...func for _, fn := range munge { fn(¶ms) } - key, err := db.InsertAPIKey(genCtx, params) + key, err = db.InsertAPIKey(genCtx, params) require.NoError(t, err, "insert api key") return key, fmt.Sprintf("%s-%s", key.ID, secret) } @@ -980,16 +980,15 @@ func WorkspaceResourceMetadatums(t testing.TB, db database.Store, seed database. } func WorkspaceProxy(t testing.TB, db database.Store, orig database.WorkspaceProxy) (database.WorkspaceProxy, string) { - secret, err := cryptorand.HexString(64) + secret, hashedSecret, err := apikey.GenerateSecret(64) require.NoError(t, err, "generate secret") - hashedSecret := sha256.Sum256([]byte(secret)) proxy, err := db.InsertWorkspaceProxy(genCtx, database.InsertWorkspaceProxyParams{ ID: takeFirst(orig.ID, uuid.New()), Name: takeFirst(orig.Name, testutil.GetRandomName(t)), DisplayName: takeFirst(orig.DisplayName, testutil.GetRandomName(t)), Icon: takeFirst(orig.Icon, testutil.GetRandomName(t)), - TokenHashedSecret: hashedSecret[:], + TokenHashedSecret: hashedSecret, CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()), DerpEnabled: takeFirst(orig.DerpEnabled, false), @@ -1259,7 +1258,7 @@ func OAuth2ProviderApp(t testing.TB, db database.Store, seed database.OAuth2Prov Jwks: seed.Jwks, // pqtype.NullRawMessage{} is not comparable, use existing value SoftwareID: takeFirst(seed.SoftwareID, sql.NullString{}), SoftwareVersion: takeFirst(seed.SoftwareVersion, sql.NullString{}), - RegistrationAccessToken: takeFirst(seed.RegistrationAccessToken, sql.NullString{}), + RegistrationAccessToken: seed.RegistrationAccessToken, RegistrationClientUri: takeFirst(seed.RegistrationClientUri, sql.NullString{}), }) require.NoError(t, err, "insert oauth2 app") diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 9b678a8e278d0..ffb91b69acd8b 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5,7 +5,6 @@ package dbmetrics import ( "context" - "database/sql" "slices" "time" @@ -474,6 +473,13 @@ func (m queryMetricsStore) DeleteTailnetTunnel(ctx context.Context, arg database return r0, r1 } +func (m queryMetricsStore) DeleteTask(ctx context.Context, arg database.DeleteTaskParams) (database.TaskTable, error) { + start := time.Now() + r0, r1 := m.s.DeleteTask(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteTask").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.DeleteUserSecret(ctx, id) @@ -1097,7 +1103,7 @@ func (m queryMetricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid return r0, r1 } -func (m queryMetricsStore) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken sql.NullString) (database.OAuth2ProviderApp, error) { +func (m queryMetricsStore) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken []byte) (database.OAuth2ProviderApp, error) { start := time.Now() r0, r1 := m.s.GetOAuth2ProviderAppByRegistrationToken(ctx, registrationAccessToken) m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppByRegistrationToken").Observe(time.Since(start).Seconds()) @@ -2882,6 +2888,13 @@ func (m queryMetricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.Up return err } +func (m queryMetricsStore) UpdateAPIKeySettings(ctx context.Context, arg database.UpdateAPIKeySettingsParams) (database.APIKey, error) { + start := time.Now() + r0, r1 := m.s.UpdateAPIKeySettings(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateAPIKeySettings").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { start := time.Now() key, err := m.s.UpdateCryptoKeyDeletesAt(ctx, arg) @@ -3064,6 +3077,13 @@ func (m queryMetricsStore) UpdateTailnetPeerStatusByCoordinator(ctx context.Cont return r0 } +func (m queryMetricsStore) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) { + start := time.Now() + r0, r1 := m.s.UpdateTaskWorkspaceID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTaskWorkspaceID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpdateTemplateACLByID(ctx context.Context, arg database.UpdateTemplateACLByIDParams) error { start := time.Now() err := m.s.UpdateTemplateACLByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 06b7f7ae3efe9..921a9a96edb31 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -11,7 +11,6 @@ package dbmock import ( context "context" - sql "database/sql" reflect "reflect" time "time" @@ -880,6 +879,21 @@ func (mr *MockStoreMockRecorder) DeleteTailnetTunnel(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTailnetTunnel", reflect.TypeOf((*MockStore)(nil).DeleteTailnetTunnel), ctx, arg) } +// DeleteTask mocks base method. +func (m *MockStore) DeleteTask(ctx context.Context, arg database.DeleteTaskParams) (database.TaskTable, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTask", ctx, arg) + ret0, _ := ret[0].(database.TaskTable) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteTask indicates an expected call of DeleteTask. +func (mr *MockStoreMockRecorder) DeleteTask(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTask", reflect.TypeOf((*MockStore)(nil).DeleteTask), ctx, arg) +} + // DeleteUserSecret mocks base method. func (m *MockStore) DeleteUserSecret(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() @@ -2310,7 +2324,7 @@ func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppByID(ctx, id any) *gomock.C } // GetOAuth2ProviderAppByRegistrationToken mocks base method. -func (m *MockStore) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken sql.NullString) (database.OAuth2ProviderApp, error) { +func (m *MockStore) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken []byte) (database.OAuth2ProviderApp, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetOAuth2ProviderAppByRegistrationToken", ctx, registrationAccessToken) ret0, _ := ret[0].(database.OAuth2ProviderApp) @@ -6201,6 +6215,21 @@ func (mr *MockStoreMockRecorder) UpdateAPIKeyByID(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAPIKeyByID", reflect.TypeOf((*MockStore)(nil).UpdateAPIKeyByID), ctx, arg) } +// UpdateAPIKeySettings mocks base method. +func (m *MockStore) UpdateAPIKeySettings(ctx context.Context, arg database.UpdateAPIKeySettingsParams) (database.APIKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAPIKeySettings", ctx, arg) + ret0, _ := ret[0].(database.APIKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateAPIKeySettings indicates an expected call of UpdateAPIKeySettings. +func (mr *MockStoreMockRecorder) UpdateAPIKeySettings(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAPIKeySettings", reflect.TypeOf((*MockStore)(nil).UpdateAPIKeySettings), ctx, arg) +} + // UpdateCryptoKeyDeletesAt mocks base method. func (m *MockStore) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.UpdateCryptoKeyDeletesAtParams) (database.CryptoKey, error) { m.ctrl.T.Helper() @@ -6578,6 +6607,21 @@ func (mr *MockStoreMockRecorder) UpdateTailnetPeerStatusByCoordinator(ctx, arg a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTailnetPeerStatusByCoordinator", reflect.TypeOf((*MockStore)(nil).UpdateTailnetPeerStatusByCoordinator), ctx, arg) } +// UpdateTaskWorkspaceID mocks base method. +func (m *MockStore) UpdateTaskWorkspaceID(ctx context.Context, arg database.UpdateTaskWorkspaceIDParams) (database.TaskTable, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTaskWorkspaceID", ctx, arg) + ret0, _ := ret[0].(database.TaskTable) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateTaskWorkspaceID indicates an expected call of UpdateTaskWorkspaceID. +func (mr *MockStoreMockRecorder) UpdateTaskWorkspaceID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTaskWorkspaceID", reflect.TypeOf((*MockStore)(nil).UpdateTaskWorkspaceID), ctx, arg) +} + // UpdateTemplateACLByID mocks base method. func (m *MockStore) UpdateTemplateACLByID(ctx context.Context, arg database.UpdateTemplateACLByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dbrollup/dbrollup_test.go b/coderd/database/dbrollup/dbrollup_test.go index 2c727a6ca101a..c0417cd63134c 100644 --- a/coderd/database/dbrollup/dbrollup_test.go +++ b/coderd/database/dbrollup/dbrollup_test.go @@ -52,10 +52,6 @@ func (w *wrapUpsertDB) UpsertTemplateUsageStats(ctx context.Context) error { func TestRollup_TwoInstancesUseLocking(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("Skipping test; only works with PostgreSQL.") - } - db, ps := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) logger := testutil.Logger(t) diff --git a/coderd/database/dbtestutil/db.go b/coderd/database/dbtestutil/db.go index b4ddc491f3781..3d636e6833131 100644 --- a/coderd/database/dbtestutil/db.go +++ b/coderd/database/dbtestutil/db.go @@ -23,13 +23,6 @@ import ( "github.com/coder/coder/v2/testutil" ) -// WillUsePostgres returns true if a call to NewDB() will return a real, postgres-backed Store and Pubsub. -// TODO(hugodutka): since we removed the in-memory database, this is always true, -// and we need to remove this function. https://github.com/coder/internal/issues/758 -func WillUsePostgres() bool { - return true -} - type options struct { fixedTimezone string dumpOnFailure bool @@ -75,10 +68,6 @@ func withReturnSQLDB(f func(*sql.DB)) Option { func NewDBWithSQLDB(t testing.TB, opts ...Option) (database.Store, pubsub.Pubsub, *sql.DB) { t.Helper() - if !WillUsePostgres() { - t.Fatal("cannot use NewDBWithSQLDB without PostgreSQL, consider adding `if !dbtestutil.WillUsePostgres() { t.Skip() }` to this test") - } - var sqlDB *sql.DB opts = append(opts, withReturnSQLDB(func(db *sql.DB) { sqlDB = db diff --git a/coderd/database/dbtestutil/postgres_test.go b/coderd/database/dbtestutil/postgres_test.go index e653895f8e961..ecf18c9cfdecb 100644 --- a/coderd/database/dbtestutil/postgres_test.go +++ b/coderd/database/dbtestutil/postgres_test.go @@ -20,9 +20,6 @@ func TestMain(m *testing.M) { func TestOpen(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } connect, err := dbtestutil.Open(t) require.NoError(t, err) @@ -37,9 +34,6 @@ func TestOpen(t *testing.T) { func TestOpen_InvalidDBFrom(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } _, err := dbtestutil.Open(t, dbtestutil.WithDBFrom("__invalid__")) require.Error(t, err) @@ -49,9 +43,6 @@ func TestOpen_InvalidDBFrom(t *testing.T) { func TestOpen_ValidDBFrom(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } // first check if we can create a new template db dsn, err := dbtestutil.Open(t, dbtestutil.WithDBFrom("")) @@ -115,9 +106,6 @@ func TestOpen_ValidDBFrom(t *testing.T) { func TestOpen_Panic(t *testing.T) { t.Skip("unskip this to manually test that we don't leak a database into postgres") t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } _, err := dbtestutil.Open(t) require.NoError(t, err) @@ -127,9 +115,6 @@ func TestOpen_Panic(t *testing.T) { func TestOpen_Timeout(t *testing.T) { t.Skip("unskip this and set a short timeout to manually test that we don't leak a database into postgres") t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } _, err := dbtestutil.Open(t) require.NoError(t, err) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index a01ddb8d1fa7d..17ba8442f42e7 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1055,7 +1055,8 @@ CREATE TABLE aibridge_interceptions ( provider text NOT NULL, model text NOT NULL, started_at timestamp with time zone NOT NULL, - metadata jsonb + metadata jsonb, + ended_at timestamp with time zone ); COMMENT ON TABLE aibridge_interceptions IS 'Audit log of requests intercepted by AI Bridge'; @@ -1125,7 +1126,8 @@ CREATE TABLE api_keys ( ip_address inet DEFAULT '0.0.0.0'::inet NOT NULL, token_name text DEFAULT ''::text NOT NULL, scopes api_key_scope[] NOT NULL, - allow_list text[] NOT NULL + allow_list text[] NOT NULL, + CONSTRAINT api_keys_allow_list_not_empty CHECK ((array_length(allow_list, 1) > 0)) ); COMMENT ON COLUMN api_keys.hashed_secret IS 'hashed_secret contains a SHA256 hash of the key secret. This is considered a secret and MUST NOT be returned from the API as it is used for API key encryption in app proxying code.'; @@ -1536,7 +1538,7 @@ CREATE TABLE oauth2_provider_apps ( jwks jsonb, software_id text, software_version text, - registration_access_token text, + registration_access_token bytea, registration_client_uri text ); diff --git a/coderd/database/migrations/000386_aibridge_interceptions_ended_at.down.sql b/coderd/database/migrations/000386_aibridge_interceptions_ended_at.down.sql new file mode 100644 index 0000000000000..f578deb23c4c0 --- /dev/null +++ b/coderd/database/migrations/000386_aibridge_interceptions_ended_at.down.sql @@ -0,0 +1 @@ +ALTER TABLE aibridge_interceptions DROP COLUMN ended_at; diff --git a/coderd/database/migrations/000386_aibridge_interceptions_ended_at.up.sql b/coderd/database/migrations/000386_aibridge_interceptions_ended_at.up.sql new file mode 100644 index 0000000000000..e4cca7e5a5c56 --- /dev/null +++ b/coderd/database/migrations/000386_aibridge_interceptions_ended_at.up.sql @@ -0,0 +1 @@ +ALTER TABLE aibridge_interceptions ADD COLUMN ended_at TIMESTAMP WITH TIME ZONE DEFAULT NULL; diff --git a/coderd/database/migrations/000387_migrate_task_workspaces.down.sql b/coderd/database/migrations/000387_migrate_task_workspaces.down.sql new file mode 100644 index 0000000000000..b26683717106f --- /dev/null +++ b/coderd/database/migrations/000387_migrate_task_workspaces.down.sql @@ -0,0 +1,3 @@ +-- No-op: This migration is not reversible as it transforms existing data into +-- a new schema. Rolling back would require deleting tasks and potentially +-- losing data. diff --git a/coderd/database/migrations/000387_migrate_task_workspaces.up.sql b/coderd/database/migrations/000387_migrate_task_workspaces.up.sql new file mode 100644 index 0000000000000..8c09cfe44dc37 --- /dev/null +++ b/coderd/database/migrations/000387_migrate_task_workspaces.up.sql @@ -0,0 +1,113 @@ +-- Migrate existing task workspaces to the new tasks data model. This migration +-- identifies workspaces that were created as tasks (has_ai_task = true) and +-- populates the tasks and task_workspace_apps tables with their data. + +-- Step 1: Create tasks from workspaces with has_ai_task TRUE in their latest build. +INSERT INTO tasks ( + id, + organization_id, + owner_id, + name, + workspace_id, + template_version_id, + template_parameters, + prompt, + created_at, + deleted_at +) +SELECT + gen_random_uuid() AS id, + w.organization_id, + w.owner_id, + w.name, + w.id AS workspace_id, + latest_task_build.template_version_id, + COALESCE(params.template_parameters, '{}'::jsonb) AS template_parameters, + COALESCE(ai_prompt.value, '') AS prompt, + w.created_at, + CASE WHEN w.deleted = true THEN w.deleting_at ELSE NULL END AS deleted_at +FROM workspaces w +INNER JOIN LATERAL ( + -- Find the latest build for this workspace that has has_ai_task = true. + SELECT + wb.template_version_id + FROM workspace_builds wb + WHERE wb.workspace_id = w.id + AND wb.has_ai_task = true + ORDER BY wb.build_number DESC + LIMIT 1 +) latest_task_build ON true +LEFT JOIN LATERAL ( + -- Find the latest build that has a non-empty AI Prompt parameter. + SELECT + wb.id + FROM workspace_builds wb + WHERE wb.workspace_id = w.id + AND EXISTS ( + SELECT 1 + FROM workspace_build_parameters wbp + WHERE wbp.workspace_build_id = wb.id + AND wbp.name = 'AI Prompt' + AND wbp.value != '' + ) + ORDER BY wb.build_number DESC + LIMIT 1 +) latest_prompt_build ON true +LEFT JOIN LATERAL ( + -- Extract the AI Prompt parameter value from the prompt build. + SELECT wbp.value + FROM workspace_build_parameters wbp + WHERE wbp.workspace_build_id = latest_prompt_build.id + AND wbp.name = 'AI Prompt' + LIMIT 1 +) ai_prompt ON true +LEFT JOIN LATERAL ( + -- Aggregate all other parameters (excluding AI Prompt) from the prompt build. + SELECT jsonb_object_agg(wbp.name, wbp.value) AS template_parameters + FROM workspace_build_parameters wbp + WHERE wbp.workspace_build_id = latest_prompt_build.id + AND wbp.name != 'AI Prompt' +) params ON true +WHERE + -- Skip deleted workspaces because of duplicate name. + w.deleted = false + -- Safe-guard, do not create tasks for workspaces that are already tasks. + AND NOT EXISTS ( + SELECT 1 + FROM tasks t + WHERE t.workspace_id = w.id + ); + +-- Step 2: Populate task_workspace_apps table with build/agent/app information. +INSERT INTO task_workspace_apps ( + task_id, + workspace_build_number, + workspace_agent_id, + workspace_app_id +) +SELECT + t.id AS task_id, + latest_build.build_number AS workspace_build_number, + sidebar_app.agent_id AS workspace_agent_id, + sidebar_app.id AS workspace_app_id +FROM tasks t +INNER JOIN LATERAL ( + -- Find the latest build for this tasks workspace. + SELECT + wb.build_number, + wb.ai_task_sidebar_app_id + FROM workspace_builds wb + WHERE wb.workspace_id = t.workspace_id + ORDER BY wb.build_number DESC + LIMIT 1 +) latest_build ON true +-- Get the sidebar app (optional, can be NULL). +LEFT JOIN workspace_apps sidebar_app + ON sidebar_app.id = latest_build.ai_task_sidebar_app_id +WHERE + -- Safe-guard, do not create for existing tasks. + NOT EXISTS ( + SELECT 1 + FROM task_workspace_apps twa + WHERE twa.task_id = t.id + ); diff --git a/coderd/database/migrations/000388_oauth_app_byte_reg_access_token.down.sql b/coderd/database/migrations/000388_oauth_app_byte_reg_access_token.down.sql new file mode 100644 index 0000000000000..3e56dbf873511 --- /dev/null +++ b/coderd/database/migrations/000388_oauth_app_byte_reg_access_token.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE oauth2_provider_apps + ALTER COLUMN registration_access_token + SET DATA TYPE text + USING encode(registration_access_token, 'escape'); diff --git a/coderd/database/migrations/000388_oauth_app_byte_reg_access_token.up.sql b/coderd/database/migrations/000388_oauth_app_byte_reg_access_token.up.sql new file mode 100644 index 0000000000000..b278fed80e4ff --- /dev/null +++ b/coderd/database/migrations/000388_oauth_app_byte_reg_access_token.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE oauth2_provider_apps + ALTER COLUMN registration_access_token + SET DATA TYPE bytea + USING decode(registration_access_token, 'escape'); diff --git a/coderd/database/migrations/000389_api_key_allow_list_constraint.down.sql b/coderd/database/migrations/000389_api_key_allow_list_constraint.down.sql new file mode 100644 index 0000000000000..aa6aa87f10522 --- /dev/null +++ b/coderd/database/migrations/000389_api_key_allow_list_constraint.down.sql @@ -0,0 +1,3 @@ +-- Drop all CHECK constraints added in the up migration +ALTER TABLE api_keys +DROP CONSTRAINT api_keys_allow_list_not_empty; diff --git a/coderd/database/migrations/000389_api_key_allow_list_constraint.up.sql b/coderd/database/migrations/000389_api_key_allow_list_constraint.up.sql new file mode 100644 index 0000000000000..6dc46b522be92 --- /dev/null +++ b/coderd/database/migrations/000389_api_key_allow_list_constraint.up.sql @@ -0,0 +1,10 @@ +-- Defensively update any API keys with empty allow_list to have default '*:*' +-- This ensures all existing keys have at least one entry before adding the constraint +UPDATE api_keys +SET allow_list = ARRAY['*:*'] +WHERE allow_list = ARRAY[]::text[] OR array_length(allow_list, 1) IS NULL; + +-- Add CHECK constraint to ensure allow_list array is never empty +ALTER TABLE api_keys +ADD CONSTRAINT api_keys_allow_list_not_empty +CHECK (array_length(allow_list, 1) > 0); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index f31a3adb0eb3b..7bab30c0d45e7 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -469,3 +469,416 @@ func TestMigration000362AggregateUsageEvents(t *testing.T) { require.JSONEq(t, string(expectedDailyRows[i].usageData), string(row.UsageData)) } } + +func TestMigration000387MigrateTaskWorkspaces(t *testing.T) { + t.Parallel() + + // This test verifies the migration of task workspaces to the new tasks data model. + // Test cases: + // + // Task 1 (ws1) - Basic case: + // - Single build with has_ai_task=true, prompt, and parameters + // - Verifies: all task fields are populated correctly + // + // Task 2 (ws2) - No AI Prompt parameter: + // - Single build with has_ai_task=true but NO AI Prompt parameter + // - Verifies: prompt defaults to empty string (tests LEFT JOIN for optional prompt) + // + // Task 3 (ws3) - Latest build is stop: + // - Build 1: start with agents/apps and prompt + // - Build 2: stop build (references same app via ai_task_sidebar_app_id) + // - Verifies: twa uses latest build number with agents/apps from that build's ai_task_sidebar_app_id + // + // Antagonists - Should NOT be migrated: + // - Regular workspace without has_ai_task flag + // - Deleted workspace (w.deleted = true) + + const migrationVersion = 387 + + ctx := testutil.Context(t, testutil.WaitLong) + sqlDB := testSQLDB(t) + + // Migrate up to the migration before the task workspace migration. + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + for { + version, more, err := next() + require.NoError(t, err) + if !more { + t.Fatalf("migration %d not found", migrationVersion) + } + if version == migrationVersion-1 { + break + } + } + + now := time.Now().UTC().Truncate(time.Microsecond) + deletingAt := now.Add(24 * time.Hour).Truncate(time.Microsecond) + + // Define all IDs upfront. + orgID := uuid.New() + userID := uuid.New() + templateID := uuid.New() + templateVersionID := uuid.New() + templateJobID := uuid.New() + + // Task workspace 1: basic case with prompt and parameters. + ws1ID := uuid.New() + ws1Build1JobID := uuid.New() + ws1Build1ID := uuid.New() + ws1Resource1ID := uuid.New() + ws1Agent1ID := uuid.New() + ws1App1ID := uuid.New() + + // Task workspace 2: no AI Prompt parameter. + ws2ID := uuid.New() + ws2Build1JobID := uuid.New() + ws2Build1ID := uuid.New() + ws2Resource1ID := uuid.New() + ws2Agent1ID := uuid.New() + ws2App1ID := uuid.New() + + // Task workspace 3: has both start and stop builds. + ws3ID := uuid.New() + ws3Build1JobID := uuid.New() + ws3Build1ID := uuid.New() + ws3Resource1ID := uuid.New() + ws3Agent1ID := uuid.New() + ws3App1ID := uuid.New() + ws3Build2JobID := uuid.New() + ws3Build2ID := uuid.New() + ws3Resource2ID := uuid.New() + + // Antagonist 1: deleted workspace. + wsAntDeletedID := uuid.New() + wsAntDeletedBuild1JobID := uuid.New() + wsAntDeletedBuild1ID := uuid.New() + wsAntDeletedResource1ID := uuid.New() + wsAntDeletedAgent1ID := uuid.New() + wsAntDeletedApp1ID := uuid.New() + + // Antagonist 2: regular workspace without has_ai_task. + wsAntID := uuid.New() + wsAntBuild1JobID := uuid.New() + wsAntBuild1ID := uuid.New() + + // Create all fixtures in a single transaction. + tx, err := sqlDB.BeginTx(ctx, nil) + require.NoError(t, err) + defer tx.Rollback() + + // Execute fixture setup as individual statements. + fixtures := []struct { + query string + args []any + }{ + // Setup organization, user, and template. + { + `INSERT INTO organizations (id, name, display_name, description, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)`, + []any{orgID, "test-org", "Test Org", "Test Org", now, now}, + }, + { + `INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{userID, "testuser", "test@example.com", []byte{}, now, now, "active", []byte("{}"), "password"}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{templateJobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "template_version_import", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO template_versions (id, organization_id, name, readme, created_at, updated_at, job_id, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + []any{templateVersionID, orgID, "v1.0", "Test template", now, now, templateJobID, userID}, + }, + { + `INSERT INTO templates (id, organization_id, name, created_at, updated_at, provisioner, active_version_id, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + []any{templateID, orgID, "test-template", now, now, "terraform", templateVersionID, userID}, + }, + { + `UPDATE template_versions SET template_id = $1 WHERE id = $2`, + []any{templateID, templateVersionID}, + }, + + // Task workspace 1 is a normal start build. + { + `INSERT INTO workspaces (id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, last_used_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{ws1ID, now, now, userID, orgID, templateID, false, "task-ws-1", now}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{ws1Build1JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, daily_cost, instance_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws1Resource1ID, now, ws1Build1JobID, "start", "docker_container", "main", false, "", 0, ""}, + }, + { + `INSERT INTO workspace_agents (id, created_at, updated_at, name, resource_id, auth_token, architecture, operating_system, directory, connection_timeout_seconds, lifecycle_state, logs_length, logs_overflowed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + []any{ws1Agent1ID, now, now, "agent1", ws1Resource1ID, uuid.New(), "amd64", "linux", "/home/coder", 120, "ready", 0, false}, + }, + { + `INSERT INTO workspace_apps (id, created_at, agent_id, slug, display_name, icon, command, url, subdomain, external) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws1App1ID, now, ws1Agent1ID, "code-server", "Code Server", "", "", "http://localhost:8080", false, false}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, has_ai_task, ai_task_sidebar_app_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + []any{ws1Build1ID, now, now, ws1ID, templateVersionID, 1, "start", userID, []byte{}, ws1Build1JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour), true, ws1App1ID}, + }, + { + `INSERT INTO workspace_build_parameters (workspace_build_id, name, value) VALUES ($1, $2, $3)`, + []any{ws1Build1ID, "AI Prompt", "Build a web server"}, + }, + { + `INSERT INTO workspace_build_parameters (workspace_build_id, name, value) VALUES ($1, $2, $3)`, + []any{ws1Build1ID, "region", "us-east-1"}, + }, + { + `INSERT INTO workspace_build_parameters (workspace_build_id, name, value) VALUES ($1, $2, $3)`, + []any{ws1Build1ID, "instance_type", "t2.micro"}, + }, + + // Task workspace 2: no AI Prompt parameter (tests LEFT JOIN). + { + `INSERT INTO workspaces (id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, last_used_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{ws2ID, now, now, userID, orgID, templateID, false, "task-ws-2-no-prompt", now}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{ws2Build1JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, daily_cost, instance_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws2Resource1ID, now, ws2Build1JobID, "start", "docker_container", "main", false, "", 0, ""}, + }, + { + `INSERT INTO workspace_agents (id, created_at, updated_at, name, resource_id, auth_token, architecture, operating_system, directory, connection_timeout_seconds, lifecycle_state, logs_length, logs_overflowed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + []any{ws2Agent1ID, now, now, "agent2", ws2Resource1ID, uuid.New(), "amd64", "linux", "/home/coder", 120, "ready", 0, false}, + }, + { + `INSERT INTO workspace_apps (id, created_at, agent_id, slug, display_name, icon, command, url, subdomain, external) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws2App1ID, now, ws2Agent1ID, "terminal", "Terminal", "", "", "http://localhost:3000", false, false}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, has_ai_task, ai_task_sidebar_app_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + []any{ws2Build1ID, now, now, ws2ID, templateVersionID, 1, "start", userID, []byte{}, ws2Build1JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour), true, ws2App1ID}, + }, + // Note: No AI Prompt parameter for ws2 - this tests the LEFT JOIN for optional prompt. + + // Task workspace 3: has both start and stop builds. + { + `INSERT INTO workspaces (id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, last_used_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{ws3ID, now, now, userID, orgID, templateID, false, "task-ws-3-stop", now}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{ws3Build1JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, daily_cost, instance_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws3Resource1ID, now, ws3Build1JobID, "start", "docker_container", "main", false, "", 0, ""}, + }, + { + `INSERT INTO workspace_agents (id, created_at, updated_at, name, resource_id, auth_token, architecture, operating_system, directory, connection_timeout_seconds, lifecycle_state, logs_length, logs_overflowed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + []any{ws3Agent1ID, now, now, "agent3", ws3Resource1ID, uuid.New(), "amd64", "linux", "/home/coder", 120, "ready", 0, false}, + }, + { + `INSERT INTO workspace_apps (id, created_at, agent_id, slug, display_name, icon, command, url, subdomain, external) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws3App1ID, now, ws3Agent1ID, "app3", "App3", "", "", "http://localhost:5000", false, false}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, has_ai_task, ai_task_sidebar_app_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + []any{ws3Build1ID, now, now, ws3ID, templateVersionID, 1, "start", userID, []byte{}, ws3Build1JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour), true, ws3App1ID}, + }, + { + `INSERT INTO workspace_build_parameters (workspace_build_id, name, value) VALUES ($1, $2, $3)`, + []any{ws3Build1ID, "AI Prompt", "Task with stop build"}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{ws3Build2JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, daily_cost, instance_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{ws3Resource2ID, now, ws3Build2JobID, "stop", "docker_container", "main", false, "", 0, ""}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, has_ai_task, ai_task_sidebar_app_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + []any{ws3Build2ID, now, now, ws3ID, templateVersionID, 2, "stop", userID, []byte{}, ws3Build2JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour), true, ws3App1ID}, + }, + + // Antagonist 1: deleted workspace. + { + `INSERT INTO workspaces (id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, last_used_at, deleting_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{wsAntDeletedID, now, now, userID, orgID, templateID, true, "deleted-task-workspace", now, deletingAt}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{wsAntDeletedBuild1JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name, hide, icon, daily_cost, instance_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{wsAntDeletedResource1ID, now, wsAntDeletedBuild1JobID, "start", "docker_container", "main", false, "", 0, ""}, + }, + { + `INSERT INTO workspace_agents (id, created_at, updated_at, name, resource_id, auth_token, architecture, operating_system, directory, connection_timeout_seconds, lifecycle_state, logs_length, logs_overflowed) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + []any{wsAntDeletedAgent1ID, now, now, "agent-deleted", wsAntDeletedResource1ID, uuid.New(), "amd64", "linux", "/home/coder", 120, "ready", 0, false}, + }, + { + `INSERT INTO workspace_apps (id, created_at, agent_id, slug, display_name, icon, command, url, subdomain, external) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + []any{wsAntDeletedApp1ID, now, wsAntDeletedAgent1ID, "app-deleted", "AppDeleted", "", "", "http://localhost:6000", false, false}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, has_ai_task, ai_task_sidebar_app_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + []any{wsAntDeletedBuild1ID, now, now, wsAntDeletedID, templateVersionID, 1, "start", userID, []byte{}, wsAntDeletedBuild1JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour), true, wsAntDeletedApp1ID}, + }, + { + `INSERT INTO workspace_build_parameters (workspace_build_id, name, value) VALUES ($1, $2, $3)`, + []any{wsAntDeletedBuild1ID, "AI Prompt", "Should not migrate deleted"}, + }, + + // Antagonist 2: regular workspace without has_ai_task. + { + `INSERT INTO workspaces (id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, last_used_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{wsAntID, now, now, userID, orgID, templateID, false, "regular-workspace", now}, + }, + { + `INSERT INTO provisioner_jobs (id, created_at, updated_at, started_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, file_id, type, input, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{wsAntBuild1JobID, now, now, now, now, "", orgID, userID, "terraform", "file", uuid.New(), "workspace_build", []byte("{}"), []byte("{}")}, + }, + { + `INSERT INTO workspace_builds (id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + []any{wsAntBuild1ID, now, now, wsAntID, templateVersionID, 1, "start", userID, []byte{}, wsAntBuild1JobID, now.Add(8 * time.Hour), "initiator", 0, now.Add(8 * time.Hour)}, + }, + } + + for _, fixture := range fixtures { + _, err = tx.ExecContext(ctx, fixture.query, fixture.args...) + require.NoError(t, err) + } + + err = tx.Commit() + require.NoError(t, err) + + // Run the migration. + version, _, err := next() + require.NoError(t, err) + require.EqualValues(t, migrationVersion, version) + + // Should have exactly 3 tasks (not antagonists). + var taskCount int + err = sqlDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM tasks").Scan(&taskCount) + require.NoError(t, err) + require.Equal(t, 3, taskCount, "should have created 3 tasks from workspaces") + + // Verify task 1, normal start build. + var task1 struct { + id uuid.UUID + name string + workspaceID uuid.UUID + templateVersionID uuid.UUID + prompt string + templateParameters []byte + createdAt time.Time + deletedAt *time.Time + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT id, name, workspace_id, template_version_id, prompt, template_parameters, created_at, deleted_at + FROM tasks WHERE workspace_id = $1 + `, ws1ID).Scan(&task1.id, &task1.name, &task1.workspaceID, &task1.templateVersionID, &task1.prompt, &task1.templateParameters, &task1.createdAt, &task1.deletedAt) + require.NoError(t, err) + require.Equal(t, "task-ws-1", task1.name) + require.Equal(t, "Build a web server", task1.prompt) + require.JSONEq(t, `{"region":"us-east-1","instance_type":"t2.micro"}`, string(task1.templateParameters)) + require.Nil(t, task1.deletedAt) + + // Verify task_workspace_apps for task 1. + var twa1 struct { + buildNumber int32 + agentID uuid.UUID + appID uuid.UUID + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT workspace_build_number, workspace_agent_id, workspace_app_id + FROM task_workspace_apps WHERE task_id = $1 + `, task1.id).Scan(&twa1.buildNumber, &twa1.agentID, &twa1.appID) + require.NoError(t, err) + require.Equal(t, int32(1), twa1.buildNumber) + require.Equal(t, ws1Agent1ID, twa1.agentID) + require.Equal(t, ws1App1ID, twa1.appID) + + // Verify task 2, no AI Prompt parameter. + var task2 struct { + id uuid.UUID + name string + prompt string + templateParameters []byte + deletedAt *time.Time + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT id, name, prompt, template_parameters, deleted_at + FROM tasks WHERE workspace_id = $1 + `, ws2ID).Scan(&task2.id, &task2.name, &task2.prompt, &task2.templateParameters, &task2.deletedAt) + require.NoError(t, err) + require.Equal(t, "task-ws-2-no-prompt", task2.name) + require.Equal(t, "", task2.prompt, "prompt should be empty string when no AI Prompt parameter") + require.JSONEq(t, `{}`, string(task2.templateParameters), "no parameters") + require.Nil(t, task2.deletedAt) + + // Verify task_workspace_apps for task 2. + var twa2 struct { + buildNumber int32 + agentID uuid.UUID + appID uuid.UUID + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT workspace_build_number, workspace_agent_id, workspace_app_id + FROM task_workspace_apps WHERE task_id = $1 + `, task2.id).Scan(&twa2.buildNumber, &twa2.agentID, &twa2.appID) + require.NoError(t, err) + require.Equal(t, int32(1), twa2.buildNumber) + require.Equal(t, ws2Agent1ID, twa2.agentID) + require.Equal(t, ws2App1ID, twa2.appID) + + // Verify task 3, has both start and stop builds. + var task3 struct { + id uuid.UUID + name string + prompt string + templateParameters []byte + templateVersionID uuid.UUID + deletedAt *time.Time + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT id, name, prompt, template_parameters, template_version_id, deleted_at + FROM tasks WHERE workspace_id = $1 + `, ws3ID).Scan(&task3.id, &task3.name, &task3.prompt, &task3.templateParameters, &task3.templateVersionID, &task3.deletedAt) + require.NoError(t, err) + require.Equal(t, "task-ws-3-stop", task3.name) + require.Equal(t, "Task with stop build", task3.prompt) + require.JSONEq(t, `{}`, string(task3.templateParameters), "no other parameters") + require.Equal(t, templateVersionID, task3.templateVersionID) + require.Nil(t, task3.deletedAt) + + // Verify task_workspace_apps for task 3 uses latest build and its ai_task_sidebar_app_id. + var twa3 struct { + buildNumber int32 + agentID uuid.UUID + appID uuid.UUID + } + err = sqlDB.QueryRowContext(ctx, ` + SELECT workspace_build_number, workspace_agent_id, workspace_app_id + FROM task_workspace_apps WHERE task_id = $1 + `, task3.id).Scan(&twa3.buildNumber, &twa3.agentID, &twa3.appID) + require.NoError(t, err) + require.Equal(t, int32(2), twa3.buildNumber, "should use latest build number") + require.Equal(t, ws3Agent1ID, twa3.agentID, "should use agent from latest build's ai_task_sidebar_app_id") + require.Equal(t, ws3App1ID, twa3.appID, "should use app from latest build's ai_task_sidebar_app_id") + + // Verify antagonists should NOT be migrated. + var antCount int + err = sqlDB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM tasks + WHERE workspace_id IN ($1, $2) + `, wsAntDeletedID, wsAntID).Scan(&antCount) + require.NoError(t, err) + require.Equal(t, 0, antCount, "antagonist workspaces (deleted and regular) should not be migrated") +} diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index a50024f5f7580..53fbda6ee4e58 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -245,6 +245,37 @@ func (s APIKeyScopes) Name() rbac.RoleIdentifier { return rbac.RoleIdentifier{Name: "scopes[" + strings.Join(names, "+") + "]"} } +// collectCompositeDefaults returns the merged default allow list entries from +// all composite scopes in the list. Non-composite scopes (built-in and +// low-level) do not contribute defaults. The result is a union of all defaults +// from composite scopes, normalized and deduplicated. +func (s APIKeyScopes) collectCompositeDefaults() ([]rbac.AllowListElement, error) { + if len(s) == 0 { + return []rbac.AllowListElement{}, nil + } + + // Collect default allow lists from all composite scopes. + var allDefaults [][]rbac.AllowListElement + for _, scope := range s { + defaults, ok := rbac.CompositeDefaultAllowList(scope.ToRBAC()) + if ok && len(defaults) > 0 { + allDefaults = append(allDefaults, defaults) + } + } + + if len(allDefaults) == 0 { + return []rbac.AllowListElement{}, nil + } + + // Union all defaults into a single list. + merged, err := rbac.UnionAllowLists(allDefaults...) + if err != nil { + return nil, xerrors.Errorf("merge composite defaults: %w", err) + } + + return merged, nil +} + // APIKeyScopeSet merges expanded scopes with the API key's DB allow_list. If // the DB allow_list is a wildcard or empty, the merged scope's allow list is // unchanged. Otherwise, the DB allow_list overrides the merged AllowIDList to @@ -264,6 +295,22 @@ func (s APIKeyScopeSet) Expand() (rbac.Scope, error) { return rbac.Scope{}, err } merged.AllowIDList = rbac.IntersectAllowLists(merged.AllowIDList, s.AllowList) + + // Apply composite defaults for missing resource types. This allows + // composite scopes like coder:workspaces.create to automatically include + // required resource types (e.g., templates) even if the user didn't + // explicitly specify them in the allow list. + defaults, err := s.Scopes.collectCompositeDefaults() + if err != nil { + return rbac.Scope{}, err + } + if len(defaults) > 0 { + merged.AllowIDList, err = rbac.MergeDefaultsForMissingTypes(merged.AllowIDList, defaults) + if err != nil { + return rbac.Scope{}, xerrors.Errorf("apply composite defaults: %w", err) + } + } + return merged, nil } diff --git a/coderd/database/modelmethods_internal_test.go b/coderd/database/modelmethods_internal_test.go index 574d1892061ad..c61c9cfea2c32 100644 --- a/coderd/database/modelmethods_internal_test.go +++ b/coderd/database/modelmethods_internal_test.go @@ -93,7 +93,7 @@ func TestAPIKeyScopesExpand(t *testing.T) { expanded, err := effective.Expand() require.NoError(t, err) require.Len(t, expanded.AllowIDList, 1) - require.Equal(t, "workspace", expanded.AllowIDList[0].Type) + require.Equal(t, rbac.ResourceWorkspace.Type, expanded.AllowIDList[0].Type) require.Equal(t, workspaceID.String(), expanded.AllowIDList[0].ID) }) @@ -143,6 +143,159 @@ func TestAPIKeyScopesExpand(t *testing.T) { }) } +func TestAPIKeyScopesCompositeDefaults(t *testing.T) { + t.Parallel() + + workspaceID := uuid.NewString() + templateID := uuid.NewString() + + t.Run("workspace_create_adds_template_default", func(t *testing.T) { + t.Parallel() + // User creates token with workspace.create scope and only specifies workspace in allow list + set := APIKeyScopes{"coder:workspaces.create"}.WithAllowList(AllowList{ + {Type: rbac.ResourceWorkspace.Type, ID: workspaceID}, + }) + + expanded, err := set.Expand() + require.NoError(t, err) + + // Should have both workspace (user-specified) and template (default) + require.Len(t, expanded.AllowIDList, 2) + require.ElementsMatch(t, []rbac.AllowListElement{ + {Type: rbac.ResourceTemplate.Type, ID: policy.WildcardSymbol}, + {Type: rbac.ResourceWorkspace.Type, ID: workspaceID}, + }, expanded.AllowIDList) + }) + + t.Run("workspace_create_empty_workspace_id", func(t *testing.T) { + t.Parallel() + // User creates token with empty workspace ID (for creation checks) + set := APIKeyScopes{"coder:workspaces.create"}.WithAllowList(AllowList{ + {Type: rbac.ResourceWorkspace.Type, ID: ""}, + }) + + expanded, err := set.Expand() + require.NoError(t, err) + + // Should have both workspace with empty ID and template wildcard + require.Len(t, expanded.AllowIDList, 2) + require.ElementsMatch(t, []rbac.AllowListElement{ + {Type: rbac.ResourceTemplate.Type, ID: policy.WildcardSymbol}, + {Type: rbac.ResourceWorkspace.Type, ID: ""}, + }, expanded.AllowIDList) + }) + + t.Run("workspace_create_both_types_present_no_defaults", func(t *testing.T) { + t.Parallel() + // User specifies both workspace and template - no defaults added + set := APIKeyScopes{"coder:workspaces.create"}.WithAllowList(AllowList{ + {Type: rbac.ResourceWorkspace.Type, ID: workspaceID}, + {Type: rbac.ResourceTemplate.Type, ID: templateID}, + }) + + expanded, err := set.Expand() + require.NoError(t, err) + + // Should keep user's specific template ID, not elevate to wildcard + require.Len(t, expanded.AllowIDList, 2) + require.ElementsMatch(t, []rbac.AllowListElement{ + {Type: rbac.ResourceTemplate.Type, ID: templateID}, + {Type: rbac.ResourceWorkspace.Type, ID: workspaceID}, + }, expanded.AllowIDList) + }) + + t.Run("workspace_operate_multiple_defaults", func(t *testing.T) { + t.Parallel() + // workspace.operate has defaults for template and user + set := APIKeyScopes{"coder:workspaces.operate"}.WithAllowList(AllowList{ + {Type: rbac.ResourceWorkspace.Type, ID: workspaceID}, + }) + + expanded, err := set.Expand() + require.NoError(t, err) + + // Should add both template and user defaults + require.Len(t, expanded.AllowIDList, 3) + require.ElementsMatch(t, []rbac.AllowListElement{ + {Type: rbac.ResourceTemplate.Type, ID: policy.WildcardSymbol}, + {Type: rbac.ResourceUser.Type, ID: policy.WildcardSymbol}, + {Type: rbac.ResourceWorkspace.Type, ID: workspaceID}, + }, expanded.AllowIDList) + }) + + t.Run("multiple_composite_scopes_merge_defaults", func(t *testing.T) { + t.Parallel() + // Multiple composite scopes should merge their defaults + set := APIKeyScopes{"coder:workspaces.create", "coder:workspaces.operate"}.WithAllowList(AllowList{ + {Type: rbac.ResourceWorkspace.Type, ID: workspaceID}, + }) + + expanded, err := set.Expand() + require.NoError(t, err) + + // create has [workspace:, template:*] + // operate has [template:*, user:*] + // merged defaults: [workspace:, template:*, user:*] + // but workspace is already in allow list, so only add template and user + require.Len(t, expanded.AllowIDList, 3) + require.ElementsMatch(t, []rbac.AllowListElement{ + {Type: rbac.ResourceTemplate.Type, ID: policy.WildcardSymbol}, + {Type: rbac.ResourceUser.Type, ID: policy.WildcardSymbol}, + {Type: rbac.ResourceWorkspace.Type, ID: workspaceID}, + }, expanded.AllowIDList) + }) + + t.Run("wildcard_allow_list_skips_defaults", func(t *testing.T) { + t.Parallel() + // Global wildcard should not be modified by defaults + set := APIKeyScopes{"coder:workspaces.create"}.WithAllowList(AllowList{ + {Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}, + }) + + expanded, err := set.Expand() + require.NoError(t, err) + + // Should remain wildcard, no defaults added + requireAllowAll(t, expanded) + }) + + t.Run("low_level_scopes_no_defaults", func(t *testing.T) { + t.Parallel() + // Low-level scopes don't have composite defaults + set := APIKeyScopes{ApiKeyScopeWorkspaceRead}.WithAllowList(AllowList{ + {Type: rbac.ResourceWorkspace.Type, ID: workspaceID}, + }) + + expanded, err := set.Expand() + require.NoError(t, err) + + // No defaults, just the user-specified allow list + require.Len(t, expanded.AllowIDList, 1) + require.Equal(t, rbac.AllowListElement{ + Type: rbac.ResourceWorkspace.Type, + ID: workspaceID, + }, expanded.AllowIDList[0]) + }) + + t.Run("builtin_scopes_no_defaults", func(t *testing.T) { + t.Parallel() + // Built-in scopes like coder:all don't have specific defaults + set := APIKeyScopes{ApiKeyScopeCoderAll}.WithAllowList(AllowList{ + {Type: rbac.ResourceWorkspace.Type, ID: workspaceID}, + }) + + expanded, err := set.Expand() + require.NoError(t, err) + + // No defaults, just the user-specified allow list + require.Len(t, expanded.AllowIDList, 1) + require.Equal(t, rbac.AllowListElement{ + Type: rbac.ResourceWorkspace.Type, + ID: workspaceID, + }, expanded.AllowIDList[0]) + }) +} + // Helpers func requirePermission(t *testing.T, s rbac.Scope, resource string, action policy.Action) { t.Helper() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 1400893a26fc3..c9c7879627684 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -804,6 +804,7 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, ar &i.AIBridgeInterception.Model, &i.AIBridgeInterception.StartedAt, &i.AIBridgeInterception.Metadata, + &i.AIBridgeInterception.EndedAt, &i.VisibleUser.ID, &i.VisibleUser.Username, &i.VisibleUser.Name, diff --git a/coderd/database/models.go b/coderd/database/models.go index 2d02d2c14d051..d5db3a3819e17 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3613,6 +3613,7 @@ type AIBridgeInterception struct { Model string `db:"model" json:"model"` StartedAt time.Time `db:"started_at" json:"started_at"` Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"` + EndedAt sql.NullTime `db:"ended_at" json:"ended_at"` } // Audit log of tokens used by intercepted requests in AI Bridge @@ -3955,7 +3956,7 @@ type OAuth2ProviderApp struct { // RFC 7591: Version of the client software SoftwareVersion sql.NullString `db:"software_version" json:"software_version"` // RFC 7592: Hashed registration access token for client management - RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"` + RegistrationAccessToken []byte `db:"registration_access_token" json:"registration_access_token"` // RFC 7592: URI for client configuration endpoint RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"` } diff --git a/coderd/database/pubsub/pubsub_test.go b/coderd/database/pubsub/pubsub_test.go index 4f4a387276355..79ce80ea5448e 100644 --- a/coderd/database/pubsub/pubsub_test.go +++ b/coderd/database/pubsub/pubsub_test.go @@ -19,9 +19,6 @@ import ( func TestPGPubsub_Metrics(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } logger := testutil.Logger(t) connectionURL, err := dbtestutil.Open(t) @@ -122,9 +119,6 @@ func TestPGPubsub_Metrics(t *testing.T) { func TestPGPubsubDriver(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } ctx := testutil.Context(t, testutil.WaitLong) logger := slogtest.Make(t, &slogtest.Options{ diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3a832752977d5..3d1040423033c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -6,7 +6,6 @@ package database import ( "context" - "database/sql" "time" "github.com/google/uuid" @@ -120,6 +119,7 @@ type sqlcQuerier interface { DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) + DeleteTask(ctx context.Context, arg DeleteTaskParams) (TaskTable, error) DeleteUserSecret(ctx context.Context, id uuid.UUID) error DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error @@ -245,7 +245,7 @@ type sqlcQuerier interface { // RFC 7591/7592 Dynamic Client Registration queries GetOAuth2ProviderAppByClientID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) - GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken sql.NullString) (OAuth2ProviderApp, error) + GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken []byte) (OAuth2ProviderApp, error) GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppSecret, error) @@ -631,6 +631,7 @@ type sqlcQuerier interface { UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error + UpdateAPIKeySettings(ctx context.Context, arg UpdateAPIKeySettingsParams) (APIKey, error) UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error) UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error) UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) @@ -657,6 +658,7 @@ type sqlcQuerier interface { UpdateProvisionerJobWithCompleteWithStartedAtByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteWithStartedAtByIDParams) error UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error) UpdateTailnetPeerStatusByCoordinator(ctx context.Context, arg UpdateTailnetPeerStatusByCoordinatorParams) error + UpdateTaskWorkspaceID(ctx context.Context, arg UpdateTaskWorkspaceIDParams) (TaskTable, error) UpdateTemplateACLByID(ctx context.Context, arg UpdateTemplateACLByIDParams) error UpdateTemplateAccessControlByID(ctx context.Context, arg UpdateTemplateAccessControlByIDParams) error UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 5c894f98e9957..996a3a833b883 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -3742,9 +3742,6 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) { func TestGetProvisionerJobsByIDsWithQueuePosition_MixedStatuses(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.SkipNow() - } db, _ := dbtestutil.NewDB(t) now := dbtime.Now() @@ -4084,10 +4081,6 @@ func TestGetUserStatusCounts(t *testing.T) { t.Parallel() t.Skip("https://github.com/coder/internal/issues/464") - if !dbtestutil.WillUsePostgres() { - t.SkipNow() - } - timezones := []string{ "Canada/Newfoundland", "Africa/Johannesburg", @@ -4625,10 +4618,6 @@ func TestGetUserStatusCounts(t *testing.T) { func TestOrganizationDeleteTrigger(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.SkipNow() - } - t.Run("WorkspaceExists", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -4928,7 +4917,7 @@ func createPrebuiltWorkspace( dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ CreatedAt: createdAt, WorkspaceID: workspace.ID, - TemplateVersionID: extTmplVersion.ID, + TemplateVersionID: extTmplVersion.TemplateVersion.ID, BuildNumber: 1, Transition: database.WorkspaceTransitionStart, InitiatorID: tmpl.CreatedBy, @@ -4942,9 +4931,6 @@ func createPrebuiltWorkspace( func TestWorkspacePrebuildsView(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.SkipNow() - } now := dbtime.Now() orgID := uuid.New() @@ -5046,9 +5032,6 @@ func TestWorkspacePrebuildsView(t *testing.T) { func TestGetPresetsBackoff(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.SkipNow() - } now := dbtime.Now() orgID := uuid.New() @@ -5565,9 +5548,6 @@ func TestGetPresetsBackoff(t *testing.T) { func TestGetPresetsAtFailureLimit(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.SkipNow() - } now := dbtime.Now() hourBefore := now.Add(-time.Hour) @@ -5871,10 +5851,6 @@ func TestGetPresetsAtFailureLimit(t *testing.T) { func TestWorkspaceAgentNameUniqueTrigger(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test makes use of a database trigger not implemented in dbmem") - } - createWorkspaceWithAgent := func(t *testing.T, db database.Store, org database.Organization, agentName string) (database.WorkspaceBuild, database.WorkspaceResource, database.WorkspaceAgent) { t.Helper() @@ -6141,10 +6117,6 @@ func requireUsersMatch(t testing.TB, expected []database.User, found []database. func TestGetRunningPrebuiltWorkspaces(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("Test requires PostgreSQL for complex queries") - } - ctx := testutil.Context(t, testutil.WaitLong) db, _ := dbtestutil.NewDB(t) now := dbtime.Now() @@ -7568,3 +7540,187 @@ func TestListTasks(t *testing.T) { }) } } + +func TestUpdateTaskWorkspaceID(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + + // Create organization, users, template, and template version. + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + TemplateID: uuid.NullUUID{Valid: true, UUID: template.ID}, + CreatedBy: user.ID, + }) + + // Create another template for mismatch test. + template2 := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + + tests := []struct { + name string + setupTask func(t *testing.T) database.Task + setupWS func(t *testing.T) database.WorkspaceTable + wantErr bool + wantNoRow bool + }{ + { + name: "successful update with matching template", + setupTask: func(t *testing.T) database.Task { + return dbgen.Task(t, db, database.TaskTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + Name: testutil.GetRandomName(t), + WorkspaceID: uuid.NullUUID{}, + TemplateVersionID: templateVersion.ID, + Prompt: "Test prompt", + }) + }, + setupWS: func(t *testing.T) database.WorkspaceTable { + return dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateID: template.ID, + }) + }, + wantErr: false, + wantNoRow: false, + }, + { + name: "task already has workspace_id", + setupTask: func(t *testing.T) database.Task { + existingWS := dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateID: template.ID, + }) + return dbgen.Task(t, db, database.TaskTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + Name: testutil.GetRandomName(t), + WorkspaceID: uuid.NullUUID{Valid: true, UUID: existingWS.ID}, + TemplateVersionID: templateVersion.ID, + Prompt: "Test prompt", + }) + }, + setupWS: func(t *testing.T) database.WorkspaceTable { + return dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateID: template.ID, + }) + }, + wantErr: false, + wantNoRow: true, // No row should be returned because WHERE condition fails. + }, + { + name: "template mismatch between task and workspace", + setupTask: func(t *testing.T) database.Task { + return dbgen.Task(t, db, database.TaskTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + Name: testutil.GetRandomName(t), + WorkspaceID: uuid.NullUUID{}, // NULL workspace_id + TemplateVersionID: templateVersion.ID, + Prompt: "Test prompt", + }) + }, + setupWS: func(t *testing.T) database.WorkspaceTable { + return dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateID: template2.ID, // Different template, JOIN will fail. + }) + }, + wantErr: false, + wantNoRow: true, // No row should be returned because JOIN condition fails. + }, + { + name: "task does not exist", + setupTask: func(t *testing.T) database.Task { + return database.Task{ + ID: uuid.New(), // Non-existent task ID. + } + }, + setupWS: func(t *testing.T) database.WorkspaceTable { + return dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateID: template.ID, + }) + }, + wantErr: false, + wantNoRow: true, + }, + { + name: "workspace does not exist", + setupTask: func(t *testing.T) database.Task { + return dbgen.Task(t, db, database.TaskTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + Name: testutil.GetRandomName(t), + WorkspaceID: uuid.NullUUID{}, + TemplateVersionID: templateVersion.ID, + Prompt: "Test prompt", + }) + }, + setupWS: func(t *testing.T) database.WorkspaceTable { + return database.WorkspaceTable{ + ID: uuid.New(), // Non-existent workspace ID. + } + }, + wantErr: false, + wantNoRow: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + task := tt.setupTask(t) + workspace := tt.setupWS(t) + + updatedTask, err := db.UpdateTaskWorkspaceID(ctx, database.UpdateTaskWorkspaceIDParams{ + ID: task.ID, + WorkspaceID: uuid.NullUUID{Valid: true, UUID: workspace.ID}, + }) + + if tt.wantErr { + require.Error(t, err) + return + } + + if tt.wantNoRow { + require.ErrorIs(t, err, sql.ErrNoRows) + return + } + + require.NoError(t, err) + require.Equal(t, task.ID, updatedTask.ID) + require.True(t, updatedTask.WorkspaceID.Valid) + require.Equal(t, workspace.ID, updatedTask.WorkspaceID.UUID) + require.Equal(t, task.OrganizationID, updatedTask.OrganizationID) + require.Equal(t, task.OwnerID, updatedTask.OwnerID) + require.Equal(t, task.Name, updatedTask.Name) + require.Equal(t, task.TemplateVersionID, updatedTask.TemplateVersionID) + + // Verify the update persisted by fetching the task again. + fetchedTask, err := db.GetTaskByID(ctx, task.ID) + require.NoError(t, err) + require.True(t, fetchedTask.WorkspaceID.Valid) + require.Equal(t, workspace.ID, fetchedTask.WorkspaceID.UUID) + }) + } +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5b4cf7b21e079..185351c4c04bd 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -168,7 +168,7 @@ func (q *sqlQuerier) CountAIBridgeInterceptions(ctx context.Context, arg CountAI const getAIBridgeInterceptionByID = `-- name: GetAIBridgeInterceptionByID :one SELECT - id, initiator_id, provider, model, started_at, metadata + id, initiator_id, provider, model, started_at, metadata, ended_at FROM aibridge_interceptions WHERE @@ -185,13 +185,14 @@ func (q *sqlQuerier) GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UU &i.Model, &i.StartedAt, &i.Metadata, + &i.EndedAt, ) return i, err } const getAIBridgeInterceptions = `-- name: GetAIBridgeInterceptions :many SELECT - id, initiator_id, provider, model, started_at, metadata + id, initiator_id, provider, model, started_at, metadata, ended_at FROM aibridge_interceptions ` @@ -212,6 +213,7 @@ func (q *sqlQuerier) GetAIBridgeInterceptions(ctx context.Context) ([]AIBridgeIn &i.Model, &i.StartedAt, &i.Metadata, + &i.EndedAt, ); err != nil { return nil, err } @@ -361,7 +363,7 @@ INSERT INTO aibridge_interceptions ( ) VALUES ( $1, $2, $3, $4, COALESCE($5::jsonb, '{}'::jsonb), $6 ) -RETURNING id, initiator_id, provider, model, started_at, metadata +RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at ` type InsertAIBridgeInterceptionParams struct { @@ -390,6 +392,7 @@ func (q *sqlQuerier) InsertAIBridgeInterception(ctx context.Context, arg InsertA &i.Model, &i.StartedAt, &i.Metadata, + &i.EndedAt, ) return i, err } @@ -528,7 +531,7 @@ func (q *sqlQuerier) InsertAIBridgeUserPrompt(ctx context.Context, arg InsertAIB const listAIBridgeInterceptions = `-- name: ListAIBridgeInterceptions :many SELECT - aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, + aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, visible_users.id, visible_users.username, visible_users.name, visible_users.avatar_url FROM aibridge_interceptions @@ -625,6 +628,7 @@ func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBr &i.AIBridgeInterception.Model, &i.AIBridgeInterception.StartedAt, &i.AIBridgeInterception.Metadata, + &i.AIBridgeInterception.EndedAt, &i.VisibleUser.ID, &i.VisibleUser.Username, &i.VisibleUser.Name, @@ -1157,6 +1161,56 @@ func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDP return err } +const updateAPIKeySettings = `-- name: UpdateAPIKeySettings :one +UPDATE api_keys +SET + scopes = $1, + allow_list = $2, + lifetime_seconds = $3, + expires_at = $4, + updated_at = $5 +WHERE + id = $6 +RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes, allow_list +` + +type UpdateAPIKeySettingsParams struct { + Scopes APIKeyScopes `db:"scopes" json:"scopes"` + AllowList AllowList `db:"allow_list" json:"allow_list"` + LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID string `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateAPIKeySettings(ctx context.Context, arg UpdateAPIKeySettingsParams) (APIKey, error) { + row := q.db.QueryRowContext(ctx, updateAPIKeySettings, + arg.Scopes, + arg.AllowList, + arg.LifetimeSeconds, + arg.ExpiresAt, + arg.UpdatedAt, + arg.ID, + ) + var i APIKey + err := row.Scan( + &i.ID, + &i.HashedSecret, + &i.UserID, + &i.LastUsed, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.LoginType, + &i.LifetimeSeconds, + &i.IPAddress, + &i.TokenName, + &i.Scopes, + &i.AllowList, + ) + return i, err +} + const countAuditLogs = `-- name: CountAuditLogs :one SELECT COUNT(*) FROM audit_logs @@ -6202,7 +6256,7 @@ const getOAuth2ProviderAppByRegistrationToken = `-- name: GetOAuth2ProviderAppBy SELECT id, created_at, updated_at, name, icon, callback_url, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri FROM oauth2_provider_apps WHERE registration_access_token = $1 ` -func (q *sqlQuerier) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken sql.NullString) (OAuth2ProviderApp, error) { +func (q *sqlQuerier) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken []byte) (OAuth2ProviderApp, error) { row := q.db.QueryRowContext(ctx, getOAuth2ProviderAppByRegistrationToken, registrationAccessToken) var i OAuth2ProviderApp err := row.Scan( @@ -6603,7 +6657,7 @@ type InsertOAuth2ProviderAppParams struct { Jwks pqtype.NullRawMessage `db:"jwks" json:"jwks"` SoftwareID sql.NullString `db:"software_id" json:"software_id"` SoftwareVersion sql.NullString `db:"software_version" json:"software_version"` - RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"` + RegistrationAccessToken []byte `db:"registration_access_token" json:"registration_access_token"` RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"` } @@ -12577,6 +12631,39 @@ func (q *sqlQuerier) UpsertTailnetTunnel(ctx context.Context, arg UpsertTailnetT return i, err } +const deleteTask = `-- name: DeleteTask :one +UPDATE tasks +SET + deleted_at = $1::timestamptz +WHERE + id = $2::uuid + AND deleted_at IS NULL +RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at +` + +type DeleteTaskParams struct { + DeletedAt time.Time `db:"deleted_at" json:"deleted_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) DeleteTask(ctx context.Context, arg DeleteTaskParams) (TaskTable, error) { + row := q.db.QueryRowContext(ctx, deleteTask, arg.DeletedAt, arg.ID) + var i TaskTable + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.OwnerID, + &i.Name, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.TemplateParameters, + &i.Prompt, + &i.CreatedAt, + &i.DeletedAt, + ) + return i, err +} + const getTaskByID = `-- name: GetTaskByID :one SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status, workspace_build_number, workspace_agent_id, workspace_app_id FROM tasks_with_status WHERE id = $1::uuid ` @@ -12680,16 +12767,18 @@ SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, t WHERE tws.deleted_at IS NULL AND CASE WHEN $1::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.owner_id = $1::UUID ELSE TRUE END AND CASE WHEN $2::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.organization_id = $2::UUID ELSE TRUE END +AND CASE WHEN $3::text != '' THEN tws.status = $3::task_status ELSE TRUE END ORDER BY tws.created_at DESC ` type ListTasksParams struct { OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Status string `db:"status" json:"status"` } func (q *sqlQuerier) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task, error) { - rows, err := q.db.QueryContext(ctx, listTasks, arg.OwnerID, arg.OrganizationID) + rows, err := q.db.QueryContext(ctx, listTasks, arg.OwnerID, arg.OrganizationID, arg.Status) if err != nil { return nil, err } @@ -12726,6 +12815,49 @@ func (q *sqlQuerier) ListTasks(ctx context.Context, arg ListTasksParams) ([]Task return items, nil } +const updateTaskWorkspaceID = `-- name: UpdateTaskWorkspaceID :one +UPDATE + tasks +SET + workspace_id = $2 +FROM + workspaces w +JOIN + template_versions tv +ON + tv.template_id = w.template_id +WHERE + tasks.id = $1 + AND tasks.workspace_id IS NULL + AND w.id = $2 + AND tv.id = tasks.template_version_id +RETURNING + tasks.id, tasks.organization_id, tasks.owner_id, tasks.name, tasks.workspace_id, tasks.template_version_id, tasks.template_parameters, tasks.prompt, tasks.created_at, tasks.deleted_at +` + +type UpdateTaskWorkspaceIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` +} + +func (q *sqlQuerier) UpdateTaskWorkspaceID(ctx context.Context, arg UpdateTaskWorkspaceIDParams) (TaskTable, error) { + row := q.db.QueryRowContext(ctx, updateTaskWorkspaceID, arg.ID, arg.WorkspaceID) + var i TaskTable + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.OwnerID, + &i.Name, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.TemplateParameters, + &i.Prompt, + &i.CreatedAt, + &i.DeletedAt, + ) + return i, err +} + const upsertTaskWorkspaceApp = `-- name: UpsertTaskWorkspaceApp :one INSERT INTO task_workspace_apps (task_id, workspace_build_number, workspace_agent_id, workspace_app_id) diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index c067305755078..88fd2fe8e5b83 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -66,6 +66,18 @@ SET WHERE id = $1; +-- name: UpdateAPIKeySettings :one +UPDATE api_keys +SET + scopes = @scopes, + allow_list = @allow_list, + lifetime_seconds = @lifetime_seconds, + expires_at = @expires_at, + updated_at = @updated_at +WHERE + id = @id +RETURNING *; + -- name: DeleteAPIKeyByID :exec DELETE FROM api_keys diff --git a/coderd/database/queries/tasks.sql b/coderd/database/queries/tasks.sql index 0ce0b6f85fe0a..6c076b8ccaacf 100644 --- a/coderd/database/queries/tasks.sql +++ b/coderd/database/queries/tasks.sql @@ -5,6 +5,25 @@ VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; +-- name: UpdateTaskWorkspaceID :one +UPDATE + tasks +SET + workspace_id = $2 +FROM + workspaces w +JOIN + template_versions tv +ON + tv.template_id = w.template_id +WHERE + tasks.id = $1 + AND tasks.workspace_id IS NULL + AND w.id = $2 + AND tv.id = tasks.template_version_id +RETURNING + tasks.*; + -- name: UpsertTaskWorkspaceApp :one INSERT INTO task_workspace_apps (task_id, workspace_build_number, workspace_agent_id, workspace_app_id) @@ -27,4 +46,14 @@ SELECT * FROM tasks_with_status tws WHERE tws.deleted_at IS NULL AND CASE WHEN @owner_id::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.owner_id = @owner_id::UUID ELSE TRUE END AND CASE WHEN @organization_id::UUID != '00000000-0000-0000-0000-000000000000' THEN tws.organization_id = @organization_id::UUID ELSE TRUE END +AND CASE WHEN @status::text != '' THEN tws.status = @status::task_status ELSE TRUE END ORDER BY tws.created_at DESC; + +-- name: DeleteTask :one +UPDATE tasks +SET + deleted_at = @deleted_at::timestamptz +WHERE + id = @id::uuid + AND deleted_at IS NULL +RETURNING *; diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 08b835ccc6630..29296fea59f5b 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -2,8 +2,6 @@ package httpmw import ( "context" - "crypto/sha256" - "crypto/subtle" "database/sql" "errors" "fmt" @@ -20,6 +18,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -188,8 +187,7 @@ func APIKeyFromRequest(ctx context.Context, db database.Store, sessionTokenFunc } // Checking to see if the secret is valid. - hashedSecret := sha256.Sum256([]byte(keySecret)) - if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 { + if !apikey.ValidateHash(key.HashedSecret, keySecret) { return nil, codersdk.Response{ Message: SignedOutErrorMessage, Detail: "API key secret is invalid.", diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index 6e00b7a4535e2..020dc28e60139 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -19,6 +19,7 @@ import ( "golang.org/x/exp/slices" "golang.org/x/oauth2" + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" @@ -32,10 +33,10 @@ import ( "github.com/coder/coder/v2/testutil" ) -func randomAPIKeyParts() (id string, secret string) { +func randomAPIKeyParts() (id string, secret string, hashedSecret []byte) { id, _ = cryptorand.String(10) - secret, _ = cryptorand.String(22) - return id, secret + secret, hashedSecret, _ = apikey.GenerateSecret(22) + return id, secret, hashedSecret } func TestAPIKey(t *testing.T) { @@ -171,10 +172,10 @@ func TestAPIKey(t *testing.T) { t.Run("NotFound", func(t *testing.T) { t.Parallel() var ( - db, _ = dbtestutil.NewDB(t) - id, secret = randomAPIKeyParts() - r = httptest.NewRequest("GET", "/", nil) - rw = httptest.NewRecorder() + db, _ = dbtestutil.NewDB(t) + id, secret, _ = randomAPIKeyParts() + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() ) r.Header.Set(codersdk.SessionTokenHeader, fmt.Sprintf("%s-%s", id, secret)) diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 030f23935ef83..529ba94774539 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -2,7 +2,6 @@ package httpmw_test import ( "context" - "crypto/sha256" "fmt" "net" "net/http" @@ -142,10 +141,7 @@ func TestExtractUserRoles(t *testing.T) { } func addUser(t *testing.T, db database.Store, roles ...string) (database.User, string) { - var ( - id, secret = randomAPIKeyParts() - hashed = sha256.Sum256([]byte(secret)) - ) + id, secret, hashed := randomAPIKeyParts() if roles == nil { roles = []string{} } @@ -169,7 +165,7 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s _, err = db.InsertAPIKey(context.Background(), database.InsertAPIKeyParams{ ID: id, UserID: user.ID, - HashedSecret: hashed[:], + HashedSecret: hashed, LastUsed: dbtime.Now(), ExpiresAt: dbtime.Now().Add(time.Minute), LoginType: database.LoginTypePassword, diff --git a/coderd/httpmw/taskparam.go b/coderd/httpmw/taskparam.go new file mode 100644 index 0000000000000..6ecc888b378fe --- /dev/null +++ b/coderd/httpmw/taskparam.go @@ -0,0 +1,57 @@ +package httpmw + +import ( + "context" + "net/http" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw/loggermw" + "github.com/coder/coder/v2/codersdk" +) + +type taskParamContextKey struct{} + +// TaskParam returns the task from the ExtractTaskParam handler. +func TaskParam(r *http.Request) database.Task { + task, ok := r.Context().Value(taskParamContextKey{}).(database.Task) + if !ok { + panic("developer error: task param middleware not provided") + } + return task +} + +// ExtractTaskParam grabs a task from the "task" URL parameter by UUID. +func ExtractTaskParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + taskID, parsed := ParseUUIDParam(rw, r, "task") + if !parsed { + return + } + task, err := db.GetTaskByID(ctx, taskID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching task.", + Detail: err.Error(), + }) + return + } + + ctx = context.WithValue(ctx, taskParamContextKey{}, task) + + if rlogger := loggermw.RequestLoggerFromContext(ctx); rlogger != nil { + rlogger.WithFields(slog.F("task_id", task.ID), slog.F("task_name", task.Name)) + } + + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/httpmw/taskparam_test.go b/coderd/httpmw/taskparam_test.go new file mode 100644 index 0000000000000..559ccc2a2df2d --- /dev/null +++ b/coderd/httpmw/taskparam_test.go @@ -0,0 +1,120 @@ +package httpmw_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +func TestTaskParam(t *testing.T) { + t.Parallel() + + setup := func(db database.Store) (*http.Request, database.User) { + user := dbgen.User(t, db, database.User{}) + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + }) + + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set(codersdk.SessionTokenHeader, token) + + ctx := chi.NewRouteContext() + ctx.URLParams.Add("user", "me") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx)) + return r, user + } + + t.Run("None", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + rtr := chi.NewRouter() + rtr.Use(httpmw.ExtractTaskParam(db)) + rtr.Get("/", nil) + r, _ := setup(db) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + rtr := chi.NewRouter() + rtr.Use(httpmw.ExtractTaskParam(db)) + rtr.Get("/", nil) + r, _ := setup(db) + chi.RouteContext(r.Context()).URLParams.Add("task", uuid.NewString()) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) + + t.Run("Found", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + rtr := chi.NewRouter() + rtr.Use( + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + }), + httpmw.ExtractTaskParam(db), + ) + rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { + _ = httpmw.TaskParam(r) + rw.WriteHeader(http.StatusOK) + }) + r, user := setup(db) + org := dbgen.Organization(t, db, database.Organization{}) + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{ + UUID: tpl.ID, + Valid: true, + }, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + Name: "test-workspace", + OrganizationID: org.ID, + TemplateID: tpl.ID, + }) + task := dbgen.Task(t, db, database.TaskTable{ + Name: "test-task", + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateVersionID: tv.ID, + WorkspaceID: uuid.NullUUID{UUID: workspace.ID, Valid: true}, + Prompt: "test prompt", + }) + chi.RouteContext(r.Context()).URLParams.Add("task", task.ID.String()) + rw := httptest.NewRecorder() + rtr.ServeHTTP(rw, r) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) +} diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 7b9871ce6dc41..e83cbe437e9ac 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -2,7 +2,6 @@ package httpmw_test import ( "context" - "crypto/sha256" "encoding/json" "fmt" "net" @@ -31,10 +30,7 @@ func TestWorkspaceParam(t *testing.T) { t.Parallel() setup := func(db database.Store) (*http.Request, database.User) { - var ( - id, secret = randomAPIKeyParts() - hashed = sha256.Sum256([]byte(secret)) - ) + id, secret, hashed := randomAPIKeyParts() r := httptest.NewRequest("GET", "/", nil) r.Header.Set(codersdk.SessionTokenHeader, fmt.Sprintf("%s-%s", id, secret)) @@ -44,7 +40,7 @@ func TestWorkspaceParam(t *testing.T) { user, err := db.InsertUser(r.Context(), database.InsertUserParams{ ID: userID, Email: "testaccount@coder.com", - HashedPassword: hashed[:], + HashedPassword: hashed, Username: username, CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), @@ -63,7 +59,7 @@ func TestWorkspaceParam(t *testing.T) { _, err = db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ ID: id, UserID: user.ID, - HashedSecret: hashed[:], + HashedSecret: hashed, LastUsed: dbtime.Now(), ExpiresAt: dbtime.Now().Add(time.Minute), LoginType: database.LoginTypePassword, diff --git a/coderd/httpmw/workspaceproxy.go b/coderd/httpmw/workspaceproxy.go index 1f2de1ed46160..39f665210b66f 100644 --- a/coderd/httpmw/workspaceproxy.go +++ b/coderd/httpmw/workspaceproxy.go @@ -2,8 +2,6 @@ package httpmw import ( "context" - "crypto/sha256" - "crypto/subtle" "database/sql" "net/http" "strings" @@ -12,6 +10,7 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" @@ -125,8 +124,7 @@ func ExtractWorkspaceProxy(opts ExtractWorkspaceProxyConfig) func(http.Handler) } // Do a subtle constant time comparison of the hash of the secret. - hashedSecret := sha256.Sum256([]byte(secret)) - if subtle.ConstantTimeCompare(proxy.TokenHashedSecret, hashedSecret[:]) != 1 { + if !apikey.ValidateHash(proxy.TokenHashedSecret, secret) { httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{ Message: "Invalid external proxy token", Detail: "Invalid proxy token secret.", diff --git a/coderd/notifications/metrics_test.go b/coderd/notifications/metrics_test.go index 6ba6635a50c4c..975a6db0dd02b 100644 --- a/coderd/notifications/metrics_test.go +++ b/coderd/notifications/metrics_test.go @@ -33,9 +33,6 @@ func TestMetrics(t *testing.T) { t.Parallel() // SETUP - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") - } ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) @@ -390,13 +387,6 @@ func TestInflightDispatchesMetric(t *testing.T) { func TestCustomMethodMetricCollection(t *testing.T) { t.Parallel() - - // SETUP - if !dbtestutil.WillUsePostgres() { - // UpdateNotificationTemplateMethodByID only makes sense with a real database. - t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") - } - ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index d3d9ff2b85736..d395bd748cd5a 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -66,11 +66,6 @@ func TestMain(m *testing.M) { func TestBasicNotificationRoundtrip(t *testing.T) { t.Parallel() - // SETUP - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") - } - ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -278,11 +273,6 @@ func TestWebhookDispatch(t *testing.T) { func TestBackpressure(t *testing.T) { t.Parallel() - // SETUP - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") - } - store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitShort)) @@ -407,11 +397,6 @@ func TestBackpressure(t *testing.T) { func TestRetries(t *testing.T) { t.Parallel() - // SETUP - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") - } - const maxAttempts = 3 ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) @@ -507,11 +492,6 @@ func TestRetries(t *testing.T) { func TestExpiredLeaseIsRequeued(t *testing.T) { t.Parallel() - // SETUP - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") - } - ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -751,10 +731,6 @@ func enumerateAllTemplates(t *testing.T) ([]string, error) { func TestNotificationTemplates_Golden(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on the notification templates added by migrations in the database") - } - const ( username = "bob" password = "🤫" @@ -1705,10 +1681,6 @@ func normalizeGoldenWebhook(content []byte) []byte { func TestDisabledByDefaultBeforeEnqueue(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it is testing business-logic implemented in the database") - } - ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, _ := dbtestutil.NewDB(t) logbuf := strings.Builder{} @@ -1732,11 +1704,6 @@ func TestDisabledByDefaultBeforeEnqueue(t *testing.T) { func TestDisabledBeforeEnqueue(t *testing.T) { t.Parallel() - // SETUP - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it is testing business-logic implemented in the database") - } - ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, _ := dbtestutil.NewDB(t) logbuf := strings.Builder{} @@ -1771,11 +1738,6 @@ func TestDisabledBeforeEnqueue(t *testing.T) { func TestDisabledAfterEnqueue(t *testing.T) { t.Parallel() - // SETUP - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it is testing business-logic implemented in the database") - } - ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -1827,11 +1789,6 @@ func TestDisabledAfterEnqueue(t *testing.T) { func TestCustomNotificationMethod(t *testing.T) { t.Parallel() - // SETUP - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") - } - ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logger := testutil.Logger(t) @@ -1929,12 +1886,6 @@ func TestCustomNotificationMethod(t *testing.T) { func TestNotificationsTemplates(t *testing.T) { t.Parallel() - // SETUP - if !dbtestutil.WillUsePostgres() { - // Notification system templates are only served from the database and not dbmem at this time. - t.Skip("This test requires postgres; it relies on business-logic only implemented in the database") - } - ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) api := coderdtest.New(t, createOpts(t)) @@ -1966,11 +1917,6 @@ func createOpts(t *testing.T) *coderdtest.Options { func TestNotificationDuplicates(t *testing.T) { t.Parallel() - // SETUP - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it is testing the dedupe hash trigger in the database") - } - ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) store, pubsub := dbtestutil.NewDB(t) logbuf := strings.Builder{} diff --git a/coderd/oauth2.go b/coderd/oauth2.go index 1e28f9b65bbb8..ac0c87545ead9 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -160,6 +160,19 @@ func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc { return oauth2provider.RevokeApp(api.Database) } +// @Summary Revoke OAuth2 tokens (RFC 7009). +// @ID oauth2-token-revocation +// @Accept x-www-form-urlencoded +// @Tags Enterprise +// @Param client_id formData string true "Client ID for authentication" +// @Param token formData string true "The token to revoke" +// @Param token_type_hint formData string false "Hint about token type (access_token or refresh_token)" +// @Success 200 "Token successfully revoked" +// @Router /oauth2/revoke [post] +func (api *API) revokeOAuth2Token() http.HandlerFunc { + return oauth2provider.RevokeToken(api.Database, api.Logger) +} + // @Summary OAuth2 authorization server metadata. // @ID oauth2-authorization-server-metadata // @Produce json diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index d5755b695d393..72564a2a0d85e 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -620,7 +620,7 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { CreatedAt: dbtime.Now(), ExpiresAt: expires, HashPrefix: []byte(token.Prefix), - RefreshHash: []byte(token.Hashed), + RefreshHash: token.Hashed, AppSecretID: secret.ID, APIKeyID: newKey.ID, UserID: user.ID, @@ -720,7 +720,7 @@ func TestOAuth2ProviderRevoke(t *testing.T) { }, }, { - name: "DeleteToken", + name: "DeleteApp", fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) { err := client.RevokeOAuth2ProviderApp(ctx, s.app.ID) require.NoError(t, err) @@ -1603,5 +1603,80 @@ func TestOAuth2RegistrationAccessToken(t *testing.T) { }) } +// TestOAuth2CoderClient verfies a codersdk client can be used with an oauth client. +func TestOAuth2CoderClient(t *testing.T) { + t.Parallel() + + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + + // Setup an oauth app + ctx := testutil.Context(t, testutil.WaitLong) + app, err := owner.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: "new-app", + CallbackURL: "http://localhost", + }) + require.NoError(t, err) + + appsecret, err := owner.PostOAuth2ProviderAppSecret(ctx, app.ID) + require.NoError(t, err) + + cfg := &oauth2.Config{ + ClientID: app.ID.String(), + ClientSecret: appsecret.ClientSecretFull, + Endpoint: oauth2.Endpoint{ + AuthURL: app.Endpoints.Authorization, + DeviceAuthURL: app.Endpoints.DeviceAuth, + TokenURL: app.Endpoints.Token, + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: app.CallbackURL, + Scopes: []string{}, + } + + // Make a new user + client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID) + + // Do an OAuth2 token exchange and get a new client with an oauth token + state := uuid.NewString() + + // Get an OAuth2 code for a token exchange + code, err := oidctest.OAuth2GetCode( + cfg.AuthCodeURL(state), + func(req *http.Request) (*http.Response, error) { + // Change to POST to simulate the form submission + req.Method = http.MethodPost + + // Prevent automatic redirect following + client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + return client.Request(ctx, req.Method, req.URL.String(), nil) + }, + ) + require.NoError(t, err) + + token, err := cfg.Exchange(ctx, code) + require.NoError(t, err) + + // Use the oauth client's authentication + // TODO: The SDK could probably support this with a better syntax/api. + oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) + usingOauth := codersdk.New(owner.URL) + usingOauth.HTTPClient = oauthClient + + me, err := usingOauth.User(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, user.ID, me.ID) + + // Revoking the refresh token should prevent further access + // Revoking the refresh also invalidates the associated access token. + err = usingOauth.RevokeOAuth2Token(ctx, app.ID, token.RefreshToken) + require.NoError(t, err) + + _, err = usingOauth.User(ctx, codersdk.Me) + require.Error(t, err) +} + // NOTE: OAuth2 client registration validation tests have been migrated to // oauth2provider/validation_test.go for better separation of concerns diff --git a/coderd/oauth2provider/app_secrets.go b/coderd/oauth2provider/app_secrets.go index 5549ece4266f2..3eff684123c0e 100644 --- a/coderd/oauth2provider/app_secrets.go +++ b/coderd/oauth2provider/app_secrets.go @@ -66,7 +66,7 @@ func CreateAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logg ID: uuid.New(), CreatedAt: dbtime.Now(), SecretPrefix: []byte(secret.Prefix), - HashedSecret: []byte(secret.Hashed), + HashedSecret: secret.Hashed, // DisplaySecret is the last six characters of the original unhashed secret. // This is done so they can be differentiated and it matches how GitHub // displays their client secrets. diff --git a/coderd/oauth2provider/apps.go b/coderd/oauth2provider/apps.go index 74bafb851ef1a..81ff8b0e24095 100644 --- a/coderd/oauth2provider/apps.go +++ b/coderd/oauth2provider/apps.go @@ -110,7 +110,7 @@ func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo Jwks: pqtype.NullRawMessage{}, SoftwareID: sql.NullString{}, SoftwareVersion: sql.NullString{}, - RegistrationAccessToken: sql.NullString{}, + RegistrationAccessToken: nil, RegistrationClientUri: sql.NullString{}, }) if err != nil { diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index d6b05fc51673f..d738e781e8a34 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -165,7 +165,7 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc { // has left) then they can just retry immediately and get a new code. ExpiresAt: dbtime.Now().Add(time.Duration(10) * time.Minute), SecretPrefix: []byte(code.Prefix), - HashedSecret: []byte(code.Hashed), + HashedSecret: code.Hashed, AppID: app.ID, UserID: apiKey.UserID, ResourceUri: sql.NullString{String: params.resource, Valid: params.resource != ""}, diff --git a/coderd/oauth2provider/registration.go b/coderd/oauth2provider/registration.go index 63d2de4f48394..807c39371d8a4 100644 --- a/coderd/oauth2provider/registration.go +++ b/coderd/oauth2provider/registration.go @@ -15,21 +15,14 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" - "github.com/coder/coder/v2/coderd/userpassword" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/cryptorand" -) - -// Constants for OAuth2 secret generation (RFC 7591) -const ( - secretLength = 40 // Length of the actual secret part - displaySecretLength = 6 // Length of visible part in UI (last 6 characters) ) // CreateDynamicClientRegistration returns an http.HandlerFunc that handles POST /oauth2/register @@ -106,7 +99,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi Jwks: pqtype.NullRawMessage{RawMessage: req.JWKS, Valid: len(req.JWKS) > 0}, SoftwareID: sql.NullString{String: req.SoftwareID, Valid: req.SoftwareID != ""}, SoftwareVersion: sql.NullString{String: req.SoftwareVersion, Valid: req.SoftwareVersion != ""}, - RegistrationAccessToken: sql.NullString{String: hashedRegToken, Valid: true}, + RegistrationAccessToken: hashedRegToken, RegistrationClientUri: sql.NullString{String: fmt.Sprintf("%s/oauth2/clients/%s", accessURL.String(), clientID), Valid: true}, }) if err != nil { @@ -121,7 +114,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi } // Create client secret - parse the formatted secret to get components - parsedSecret, err := parseFormattedSecret(clientSecret) + parsedSecret, err := ParseFormattedSecret(clientSecret) if err != nil { writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, "server_error", "Failed to parse generated secret") @@ -132,8 +125,8 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi _, err = db.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppSecretParams{ ID: uuid.New(), CreatedAt: now, - SecretPrefix: []byte(parsedSecret.prefix), - HashedSecret: []byte(hashedSecret), + SecretPrefix: []byte(parsedSecret.Prefix), + HashedSecret: hashedSecret, DisplaySecret: createDisplaySecret(clientSecret), AppID: clientID, }) @@ -230,7 +223,7 @@ func GetClientConfiguration(db database.Store) http.HandlerFunc { TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String, Scope: app.Scope.String, Contacts: app.Contacts, - RegistrationAccessToken: "", // RFC 7592: Not returned in GET responses for security + RegistrationAccessToken: nil, // RFC 7592: Not returned in GET responses for security RegistrationClientURI: app.RegistrationClientUri.String, } @@ -354,7 +347,7 @@ func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger TokenEndpointAuthMethod: updatedApp.TokenEndpointAuthMethod.String, Scope: updatedApp.Scope.String, Contacts: updatedApp.Contacts, - RegistrationAccessToken: updatedApp.RegistrationAccessToken.String, + RegistrationAccessToken: updatedApp.RegistrationAccessToken, RegistrationClientURI: updatedApp.RegistrationClientUri.String, } @@ -482,20 +475,14 @@ func RequireRegistrationAccessToken(db database.Store) func(http.Handler) http.H } // Verify the registration access token - if !app.RegistrationAccessToken.Valid { + if len(app.RegistrationAccessToken) == 0 { writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, "server_error", "Client has no registration access token") return } // Compare the provided token with the stored hash - valid, err := userpassword.Compare(app.RegistrationAccessToken.String, token) - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to verify registration access token") - return - } - if !valid { + if !apikey.ValidateHash(app.RegistrationAccessToken, token) { writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, "invalid_token", "Invalid registration access token") return @@ -510,30 +497,19 @@ func RequireRegistrationAccessToken(db database.Store) func(http.Handler) http.H // Helper functions for RFC 7591 Dynamic Client Registration // generateClientCredentials generates a client secret for OAuth2 apps -func generateClientCredentials() (plaintext, hashed string, err error) { +func generateClientCredentials() (plaintext string, hashed []byte, err error) { // Use the same pattern as existing OAuth2 app secrets secret, err := GenerateSecret() if err != nil { - return "", "", xerrors.Errorf("generate secret: %w", err) + return "", nil, xerrors.Errorf("generate secret: %w", err) } return secret.Formatted, secret.Hashed, nil } // generateRegistrationAccessToken generates a registration access token for RFC 7592 -func generateRegistrationAccessToken() (plaintext, hashed string, err error) { - token, err := cryptorand.String(secretLength) - if err != nil { - return "", "", xerrors.Errorf("generate registration token: %w", err) - } - - // Hash the token for storage - hashedToken, err := userpassword.Hash(token) - if err != nil { - return "", "", xerrors.Errorf("hash registration token: %w", err) - } - - return token, hashedToken, nil +func generateRegistrationAccessToken() (plaintext string, hashed []byte, err error) { + return apikey.GenerateSecret(secretLength) } // writeOAuth2RegistrationError writes RFC 7591 compliant error responses @@ -551,27 +527,6 @@ func writeOAuth2RegistrationError(_ context.Context, rw http.ResponseWriter, sta _ = json.NewEncoder(rw).Encode(errorResponse) } -// parsedSecret represents the components of a formatted OAuth2 secret -type parsedSecret struct { - prefix string - secret string -} - -// parseFormattedSecret parses a formatted secret like "coder_prefix_secret" -func parseFormattedSecret(secret string) (parsedSecret, error) { - parts := strings.Split(secret, "_") - if len(parts) != 3 { - return parsedSecret{}, xerrors.Errorf("incorrect number of parts: %d", len(parts)) - } - if parts[0] != "coder" { - return parsedSecret{}, xerrors.Errorf("incorrect scheme: %s", parts[0]) - } - return parsedSecret{ - prefix: parts[1], - secret: parts[2], - }, nil -} - // createDisplaySecret creates a display version of the secret showing only the last few characters func createDisplaySecret(secret string) string { if len(secret) <= displaySecretLength { diff --git a/coderd/oauth2provider/revoke.go b/coderd/oauth2provider/revoke.go index 243ce750288bb..19f3fb803a88c 100644 --- a/coderd/oauth2provider/revoke.go +++ b/coderd/oauth2provider/revoke.go @@ -1,15 +1,211 @@ package oauth2provider import ( + "context" + "crypto/sha256" + "crypto/subtle" "database/sql" "errors" "net/http" + "strings" + "golang.org/x/xerrors" + + "github.com/google/uuid" + + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" ) +var ( + // ErrTokenNotBelongsToClient is returned when a token does not belong to the requesting client + ErrTokenNotBelongsToClient = xerrors.New("token does not belong to requesting client") + // ErrInvalidTokenFormat is returned when a token has an invalid format + ErrInvalidTokenFormat = xerrors.New("invalid token format") +) + +// RevokeToken implements RFC 7009 OAuth2 Token Revocation +// Authentication is unique for this endpoint in that it does not use the +// standard token authentication middleware. Instead, it expects the token that +// is being revoked to be valid. +// TODO: Currently the token validation occurs in the revocation logic itself. +// This code should be refactored to share token validation logic with other parts +// of the OAuth2 provider/http middleware. +func RevokeToken(db database.Store, logger slog.Logger) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + app := httpmw.OAuth2ProviderApp(r) + + // RFC 7009 requires POST method with application/x-www-form-urlencoded + if r.Method != http.MethodPost { + httpapi.WriteOAuth2Error(ctx, rw, http.StatusMethodNotAllowed, "invalid_request", "Method not allowed") + return + } + + if err := r.ParseForm(); err != nil { + httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid form data") + return + } + + // RFC 7009 requires 'token' parameter + token := r.Form.Get("token") + if token == "" { + httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Missing token parameter") + return + } + + // Determine if this is a refresh token (starts with "coder_") or API key + // APIKeys do not have the SecretIdentifier prefix. + const coderPrefix = SecretIdentifier + "_" + isRefreshToken := strings.HasPrefix(token, coderPrefix) + + // Revoke the token with ownership verification + err := db.InTx(func(tx database.Store) error { + if isRefreshToken { + // Handle refresh token revocation + return revokeRefreshTokenInTx(ctx, tx, token, app.ID) + } + // Handle API key revocation + return revokeAPIKeyInTx(ctx, tx, token, app.ID) + }, nil) + if err != nil { + if errors.Is(err, ErrTokenNotBelongsToClient) { + // RFC 7009: Return success even if token doesn't belong to client (don't reveal token existence) + logger.Debug(ctx, "token revocation failed: token does not belong to requesting client", + slog.F("client_id", app.ID.String()), + slog.F("app_name", app.Name)) + rw.WriteHeader(http.StatusOK) + return + } + if errors.Is(err, ErrInvalidTokenFormat) { + // Invalid token format should return 400 bad request + logger.Debug(ctx, "token revocation failed: invalid token format", + slog.F("client_id", app.ID.String()), + slog.F("app_name", app.Name)) + httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid token format") + return + } + logger.Error(ctx, "token revocation failed with internal server error", + slog.Error(err), + slog.F("client_id", app.ID.String()), + slog.F("app_name", app.Name)) + httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Internal server error") + return + } + + // RFC 7009: successful revocation returns HTTP 200 + rw.WriteHeader(http.StatusOK) + } +} + +func revokeRefreshTokenInTx(ctx context.Context, db database.Store, token string, appID uuid.UUID) error { + // Parse the refresh token using the existing function + parsedToken, err := ParseFormattedSecret(token) + if err != nil { + return ErrInvalidTokenFormat + } + + // Try to find refresh token by prefix + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemOAuth2(ctx), []byte(parsedToken.Prefix)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // Token not found - return success per RFC 7009 (don't reveal token existence) + return nil + } + return xerrors.Errorf("get oauth2 provider app token by prefix: %w", err) + } + + equal := apikey.ValidateHash(dbToken.RefreshHash, parsedToken.Secret) + if !equal { + return xerrors.Errorf("invalid refresh token") + } + + // Verify ownership + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + appSecret, err := db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx), dbToken.AppSecretID) + if err != nil { + return xerrors.Errorf("get oauth2 provider app secret: %w", err) + } + if appSecret.AppID != appID { + return ErrTokenNotBelongsToClient + } + + // Delete the associated API key, which should cascade to remove the refresh token + // According to RFC 7009, when a refresh token is revoked, associated access tokens should be invalidated + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + err = db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), dbToken.APIKeyID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("delete api key: %w", err) + } + + return nil +} + +func revokeAPIKeyInTx(ctx context.Context, db database.Store, token string, appID uuid.UUID) error { + keyID, secret, err := httpmw.SplitAPIToken(token) + if err != nil { + return ErrInvalidTokenFormat + } + + // Get the API key + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + apiKey, err := db.GetAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), keyID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // API key not found - return success per RFC 7009 (don't reveal token existence) + return nil + } + return xerrors.Errorf("get api key by id: %w", err) + } + + // Checking to see if the provided secret matches the stored hashed secret + hashedSecret := sha256.Sum256([]byte(secret)) + if subtle.ConstantTimeCompare(apiKey.HashedSecret, hashedSecret[:]) != 1 { + return xerrors.Errorf("invalid api key") + } + + // Verify the API key was created by OAuth2 + if apiKey.LoginType != database.LoginTypeOAuth2ProviderApp { + return xerrors.New("api key is not an oauth2 token") + } + + // Find the associated OAuth2 token to verify ownership + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + dbToken, err := db.GetOAuth2ProviderAppTokenByAPIKeyID(dbauthz.AsSystemOAuth2(ctx), apiKey.ID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // No associated OAuth2 token - return success per RFC 7009 + return nil + } + return xerrors.Errorf("get oauth2 provider app token by api key id: %w", err) + } + + // Verify the token belongs to the requesting app + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + appSecret, err := db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx), dbToken.AppSecretID) + if err != nil { + return xerrors.Errorf("get oauth2 provider app secret for api key verification: %w", err) + } + + if appSecret.AppID != appID { + return ErrTokenNotBelongsToClient + } + + // Delete the API key + //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint + err = db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), apiKey.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("delete api key for revocation: %w", err) + } + + return nil +} + func RevokeApp(db database.Store) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/coderd/oauth2provider/secrets.go b/coderd/oauth2provider/secrets.go index a360c0b325c89..ee6a7b315d843 100644 --- a/coderd/oauth2provider/secrets.go +++ b/coderd/oauth2provider/secrets.go @@ -2,32 +2,68 @@ package oauth2provider import ( "fmt" + "strings" - "github.com/coder/coder/v2/coderd/userpassword" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/cryptorand" ) +const ( + // SecretIdentifier is the prefix added to all generated secrets. + SecretIdentifier = "coder" +) + +// Constants for OAuth2 secret generation +const ( + secretLength = 40 // Length of the actual secret part + displaySecretLength = 6 // Length of visible part in UI (last 6 characters) +) + +type HashedAppSecret struct { + AppSecret + // Hashed is the server stored hash(secret,salt,...). Used for verifying a + // secret. + Hashed []byte +} + type AppSecret struct { // Formatted contains the secret. This value is owned by the client, not the // server. It is formatted to include the prefix. Formatted string + // Secret is the raw secret value. This value should only be known to the client. + Secret string // Prefix is the ID of this secret owned by the server. When a client uses a // secret, this is the matching string to do a lookup on the hashed value. We // cannot use the hashed value directly because the server does not store the // salt. Prefix string - // Hashed is the server stored hash(secret,salt,...). Used for verifying a - // secret. - Hashed string +} + +// ParseFormattedSecret parses a formatted secret like "coder__ 0 { + return filter, errors + } + + parser := httpapi.NewQueryParamParser() + filter.OwnerID = parseUser(ctx, db, parser, values, "owner", actorID) + filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization") + filter.Status = parser.String(values, "", "status") + + parser.ErrorExcessParams(values) + return filter, parser.Errors +} + func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) { searchValues := make(url.Values) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 84d4509d0ec60..44ae9d1021159 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -944,3 +944,199 @@ func TestSearchTemplates(t *testing.T) { }) } } + +func TestSearchTasks(t *testing.T) { + t.Parallel() + + userID := uuid.MustParse("10000000-0000-0000-0000-000000000001") + orgID := uuid.MustParse("20000000-0000-0000-0000-000000000001") + + testCases := []struct { + Name string + Query string + ActorID uuid.UUID + Expected database.ListTasksParams + ExpectedErrorContains string + Setup func(t *testing.T, db database.Store) + }{ + { + Name: "Empty", + Query: "", + Expected: database.ListTasksParams{}, + }, + { + Name: "OwnerUsername", + Query: "owner:alice", + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: userID, + Username: "alice", + }) + }, + Expected: database.ListTasksParams{ + OwnerID: userID, + }, + }, + { + Name: "OwnerMe", + Query: "owner:me", + ActorID: userID, + Expected: database.ListTasksParams{ + OwnerID: userID, + }, + }, + { + Name: "OwnerUUID", + Query: fmt.Sprintf("owner:%s", userID), + Expected: database.ListTasksParams{ + OwnerID: userID, + }, + }, + { + Name: "StatusActive", + Query: "status:active", + Expected: database.ListTasksParams{ + Status: "active", + }, + }, + { + Name: "StatusPending", + Query: "status:pending", + Expected: database.ListTasksParams{ + Status: "pending", + }, + }, + { + Name: "Organization", + Query: "organization:acme", + Setup: func(t *testing.T, db database.Store) { + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + Name: "acme", + }) + }, + Expected: database.ListTasksParams{ + OrganizationID: orgID, + }, + }, + { + Name: "OrganizationUUID", + Query: fmt.Sprintf("organization:%s", orgID), + Expected: database.ListTasksParams{ + OrganizationID: orgID, + }, + }, + { + Name: "Combined", + Query: "owner:alice organization:acme status:active", + Setup: func(t *testing.T, db database.Store) { + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + Name: "acme", + }) + dbgen.User(t, db, database.User{ + ID: userID, + Username: "alice", + }) + }, + Expected: database.ListTasksParams{ + OwnerID: userID, + OrganizationID: orgID, + Status: "active", + }, + }, + { + Name: "QuotedOwner", + Query: `owner:"alice"`, + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: userID, + Username: "alice", + }) + }, + Expected: database.ListTasksParams{ + OwnerID: userID, + }, + }, + { + Name: "QuotedStatus", + Query: `status:"pending"`, + Expected: database.ListTasksParams{ + Status: "pending", + }, + }, + { + Name: "DefaultToOwner", + Query: "alice", + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: userID, + Username: "alice", + }) + }, + Expected: database.ListTasksParams{ + OwnerID: userID, + }, + }, + { + Name: "InvalidOwner", + Query: "owner:nonexistent", + ExpectedErrorContains: "does not exist", + }, + { + Name: "InvalidOrganization", + Query: "organization:nonexistent", + ExpectedErrorContains: "does not exist", + }, + { + Name: "ExtraParam", + Query: "owner:alice invalid:param", + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: userID, + Username: "alice", + }) + }, + ExpectedErrorContains: "is not a valid query param", + }, + { + Name: "ExtraColon", + Query: "owner:alice:extra", + ExpectedErrorContains: "can only contain 1 ':'", + }, + { + Name: "PrefixColon", + Query: ":owner", + ExpectedErrorContains: "cannot start or end with ':'", + }, + { + Name: "SuffixColon", + Query: "owner:", + ExpectedErrorContains: "cannot start or end with ':'", + }, + } + + for _, c := range testCases { + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + if c.Setup != nil { + c.Setup(t, db) + } + + values, errs := searchquery.Tasks(context.Background(), db, c.Query, c.ActorID) + if c.ExpectedErrorContains != "" { + require.True(t, len(errs) > 0, "expect some errors") + var s strings.Builder + for _, err := range errs { + _, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail)) + } + require.Contains(t, s.String(), c.ExpectedErrorContains) + } else { + require.Len(t, errs, 0, "expected no error") + require.Equal(t, c.Expected, values, "expected values") + } + }) + } +} diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 37e41fcd70c62..1526f51f167e8 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -734,6 +734,7 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { dbTasks, err := r.options.Database.ListTasks(ctx, database.ListTasksParams{ OwnerID: uuid.Nil, OrganizationID: uuid.Nil, + Status: "", }) if err != nil { return err diff --git a/coderd/templates_test.go b/coderd/templates_test.go index c470dd17c664a..df50b28ab861e 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -944,10 +944,6 @@ func TestPatchTemplateMeta(t *testing.T) { t.Run("AlreadyExists", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires Postgres constraints") - } - ownerClient := coderdtest.New(t, nil) owner := coderdtest.CreateFirstUser(t, ownerClient) client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID)) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 48f690d26d2eb..f282f8420b52e 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -1422,9 +1422,6 @@ func TestTemplateVersionDryRun(t *testing.T) { t.Run("Pending", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } store, ps, db := dbtestutil.NewDBWithSQLDB(t) client, closer := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ diff --git a/coderd/users.go b/coderd/users.go index e24790e7455e7..30fa7bf7cabeb 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1608,5 +1608,6 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey { Scopes: scopes, LifetimeSeconds: k.LifetimeSeconds, TokenName: k.TokenName, + AllowList: db2sdk.List(k.AllowList, db2sdk.APIAllowListTarget), } } diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index b6409d8ed781d..1e3020376041b 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1181,9 +1181,9 @@ func (api *API) convertWorkspaceBuild( if build.HasAITask.Valid { hasAITask = &build.HasAITask.Bool } - var aiTasksSidebarAppID *uuid.UUID + var taskAppID *uuid.UUID if build.AITaskSidebarAppID.Valid { - aiTasksSidebarAppID = &build.AITaskSidebarAppID.UUID + taskAppID = &build.AITaskSidebarAppID.UUID } var hasExternalAgent *bool @@ -1218,7 +1218,8 @@ func (api *API) convertWorkspaceBuild( MatchedProvisioners: &matchedProvisioners, TemplateVersionPresetID: presetID, HasAITask: hasAITask, - AITaskSidebarAppID: aiTasksSidebarAppID, + AITaskSidebarAppID: taskAppID, + TaskAppID: taskAppID, HasExternalAgent: hasExternalAgent, }, nil } diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 2c518a95e53a6..f857296db1a5c 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -634,9 +634,7 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) { t.Run("Cancel with expect_state=pending", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } + // Given: a coderd instance with a provisioner daemon store, ps, db := dbtestutil.NewDBWithSQLDB(t) client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ @@ -732,9 +730,7 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) { t.Run("Cancel with expect_state=running when job is pending - should fail with 412", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } + // Given: a coderd instance with a provisioner daemon store, ps, db := dbtestutil.NewDBWithSQLDB(t) client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ @@ -1731,9 +1727,7 @@ func TestPostWorkspaceBuild(t *testing.T) { t.Run("NoProvisionersAvailable", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } + // Given: a coderd instance with a provisioner daemon store, ps, db := dbtestutil.NewDBWithSQLDB(t) client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ @@ -1777,9 +1771,7 @@ func TestPostWorkspaceBuild(t *testing.T) { t.Run("AllProvisionersStale", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } + // Given: a coderd instance with a provisioner daemon store, ps, db := dbtestutil.NewDBWithSQLDB(t) client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ diff --git a/coderd/workspaces.go b/coderd/workspaces.go index d67fa2ef4b5a7..e8b7ff51530c3 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -388,7 +388,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req AvatarURL: member.AvatarURL, } - w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, r) + w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, r, nil) if err != nil { httperror.WriteResponseError(ctx, rw, err) return @@ -484,7 +484,7 @@ func (api *API) postUserWorkspaces(rw http.ResponseWriter, r *http.Request) { defer commitAudit() - w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, r) + w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, r, nil) if err != nil { httperror.WriteResponseError(ctx, rw, err) return @@ -499,6 +499,15 @@ type workspaceOwner struct { AvatarURL string } +type createWorkspaceOptions struct { + // preCreateInTX is a function that is called within the transaction, before + // the workspace is created. + preCreateInTX func(ctx context.Context, tx database.Store) error + // postCreateInTX is a function that is called within the transaction, after + // the workspace is created but before the workspace build is created. + postCreateInTX func(ctx context.Context, tx database.Store, workspace database.Workspace) error +} + func createWorkspace( ctx context.Context, auditReq *audit.Request[database.WorkspaceTable], @@ -507,7 +516,12 @@ func createWorkspace( owner workspaceOwner, req codersdk.CreateWorkspaceRequest, r *http.Request, + opts *createWorkspaceOptions, ) (codersdk.Workspace, error) { + if opts == nil { + opts = &createWorkspaceOptions{} + } + template, err := requestTemplate(ctx, req, api.Database) if err != nil { return codersdk.Workspace{}, err @@ -636,6 +650,16 @@ func createWorkspace( claimedWorkspace *database.Workspace ) + // If a preCreate hook is provided, execute it before creating or + // claiming the workspace. This can be used to perform additional + // setup or validation before the workspace is created (e.g. task + // creation). + if opts.preCreateInTX != nil { + if err := opts.preCreateInTX(ctx, db); err != nil { + return xerrors.Errorf("workspace preCreate failed: %w", err) + } + } + // Use injected Clock to allow time mocking in tests now := dbtime.Time(api.Clock.Now()) @@ -729,6 +753,15 @@ func createWorkspace( return xerrors.Errorf("get workspace by ID: %w", err) } + // If the postCreate hook is provided, execute it. This can be used to + // perform additional actions after the workspace has been created, like + // linking the workspace to a task. + if opts.postCreateInTX != nil { + if err := opts.postCreateInTX(ctx, db, workspace); err != nil { + return xerrors.Errorf("workspace postCreate failed: %w", err) + } + } + builder := wsbuilder.New(workspace, database.WorkspaceTransitionStart, *api.BuildUsageChecker.Load()). Reason(database.BuildReasonInitiator). Initiator(initiatorID). diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 3a24eafde1a95..51134dce27951 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1240,9 +1240,7 @@ func TestPostWorkspacesByOrganization(t *testing.T) { t.Run("NoProvisionersAvailable", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } + // Given: a coderd instance with a provisioner daemon store, ps, db := dbtestutil.NewDBWithSQLDB(t) client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ @@ -1283,9 +1281,6 @@ func TestPostWorkspacesByOrganization(t *testing.T) { t.Run("AllProvisionersStale", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } // Given: a coderd instance with a provisioner daemon store, ps, db := dbtestutil.NewDBWithSQLDB(t) diff --git a/coderd/workspacestats/tracker_test.go b/coderd/workspacestats/tracker_test.go index 2803e5a5322b3..fde8c9f2dad90 100644 --- a/coderd/workspacestats/tracker_test.go +++ b/coderd/workspacestats/tracker_test.go @@ -110,9 +110,6 @@ func TestTracker(t *testing.T) { // This test performs a more 'integration-style' test with multiple instances. func TestTracker_MultipleInstances(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test only makes sense with postgres") - } // Given we have two coderd instances connected to the same database var ( diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index 0a6e85c94782c..afe0e19f78831 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -56,7 +56,7 @@ type AIBridgeToolUsage struct { } type AIBridgeListInterceptionsResponse struct { - Total int64 `json:"total"` + Count int64 `json:"count"` Results []AIBridgeInterception `json:"results"` } diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 67e68daa706cf..9f390202e4fd2 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -89,6 +89,44 @@ func (c *ExperimentalClient) CreateTask(ctx context.Context, user string, reques return task, nil } +// TaskStatus represents the status of a task. +// +// Experimental: This type is experimental and may change in the future. +type TaskStatus string + +const ( + // TaskStatusPending indicates the task has been created but no workspace + // has been provisioned yet, or the workspace build job status is unknown. + TaskStatusPending TaskStatus = "pending" + // TaskStatusInitializing indicates the workspace build is pending/running, + // the agent is connecting, or apps are initializing. + TaskStatusInitializing TaskStatus = "initializing" + // TaskStatusActive indicates the task's workspace is running with a + // successful start transition, the agent is connected, and all workspace + // apps are either healthy or disabled. + TaskStatusActive TaskStatus = "active" + // TaskStatusPaused indicates the task's workspace has been stopped or + // deleted (stop/delete transition with successful job status). + TaskStatusPaused TaskStatus = "paused" + // TaskStatusUnknown indicates the task's status cannot be determined + // based on the workspace build, agent lifecycle, or app health states. + TaskStatusUnknown TaskStatus = "unknown" + // TaskStatusError indicates the task's workspace build job has failed, + // or the workspace apps are reporting unhealthy status. + TaskStatusError TaskStatus = "error" +) + +func AllTaskStatuses() []TaskStatus { + return []TaskStatus{ + TaskStatusPending, + TaskStatusInitializing, + TaskStatusActive, + TaskStatusPaused, + TaskStatusError, + TaskStatusUnknown, + } +} + // TaskState represents the high-level lifecycle of a task. // // Experimental: This type is experimental and may change in the future. @@ -96,10 +134,18 @@ type TaskState string // TaskState enums. const ( - TaskStateWorking TaskState = "working" - TaskStateIdle TaskState = "idle" + // TaskStateWorking indicates the AI agent is actively processing work. + // Reported when the agent is performing actions or the screen is changing. + TaskStateWorking TaskState = "working" + // TaskStateIdle indicates the AI agent's screen is stable and no work + // is being performed. Reported automatically by the screen watcher. + TaskStateIdle TaskState = "idle" + // TaskStateComplete indicates the AI agent has successfully completed + // the task. Reported via the workspace app status. TaskStateComplete TaskState = "complete" - TaskStateFailed TaskState = "failed" + // TaskStateFailed indicates the AI agent reported a failure state. + // Reported via the workspace app status. + TaskStateFailed TaskState = "failed" ) // Task represents a task. @@ -110,20 +156,24 @@ type Task struct { OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"` OwnerID uuid.UUID `json:"owner_id" format:"uuid" table:"owner id"` OwnerName string `json:"owner_name" table:"owner name"` + OwnerAvatarURL string `json:"owner_avatar_url,omitempty" table:"owner avatar url"` Name string `json:"name" table:"name,default_sort"` TemplateID uuid.UUID `json:"template_id" format:"uuid" table:"template id"` + TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid" table:"template version id"` TemplateName string `json:"template_name" table:"template name"` TemplateDisplayName string `json:"template_display_name" table:"template display name"` TemplateIcon string `json:"template_icon" table:"template icon"` WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid" table:"workspace id"` + WorkspaceName string `json:"workspace_name" table:"workspace name"` + WorkspaceStatus WorkspaceStatus `json:"workspace_status,omitempty" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted" table:"workspace status"` WorkspaceBuildNumber int32 `json:"workspace_build_number,omitempty" table:"workspace build number"` WorkspaceAgentID uuid.NullUUID `json:"workspace_agent_id" format:"uuid" table:"workspace agent id"` WorkspaceAgentLifecycle *WorkspaceAgentLifecycle `json:"workspace_agent_lifecycle" table:"workspace agent lifecycle"` WorkspaceAgentHealth *WorkspaceAgentHealth `json:"workspace_agent_health" table:"workspace agent health"` WorkspaceAppID uuid.NullUUID `json:"workspace_app_id" format:"uuid" table:"workspace app id"` InitialPrompt string `json:"initial_prompt" table:"initial prompt"` - Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted" table:"status"` - CurrentState *TaskStateEntry `json:"current_state" table:"cs,recursive_inline"` + Status TaskStatus `json:"status" enums:"pending,initializing,active,paused,unknown,error" table:"status"` + CurrentState *TaskStateEntry `json:"current_state" table:"cs,recursive_inline,empty_nil"` CreatedAt time.Time `json:"created_at" format:"date-time" table:"created at"` UpdatedAt time.Time `json:"updated_at" format:"date-time" table:"updated at"` } @@ -144,12 +194,45 @@ type TaskStateEntry struct { type TasksFilter struct { // Owner can be a username, UUID, or "me". Owner string `json:"owner,omitempty"` - // Status is a task status. - Status string `json:"status,omitempty" typescript:"-"` - // Offset is the number of tasks to skip before returning results. - Offset int `json:"offset,omitempty" typescript:"-"` - // Limit is a limit on the number of tasks returned. - Limit int `json:"limit,omitempty" typescript:"-"` + // Organization can be an organization name or UUID. + Organization string `json:"organization,omitempty"` + // Status filters the tasks by their task status. + Status TaskStatus `json:"status,omitempty"` + // FilterQuery allows specifying a raw filter query. + FilterQuery string `json:"filter_query,omitempty"` +} + +// TaskListResponse is the response shape for tasks list. +// +// Experimental response shape for tasks list (server returns []Task). +type TasksListResponse struct { + Tasks []Task `json:"tasks"` + Count int `json:"count"` +} + +func (f TasksFilter) asRequestOption() RequestOption { + return func(r *http.Request) { + var params []string + // Make sure all user input is quoted to ensure it's parsed as a single + // string. + if f.Owner != "" { + params = append(params, fmt.Sprintf("owner:%q", f.Owner)) + } + if f.Organization != "" { + params = append(params, fmt.Sprintf("organization:%q", f.Organization)) + } + if f.Status != "" { + params = append(params, fmt.Sprintf("status:%q", string(f.Status))) + } + if f.FilterQuery != "" { + // If custom stuff is added, just add it on here. + params = append(params, f.FilterQuery) + } + + q := r.URL.Query() + q.Set("q", strings.Join(params, " ")) + r.URL.RawQuery = q.Encode() + } } // Tasks lists all tasks belonging to the user or specified owner. @@ -160,15 +243,7 @@ func (c *ExperimentalClient) Tasks(ctx context.Context, filter *TasksFilter) ([] filter = &TasksFilter{} } - var wsFilter WorkspaceFilter - wsFilter.Owner = filter.Owner - wsFilter.Status = filter.Status - page := Pagination{ - Offset: filter.Offset, - Limit: filter.Limit, - } - - res, err := c.Request(ctx, http.MethodGet, "/api/experimental/tasks", nil, wsFilter.asRequestOption(), page.asRequestOption()) + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/tasks", nil, filter.asRequestOption()) if err != nil { return nil, err } @@ -177,12 +252,7 @@ func (c *ExperimentalClient) Tasks(ctx context.Context, filter *TasksFilter) ([] return nil, ReadBodyAsError(res) } - // Experimental response shape for tasks list (server returns []Task). - type tasksListResponse struct { - Tasks []Task `json:"tasks"` - Count int `json:"count"` - } - var tres tasksListResponse + var tres TasksListResponse if err := json.NewDecoder(res.Body).Decode(&tres); err != nil { return nil, err } @@ -211,6 +281,72 @@ func (c *ExperimentalClient) TaskByID(ctx context.Context, id uuid.UUID) (Task, return task, nil } +func splitTaskIdentifier(identifier string) (owner string, taskName string, err error) { + parts := strings.Split(identifier, "/") + + switch len(parts) { + case 1: + owner = Me + taskName = parts[0] + case 2: + owner = parts[0] + taskName = parts[1] + default: + return "", "", xerrors.Errorf("invalid task identifier: %q", identifier) + } + return owner, taskName, nil +} + +// TaskByIdentifier fetches and returns a task by an identifier, which may be +// either a UUID, a name (for a task owned by the current user), or a +// "user/task" combination, where user is either a username or UUID. +// +// Since there is no TaskByOwnerAndName endpoint yet, this function uses the +// list endpoint with filtering when a name is provided. +func (c *ExperimentalClient) TaskByIdentifier(ctx context.Context, identifier string) (Task, error) { + identifier = strings.TrimSpace(identifier) + + // Try parsing as UUID first. + if taskID, err := uuid.Parse(identifier); err == nil { + return c.TaskByID(ctx, taskID) + } + + // Not a UUID, treat as identifier. + owner, taskName, err := splitTaskIdentifier(identifier) + if err != nil { + return Task{}, err + } + + tasks, err := c.Tasks(ctx, &TasksFilter{ + Owner: owner, + }) + if err != nil { + return Task{}, xerrors.Errorf("list tasks for owner %q: %w", owner, err) + } + + if taskID, err := uuid.Parse(taskName); err == nil { + // Find task by ID. + for _, task := range tasks { + if task.ID == taskID { + return task, nil + } + } + } else { + // Find task by name. + for _, task := range tasks { + if task.Name == taskName { + return task, nil + } + } + } + + // Mimic resource not found from API. + var notFoundErr error = &Error{ + Response: Response{Message: "Resource not found or you do not have access to this resource"}, + } + return Task{}, xerrors.Errorf("task %q not found for owner %q: %w", taskName, owner, notFoundErr) +} + // DeleteTask deletes a task by its ID. // // Experimental: This method is experimental and may change in the future. diff --git a/codersdk/aitasks_internal_test.go b/codersdk/aitasks_internal_test.go new file mode 100644 index 0000000000000..b10a8659a64e2 --- /dev/null +++ b/codersdk/aitasks_internal_test.go @@ -0,0 +1,75 @@ +package codersdk + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_splitTaskIdentifier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + identifier string + expectedOwner string + expectedTask string + expectErr bool + }{ + { + name: "bare task name", + identifier: "mytask", + expectedOwner: Me, + expectedTask: "mytask", + expectErr: false, + }, + { + name: "owner/task format", + identifier: "alice/her-task", + expectedOwner: "alice", + expectedTask: "her-task", + expectErr: false, + }, + { + name: "uuid/task format", + identifier: "550e8400-e29b-41d4-a716-446655440000/task1", + expectedOwner: "550e8400-e29b-41d4-a716-446655440000", + expectedTask: "task1", + expectErr: false, + }, + { + name: "owner/uuid format", + identifier: "alice/3abe1dcf-cd87-4078-8b54-c0e2058ad2e2", + expectedOwner: "alice", + expectedTask: "3abe1dcf-cd87-4078-8b54-c0e2058ad2e2", + expectErr: false, + }, + { + name: "too many slashes", + identifier: "owner/task/extra", + expectErr: true, + }, + { + name: "empty parts acceptable", + identifier: "/task", + expectedOwner: "", + expectedTask: "task", + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + owner, taskName, err := splitTaskIdentifier(tt.identifier) + if tt.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedOwner, owner) + assert.Equal(t, tt.expectedTask, taskName) + } + }) + } +} diff --git a/codersdk/apikey.go b/codersdk/apikey.go index ff5d749151e8f..c18a68b01deca 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -12,17 +12,18 @@ import ( // APIKey: do not ever return the HashedSecret type APIKey struct { - ID string `json:"id" validate:"required"` - UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"` - LastUsed time.Time `json:"last_used" validate:"required" format:"date-time"` - ExpiresAt time.Time `json:"expires_at" validate:"required" format:"date-time"` - CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"` - LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"` - Scope APIKeyScope `json:"scope" enums:"all,application_connect"` // Deprecated: use Scopes instead. - Scopes []APIKeyScope `json:"scopes"` - TokenName string `json:"token_name" validate:"required"` - LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` + ID string `json:"id" validate:"required"` + UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"` + LastUsed time.Time `json:"last_used" validate:"required" format:"date-time"` + ExpiresAt time.Time `json:"expires_at" validate:"required" format:"date-time"` + CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"` + LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"` + Scope APIKeyScope `json:"scope" enums:"all,application_connect"` // Deprecated: use Scopes instead. + Scopes []APIKeyScope `json:"scopes"` + TokenName string `json:"token_name" validate:"required"` + LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` + AllowList []APIAllowListTarget `json:"allow_list"` } // LoginType is the type of login used to create the API key. @@ -51,6 +52,13 @@ type CreateTokenRequest struct { AllowList []APIAllowListTarget `json:"allow_list,omitempty"` } +type UpdateTokenRequest struct { + Scope *APIKeyScope `json:"scope,omitempty"` + Scopes *[]APIKeyScope `json:"scopes,omitempty"` + AllowList *[]APIAllowListTarget `json:"allow_list,omitempty"` + Lifetime *time.Duration `json:"lifetime,omitempty"` +} + // GenerateAPIKeyResponse contains an API key for a user. type GenerateAPIKeyResponse struct { Key string `json:"key"` @@ -73,6 +81,19 @@ func (c *Client) CreateToken(ctx context.Context, userID string, req CreateToken return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) } +func (c *Client) UpdateToken(ctx context.Context, userID string, tokenName string, req UpdateTokenRequest) (*APIKey, error) { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/users/%s/keys/tokens/%s", userID, tokenName), req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode > http.StatusOK { + return nil, ReadBodyAsError(res) + } + var key APIKey + return &key, json.NewDecoder(res.Body).Decode(&key) +} + // CreateAPIKey generates an API key for the user ID provided. // CreateToken should be used over CreateAPIKey. CreateToken allows better // tracking of the token's usage and allows for custom expiration. diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index df7fe96c4585e..c4af9ae33e364 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -232,6 +232,7 @@ var PublicAPIKeyScopes = []APIKeyScope{ APIKeyScopeTemplateRead, APIKeyScopeTemplateUpdate, APIKeyScopeTemplateUse, + APIKeyScopeUserRead, APIKeyScopeUserReadPersonal, APIKeyScopeUserUpdatePersonal, APIKeyScopeUserSecretAll, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 008ae6e2b93b1..2f6f6bba03697 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -985,7 +985,7 @@ func DefaultSupportLinks(docsURL string) []LinkConfig { }, { Name: "Join the Coder Discord", - Target: "https://coder.com/chat?utm_source=coder&utm_medium=coder&utm_campaign=server-footer", + Target: "https://discord.gg/coder", Icon: "chat", }, { @@ -3339,7 +3339,9 @@ type SupportConfig struct { type LinkConfig struct { Name string `json:"name" yaml:"name"` Target string `json:"target" yaml:"target"` - Icon string `json:"icon" yaml:"icon" enums:"bug,chat,docs"` + Icon string `json:"icon" yaml:"icon" enums:"bug,chat,docs,star"` + + Location string `json:"location,omitempty" yaml:"location,omitempty" enums:"navbar,dropdown"` } // Validate checks cross-field constraints for deployment values. diff --git a/codersdk/oauth2.go b/codersdk/oauth2.go index c2c59ed599190..79b2186480b9c 100644 --- a/codersdk/oauth2.go +++ b/codersdk/oauth2.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "github.com/google/uuid" ) @@ -26,6 +27,7 @@ type OAuth2ProviderApp struct { type OAuth2AppEndpoints struct { Authorization string `json:"authorization"` Token string `json:"token"` + TokenRevoke string `json:"token_revoke"` // DeviceAuth is optional. DeviceAuth string `json:"device_authorization"` } @@ -212,6 +214,26 @@ func (e OAuth2ProviderResponseType) Valid() bool { return false } +// RevokeOAuth2Token revokes a specific OAuth2 token using RFC 7009 token revocation. +func (c *Client) RevokeOAuth2Token(ctx context.Context, clientID uuid.UUID, token string) error { + form := url.Values{} + form.Set("token", token) + // Client authentication is handled via the client_id in the app middleware + form.Set("client_id", clientID.String()) + + res, err := c.Request(ctx, http.MethodPost, "/oauth2/revoke", strings.NewReader(form.Encode()), func(r *http.Request) { + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + }) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + return nil +} + // RevokeOAuth2ProviderApp completely revokes an app's access for the // authenticated user. func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) error { @@ -464,6 +486,6 @@ type OAuth2ClientConfiguration struct { TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` Scope string `json:"scope,omitempty"` Contacts []string `json:"contacts,omitempty"` - RegistrationAccessToken string `json:"registration_access_token"` + RegistrationAccessToken []byte `json:"registration_access_token"` RegistrationClientURI string `json:"registration_client_uri"` } diff --git a/codersdk/toolsdk/bash.go b/codersdk/toolsdk/bash.go index 7497363c2a54e..55b3dd58bcdfb 100644 --- a/codersdk/toolsdk/bash.go +++ b/codersdk/toolsdk/bash.go @@ -266,20 +266,25 @@ func getWorkspaceAgent(workspace codersdk.Workspace, agentName string) (codersdk return codersdk.WorkspaceAgent{}, xerrors.Errorf("multiple agents found, please specify the agent name, available agents: %v", availableNames) } -// namedWorkspace gets a workspace by owner/name or just name -func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) { - // Parse owner and workspace name +func splitNameAndOwner(identifier string) (name string, owner string) { + // Parse owner and name (workspace, task). parts := strings.SplitN(identifier, "/", 2) - var owner, workspaceName string if len(parts) == 2 { owner = parts[0] - workspaceName = parts[1] + name = parts[1] } else { owner = "me" - workspaceName = identifier + name = identifier } + return name, owner +} + +// namedWorkspace gets a workspace by owner/name or just name +func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) { + workspaceName, owner := splitNameAndOwner(identifier) + // Handle -- separator format (convert to / format) if strings.Contains(identifier, "--") && !strings.Contains(identifier, "/") { dashParts := strings.SplitN(identifier, "--", 2) diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 566b2fcaf9d64..43ed6ab98ac3c 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -1909,24 +1909,12 @@ var DeleteTask = Tool[DeleteTaskArgs, codersdk.Response]{ expClient := codersdk.NewExperimentalClient(deps.coderClient) - var owner string - id, err := uuid.Parse(args.TaskID) - if err == nil { - task, err := expClient.TaskByID(ctx, id) - if err != nil { - return codersdk.Response{}, xerrors.Errorf("get task %q: %w", args.TaskID, err) - } - owner = task.OwnerName - } else { - ws, err := normalizedNamedWorkspace(ctx, deps.coderClient, args.TaskID) - if err != nil { - return codersdk.Response{}, xerrors.Errorf("get task workspace %q: %w", args.TaskID, err) - } - owner = ws.OwnerName - id = ws.ID + task, err := expClient.TaskByIdentifier(ctx, args.TaskID) + if err != nil { + return codersdk.Response{}, xerrors.Errorf("resolve task: %w", err) } - err = expClient.DeleteTask(ctx, owner, id) + err = expClient.DeleteTask(ctx, task.OwnerName, task.ID) if err != nil { return codersdk.Response{}, xerrors.Errorf("delete task: %w", err) } @@ -1938,8 +1926,8 @@ var DeleteTask = Tool[DeleteTaskArgs, codersdk.Response]{ } type ListTasksArgs struct { - Status string `json:"status"` - User string `json:"user"` + Status codersdk.TaskStatus `json:"status"` + User string `json:"user"` } type ListTasksResponse struct { @@ -1990,7 +1978,7 @@ type GetTaskStatusArgs struct { } type GetTaskStatusResponse struct { - Status codersdk.WorkspaceStatus `json:"status"` + Status codersdk.TaskStatus `json:"status"` State *codersdk.TaskStateEntry `json:"state"` } @@ -2016,18 +2004,9 @@ var GetTaskStatus = Tool[GetTaskStatusArgs, GetTaskStatusResponse]{ expClient := codersdk.NewExperimentalClient(deps.coderClient) - id, err := uuid.Parse(args.TaskID) - if err != nil { - ws, err := normalizedNamedWorkspace(ctx, deps.coderClient, args.TaskID) - if err != nil { - return GetTaskStatusResponse{}, xerrors.Errorf("get task workspace %q: %w", args.TaskID, err) - } - id = ws.ID - } - - task, err := expClient.TaskByID(ctx, id) + task, err := expClient.TaskByIdentifier(ctx, args.TaskID) if err != nil { - return GetTaskStatusResponse{}, xerrors.Errorf("get task %q: %w", args.TaskID, err) + return GetTaskStatusResponse{}, xerrors.Errorf("resolve task %q: %w", args.TaskID, err) } return GetTaskStatusResponse{ @@ -2071,12 +2050,13 @@ var SendTaskInput = Tool[SendTaskInputArgs, codersdk.Response]{ } expClient := codersdk.NewExperimentalClient(deps.coderClient) - id, owner, err := resolveTaskID(ctx, deps.coderClient, args.TaskID) + + task, err := expClient.TaskByIdentifier(ctx, args.TaskID) if err != nil { - return codersdk.Response{}, err + return codersdk.Response{}, xerrors.Errorf("resolve task %q: %w", args.TaskID, err) } - err = expClient.TaskSend(ctx, owner, id, codersdk.TaskSendRequest{ + err = expClient.TaskSend(ctx, task.OwnerName, task.ID, codersdk.TaskSendRequest{ Input: args.Input, }) if err != nil { @@ -2114,12 +2094,13 @@ var GetTaskLogs = Tool[GetTaskLogsArgs, codersdk.TaskLogsResponse]{ } expClient := codersdk.NewExperimentalClient(deps.coderClient) - id, owner, err := resolveTaskID(ctx, deps.coderClient, args.TaskID) + + task, err := expClient.TaskByIdentifier(ctx, args.TaskID) if err != nil { return codersdk.TaskLogsResponse{}, err } - logs, err := expClient.TaskLogs(ctx, owner, id) + logs, err := expClient.TaskLogs(ctx, task.OwnerName, task.ID) if err != nil { return codersdk.TaskLogsResponse{}, xerrors.Errorf("get task logs %q: %w", args.TaskID, err) } @@ -2128,13 +2109,6 @@ var GetTaskLogs = Tool[GetTaskLogsArgs, codersdk.TaskLogsResponse]{ }, } -// normalizedNamedWorkspace normalizes the workspace name before getting the -// workspace by name. -func normalizedNamedWorkspace(ctx context.Context, client *codersdk.Client, name string) (codersdk.Workspace, error) { - // Maybe namedWorkspace should itself call NormalizeWorkspaceInput? - return namedWorkspace(ctx, client, NormalizeWorkspaceInput(name)) -} - // NormalizeWorkspaceInput converts workspace name input to standard format. // Handles the following input formats: // - workspace → workspace @@ -2205,15 +2179,3 @@ func taskIDDescription(action string) string { func userDescription(action string) string { return fmt.Sprintf("Username or ID of the user for which to %s. Omit or use the `me` keyword to %s for the authenticated user.", action, action) } - -func resolveTaskID(ctx context.Context, coderClient *codersdk.Client, taskID string) (uuid.UUID, string, error) { - id, err := uuid.Parse(taskID) - if err == nil { - return id, codersdk.Me, nil - } - ws, err := normalizedNamedWorkspace(ctx, coderClient, taskID) - if err != nil { - return uuid.UUID{}, codersdk.Me, xerrors.Errorf("get task workspace %q: %w", taskID, err) - } - return ws.ID, ws.OwnerName, nil -} diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index c87a5e0358904..44da500400e5e 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -35,6 +35,7 @@ import ( "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/codersdk/toolsdk" "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" ) @@ -797,7 +798,7 @@ func TestTools(t *testing.T) { } }) - t.Run("WorkspaceCreateTask", func(t *testing.T) { + t.Run("CreateTask", func(t *testing.T) { t.Parallel() presetID := uuid.New() @@ -881,7 +882,7 @@ func TestTools(t *testing.T) { } }) - t.Run("WorkspaceDeleteTask", func(t *testing.T) { + t.Run("DeleteTask", func(t *testing.T) { t.Parallel() // nolint:gocritic // This is in a test package and does not end up in the build @@ -894,21 +895,37 @@ func TestTools(t *testing.T) { }, }).Do() - // nolint:gocritic // This is in a test package and does not end up in the build - ws1 := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + ws1Table := dbgen.Workspace(t, store, database.WorkspaceTable{ Name: "delete-task-workspace-1", OrganizationID: owner.OrganizationID, OwnerID: member.ID, TemplateID: aiTV.Template.ID, - }).WithTask(nil).Do() + }) + task1 := dbgen.Task(t, store, database.TaskTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + Name: ws1Table.Name, + WorkspaceID: uuid.NullUUID{UUID: ws1Table.ID, Valid: true}, + TemplateVersionID: aiTV.TemplateVersion.ID, + Prompt: "delete task 1", + }) + _ = dbfake.WorkspaceBuild(t, store, ws1Table).WithTask(nil).Do() - // nolint:gocritic // This is in a test package and does not end up in the build - _ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + ws2Table := dbgen.Workspace(t, store, database.WorkspaceTable{ Name: "delete-task-workspace-2", OrganizationID: owner.OrganizationID, OwnerID: member.ID, TemplateID: aiTV.Template.ID, - }).WithTask(nil).Do() + }) + task2 := dbgen.Task(t, store, database.TaskTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + Name: ws2Table.Name, + WorkspaceID: uuid.NullUUID{UUID: ws2Table.ID, Valid: true}, + TemplateVersionID: aiTV.TemplateVersion.ID, + Prompt: "delete task 2", + }) + _ = dbfake.WorkspaceBuild(t, store, ws2Table).WithTask(nil).Do() tests := []struct { name string @@ -918,13 +935,13 @@ func TestTools(t *testing.T) { { name: "ByUUID", args: toolsdk.DeleteTaskArgs{ - TaskID: ws1.Workspace.ID.String(), + TaskID: task1.ID.String(), }, }, { - name: "ByWorkspaceIdentifier", + name: "ByIdentifier", args: toolsdk.DeleteTaskArgs{ - TaskID: "delete-task-workspace-2", + TaskID: task2.Name, }, }, { @@ -973,45 +990,64 @@ func TestTools(t *testing.T) { } }) - t.Run("WorkspaceListTasks", func(t *testing.T) { + t.Run("ListTasks", func(t *testing.T) { t.Parallel() - taskClient, taskUser := coderdtest.CreateAnotherUserMutators(t, client, owner.OrganizationID, nil) - - // nolint:gocritic // This is in a test package and does not end up in the build - aiTV := dbfake.TemplateVersion(t, store).Seed(database.TemplateVersion{ - OrganizationID: owner.OrganizationID, - CreatedBy: owner.UserID, - HasAITask: sql.NullBool{ - Bool: true, - Valid: true, + ctx := testutil.Context(t, testutil.WaitLong) + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + _, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + taskClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Create a template with AI task support using the proper flow. + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: []*proto.Response{ + {Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{{Name: "AI Prompt", Type: "string"}}, + HasAiTasks: true, + }}}, }, - }).Do() + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + expClient := codersdk.NewExperimentalClient(client) + taskExpClient := codersdk.NewExperimentalClient(taskClient) // This task should not show up since listing is user-scoped. - // nolint:gocritic // This is in a test package and does not end up in the build - _ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ - Name: "list-task-workspace-member", - OrganizationID: owner.OrganizationID, - OwnerID: member.ID, - TemplateID: aiTV.Template.ID, - }).WithTask(nil).Do() + _, err := expClient.CreateTask(ctx, member.Username, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: "task for member", + Name: "list-task-workspace-member", + }) + require.NoError(t, err) - // These tasks should show up. + // Create tasks for taskUser. These should show up in the list. for i := range 5 { - // nolint:gocritic // This is in a test package and does not end up in the build - var transition database.WorkspaceTransition + taskName := fmt.Sprintf("list-task-workspace-%d", i) + task, err := taskExpClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ + TemplateVersionID: template.ActiveVersionID, + Input: fmt.Sprintf("task %d", i), + Name: taskName, + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid, "task should have workspace ID") + + // For the first task, stop the workspace to make it paused. if i == 0 { - // nolint:gocritic // This is in a test package and does not end up in the build - transition = database.WorkspaceTransitionStop + ws, err := taskClient.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, taskClient, ws.LatestBuild.ID) + + // Stop the workspace to set task status to paused. + build, err := taskClient.CreateWorkspaceBuild(ctx, task.WorkspaceID.UUID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStop, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, taskClient, build.ID) } - // nolint:gocritic // This is in a test package and does not end up in the build - _ = dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ - Name: fmt.Sprintf("list-task-workspace-%d", i), - OrganizationID: owner.OrganizationID, - OwnerID: taskUser.ID, - TemplateID: aiTV.Template.ID, - }).Seed(database.WorkspaceBuild{Transition: transition}).WithTask(nil).Do() } tests := []struct { @@ -1034,7 +1070,7 @@ func TestTools(t *testing.T) { { name: "ListFiltered", args: toolsdk.ListTasksArgs{ - Status: "stopped", + Status: codersdk.TaskStatusPaused, }, expected: []string{ "list-task-workspace-0", @@ -1064,7 +1100,7 @@ func TestTools(t *testing.T) { } }) - t.Run("WorkspaceGetTask", func(t *testing.T) { + t.Run("GetTask", func(t *testing.T) { t.Parallel() // nolint:gocritic // This is in a test package and does not end up in the build @@ -1077,33 +1113,41 @@ func TestTools(t *testing.T) { }, }).Do() - // nolint:gocritic // This is in a test package and does not end up in the build - ws1 := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + ws1Table := dbgen.Workspace(t, store, database.WorkspaceTable{ Name: "get-task-workspace-1", OrganizationID: owner.OrganizationID, OwnerID: member.ID, TemplateID: aiTV.Template.ID, - }).WithTask(nil).Do() + }) + task := dbgen.Task(t, store, database.TaskTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + Name: "get-task-1", + WorkspaceID: uuid.NullUUID{UUID: ws1Table.ID, Valid: true}, + TemplateVersionID: aiTV.TemplateVersion.ID, + Prompt: "get task", + }) + _ = dbfake.WorkspaceBuild(t, store, ws1Table).WithTask(nil).Do() tests := []struct { name string args toolsdk.GetTaskStatusArgs - expected codersdk.WorkspaceStatus + expected codersdk.TaskStatus error string }{ { name: "ByUUID", args: toolsdk.GetTaskStatusArgs{ - TaskID: ws1.Workspace.ID.String(), + TaskID: task.ID.String(), }, - expected: codersdk.WorkspaceStatusRunning, + expected: codersdk.TaskStatusInitializing, }, { - name: "ByWorkspaceIdentifier", + name: "ByIdentifier", args: toolsdk.GetTaskStatusArgs{ - TaskID: "get-task-workspace-1", + TaskID: task.Name, }, - expected: codersdk.WorkspaceStatusRunning, + expected: codersdk.TaskStatusInitializing, }, { name: "NoID", @@ -1332,13 +1376,21 @@ func TestTools(t *testing.T) { }, }).Do() - // nolint:gocritic // This is in a test package and does not end up in the build - ws := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ - Name: "send-task-input", + wsTable := dbgen.Workspace(t, store, database.WorkspaceTable{ + Name: "send-task-input-ws", OrganizationID: owner.OrganizationID, OwnerID: member.ID, TemplateID: aiTV.Template.ID, - }).WithTask(&proto.App{Url: srv.URL}).Do() + }) + task := dbgen.Task(t, store, database.TaskTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + Name: "send-task-input", + WorkspaceID: uuid.NullUUID{UUID: wsTable.ID, Valid: true}, + TemplateVersionID: aiTV.TemplateVersion.ID, + Prompt: "send task input", + }) + ws := dbfake.WorkspaceBuild(t, store, wsTable).WithTask(&proto.App{Url: srv.URL}).Do() _ = agenttest.New(t, client.URL, ws.AgentToken) coderdtest.NewWorkspaceAgentWaiter(t, client, ws.Workspace.ID).Wait() @@ -1351,14 +1403,14 @@ func TestTools(t *testing.T) { { name: "ByUUID", args: toolsdk.SendTaskInputArgs{ - TaskID: ws.Workspace.ID.String(), + TaskID: task.ID.String(), Input: "frob the baz", }, }, { - name: "ByWorkspaceIdentifier", + name: "ByIdentifier", args: toolsdk.SendTaskInputArgs{ - TaskID: "send-task-input", + TaskID: task.Name, Input: "frob the baz", }, }, @@ -1396,7 +1448,7 @@ func TestTools(t *testing.T) { TaskID: r.Workspace.ID.String(), Input: "this is ignored", }, - error: "Task is not configured with a sidebar app", + error: "Resource not found", }, } @@ -1461,13 +1513,21 @@ func TestTools(t *testing.T) { }, }).Do() - // nolint:gocritic // This is in a test package and does not end up in the build - ws := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ - Name: "get-task-logs", + wsTable := dbgen.Workspace(t, store, database.WorkspaceTable{ + Name: "get-task-logs-ws", OrganizationID: owner.OrganizationID, OwnerID: member.ID, TemplateID: aiTV.Template.ID, - }).WithTask(&proto.App{Url: srv.URL}).Do() + }) + task := dbgen.Task(t, store, database.TaskTable{ + OrganizationID: owner.OrganizationID, + OwnerID: member.ID, + Name: "get-task-logs", + WorkspaceID: uuid.NullUUID{UUID: wsTable.ID, Valid: true}, + TemplateVersionID: aiTV.TemplateVersion.ID, + Prompt: "get task logs", + }) + ws := dbfake.WorkspaceBuild(t, store, wsTable).WithTask(&proto.App{Url: srv.URL}).Do() _ = agenttest.New(t, client.URL, ws.AgentToken) coderdtest.NewWorkspaceAgentWaiter(t, client, ws.Workspace.ID).Wait() @@ -1481,14 +1541,14 @@ func TestTools(t *testing.T) { { name: "ByUUID", args: toolsdk.GetTaskLogsArgs{ - TaskID: ws.Workspace.ID.String(), + TaskID: task.ID.String(), }, expected: messages, }, { - name: "ByWorkspaceIdentifier", + name: "ByIdentifier", args: toolsdk.GetTaskLogsArgs{ - TaskID: "get-task-logs", + TaskID: task.Name, }, expected: messages, }, @@ -1516,7 +1576,7 @@ func TestTools(t *testing.T) { args: toolsdk.GetTaskLogsArgs{ TaskID: r.Workspace.ID.String(), }, - error: "Task is not configured with a sidebar app", + error: "Resource not found", }, } diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index bb9511178c7f4..fee4c114b7eae 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -89,8 +89,10 @@ type WorkspaceBuild struct { MatchedProvisioners *MatchedProvisioners `json:"matched_provisioners,omitempty"` TemplateVersionPresetID *uuid.UUID `json:"template_version_preset_id" format:"uuid"` HasAITask *bool `json:"has_ai_task,omitempty"` - AITaskSidebarAppID *uuid.UUID `json:"ai_task_sidebar_app_id,omitempty" format:"uuid"` - HasExternalAgent *bool `json:"has_external_agent,omitempty"` + // Deprecated: This field has been replaced with `TaskAppID` + AITaskSidebarAppID *uuid.UUID `json:"ai_task_sidebar_app_id,omitempty" format:"uuid"` + TaskAppID *uuid.UUID `json:"task_app_id,omitempty" format:"uuid"` + HasExternalAgent *bool `json:"has_external_agent,omitempty"` } // WorkspaceResource describes resources used to create a workspace, for instance: diff --git a/compose.yaml b/compose.yaml index 409ecda158c1b..6bb78b6123a4a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,7 +2,7 @@ services: coder: # This MUST be stable for our documentation and # other automations. - image: ghcr.io/coder/coder:${CODER_VERSION:-latest} + image: ${CODER_REPO:-ghcr.io/coder/coder}:${CODER_VERSION:-latest} ports: - "7080:7080" environment: diff --git a/docs/admin/setup/appearance.md b/docs/admin/setup/appearance.md index 38c85a5439d89..66dbc2587e78e 100644 --- a/docs/admin/setup/appearance.md +++ b/docs/admin/setup/appearance.md @@ -57,7 +57,18 @@ server. The link icons are optional, and can be set to any url or [builtin icon](../templates/extending-templates/icons.md#bundled-icons), -additionally `bug`, `chat`, and `docs` are available as three special icons. +additionally `bug`, `chat`, `docs`, and `star` are available as special icons. + +### Location + +The `location` property is optional and determines where the support link will +be displayed: + +- `navbar` - displays the link as a button in the top navigation bar +- `dropdown` - displays the link in the user dropdown menu (default) + +If the `location` property is not specified, the link will be displayed in the +user dropdown menu. ### Configuration @@ -77,7 +88,7 @@ coder: "https://codercom.slack.com/archives/C014JH42DBJ", "icon": "/icon/slack.svg"}, {"name": "Hello Discord", "target": "https://discord.gg/coder", "icon": - "/icon/discord.svg"}, + "/icon/discord.svg", "location": "navbar"}, {"name": "Hello Foobar", "target": "https://foo.com/bar", "icon": "/emojis/1f3e1.png"}] ``` @@ -88,12 +99,12 @@ if running as a system service, set an environment variable `CODER_SUPPORT_LINKS` in `/etc/coder.d/coder.env` as follows, ```env -CODER_SUPPORT_LINKS='[{"name": "Hello GitHub", "target": "https://github.com/coder/coder", "icon": "bug"}, {"name": "Hello Slack", "target": "https://codercom.slack.com/archives/C014JH42DBJ", "icon": "https://raw.githubusercontent.com/coder/coder/main/site/static/icon/slack.svg"}, {"name": "Hello Discord", "target": "https://discord.gg/coder", "icon": "https://raw.githubusercontent.com/coder/coder/main/site/static/icon/discord.svg"}, {"name": "Hello Foobar", "target": "https://discord.gg/coder", "icon": "/emojis/1f3e1.png"}]' +CODER_SUPPORT_LINKS='[{"name": "Hello GitHub", "target": "https://github.com/coder/coder", "icon": "bug"}, {"name": "Hello Slack", "target": "https://codercom.slack.com/archives/C014JH42DBJ", "icon": "https://raw.githubusercontent.com/coder/coder/main/site/static/icon/slack.svg"}, {"name": "Hello Discord", "target": "https://discord.gg/coder", "icon": "https://raw.githubusercontent.com/coder/coder/main/site/static/icon/discord.svg", "location": "navbar"}, {"name": "Hello Foobar", "target": "https://discord.gg/coder", "icon": "/emojis/1f3e1.png"}]' ``` For CLI, use, ```shell -export CODER_SUPPORT_LINKS='[{"name": "Hello GitHub", "target": "https://github.com/coder/coder", "icon": "bug"}, {"name": "Hello Slack", "target": "https://codercom.slack.com/archives/C014JH42DBJ", "icon": "https://raw.githubusercontent.com/coder/coder/main/site/static/icon/slack.svg"}, {"name": "Hello Discord", "target": "https://discord.gg/coder", "icon": "https://raw.githubusercontent.com/coder/coder/main/site/static/icon/discord.svg"}, {"name": "Hello Foobar", "target": "https://discord.gg/coder", "icon": "/emojis/1f3e1.png"}]' +export CODER_SUPPORT_LINKS='[{"name": "Hello GitHub", "target": "https://github.com/coder/coder", "icon": "bug"}, {"name": "Hello Slack", "target": "https://codercom.slack.com/archives/C014JH42DBJ", "icon": "https://raw.githubusercontent.com/coder/coder/main/site/static/icon/slack.svg"}, {"name": "Hello Discord", "target": "https://discord.gg/coder", "icon": "https://raw.githubusercontent.com/coder/coder/main/site/static/icon/discord.svg", "location": "navbar"}, {"name": "Hello Foobar", "target": "https://discord.gg/coder", "icon": "/emojis/1f3e1.png"}]' coder-server ``` diff --git a/docs/ai-coder/agent-boundary.md b/docs/ai-coder/agent-boundary.md index e7605d1481bea..36e36a08b6d2f 100644 --- a/docs/ai-coder/agent-boundary.md +++ b/docs/ai-coder/agent-boundary.md @@ -4,118 +4,47 @@ Agent Boundaries are process-level firewalls that restrict and audit what autono ![Screenshot of Agent Boundaries blocking a process](../images/guides/ai-agents/boundary.png)Example of Agent Boundaries blocking a process. -The easiest way to use Agent Boundaries is through existing Coder modules, such as the [Claude Code module](https://registry.coder.com/modules/coder/claude-code). It can also be ran directly in the terminal by installing the [CLI](https://github.com/coder/boundary). - -> [!NOTE] -> The Coder Boundary CLI is free and open source. Integrations with the core product, such as with modules offering stronger isolation, are available to Coder Premium customers. - ## Supported Agents -Boundary supports the securing of any terminal-based agent, including your own custom agents. +Agent Boundaries support the securing of any terminal-based agent, including your own custom agents. ## Features -Boundaries extend Coder's trusted workspaces with a defense-in-depth model that detects and prevents destructive actions without reducing productivity by slowing down workflows or blocking automation. They offer the following features: - -- _Policy-driven access controls_: limit what an agent can access (repos, registries, APIs, files, commands) -- _Network policy enforcement_: block domains, subnets, or HTTP verbs to prevent exfiltration -- _Audit-ready_: centralize logs, exportable for compliance, with full visibility into agent actions +Agent Boundaries offer network policy enforcement, which blocks domains and HTTP verbs to prevent exfiltration, and writes logs to the workspace. ## Getting Started with Boundary -For Early Access, users can use Agent Boundaries through its [open source CLI](https://github.com/coder/boundary), which can be run to wrap any process or invoked through rules in a YAML file. - -### Wrap the agent process with the Boundary CLI - -Users can also run Boundary directly in your workspace and configure it per template or per script. While free tier users won't get centralized policy management or the deeper, "strong isolation," they can still enforce per workspace network rules and log decisions locally. - -1. Install the [binary](https://github.com/coder/boundary) into the workspace image or at start-up. You can do so with the following command: - - ```hcl - curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash - ``` - -1. Use the included `Makefile` to build your project. Here are a few example commands: - - ```hcl - make build # Build for current platform - make build-all # Build for all platforms - make test # Run tests - make test-coverage # Run tests with coverage - make clean # Clean build artifacts - make fmt # Format code - make lint # Lint code - ``` - -From here, there are two ways to integrate the open source Boundary CLI into a workspace. - -#### Wrap a command inline with flags - -1. Wrap the tool you want to guard. Below are some examples of usage: - - ```hcl - # Allow only requests to github.com - boundary --allow "github.com" -- curl https://github.com - - # Allow full access to GitHub issues API, but only GET/HEAD elsewhere on GitHub - boundary \ - --allow "github.com/api/issues/*" \ - --allow "GET,HEAD github.com" \ - -- npm install - - # Default deny-all: everything is blocked unless explicitly allowed - boundary -- curl https://example.com - ``` - - Additional information, such as Allow Rules, can be found in the [repository README](https://github.com/coder/boundary). - -#### Use a config file (YAML) to set rules - -Another option is to define rules in a YAML file, which only needs to be invoked once as opposed to through flags with each command. - -1. Create a YAML file to store rules that will be applied to all `boundary` commands run in the Workspace. In this example, we call it `boundary.yaml`. - A config example can be seen below: - - ```hcl - allow: - - - domain: [github.com](http://github.com) - - path: /api/issues/* - - - domain: [github.com](http://github.com) - - methods: [GET, HEAD] - ``` - -1. Run a `boundary` command. For example: - - ```hcl - boundary run --config ./boundary.yaml -- claude - ``` - - You will notice that the rules are automatically applied without any need for additional customization. - -### Unprivileged vs. Privileged Mode - -There are two approaches you can take to secure your agentic workflows with Agent Boundary. - -#### Unprivileged Mode - -In this case, a specific agent process or tool (for example, Claude Code or a CLI agent) runs inside of a constrained sandbox. This is the default mode in which Boundary will operate in and does not require root access. - -Agents are prevented from reaching restricted domains or exfiltrating data, without blocking the rest of the dev's environment. - -This is the fastest way to add real guardrails, but a determined user could still operate a tool outside of Boundary restrictions because the broader environment allows it. This mode relies on tools respecting certain settings, like HTTP proxies, and can lead to silent failures if a tool bypasses them. - -#### Privileged Mode - -In this case, boundaries are enforced at the level of the environment that the agent lives in. These are workspace- or session-level controls, including how the developer connects to it. - -Currently, this must be turned on with a flag and ran with higher-level permissions such as root access or `CapNetAdmin`. - -In addition to process-level egress rules, privileged mode locks down all pathways that could bypass policy, such as restricting or disabling SSH tunnels or parallel unbound IDEs. This delivers deterministic, policy-as-code enforcement and offers the highest assurance for regulated environments, but results in slightly more friction for mixed human-and-agent workflows. - -### Opting out of Boundary +The easiest way to use Agent Boundaries is through existing Coder modules, such as the [Claude Code module](https://registry.coder.com/modules/coder/claude-code). It can also be ran directly in the terminal by installing the [CLI](https://github.com/coder/boundary). -If you tried Boundary through a Coder module and decided you don't want to use it, you can turn it off by setting the flag to `boundary_enabled=false`. +Below is an example of how to configure Agent Boundaries for usage in your workspace. + +```tf +module "claude-code" { + source = "dev.registry.coder.com/coder/claude-code/coder" + enable_boundary = true + boundary_version = "main" + boundary_log_dir = "/tmp/boundary_logs" + boundary_log_level = "WARN" + boundary_additional_allowed_urls = ["GET *google.com"] + boundary_proxy_port = "8087" + version = "3.2.1" +} +``` + +- `boundary_version` defines what version of Boundary is being applied. This is set to `main`, which points to the main branch of `coder/boundary`. +- `boundary_log_dir` is the directory where log files are written to when the workspace spins up. +- `boundary_log_level` defines the verbosity at which requests are logged. Boundary uses the following verbosity levels: + - `WARN`: logs only requests that have been blocked by Boundary + - `INFO`: logs all requests at a high level + - `DEBUG`: logs all requests in detail +- `boundary_additional_allowed_urls`: defines the URLs that the agent can access, in additional to the default URLs required for the agent to work + - `github.com` means only the specific domain is allowed + - `*.github.com` means only the subdomains are allowed - the specific domain is excluded + - `*github.com` means both the specific domain and all subdomains are allowed + - You can also also filter on methods, hostnames, and paths - for example, `GET,HEAD *github.com/coder`. + +You can also run Agent Boundaries directly in your workspace and configure it per template. You can do so by installing the [binary](https://github.com/coder/boundary) into the workspace image or at start-up. You can do so with the following command: + +```hcl +curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash + ``` diff --git a/docs/ai-coder/ai-bridge.md b/docs/ai-coder/ai-bridge.md index 0a9ebeba81f63..c7cfbe7d85ea2 100644 --- a/docs/ai-coder/ai-bridge.md +++ b/docs/ai-coder/ai-bridge.md @@ -81,6 +81,44 @@ Bridge is compatible with _[Google Vertex AI](https://cloud.google.com/vertex-ai > [!NOTE] > See [Supported APIs](#supported-apis) section below for a comprehensive list. +## Client Configuration + +Once AI Bridge is enabled on the server, your users need to configure their AI coding tools to use it. This section explains how users should configure their clients to connect to AI Bridge. + +### Setting Base URLs + +The exact configuration method varies by client — some use environment variables, others use configuration files or UI settings: + +- **OpenAI-compatible clients**: Set the base URL (commonly via the `OPENAI_BASE_URL` environment variable) to `https://coder.example.com/api/experimental/aibridge/openai/v1` +- **Anthropic-compatible clients**: Set the base URL (commonly via the `ANTHROPIC_BASE_URL` environment variable) to `https://coder.example.com/api/experimental/aibridge/anthropic` + +Replace `coder.example.com` with your actual Coder deployment URL. + +### Authentication + +Instead of distributing provider-specific API keys (OpenAI/Anthropic keys) to users, they authenticate to AI Bridge using their **Coder session token** or **API key**: + +- **OpenAI clients**: Users set `OPENAI_API_KEY` to their Coder session token or API key +- **Anthropic clients**: Users set `ANTHROPIC_API_KEY` to their Coder session token or API key + +Users can generate a Coder API key using: + +```sh +coder tokens create +``` + +Template admins can pre-configure authentication in templates using [`data.coder_workspace_owner.me.session_token`](https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace_owner#session_token-1) to automatically configure the workspace owner's credentials. + +#### Compatibility Notes + +Most AI coding assistants that support custom base URLs can work with AI Bridge. However, client-specific configuration requirements vary: + +- Some clients require specific URL formats (e.g. try removing the `/v1` suffix) +- Some clients may proxy requests through their own servers, limiting compatibility (e.g. Cursor) +- Some clients may not support custom base URLs at all (e.g. Copilot CLI, Sourcegraph Amp) + +Consult your specific AI client's documentation for details on configuring custom API endpoints. + ## Collected Data Bridge collects: diff --git a/docs/install/cli.md b/docs/install/cli.md index bb70d89c6a724..38e7d2ede9f93 100644 --- a/docs/install/cli.md +++ b/docs/install/cli.md @@ -64,7 +64,7 @@ Every Coder server hosts CLI binaries for all supported platforms. You can run a script to download the appropriate CLI for your machine from your Coder deployment. -![Install Coder binary from your deplyment](../images/install/install_from_deployment.png) +![Install Coder binary from your deployment](../images/install/install_from_deployment.png) This script works within air-gapped deployments and ensures that the version of the CLI you have installed on your machine matches the version of the server. diff --git a/docs/manifest.json b/docs/manifest.json index 5b01b6e9da995..90b4fed49f620 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -917,7 +917,7 @@ "title": "Agent Boundaries", "description": "Understanding Agent Boundaries in Coder Tasks", "path": "./ai-coder/agent-boundary.md", - "state": ["beta"] + "state": ["early access"] }, { "title": "AI Bridge", @@ -1793,6 +1793,11 @@ "description": "Delete a token", "path": "reference/cli/tokens_remove.md" }, + { + "title": "tokens view", + "description": "Display detailed information about a token", + "path": "reference/cli/tokens_view.md" + }, { "title": "unfavorite", "description": "Remove a workspace from your favorites", diff --git a/docs/reference/api/aibridge.md b/docs/reference/api/aibridge.md index 54f408ec23995..2a2dd16ea93fe 100644 --- a/docs/reference/api/aibridge.md +++ b/docs/reference/api/aibridge.md @@ -28,6 +28,7 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/aibridge/intercepti ```json { + "count": 0, "results": [ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -89,8 +90,7 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/aibridge/intercepti } ] } - ], - "total": 0 + ] } ``` diff --git a/docs/reference/api/builds.md b/docs/reference/api/builds.md index 41df0b9efaf30..ea207f84eab39 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -222,6 +222,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -463,6 +464,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -1195,6 +1197,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -1509,6 +1512,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -1536,7 +1540,7 @@ Status Code **200** | Name | Type | Required | Restrictions | Description | |----------------------------------|--------------------------------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `[array item]` | array | false | | | -| `» ai_task_sidebar_app_id` | string(uuid) | false | | | +| `» ai_task_sidebar_app_id` | string(uuid) | false | | Deprecated: This field has been replaced with `TaskAppID` | | `» build_number` | integer | false | | | | `» created_at` | string(date-time) | false | | | | `» daily_cost` | integer | false | | | @@ -1687,6 +1691,7 @@ Status Code **200** | `»» type` | string | false | | | | `»» workspace_transition` | [codersdk.WorkspaceTransition](schemas.md#codersdkworkspacetransition) | false | | | | `» status` | [codersdk.WorkspaceStatus](schemas.md#codersdkworkspacestatus) | false | | | +| `» task_app_id` | string(uuid) | false | | | | `» template_version_id` | string(uuid) | false | | | | `» template_version_name` | string | false | | | | `» template_version_preset_id` | string(uuid) | false | | | @@ -2008,6 +2013,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index b6043544d4766..131223e38e5f4 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -120,6 +120,7 @@ curl -X GET http://coder-server:8080/api/v2/appearance \ "support_links": [ { "icon": "bug", + "location": "navbar", "name": "string", "target": "string" } @@ -808,7 +809,8 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps \ "endpoints": { "authorization": "string", "device_authorization": "string", - "token": "string" + "token": "string", + "token_revoke": "string" }, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -835,6 +837,7 @@ Status Code **200** | `»» authorization` | string | false | | | | `»» device_authorization` | string | false | | Device authorization is optional. | | `»» token` | string | false | | | +| `»» token_revoke` | string | false | | | | `» icon` | string | false | | | | `» id` | string(uuid) | false | | | | `» name` | string | false | | | @@ -881,7 +884,8 @@ curl -X POST http://coder-server:8080/api/v2/oauth2-provider/apps \ "endpoints": { "authorization": "string", "device_authorization": "string", - "token": "string" + "token": "string", + "token_revoke": "string" }, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -926,7 +930,8 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \ "endpoints": { "authorization": "string", "device_authorization": "string", - "token": "string" + "token": "string", + "token_revoke": "string" }, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -983,7 +988,8 @@ curl -X PUT http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \ "endpoints": { "authorization": "string", "device_authorization": "string", - "token": "string" + "token": "string", + "token_revoke": "string" }, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -1268,7 +1274,9 @@ curl -X GET http://coder-server:8080/api/v2/oauth2/clients/{client_id} \ "redirect_uris": [ "string" ], - "registration_access_token": "string", + "registration_access_token": [ + 0 + ], "registration_client_uri": "string", "response_types": [ "string" @@ -1362,7 +1370,9 @@ curl -X PUT http://coder-server:8080/api/v2/oauth2/clients/{client_id} \ "redirect_uris": [ "string" ], - "registration_access_token": "string", + "registration_access_token": [ + 0 + ], "registration_client_uri": "string", "response_types": [ "string" @@ -1499,6 +1509,42 @@ curl -X POST http://coder-server:8080/api/v2/oauth2/register \ |--------|--------------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------------| | 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.OAuth2ClientRegistrationResponse](schemas.md#codersdkoauth2clientregistrationresponse) | +## Revoke OAuth2 tokens (RFC 7009) + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/oauth2/revoke \ + +``` + +`POST /oauth2/revoke` + +> Body parameter + +```yaml +client_id: string +token: string +token_type_hint: string + +``` + +### Parameters + +| Name | In | Type | Required | Description | +|---------------------|------|--------|----------|-------------------------------------------------------| +| `body` | body | object | true | | +| `» client_id` | body | string | true | Client ID for authentication | +| `» token` | body | string | true | The token to revoke | +| `» token_type_hint` | body | string | false | Hint about token type (access_token or refresh_token) | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|----------------------------|--------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | Token successfully revoked | | + ## OAuth2 token exchange ### Code samples diff --git a/docs/reference/api/experimental.md b/docs/reference/api/experimental.md index edf6b729d83a8..34ad224bd3538 100644 --- a/docs/reference/api/experimental.md +++ b/docs/reference/api/experimental.md @@ -15,12 +15,9 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks \ ### Parameters -| Name | In | Type | Required | Description | -|------------|-------|---------|----------|-------------------------------------------| -| `q` | query | string | false | Search query for filtering tasks | -| `after_id` | query | string | false | Return tasks after this ID for pagination | -| `limit` | query | integer | false | Maximum number of tasks to return | -| `offset` | query | integer | false | Offset for pagination | +| Name | In | Type | Required | Description | +|------|-------|--------|----------|---------------------------------------------------------------------------------------------------------------------| +| `q` | query | string | false | Search query for filtering tasks. Supports: owner:, organization:, status: | ### Example responses @@ -28,9 +25,9 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks \ ### Responses -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [coderd.tasksListResponse](schemas.md#coderdtaskslistresponse) | +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TasksListResponse](schemas.md#codersdktaskslistresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -84,19 +81,19 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{id} \ +curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task} \ -H 'Accept: */*' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /api/experimental/tasks/{user}/{id}` +`GET /api/experimental/tasks/{user}/{task}` ### Parameters | Name | In | Type | Required | Description | |--------|------|--------------|----------|-------------------------------------------------------| | `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `id` | path | string(uuid) | true | Task ID | +| `task` | path | string(uuid) | true | Task ID | ### Example responses @@ -116,18 +113,18 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X DELETE http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{id} \ +curl -X DELETE http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task} \ -H 'Coder-Session-Token: API_KEY' ``` -`DELETE /api/experimental/tasks/{user}/{id}` +`DELETE /api/experimental/tasks/{user}/{task}` ### Parameters | Name | In | Type | Required | Description | |--------|------|--------------|----------|-------------------------------------------------------| | `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `id` | path | string(uuid) | true | Task ID | +| `task` | path | string(uuid) | true | Task ID | ### Responses @@ -143,19 +140,19 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{id}/logs \ +curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/logs \ -H 'Accept: */*' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /api/experimental/tasks/{user}/{id}/logs` +`GET /api/experimental/tasks/{user}/{task}/logs` ### Parameters | Name | In | Type | Required | Description | |--------|------|--------------|----------|-------------------------------------------------------| | `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `id` | path | string(uuid) | true | Task ID | +| `task` | path | string(uuid) | true | Task ID | ### Example responses @@ -175,12 +172,12 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{id}/send \ +curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{task}/send \ -H 'Content-Type: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`POST /api/experimental/tasks/{user}/{id}/send` +`POST /api/experimental/tasks/{user}/{task}/send` > Body parameter @@ -195,7 +192,7 @@ curl -X POST http://coder-server:8080/api/v2/api/experimental/tasks/{user}/{id}/ | Name | In | Type | Required | Description | |--------|------|----------------------------------------------------------------|----------|-------------------------------------------------------| | `user` | path | string | true | Username, user ID, or 'me' for the authenticated user | -| `id` | path | string(uuid) | true | Task ID | +| `task` | path | string(uuid) | true | Task ID | | `body` | body | [codersdk.TaskSendRequest](schemas.md#codersdktasksendrequest) | true | Task input request | ### Responses diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 0f3330da790cc..ad15e8fac2ae3 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -473,6 +473,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "value": [ { "icon": "bug", + "location": "navbar", "name": "string", "target": "string" } diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index a36199a6f6be0..dfc2348ee295d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -277,62 +277,6 @@ |--------------|--------|----------|--------------|-------------| | `csp-report` | object | false | | | -## coderd.tasksListResponse - -```json -{ - "count": 0, - "tasks": [ - { - "created_at": "2019-08-24T14:15:22Z", - "current_state": { - "message": "string", - "state": "working", - "timestamp": "2019-08-24T14:15:22Z", - "uri": "string" - }, - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "initial_prompt": "string", - "name": "string", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", - "owner_name": "string", - "status": "pending", - "template_display_name": "string", - "template_icon": "string", - "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", - "template_name": "string", - "updated_at": "2019-08-24T14:15:22Z", - "workspace_agent_health": { - "healthy": false, - "reason": "agent has lost connection" - }, - "workspace_agent_id": { - "uuid": "string", - "valid": true - }, - "workspace_agent_lifecycle": "created", - "workspace_app_id": { - "uuid": "string", - "valid": true - }, - "workspace_build_number": 0, - "workspace_id": { - "uuid": "string", - "valid": true - } - } - ] -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|---------|-----------------------------------------|----------|--------------|-------------| -| `count` | integer | false | | | -| `tasks` | array of [codersdk.Task](#codersdktask) | false | | | - ## codersdk.ACLAvailable ```json @@ -515,6 +459,7 @@ ```json { + "count": 0, "results": [ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -576,8 +521,7 @@ } ] } - ], - "total": 0 + ] } ``` @@ -585,8 +529,8 @@ | Name | Type | Required | Restrictions | Description | |-----------|-------------------------------------------------------------------------|----------|--------------|-------------| +| `count` | integer | false | | | | `results` | array of [codersdk.AIBridgeInterception](#codersdkaibridgeinterception) | false | | | -| `total` | integer | false | | | ## codersdk.AIBridgeOpenAIConfig @@ -742,6 +686,12 @@ ```json { + "allow_list": [ + { + "id": "string", + "type": "*" + } + ], "created_at": "2019-08-24T14:15:22Z", "expires_at": "2019-08-24T14:15:22Z", "id": "string", @@ -760,19 +710,20 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|-------------------------------------------------------|----------|--------------|---------------------------------| -| `created_at` | string | true | | | -| `expires_at` | string | true | | | -| `id` | string | true | | | -| `last_used` | string | true | | | -| `lifetime_seconds` | integer | true | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | true | | | -| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. | -| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | -| `token_name` | string | true | | | -| `updated_at` | string | true | | | -| `user_id` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|---------------------------------------------------------------------|----------|--------------|---------------------------------| +| `allow_list` | array of [codersdk.APIAllowListTarget](#codersdkapiallowlisttarget) | false | | | +| `created_at` | string | true | | | +| `expires_at` | string | true | | | +| `id` | string | true | | | +| `last_used` | string | true | | | +| `lifetime_seconds` | integer | true | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | true | | | +| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. | +| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | +| `token_name` | string | true | | | +| `updated_at` | string | true | | | +| `user_id` | string | true | | | #### Enumerated Values @@ -1110,6 +1061,7 @@ "support_links": [ { "icon": "bug", + "location": "navbar", "name": "string", "target": "string" } @@ -3138,6 +3090,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "value": [ { "icon": "bug", + "location": "navbar", "name": "string", "target": "string" } @@ -3644,6 +3597,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "value": [ { "icon": "bug", + "location": "navbar", "name": "string", "target": "string" } @@ -4752,6 +4706,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| ```json { "icon": "bug", + "location": "navbar", "name": "string", "target": "string" } @@ -4759,19 +4714,23 @@ Only certain features set these fields: - FeatureManagedAgentLimit| ### Properties -| Name | Type | Required | Restrictions | Description | -|----------|--------|----------|--------------|-------------| -| `icon` | string | false | | | -| `name` | string | false | | | -| `target` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|------------|--------|----------|--------------|-------------| +| `icon` | string | false | | | +| `location` | string | false | | | +| `name` | string | false | | | +| `target` | string | false | | | #### Enumerated Values -| Property | Value | -|----------|--------| -| `icon` | `bug` | -| `icon` | `chat` | -| `icon` | `docs` | +| Property | Value | +|------------|------------| +| `icon` | `bug` | +| `icon` | `chat` | +| `icon` | `docs` | +| `icon` | `star` | +| `location` | `navbar` | +| `location` | `dropdown` | ## codersdk.ListInboxNotificationsResponse @@ -5264,7 +5223,8 @@ Only certain features set these fields: - FeatureManagedAgentLimit| { "authorization": "string", "device_authorization": "string", - "token": "string" + "token": "string", + "token_revoke": "string" } ``` @@ -5275,6 +5235,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `authorization` | string | false | | | | `device_authorization` | string | false | | Device authorization is optional. | | `token` | string | false | | | +| `token_revoke` | string | false | | | ## codersdk.OAuth2AuthorizationServerMetadata @@ -5338,7 +5299,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "redirect_uris": [ "string" ], - "registration_access_token": "string", + "registration_access_token": [ + 0 + ], "registration_client_uri": "string", "response_types": [ "string" @@ -5353,28 +5316,28 @@ Only certain features set these fields: - FeatureManagedAgentLimit| ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------------|-----------------|----------|--------------|-------------| -| `client_id` | string | false | | | -| `client_id_issued_at` | integer | false | | | -| `client_name` | string | false | | | -| `client_secret_expires_at` | integer | false | | | -| `client_uri` | string | false | | | -| `contacts` | array of string | false | | | -| `grant_types` | array of string | false | | | -| `jwks` | object | false | | | -| `jwks_uri` | string | false | | | -| `logo_uri` | string | false | | | -| `policy_uri` | string | false | | | -| `redirect_uris` | array of string | false | | | -| `registration_access_token` | string | false | | | -| `registration_client_uri` | string | false | | | -| `response_types` | array of string | false | | | -| `scope` | string | false | | | -| `software_id` | string | false | | | -| `software_version` | string | false | | | -| `token_endpoint_auth_method` | string | false | | | -| `tos_uri` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------------|------------------|----------|--------------|-------------| +| `client_id` | string | false | | | +| `client_id_issued_at` | integer | false | | | +| `client_name` | string | false | | | +| `client_secret_expires_at` | integer | false | | | +| `client_uri` | string | false | | | +| `contacts` | array of string | false | | | +| `grant_types` | array of string | false | | | +| `jwks` | object | false | | | +| `jwks_uri` | string | false | | | +| `logo_uri` | string | false | | | +| `policy_uri` | string | false | | | +| `redirect_uris` | array of string | false | | | +| `registration_access_token` | array of integer | false | | | +| `registration_client_uri` | string | false | | | +| `response_types` | array of string | false | | | +| `scope` | string | false | | | +| `software_id` | string | false | | | +| `software_version` | string | false | | | +| `token_endpoint_auth_method` | string | false | | | +| `tos_uri` | string | false | | | ## codersdk.OAuth2ClientRegistrationRequest @@ -5586,7 +5549,8 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "endpoints": { "authorization": "string", "device_authorization": "string", - "token": "string" + "token": "string", + "token_revoke": "string" }, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -7625,6 +7589,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "value": [ { "icon": "bug", + "location": "navbar", "name": "string", "target": "string" } @@ -7713,6 +7678,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "initial_prompt": "string", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", "status": "pending", @@ -7720,6 +7686,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "updated_at": "2019-08-24T14:15:22Z", "workspace_agent_health": { "healthy": false, @@ -7738,7 +7705,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "workspace_id": { "uuid": "string", "valid": true - } + }, + "workspace_name": "string", + "workspace_status": "pending" } ``` @@ -7752,13 +7721,15 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `initial_prompt` | string | false | | | | `name` | string | false | | | | `organization_id` | string | false | | | +| `owner_avatar_url` | string | false | | | | `owner_id` | string | false | | | | `owner_name` | string | false | | | -| `status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | | +| `status` | [codersdk.TaskStatus](#codersdktaskstatus) | false | | | | `template_display_name` | string | false | | | | `template_icon` | string | false | | | | `template_id` | string | false | | | | `template_name` | string | false | | | +| `template_version_id` | string | false | | | | `updated_at` | string | false | | | | `workspace_agent_health` | [codersdk.WorkspaceAgentHealth](#codersdkworkspaceagenthealth) | false | | | | `workspace_agent_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | @@ -7766,21 +7737,29 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `workspace_app_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | | `workspace_build_number` | integer | false | | | | `workspace_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | +| `workspace_name` | string | false | | | +| `workspace_status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | | #### Enumerated Values -| Property | Value | -|----------|-------------| -| `status` | `pending` | -| `status` | `starting` | -| `status` | `running` | -| `status` | `stopping` | -| `status` | `stopped` | -| `status` | `failed` | -| `status` | `canceling` | -| `status` | `canceled` | -| `status` | `deleting` | -| `status` | `deleted` | +| Property | Value | +|--------------------|----------------| +| `status` | `pending` | +| `status` | `initializing` | +| `status` | `active` | +| `status` | `paused` | +| `status` | `unknown` | +| `status` | `error` | +| `workspace_status` | `pending` | +| `workspace_status` | `starting` | +| `workspace_status` | `running` | +| `workspace_status` | `stopping` | +| `workspace_status` | `stopped` | +| `workspace_status` | `failed` | +| `workspace_status` | `canceling` | +| `workspace_status` | `canceled` | +| `workspace_status` | `deleting` | +| `workspace_status` | `deleted` | ## codersdk.TaskLogEntry @@ -7889,6 +7868,85 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `timestamp` | string | false | | | | `uri` | string | false | | | +## codersdk.TaskStatus + +```json +"pending" +``` + +### Properties + +#### Enumerated Values + +| Value | +|----------------| +| `pending` | +| `initializing` | +| `active` | +| `paused` | +| `unknown` | +| `error` | + +## codersdk.TasksListResponse + +```json +{ + "count": 0, + "tasks": [ + { + "created_at": "2019-08-24T14:15:22Z", + "current_state": { + "message": "string", + "state": "working", + "timestamp": "2019-08-24T14:15:22Z", + "uri": "string" + }, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "initial_prompt": "string", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "owner_avatar_url": "string", + "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", + "owner_name": "string", + "status": "pending", + "template_display_name": "string", + "template_icon": "string", + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "template_name": "string", + "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "updated_at": "2019-08-24T14:15:22Z", + "workspace_agent_health": { + "healthy": false, + "reason": "agent has lost connection" + }, + "workspace_agent_id": { + "uuid": "string", + "valid": true + }, + "workspace_agent_lifecycle": "created", + "workspace_app_id": { + "uuid": "string", + "valid": true + }, + "workspace_build_number": 0, + "workspace_id": { + "uuid": "string", + "valid": true + }, + "workspace_name": "string", + "workspace_status": "pending" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|-----------------------------------------|----------|--------------|-------------| +| `count` | integer | false | | | +| `tasks` | array of [codersdk.Task](#codersdktask) | false | | | + ## codersdk.TelemetryConfig ```json @@ -9096,6 +9154,33 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `update_workspace_last_used_at` | boolean | false | | Update workspace last used at updates the last_used_at field of workspaces spawned from the template. This is useful for preventing workspaces being immediately locked when updating the inactivity_ttl field to a new, shorter value. | | `use_classic_parameter_flow` | boolean | false | | Use classic parameter flow is a flag that switches the default behavior to use the classic parameter flow when creating a workspace. This only affects deployments with the experiment "dynamic-parameters" enabled. This setting will live for a period after the experiment is made the default. An "opt-out" is present in case the new feature breaks some existing templates. | +## codersdk.UpdateTokenRequest + +```json +{ + "allow_list": [ + { + "id": "string", + "type": "*" + } + ], + "lifetime": 0, + "scope": "all", + "scopes": [ + "all" + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|---------------------------------------------------------------------|----------|--------------|-------------| +| `allow_list` | array of [codersdk.APIAllowListTarget](#codersdkapiallowlisttarget) | false | | | +| `lifetime` | integer | false | | | +| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | +| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | + ## codersdk.UpdateUserAppearanceSettingsRequest ```json @@ -10029,6 +10114,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -11198,6 +11284,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -11215,7 +11302,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | Name | Type | Required | Restrictions | Description | |------------------------------|-------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------| -| `ai_task_sidebar_app_id` | string | false | | | +| `ai_task_sidebar_app_id` | string | false | | Deprecated: This field has been replaced with `TaskAppID` | | `build_number` | integer | false | | | | `created_at` | string | false | | | | `daily_cost` | integer | false | | | @@ -11231,6 +11318,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | | | `resources` | array of [codersdk.WorkspaceResource](#codersdkworkspaceresource) | false | | | | `status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | | +| `task_app_id` | string | false | | | | `template_version_id` | string | false | | | | `template_version_name` | string | false | | | | `template_version_preset_id` | string | false | | | @@ -12020,6 +12108,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -13685,6 +13774,7 @@ None "value": [ { "icon": "bug", + "location": "navbar", "name": "string", "target": "string" } diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 0cfdd07c742dc..4acc01ff3e641 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -757,6 +757,12 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \ ```json [ { + "allow_list": [ + { + "id": "string", + "type": "*" + } + ], "created_at": "2019-08-24T14:15:22Z", "expires_at": "2019-08-24T14:15:22Z", "id": "string", @@ -784,31 +790,76 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -|----------------------|--------------------------------------------------------|----------|--------------|---------------------------------| -| `[array item]` | array | false | | | -| `» created_at` | string(date-time) | true | | | -| `» expires_at` | string(date-time) | true | | | -| `» id` | string | true | | | -| `» last_used` | string(date-time) | true | | | -| `» lifetime_seconds` | integer | true | | | -| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | true | | | -| `» scope` | [codersdk.APIKeyScope](schemas.md#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. | -| `» scopes` | array | false | | | -| `» token_name` | string | true | | | -| `» updated_at` | string(date-time) | true | | | -| `» user_id` | string(uuid) | true | | | +| Name | Type | Required | Restrictions | Description | +|----------------------|----------------------------------------------------------|----------|--------------|---------------------------------| +| `[array item]` | array | false | | | +| `» allow_list` | array | false | | | +| `»» id` | string | false | | | +| `»» type` | [codersdk.RBACResource](schemas.md#codersdkrbacresource) | false | | | +| `» created_at` | string(date-time) | true | | | +| `» expires_at` | string(date-time) | true | | | +| `» id` | string | true | | | +| `» last_used` | string(date-time) | true | | | +| `» lifetime_seconds` | integer | true | | | +| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | true | | | +| `» scope` | [codersdk.APIKeyScope](schemas.md#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. | +| `» scopes` | array | false | | | +| `» token_name` | string | true | | | +| `» updated_at` | string(date-time) | true | | | +| `» user_id` | string(uuid) | true | | | #### Enumerated Values -| Property | Value | -|--------------|-----------------------| -| `login_type` | `password` | -| `login_type` | `github` | -| `login_type` | `oidc` | -| `login_type` | `token` | -| `scope` | `all` | -| `scope` | `application_connect` | +| Property | Value | +|--------------|------------------------------------| +| `type` | `*` | +| `type` | `aibridge_interception` | +| `type` | `api_key` | +| `type` | `assign_org_role` | +| `type` | `assign_role` | +| `type` | `audit_log` | +| `type` | `connection_log` | +| `type` | `crypto_key` | +| `type` | `debug_info` | +| `type` | `deployment_config` | +| `type` | `deployment_stats` | +| `type` | `file` | +| `type` | `group` | +| `type` | `group_member` | +| `type` | `idpsync_settings` | +| `type` | `inbox_notification` | +| `type` | `license` | +| `type` | `notification_message` | +| `type` | `notification_preference` | +| `type` | `notification_template` | +| `type` | `oauth2_app` | +| `type` | `oauth2_app_code_token` | +| `type` | `oauth2_app_secret` | +| `type` | `organization` | +| `type` | `organization_member` | +| `type` | `prebuilt_workspace` | +| `type` | `provisioner_daemon` | +| `type` | `provisioner_jobs` | +| `type` | `replicas` | +| `type` | `system` | +| `type` | `tailnet_coordinator` | +| `type` | `task` | +| `type` | `template` | +| `type` | `usage_event` | +| `type` | `user` | +| `type` | `user_secret` | +| `type` | `webpush_subscription` | +| `type` | `workspace` | +| `type` | `workspace_agent_devcontainers` | +| `type` | `workspace_agent_resource_monitor` | +| `type` | `workspace_dormant` | +| `type` | `workspace_proxy` | +| `login_type` | `password` | +| `login_type` | `github` | +| `login_type` | `oidc` | +| `login_type` | `token` | +| `scope` | `all` | +| `scope` | `application_connect` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -896,6 +947,88 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens/{keyname} \ ```json { + "allow_list": [ + { + "id": "string", + "type": "*" + } + ], + "created_at": "2019-08-24T14:15:22Z", + "expires_at": "2019-08-24T14:15:22Z", + "id": "string", + "last_used": "2019-08-24T14:15:22Z", + "lifetime_seconds": 0, + "login_type": "password", + "scope": "all", + "scopes": [ + "all" + ], + "token_name": "string", + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.APIKey](schemas.md#codersdkapikey) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update token API key + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/users/{user}/keys/tokens/{keyname} \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /users/{user}/keys/tokens/{keyname}` + +> Body parameter + +```json +{ + "allow_list": [ + { + "id": "string", + "type": "*" + } + ], + "lifetime": 0, + "scope": "all", + "scopes": [ + "all" + ] +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|-----------|------|----------------------------------------------------------------------|----------|----------------------| +| `user` | path | string | true | User ID, name, or me | +| `keyname` | path | string(string) | true | Key Name | +| `body` | body | [codersdk.UpdateTokenRequest](schemas.md#codersdkupdatetokenrequest) | true | Update token request | + +### Example responses + +> 200 Response + +```json +{ + "allow_list": [ + { + "id": "string", + "type": "*" + } + ], "created_at": "2019-08-24T14:15:22Z", "expires_at": "2019-08-24T14:15:22Z", "id": "string", @@ -946,6 +1079,12 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ ```json { + "allow_list": [ + { + "id": "string", + "type": "*" + } + ], "created_at": "2019-08-24T14:15:22Z", "expires_at": "2019-08-24T14:15:22Z", "id": "string", diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 86c04f79c413c..91ab23f9260e9 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -277,6 +277,7 @@ of the template will be used. } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -568,6 +569,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -884,6 +886,7 @@ of the template will be used. } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -1161,6 +1164,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -1453,6 +1457,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", @@ -2004,6 +2009,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ } ], "status": "pending", + "task_app_id": "ca438251-3e16-4fae-b9ab-dd3c237c3735", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", "template_version_name": "string", "template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1", diff --git a/docs/reference/cli/tokens.md b/docs/reference/cli/tokens.md index 36b6575ed323f..fd4369d5e63f0 100644 --- a/docs/reference/cli/tokens.md +++ b/docs/reference/cli/tokens.md @@ -25,6 +25,10 @@ Tokens are used to authenticate automated clients to Coder. $ coder tokens ls + - Create a scoped token: + + $ coder tokens create --scope workspace:read --allow workspace: + - Remove a token by ID: $ coder tokens rm WuoWs4ZsMX @@ -32,8 +36,9 @@ Tokens are used to authenticate automated clients to Coder. ## Subcommands -| Name | Purpose | -|-------------------------------------------|----------------| -| [create](./tokens_create.md) | Create a token | -| [list](./tokens_list.md) | List tokens | -| [remove](./tokens_remove.md) | Delete a token | +| Name | Purpose | +|-------------------------------------------|--------------------------------------------| +| [create](./tokens_create.md) | Create a token | +| [list](./tokens_list.md) | List tokens | +| [view](./tokens_view.md) | Display detailed information about a token | +| [remove](./tokens_remove.md) | Delete a token | diff --git a/docs/reference/cli/tokens_create.md b/docs/reference/cli/tokens_create.md index 7ad9699c17c35..d5dd916a46e0e 100644 --- a/docs/reference/cli/tokens_create.md +++ b/docs/reference/cli/tokens_create.md @@ -37,3 +37,19 @@ Specify a human-readable name. | Environment | $CODER_TOKEN_USER | Specify the user to create the token for (Only works if logged in user is admin). + +### --scope + +| | | +|------|---------------------------| +| Type | string-array | + +Repeatable scope to attach to the token (e.g. workspace:read). + +### --allow + +| | | +|------|-------------------------| +| Type | allow-list | + +Repeatable allow-list entry (:, e.g. workspace:1234-...). diff --git a/docs/reference/cli/tokens_list.md b/docs/reference/cli/tokens_list.md index 150b411855174..53d5e9b7b57c8 100644 --- a/docs/reference/cli/tokens_list.md +++ b/docs/reference/cli/tokens_list.md @@ -25,10 +25,10 @@ Specifies whether all users' tokens will be listed or not (must have Owner role ### -c, --column -| | | -|---------|-------------------------------------------------------------------| -| Type | [id\|name\|last used\|expires at\|created at\|owner] | -| Default | id,name,last used,expires at,created at | +| | | +|---------|---------------------------------------------------------------------------------------| +| Type | [id\|name\|scopes\|allow list\|last used\|expires at\|created at\|owner] | +| Default | id,name,scopes,allow list,last used,expires at,created at | Columns to display in table output. diff --git a/docs/reference/cli/tokens_view.md b/docs/reference/cli/tokens_view.md new file mode 100644 index 0000000000000..f5008f5e41092 --- /dev/null +++ b/docs/reference/cli/tokens_view.md @@ -0,0 +1,30 @@ + +# tokens view + +Display detailed information about a token + +## Usage + +```console +coder tokens view [flags] +``` + +## Options + +### -c, --column + +| | | +|---------|---------------------------------------------------------------------------------------| +| Type | [id\|name\|scopes\|allow list\|last used\|expires at\|created at\|owner] | +| Default | id,name,scopes,allow list,last used,expires at,created at,owner | + +Columns to display in table output. + +### -o, --output + +| | | +|---------|--------------------------| +| Type | table\|json | +| Default | table | + +Output format. diff --git a/docs/user-guides/workspace-access/remote-desktops.md b/docs/user-guides/workspace-access/remote-desktops.md index 71d5944020d34..f07589a53993f 100644 --- a/docs/user-guides/workspace-access/remote-desktops.md +++ b/docs/user-guides/workspace-access/remote-desktops.md @@ -64,7 +64,7 @@ coder port-forward --tcp 3399:3389 Then, connect to your workspace via RDP at `localhost:3399`. ![windows-rdp](../../images/user-guides/remote-desktops/windows_rdp_client.png) -s + > [!NOTE] > Some versions of Windows, including Windows Server 2022, do not communicate correctly over UDP when using Coder Connect because they do not respect the maximum transmission unit (MTU) of the link. When this happens, the RDP client will appear to connect, but displays a blank screen. diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf index 0811b7b30d1da..3b7da5f5157a1 100644 --- a/dogfood/coder/main.tf +++ b/dogfood/coder/main.tf @@ -479,8 +479,8 @@ resource "coder_agent" "dev" { dir = local.repo_dir env = { OIDC_TOKEN : data.coder_workspace_owner.me.oidc_access_token, - # To Enable AI Bridge integration ANTHROPIC_BASE_URL : "https://dev.coder.com/api/experimental/aibridge/anthropic", + ANTHROPIC_AUTH_TOKEN : data.coder_workspace_owner.me.session_token } startup_script_behavior = "blocking" diff --git a/enterprise/cli/server_dbcrypt_test.go b/enterprise/cli/server_dbcrypt_test.go index 06851dd0a2eaf..b50b8c0c504cb 100644 --- a/enterprise/cli/server_dbcrypt_test.go +++ b/enterprise/cli/server_dbcrypt_test.go @@ -24,10 +24,6 @@ import ( // // nolint: paralleltest // use of t.Setenv func TestServerDBCrypt(t *testing.T) { - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires a postgres instance") - } - ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 2c28745c95a8a..dab93d8992a79 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -127,7 +127,7 @@ func (api *API) aiBridgeListInterceptions(rw http.ResponseWriter, r *http.Reques } httpapi.Write(ctx, rw, http.StatusOK, codersdk.AIBridgeListInterceptionsResponse{ - Total: count, + Count: count, Results: items, }) } diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 232cfd7072e77..5de1df5b60681 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -269,7 +269,7 @@ func TestAIBridgeListInterceptions(t *testing.T) { if len(res.Results) == 0 { break } - require.EqualValues(t, len(allInterceptionIDs), res.Total) + require.EqualValues(t, len(allInterceptionIDs), res.Count) require.Len(t, res.Results, 1) interceptionIDs = append(interceptionIDs, res.Results[0].ID) } @@ -322,7 +322,7 @@ func TestAIBridgeListInterceptions(t *testing.T) { // Admin can see all interceptions. res, err := adminExperimentalClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{}) require.NoError(t, err) - require.EqualValues(t, 2, res.Total) + require.EqualValues(t, 2, res.Count) require.Len(t, res.Results, 2) require.Equal(t, i1.ID, res.Results[0].ID) require.Equal(t, i2.ID, res.Results[1].ID) @@ -330,7 +330,7 @@ func TestAIBridgeListInterceptions(t *testing.T) { // Second user can only see their own interceptions. res, err = secondUserExperimentalClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{}) require.NoError(t, err) - require.EqualValues(t, 1, res.Total) + require.EqualValues(t, 1, res.Count) require.Len(t, res.Results, 1) require.Equal(t, i2.ID, res.Results[0].ID) }) @@ -501,7 +501,7 @@ func TestAIBridgeListInterceptions(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) res, err := experimentalClient.AIBridgeListInterceptions(ctx, tc.filter) require.NoError(t, err) - require.EqualValues(t, len(tc.want), res.Total) + require.EqualValues(t, len(tc.want), res.Count) // We just compare UUID strings for the sake of this test. wantIDs := make([]string, len(tc.want)) for i, r := range tc.want { diff --git a/enterprise/coderd/appearance_test.go b/enterprise/coderd/appearance_test.go index 81ba7eddc7354..8255dd4c8aa8c 100644 --- a/enterprise/coderd/appearance_test.go +++ b/enterprise/coderd/appearance_test.go @@ -201,6 +201,17 @@ func TestCustomSupportLinks(t *testing.T) { Target: "http://second-link-2", Icon: "bug", }, + { + Name: "First button", + Target: "http://first-button-1", + Icon: "bug", + Location: "navbar", + }, + { + Name: "Third link", + Target: "http://third-link-3", + Icon: "star", + }, } cfg := coderdtest.DeploymentValues(t) cfg.Support.Links = serpent.Struct[[]codersdk.LinkConfig]{ diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 00bb3e59e616a..c3e6e1579fe91 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -79,10 +79,6 @@ func TestEntitlements(t *testing.T) { require.Equal(t, fmt.Sprintf("%p", api.Entitlements), fmt.Sprintf("%p", api.AGPL.Entitlements)) }) t.Run("FullLicense", func(t *testing.T) { - // PGCoordinator requires a real postgres - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } t.Parallel() adminClient, _ := coderdenttest.New(t, &coderdenttest.Options{ AuditLogging: true, @@ -882,10 +878,6 @@ func (s *restartableTestServer) startWithFirstUser(t *testing.T) (client *coders func TestConn_CoordinatorRollingRestart(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } - // Although DERP will have connection issues until the connection is // reestablished, any open connections should be maintained. // diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index ce9050992eb92..a31d1d495bb6e 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -186,6 +186,8 @@ type LicenseOptions struct { // past. IssuedAt time.Time Features license.Features + + AllowEmpty bool } func (opts *LicenseOptions) WithIssuedAt(now time.Time) *LicenseOptions { @@ -276,10 +278,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { issuedAt = time.Now().Add(-time.Minute) } - if options.AccountType == "" { + if !options.AllowEmpty && options.AccountType == "" { options.AccountType = license.AccountTypeSalesforce } - if options.AccountID == "" { + if !options.AllowEmpty && options.AccountID == "" { options.AccountID = "test-account-id" } diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 40d14c294cda1..7fbac30fae744 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -612,6 +612,8 @@ var ( ErrMissingLicenseExpires = xerrors.New("license has invalid or missing license_expires claim") ErrMissingExp = xerrors.New("license has invalid or missing exp (expires at) claim") ErrMultipleIssues = xerrors.New("license has multiple issues; contact support") + ErrMissingAccountType = xerrors.New("license must contain valid account type") + ErrMissingAccountID = xerrors.New("license must contain valid account ID") ) type Features map[codersdk.FeatureName]int64 @@ -696,12 +698,20 @@ func validateClaims(tok *jwt.Token) (*Claims, error) { if claims.NotBefore == nil { return nil, ErrMissingNotBefore } - if claims.LicenseExpires == nil { + + yearsHardLimit := time.Now().Add(5 /* years */ * 365 * 24 * time.Hour) + if claims.LicenseExpires == nil || claims.LicenseExpires.Time.After(yearsHardLimit) { return nil, ErrMissingLicenseExpires } if claims.ExpiresAt == nil { return nil, ErrMissingExp } + if claims.AccountType == "" { + return nil, ErrMissingAccountType + } + if claims.AccountID == "" { + return nil, ErrMissingAccountID + } return claims, nil } return nil, xerrors.New("unable to parse Claims") diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index bbd6ef717fe8e..fbcbbf654ed09 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -54,6 +54,56 @@ func TestPostLicense(t *testing.T) { require.Contains(t, errResp.Message, "License cannot be used on this deployment!") }) + t.Run("InvalidAccountID", func(t *testing.T) { + t.Parallel() + // The generated deployment will start out with a different deployment ID. + client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) + license := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + AllowEmpty: true, + AccountID: "", + }) + _, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{ + License: license, + }) + errResp := &codersdk.Error{} + require.ErrorAs(t, err, &errResp) + require.Equal(t, http.StatusBadRequest, errResp.StatusCode()) + require.Contains(t, errResp.Message, "Invalid license") + }) + + t.Run("InvalidAccountType", func(t *testing.T) { + t.Parallel() + // The generated deployment will start out with a different deployment ID. + client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) + license := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + AllowEmpty: true, + AccountType: "", + }) + _, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{ + License: license, + }) + errResp := &codersdk.Error{} + require.ErrorAs(t, err, &errResp) + require.Equal(t, http.StatusBadRequest, errResp.StatusCode()) + require.Contains(t, errResp.Message, "Invalid license") + }) + + t.Run("InvalidLicenseExpires", func(t *testing.T) { + t.Parallel() + // The generated deployment will start out with a different deployment ID. + client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) + license := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + GraceAt: time.Unix(99999999999, 0), + }) + _, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{ + License: license, + }) + errResp := &codersdk.Error{} + require.ErrorAs(t, err, &errResp) + require.Equal(t, http.StatusBadRequest, errResp.StatusCode()) + require.Contains(t, errResp.Message, "Invalid license") + }) + t.Run("Unauthorized", func(t *testing.T) { t.Parallel() client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) diff --git a/enterprise/coderd/notifications_test.go b/enterprise/coderd/notifications_test.go index 77b057bf41657..571ed4ced00dd 100644 --- a/enterprise/coderd/notifications_test.go +++ b/enterprise/coderd/notifications_test.go @@ -12,7 +12,6 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" @@ -36,10 +35,6 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Run("Happy path", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table") - } - ctx := testutil.Context(t, testutil.WaitSuperLong) api, _ := coderdenttest.New(t, createOpts(t)) @@ -67,10 +62,6 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Run("Insufficient permissions", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table") - } - ctx := testutil.Context(t, testutil.WaitSuperLong) // Given: the first user which has an "owner" role, and another user which does not. @@ -91,10 +82,6 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Run("Invalid notification method", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table") - } - ctx := testutil.Context(t, testutil.WaitSuperLong) // Given: the first user which has an "owner" role @@ -120,10 +107,6 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) { t.Run("Not modified", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table") - } - ctx := testutil.Context(t, testutil.WaitSuperLong) api, _ := coderdenttest.New(t, createOpts(t)) diff --git a/enterprise/coderd/prebuilds/membership_test.go b/enterprise/coderd/prebuilds/membership_test.go index 80e2f907349ae..55d6557b12495 100644 --- a/enterprise/coderd/prebuilds/membership_test.go +++ b/enterprise/coderd/prebuilds/membership_test.go @@ -15,7 +15,6 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/enterprise/coderd/prebuilds" "github.com/coder/coder/v2/testutil" ) @@ -91,14 +90,6 @@ func TestReconcileAll(t *testing.T) { unrelatedOrg := dbgen.Organization(t, db, database.Organization{}) targetOrg := dbgen.Organization(t, db, database.Organization{}) - if !dbtestutil.WillUsePostgres() { - // dbmem doesn't ensure membership to the default organization - dbgen.OrganizationMember(t, db, database.OrganizationMember{ - OrganizationID: defaultOrg.ID, - UserID: database.PrebuildsSystemUserID, - }) - } - // Ensure membership to unrelated org. dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: unrelatedOrg.ID, UserID: database.PrebuildsSystemUserID}) diff --git a/enterprise/coderd/prebuilds/metricscollector_test.go b/enterprise/coderd/prebuilds/metricscollector_test.go index b852079beb2af..f9584e9ec2c25 100644 --- a/enterprise/coderd/prebuilds/metricscollector_test.go +++ b/enterprise/coderd/prebuilds/metricscollector_test.go @@ -30,10 +30,6 @@ import ( func TestMetricsCollector(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } - type metricCheck struct { name string value *float64 @@ -298,10 +294,6 @@ func TestMetricsCollector(t *testing.T) { func TestMetricsCollector_DuplicateTemplateNames(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } - type metricCheck struct { name string value *float64 @@ -478,10 +470,6 @@ func findAllMetricSeries(metricsFamilies []*prometheus_client.MetricFamily, labe func TestMetricsCollector_ReconciliationPausedMetric(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } - t.Run("reconciliation_not_paused", func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/replicas_test.go b/enterprise/coderd/replicas_test.go index 5a56817b19409..4b16f7bb70b91 100644 --- a/enterprise/coderd/replicas_test.go +++ b/enterprise/coderd/replicas_test.go @@ -20,9 +20,7 @@ import ( func TestReplicas(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("only test with real postgres") - } + t.Run("ErrorWithoutLicense", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index 917d44dff2d48..2e4690bc961a9 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -89,10 +89,6 @@ func TestBlockNonBrowser(t *testing.T) { func TestReinitializeAgent(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("dbmem cannot currently claim a workspace") - } - if runtime.GOOS == "windows" { t.Skip("test startup script is not supported on windows") } diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go index 2ebee9d93f9e8..4f3ce12056617 100644 --- a/enterprise/coderd/workspaceproxy.go +++ b/enterprise/coderd/workspaceproxy.go @@ -2,7 +2,6 @@ package coderd import ( "context" - "crypto/sha256" "database/sql" "fmt" "net/http" @@ -16,6 +15,7 @@ import ( "cdr.dev/slog" agpl "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -28,7 +28,6 @@ import ( "github.com/coder/coder/v2/coderd/workspaceapps" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/coderd/proxyhealth" "github.com/coder/coder/v2/enterprise/replicasync" "github.com/coder/coder/v2/enterprise/wsproxy/wsproxysdk" @@ -934,13 +933,13 @@ func (api *API) reconnectingPTYSignedToken(rw http.ResponseWriter, r *http.Reque } func generateWorkspaceProxyToken(id uuid.UUID) (token string, hashed []byte, err error) { - secret, err := cryptorand.HexString(64) + secret, hashedSecret, err := apikey.GenerateSecret(64) if err != nil { return "", nil, xerrors.Errorf("generate token: %w", err) } - hashedSecret := sha256.Sum256([]byte(secret)) + fullToken := fmt.Sprintf("%s:%s", id, secret) - return fullToken, hashedSecret[:], nil + return fullToken, hashedSecret, nil } func convertProxies(p []database.WorkspaceProxy, statuses map[uuid.UUID]proxyhealth.ProxyStatus) []codersdk.WorkspaceProxy { diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go index 186af3a787d94..937aa8d57433a 100644 --- a/enterprise/coderd/workspacequota_test.go +++ b/enterprise/coderd/workspacequota_test.go @@ -660,10 +660,6 @@ func TestWorkspaceQuota(t *testing.T) { func TestWorkspaceSerialization(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("Serialization errors only occur in postgres") - } - db, _ := dbtestutil.NewDB(t) user := dbgen.User(t, db, database.User{}) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 25fa1527d894e..5201e613f7a1d 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -534,10 +534,6 @@ func TestCreateUserWorkspace(t *testing.T) { t.Run("ClaimPrebuild", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("dbmem cannot currently claim a workspace") - } - client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ DeploymentValues: coderdtest.DeploymentValues(t), @@ -845,10 +841,6 @@ func TestWorkspaceAutobuild(t *testing.T) { t.Run("NoDeadlock", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skipf("Skipping non-postgres run") - } - var ( ticker = make(chan time.Time) statCh = make(chan autobuild.Stats) @@ -1654,10 +1646,6 @@ func TestWorkspaceAutobuild(t *testing.T) { t.Run("NextStartAtIsNullifiedOnScheduleChange", func(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test uses triggers so does not work with dbmem.go") - } - var ( tickCh = make(chan time.Time) statsCh = make(chan autobuild.Stats) @@ -1781,10 +1769,6 @@ func TestTemplateDoesNotAllowUserAutostop(t *testing.T) { func TestPrebuildsAutobuild(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("this test requires postgres") - } - getRunningPrebuilds := func( t *testing.T, ctx context.Context, diff --git a/enterprise/tailnet/handshaker_test.go b/enterprise/tailnet/handshaker_test.go index 523f20ea122da..dbb05418e3827 100644 --- a/enterprise/tailnet/handshaker_test.go +++ b/enterprise/tailnet/handshaker_test.go @@ -14,9 +14,7 @@ import ( func TestPGCoordinator_ReadyForHandshake_OK(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -30,9 +28,7 @@ func TestPGCoordinator_ReadyForHandshake_OK(t *testing.T) { func TestPGCoordinator_ReadyForHandshake_NoPermission(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() diff --git a/enterprise/tailnet/multiagent_test.go b/enterprise/tailnet/multiagent_test.go index fe3c3eaee04d3..c79f11153a166 100644 --- a/enterprise/tailnet/multiagent_test.go +++ b/enterprise/tailnet/multiagent_test.go @@ -23,9 +23,6 @@ import ( // +--------+ func TestPGCoordinator_MultiAgent(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) store, ps := dbtestutil.NewDB(t) @@ -60,9 +57,6 @@ func TestPGCoordinator_MultiAgent(t *testing.T) { func TestPGCoordinator_MultiAgent_CoordClose(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) store, ps := dbtestutil.NewDB(t) @@ -90,9 +84,6 @@ func TestPGCoordinator_MultiAgent_CoordClose(t *testing.T) { // +--------+ func TestPGCoordinator_MultiAgent_UnsubscribeRace(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) store, ps := dbtestutil.NewDB(t) @@ -135,9 +126,6 @@ func TestPGCoordinator_MultiAgent_UnsubscribeRace(t *testing.T) { // +--------+ func TestPGCoordinator_MultiAgent_Unsubscribe(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) store, ps := dbtestutil.NewDB(t) @@ -197,9 +185,6 @@ func TestPGCoordinator_MultiAgent_Unsubscribe(t *testing.T) { // +--------+ func TestPGCoordinator_MultiAgent_MultiCoordinator(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) store, ps := dbtestutil.NewDB(t) @@ -247,9 +232,6 @@ func TestPGCoordinator_MultiAgent_MultiCoordinator(t *testing.T) { // +--------+ func TestPGCoordinator_MultiAgent_MultiCoordinator_UpdateBeforeSubscribe(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) store, ps := dbtestutil.NewDB(t) @@ -299,9 +281,6 @@ func TestPGCoordinator_MultiAgent_MultiCoordinator_UpdateBeforeSubscribe(t *test // +--------+ func TestPGCoordinator_MultiAgent_TwoAgents(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) store, ps := dbtestutil.NewDB(t) diff --git a/enterprise/tailnet/pgcoord_internal_test.go b/enterprise/tailnet/pgcoord_internal_test.go index dacf61e42acde..88dbe245f062a 100644 --- a/enterprise/tailnet/pgcoord_internal_test.go +++ b/enterprise/tailnet/pgcoord_internal_test.go @@ -163,9 +163,7 @@ func TestHeartbeats_LostCoordinator_MarkLost(t *testing.T) { // that is, clean up peers and associated tunnels that have been lost for over 24 hours. func TestLostPeerCleanupQueries(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, _, sqlDB := dbtestutil.NewDBWithSQLDB(t, dbtestutil.WithDumpOnFailure()) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() @@ -326,9 +324,7 @@ func TestDebugTemplate(t *testing.T) { func TestGetDebug(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, _ := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() diff --git a/enterprise/tailnet/pgcoord_test.go b/enterprise/tailnet/pgcoord_test.go index 7923ffdb81519..eee64f75f4ea3 100644 --- a/enterprise/tailnet/pgcoord_test.go +++ b/enterprise/tailnet/pgcoord_test.go @@ -36,9 +36,7 @@ func TestMain(m *testing.M) { func TestPGCoordinatorSingle_ClientWithoutAgent(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -71,9 +69,7 @@ func TestPGCoordinatorSingle_ClientWithoutAgent(t *testing.T) { func TestPGCoordinatorSingle_AgentWithoutClients(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -105,9 +101,7 @@ func TestPGCoordinatorSingle_AgentWithoutClients(t *testing.T) { func TestPGCoordinatorSingle_AgentInvalidIP(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -132,9 +126,7 @@ func TestPGCoordinatorSingle_AgentInvalidIP(t *testing.T) { func TestPGCoordinatorSingle_AgentInvalidIPBits(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -160,9 +152,7 @@ func TestPGCoordinatorSingle_AgentInvalidIPBits(t *testing.T) { func TestPGCoordinatorSingle_AgentValidIP(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -199,9 +189,7 @@ func TestPGCoordinatorSingle_AgentValidIP(t *testing.T) { func TestPGCoordinatorSingle_AgentWithClient(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -248,9 +236,7 @@ func TestPGCoordinatorSingle_AgentWithClient(t *testing.T) { func TestPGCoordinatorSingle_MissedHeartbeats(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() @@ -333,9 +319,7 @@ func TestPGCoordinatorSingle_MissedHeartbeats(t *testing.T) { func TestPGCoordinatorSingle_MissedHeartbeats_NoDrop(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -379,9 +363,7 @@ func TestPGCoordinatorSingle_MissedHeartbeats_NoDrop(t *testing.T) { func TestPGCoordinatorSingle_SendsHeartbeats(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -429,9 +411,7 @@ func TestPGCoordinatorSingle_SendsHeartbeats(t *testing.T) { // +---------+ func TestPGCoordinatorDual_Mainline(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -527,9 +507,7 @@ func TestPGCoordinatorDual_Mainline(t *testing.T) { // +---------+ func TestPGCoordinator_MultiCoordinatorAgent(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -695,9 +673,7 @@ func TestPGCoordinator_Node_Empty(t *testing.T) { // do this now, but it's schematically possible, so we should make sure it doesn't break anything. func TestPGCoordinator_BidirectionalTunnels(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -710,9 +686,7 @@ func TestPGCoordinator_BidirectionalTunnels(t *testing.T) { func TestPGCoordinator_GracefulDisconnect(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -725,9 +699,7 @@ func TestPGCoordinator_GracefulDisconnect(t *testing.T) { func TestPGCoordinator_Lost(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -740,9 +712,7 @@ func TestPGCoordinator_Lost(t *testing.T) { func TestPGCoordinator_NoDeleteOnClose(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } + store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -795,10 +765,6 @@ func TestPGCoordinator_NoDeleteOnClose(t *testing.T) { func TestPGCoordinatorDual_FailedHeartbeat(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } - dburl, err := dbtestutil.Open(t) require.NoError(t, err) @@ -861,10 +827,6 @@ func TestPGCoordinatorDual_FailedHeartbeat(t *testing.T) { func TestPGCoordinatorDual_PeerReconnect(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } - store, ps := dbtestutil.NewDB(t) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) defer cancel() @@ -918,10 +880,6 @@ func TestPGCoordinatorDual_PeerReconnect(t *testing.T) { func TestPGCoordinatorPropogatedPeerContext(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("test only with postgres") - } - ctx := testutil.Context(t, testutil.WaitMedium) store, ps := dbtestutil.NewDB(t) logger := testutil.Logger(t) diff --git a/enterprise/x/aibridgedserver/aibridgedserver.go b/enterprise/x/aibridgedserver/aibridgedserver.go index ee79cd15f07d1..e766e696222d7 100644 --- a/enterprise/x/aibridgedserver/aibridgedserver.go +++ b/enterprise/x/aibridgedserver/aibridgedserver.go @@ -2,8 +2,6 @@ package aibridgedserver import ( "context" - "crypto/sha256" - "crypto/subtle" "database/sql" "encoding/json" "net/url" @@ -17,6 +15,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" @@ -358,8 +357,7 @@ func (s *Server) IsAuthorized(ctx context.Context, in *proto.IsAuthorizedRequest } // Key secret matches. - hashedSecret := sha256.Sum256([]byte(keySecret)) - if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 { + if !apikey.ValidateHash(key.HashedSecret, keySecret) { return nil, ErrInvalidKey } diff --git a/enterprise/x/aibridgedserver/aibridgedserver_test.go b/enterprise/x/aibridgedserver/aibridgedserver_test.go index 03fec9398bae4..fc8f8e8afd25b 100644 --- a/enterprise/x/aibridgedserver/aibridgedserver_test.go +++ b/enterprise/x/aibridgedserver/aibridgedserver_test.go @@ -2,7 +2,6 @@ package aibridgedserver_test import ( "context" - "crypto/sha256" "database/sql" "encoding/json" "fmt" @@ -21,6 +20,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -138,13 +138,12 @@ func TestAuthorization(t *testing.T) { } keyID, _ := cryptorand.String(10) - keySecret, _ := cryptorand.String(22) + keySecret, keySecretHashed, _ := apikey.GenerateSecret(22) token := fmt.Sprintf("%s-%s", keyID, keySecret) - keySecretHashed := sha256.Sum256([]byte(keySecret)) apiKey := database.APIKey{ ID: keyID, LifetimeSeconds: 86400, // default in db - HashedSecret: keySecretHashed[:], + HashedSecret: keySecretHashed, IPAddress: pqtype.Inet{ IPNet: net.IPNet{ IP: net.IPv4(127, 0, 0, 1), diff --git a/go.mod b/go.mod index 590a87ae4f3ee..ba9380112d91d 100644 --- a/go.mod +++ b/go.mod @@ -100,7 +100,7 @@ require ( github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 github.com/coder/quartz v0.2.1 github.com/coder/retry v1.5.1 - github.com/coder/serpent v0.10.0 + github.com/coder/serpent v0.11.0 github.com/coder/terraform-provider-coder/v2 v2.12.0 github.com/coder/websocket v1.8.13 github.com/coder/wgtunnel v0.1.13-0.20240522110300-ade90dfb2da0 @@ -173,7 +173,7 @@ require ( github.com/shirou/gopsutil/v4 v4.25.5 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/afero v1.15.0 - github.com/spf13/pflag v1.0.7 + github.com/spf13/pflag v1.0.10 github.com/sqlc-dev/pqtype v0.3.0 github.com/stretchr/testify v1.11.1 github.com/swaggo/http-swagger/v2 v2.0.1 @@ -196,7 +196,7 @@ require ( go.uber.org/mock v0.6.0 go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 golang.org/x/crypto v0.43.0 - golang.org/x/exp v0.0.0-20250911091902-df9299821621 + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 golang.org/x/mod v0.29.0 golang.org/x/net v0.46.0 golang.org/x/oauth2 v0.32.0 @@ -353,7 +353,7 @@ require ( github.com/kr/fs v0.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -477,7 +477,7 @@ require ( github.com/anthropics/anthropic-sdk-go v1.13.0 github.com/brianvoe/gofakeit/v7 v7.8.0 github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 - github.com/coder/aibridge v0.1.3 + github.com/coder/aibridge v0.1.4 github.com/coder/aisdk-go v0.0.9 github.com/coder/boundary v1.0.1-0.20250925154134-55a44f2a7945 github.com/coder/preview v1.0.4 @@ -486,6 +486,7 @@ require ( github.com/go-git/go-git/v5 v5.16.2 github.com/icholy/replace v0.6.0 github.com/mark3labs/mcp-go v0.38.0 + gonum.org/v1/gonum v0.16.0 ) require ( diff --git a/go.sum b/go.sum index f6fb4cbd526df..b5344eff068ee 100644 --- a/go.sum +++ b/go.sum @@ -915,8 +915,8 @@ github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4= -github.com/coder/aibridge v0.1.3 h1:7A9RQaHQUjtse47ShF3kBj2hMmT1R7BEFgiyByr8Vvc= -github.com/coder/aibridge v0.1.3/go.mod h1:GWc0Owtlzz5iMHosDm6FhbO+SoG5W+VeOKyP9p9g9ZM= +github.com/coder/aibridge v0.1.4 h1:MCbrq33RCrk6v16ZbQnabfUVaCAOmJR4mPwc+UvagQs= +github.com/coder/aibridge v0.1.4/go.mod h1:Q5MCfKMcKYmYl4qH1Zd0rltmPaUBPKFvIPs2k9q6qeY= github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo= github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M= github.com/coder/boundary v1.0.1-0.20250925154134-55a44f2a7945 h1:hDUf02kTX8EGR3+5B+v5KdYvORs4YNfDPci0zCs+pC0= @@ -944,8 +944,8 @@ github.com/coder/quartz v0.2.1 h1:QgQ2Vc1+mvzewg2uD/nj8MJ9p9gE+QhGJm+Z+NGnrSE= github.com/coder/quartz v0.2.1/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA= github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc= github.com/coder/retry v1.5.1/go.mod h1:blHMk9vs6LkoRT9ZHyuZo360cufXEhrxqvEzeMtRGoY= -github.com/coder/serpent v0.10.0 h1:ofVk9FJXSek+SmL3yVE3GoArP83M+1tX+H7S4t8BSuM= -github.com/coder/serpent v0.10.0/go.mod h1:cZFW6/fP+kE9nd/oRkEHJpG6sXCtQ+AX7WMMEHv0Y3Q= +github.com/coder/serpent v0.11.0 h1:VKIIbBg0ManopqqDsutBGf7YYTUXsPQgBx//m1SJQ90= +github.com/coder/serpent v0.11.0/go.mod h1:cZFW6/fP+kE9nd/oRkEHJpG6sXCtQ+AX7WMMEHv0Y3Q= github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuOD6a/zVP3rcxezNsoDseTUw= github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ= github.com/coder/tailscale v1.1.1-0.20250829055706-6eafe0f9199e h1:9RKGKzGLHtTvVBQublzDGtCtal3cXP13diCHoAIGPeI= @@ -1520,8 +1520,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/liamg/memoryfs v1.6.0 h1:jAFec2HI1PgMTem5gR7UT8zi9u4BfG5jorCRlLH06W8= github.com/liamg/memoryfs v1.6.0/go.mod h1:z7mfqXFQS8eSeBBsFjYLlxYRMRyiPktytvYCYTb3BSk= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= @@ -1776,8 +1776,8 @@ github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2 github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/sqlc-dev/pqtype v0.3.0 h1:b09TewZ3cSnO5+M1Kqq05y0+OjqIptxELaSayg7bmqk= @@ -2069,8 +2069,8 @@ golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= -golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= diff --git a/provisioner/terraform/executor.go b/provisioner/terraform/executor.go index 8940a1708bf19..67922d0ffbd48 100644 --- a/provisioner/terraform/executor.go +++ b/provisioner/terraform/executor.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "hash/crc32" "io" "os" "os/exec" @@ -220,6 +221,10 @@ func (e *executor) init(ctx, killCtx context.Context, logr logSink) error { e.mut.Lock() defer e.mut.Unlock() + // Record lock file checksum before init + lockFilePath := filepath.Join(e.workdir, ".terraform.lock.hcl") + preInitChecksum := checksumFileCRC32(ctx, e.logger, lockFilePath) + outWriter, doneOut := logWriter(logr, proto.LogLevel_DEBUG) errWriter, doneErr := logWriter(logr, proto.LogLevel_ERROR) defer func() { @@ -246,7 +251,30 @@ func (e *executor) init(ctx, killCtx context.Context, logr logSink) error { return &textFileBusyError{exitErr: exitErr, stderr: errBuf.b.String()} } } - return err + if err != nil { + return err + } + + // Check if lock file was modified + postInitChecksum := checksumFileCRC32(ctx, e.logger, lockFilePath) + if preInitChecksum != 0 && postInitChecksum != 0 && preInitChecksum != postInitChecksum { + e.logger.Warn(ctx, fmt.Sprintf(".terraform.lock.hcl was modified during init. This means provider hashes "+ + "are missing for the current platform (%s_%s). Update the lock file with:\n\n"+ + " terraform providers lock -platform=linux_amd64 -platform=linux_arm64 "+ + "-platform=darwin_amd64 -platform=darwin_arm64 -platform=windows_amd64\n", + runtime.GOOS, runtime.GOARCH), + ) + } + return nil +} + +func checksumFileCRC32(ctx context.Context, logger slog.Logger, path string) uint32 { + content, err := os.ReadFile(path) + if err != nil { + logger.Debug(ctx, "file %s does not exist or can't be read, skip checksum calculation") + return 0 + } + return crc32.ChecksumIEEE(content) } func getPlanFilePath(workdir string) string { diff --git a/provisioner/terraform/executor_internal_test.go b/provisioner/terraform/executor_internal_test.go index a39d8758893b8..04d57a1e4c9f1 100644 --- a/provisioner/terraform/executor_internal_test.go +++ b/provisioner/terraform/executor_internal_test.go @@ -2,12 +2,14 @@ package terraform import ( "encoding/json" + "os" "testing" tfjson "github.com/hashicorp/terraform-json" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" ) type mockLogger struct { @@ -171,3 +173,46 @@ func TestOnlyDataResources(t *testing.T) { }) } } + +func TestChecksumFileCRC32(t *testing.T) { + t.Parallel() + + t.Run("file exists", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + + tmpfile, err := os.CreateTemp("", "lockfile-*.hcl") + require.NoError(t, err) + defer os.Remove(tmpfile.Name()) + + content := []byte("provider \"aws\" { version = \"5.0.0\" }") + _, err = tmpfile.Write(content) + require.NoError(t, err) + tmpfile.Close() + + // Calculate checksum - expected value for this specific content + expectedChecksum := uint32(0x08f39f51) + checksum := checksumFileCRC32(ctx, logger, tmpfile.Name()) + require.Equal(t, expectedChecksum, checksum) + + // Modify file + err = os.WriteFile(tmpfile.Name(), []byte("modified content"), 0o600) + require.NoError(t, err) + + // Checksum should be different + modifiedChecksum := checksumFileCRC32(ctx, logger, tmpfile.Name()) + require.NotEqual(t, expectedChecksum, modifiedChecksum) + }) + + t.Run("file does not exist", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + + checksum := checksumFileCRC32(ctx, logger, "/nonexistent/file.hcl") + require.Zero(t, checksum) + }) +} diff --git a/scaletest/dynamicparameters/template.go b/scaletest/dynamicparameters/template.go index 9e7d8bc97f867..5faf67e531320 100644 --- a/scaletest/dynamicparameters/template.go +++ b/scaletest/dynamicparameters/template.go @@ -168,12 +168,13 @@ type SDKForDynamicParametersSetup interface { // partitioner is an internal struct to hold context and arguments for partition setup // and to provide methods for all sub-steps. type partitioner struct { - ctx context.Context - client SDKForDynamicParametersSetup - orgID uuid.UUID - templateName string - numEvals int64 - logger slog.Logger + ctx context.Context + client SDKForDynamicParametersSetup + orgID uuid.UUID + templateName string + provisionerTags map[string]string + numEvals int64 + logger slog.Logger // for testing clock quartz.Clock @@ -181,17 +182,19 @@ type partitioner struct { func SetupPartitions( ctx context.Context, client SDKForDynamicParametersSetup, - orgID uuid.UUID, templateName string, numEvals int64, + orgID uuid.UUID, templateName string, provisionerTags map[string]string, + numEvals int64, logger slog.Logger, ) ([]Partition, error) { p := &partitioner{ - ctx: ctx, - client: client, - orgID: orgID, - templateName: templateName, - numEvals: numEvals, - logger: logger, - clock: quartz.NewReal(), + ctx: ctx, + client: client, + orgID: orgID, + templateName: templateName, + provisionerTags: provisionerTags, + numEvals: numEvals, + logger: logger, + clock: quartz.NewReal(), } return p.run() } @@ -272,11 +275,12 @@ func (p *partitioner) createTemplateVersion(templateID uuid.UUID) (codersdk.Temp // Create template version versionReq := codersdk.CreateTemplateVersionRequest{ - TemplateID: templateID, - FileID: uploadResp.ID, - Message: "Initial version for scaletest dynamic parameters", - StorageMethod: codersdk.ProvisionerStorageMethodFile, - Provisioner: codersdk.ProvisionerTypeTerraform, + TemplateID: templateID, + FileID: uploadResp.ID, + Message: "Initial version for scaletest dynamic parameters", + StorageMethod: codersdk.ProvisionerStorageMethodFile, + Provisioner: codersdk.ProvisionerTypeTerraform, + ProvisionerTags: p.provisionerTags, } version, err := p.client.CreateTemplateVersion(p.ctx, p.orgID, versionReq) if err != nil { diff --git a/scaletest/dynamicparameters/template_internal_test.go b/scaletest/dynamicparameters/template_internal_test.go index 0b000a4c74981..6b1230eeae75e 100644 --- a/scaletest/dynamicparameters/template_internal_test.go +++ b/scaletest/dynamicparameters/template_internal_test.go @@ -70,6 +70,7 @@ func TestSetupPartitions_TemplateExists(t *testing.T) { t: t, expectedTemplateName: "test-template", expectedOrgID: orgID, + expectedTags: map[string]string{"foo": "bar"}, matchedProvisioners: 1, templateVersionJobStatus: codersdk.ProvisionerJobSucceeded, } @@ -77,13 +78,14 @@ func TestSetupPartitions_TemplateExists(t *testing.T) { trap := mClock.Trap().TickerFunc("waitForTemplateVersionJobs") defer trap.Close() uut := partitioner{ - ctx: ctx, - client: fClient, - orgID: orgID, - templateName: "test-template", - numEvals: 600, - logger: logger, - clock: mClock, + ctx: ctx, + client: fClient, + orgID: orgID, + templateName: "test-template", + provisionerTags: map[string]string{"foo": "bar"}, + numEvals: 600, + logger: logger, + clock: mClock, } var partitions []Partition errCh := make(chan error, 1) @@ -234,6 +236,7 @@ type fakeClient struct { expectedOrgID uuid.UUID templateByNameError error + expectedTags map[string]string matchedProvisioners int templateVersionJobStatus codersdk.ProvisionerJobStatus @@ -270,6 +273,7 @@ func (f *fakeClient) CreateTemplate(ctx context.Context, orgID uuid.UUID, create func (f *fakeClient) CreateTemplateVersion(ctx context.Context, orgID uuid.UUID, createReq codersdk.CreateTemplateVersionRequest) (codersdk.TemplateVersion, error) { f.templateVersionsCount++ + require.Equal(f.t, f.expectedTags, createReq.ProvisionerTags) return codersdk.TemplateVersion{ ID: uuid.New(), Name: f.expectedTemplateName, diff --git a/site/src/api/api.ts b/site/src/api/api.ts index a5c9148b017a0..04bf78538a343 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -21,7 +21,6 @@ */ import globalAxios, { type AxiosInstance, isAxiosError } from "axios"; import type dayjs from "dayjs"; -import type { Task } from "modules/tasks/tasks"; import userAgentParser from "ua-parser-js"; import { delay } from "../utils/delay"; import { OneWayWebSocket } from "../utils/OneWayWebSocket"; @@ -426,10 +425,6 @@ export type GetProvisionerDaemonsParams = { offline?: boolean; }; -export type TasksFilter = { - username?: string; -}; - /** * This is the container for all API methods. It's split off to make it more * clear where API methods should go, but it is eventually merged into the Api @@ -2712,28 +2707,35 @@ class ExperimentalApiMethods { return response.data; }; - getTasks = async (filter: TasksFilter): Promise => { - const queryExpressions = ["has-ai-task:true"]; - - if (filter.username) { - queryExpressions.push(`owner:${filter.username}`); + getTasks = async ( + filter: TypesGen.TasksFilter, + ): Promise => { + const query: string[] = []; + if (filter.owner) { + query.push(`owner:${filter.owner}`); + } + if (filter.status) { + query.push(`status:${filter.status}`); } - const res = await API.getWorkspaces({ - q: queryExpressions.join(" "), - }); - // Exclude prebuild workspaces as they are not user-facing. - const workspaces = res.workspaces.filter( - (workspace) => !workspace.is_prebuild, + const res = await this.axios.get( + "/api/experimental/tasks", + { + params: { + q: query.join(", "), + }, + }, ); - const prompts = await API.experimental.getAITasksPrompts( - workspaces.map((workspace) => workspace.latest_build.id), + + return res.data.tasks; + }; + + getTask = async (user: string, id: string): Promise => { + const response = await this.axios.get( + `/api/experimental/tasks/${user}/${id}`, ); - return workspaces.map((workspace) => ({ - workspace, - prompt: prompts.prompts[workspace.latest_build.id], - })); + return response.data; }; deleteTask = async (user: string, id: string): Promise => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 65b1d2d3e5302..9028cd91ce4ac 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -39,7 +39,7 @@ export interface AIBridgeInterception { // From codersdk/aibridge.go export interface AIBridgeListInterceptionsResponse { - readonly total: number; + readonly count: number; readonly results: readonly AIBridgeInterception[]; } @@ -141,6 +141,7 @@ export interface APIKey { readonly scopes: readonly APIKeyScope[]; readonly token_name: string; readonly lifetime_seconds: number; + readonly allow_list: readonly APIAllowListTarget[]; } // From codersdk/apikey.go @@ -2515,6 +2516,7 @@ export interface LinkConfig { readonly name: string; readonly target: string; readonly icon: string; + readonly location?: string; } // From codersdk/inboxnotification.go @@ -2888,6 +2890,7 @@ export interface NullHCLString { export interface OAuth2AppEndpoints { readonly authorization: string; readonly token: string; + readonly token_revoke: string; /** * DeviceAuth is optional. */ @@ -4698,19 +4701,23 @@ export interface Task { readonly organization_id: string; readonly owner_id: string; readonly owner_name: string; + readonly owner_avatar_url?: string; readonly name: string; readonly template_id: string; + readonly template_version_id: string; readonly template_name: string; readonly template_display_name: string; readonly template_icon: string; readonly workspace_id: string | null; + readonly workspace_name: string; + readonly workspace_status?: WorkspaceStatus; readonly workspace_build_number?: number; readonly workspace_agent_id: string | null; readonly workspace_agent_lifecycle: WorkspaceAgentLifecycle | null; readonly workspace_agent_health: WorkspaceAgentHealth | null; readonly workspace_app_id: string | null; readonly initial_prompt: string; - readonly status: WorkspaceStatus; + readonly status: TaskStatus; readonly current_state: TaskStateEntry | null; readonly created_at: string; readonly updated_at: string; @@ -4777,6 +4784,24 @@ export const TaskStates: TaskState[] = [ "working", ]; +// From codersdk/aitasks.go +export type TaskStatus = + | "active" + | "error" + | "initializing" + | "paused" + | "pending" + | "unknown"; + +export const TaskStatuses: TaskStatus[] = [ + "active", + "error", + "initializing", + "paused", + "pending", + "unknown", +]; + // From codersdk/aitasks.go /** * TasksFilter filters the list of tasks. @@ -4788,6 +4813,29 @@ export interface TasksFilter { * Owner can be a username, UUID, or "me". */ readonly owner?: string; + /** + * Organization can be an organization name or UUID. + */ + readonly organization?: string; + /** + * Status filters the tasks by their task status. + */ + readonly status?: TaskStatus; + /** + * FilterQuery allows specifying a raw filter query. + */ + readonly filter_query?: string; +} + +// From codersdk/aitasks.go +/** + * TaskListResponse is the response shape for tasks list. + * + * Experimental response shape for tasks list (server returns []Task). + */ +export interface TasksListResponse { + readonly tasks: readonly Task[]; + readonly count: number; } // From codersdk/deployment.go @@ -5381,6 +5429,14 @@ export interface UpdateTemplateMeta { readonly use_classic_parameter_flow?: boolean; } +// From codersdk/apikey.go +export interface UpdateTokenRequest { + readonly scope?: APIKeyScope; + readonly scopes?: APIKeyScope[]; + readonly allow_list?: APIAllowListTarget[]; + readonly lifetime?: number; +} + // From codersdk/users.go export interface UpdateUserAppearanceSettingsRequest { readonly theme_preference: string; @@ -6329,7 +6385,11 @@ export interface WorkspaceBuild { readonly matched_provisioners?: MatchedProvisioners; readonly template_version_preset_id: string | null; readonly has_ai_task?: boolean; + /** + * Deprecated: This field has been replaced with `TaskAppID` + */ readonly ai_task_sidebar_app_id?: string; + readonly task_app_id?: string; readonly has_external_agent?: boolean; } diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index 8db0252c0059d..012053341e976 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -1,4 +1,5 @@ import { buildInfo } from "api/queries/buildInfo"; +import type { LinkConfig } from "api/typesGenerated"; import { useProxy } from "contexts/ProxyContext"; import { useAuthenticated } from "hooks"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; @@ -25,12 +26,18 @@ export const Navbar: FC = () => { const canViewConnectionLog = featureVisibility.connection_log && permissions.viewAnyConnectionLog; + const uniqueLinks = new Map(); + for (const link of appearance.support_links ?? []) { + if (!uniqueLinks.has(link.name)) { + uniqueLinks.set(link.name, link); + } + } return ( = { @@ -33,6 +29,7 @@ const meta: Meta = { canViewDeployment: true, canViewHealth: true, canViewOrganizations: true, + supportLinks: [], }, decorators: [withDashboardProvider], }; @@ -102,30 +99,69 @@ export const IdleTasks: Story = { queries: [ { key: ["tasks", tasksFilter], - data: [ - { - prompt: "Task 1", - workspace: { - ...MockWorkspace, - latest_app_status: { - ...MockWorkspaceAppStatus, - state: "idle", - }, - }, - }, - { - prompt: "Task 2", - workspace: MockWorkspace, - }, - { - prompt: "Task 3", - workspace: { - ...MockWorkspace, - latest_app_status: MockWorkspaceAppStatus, - }, - }, - ], + data: MockTasks, }, ], }, }; + +export const SupportLinks: Story = { + args: { + user: MockUserMember, + canViewAuditLog: false, + canViewDeployment: false, + canViewHealth: false, + canViewOrganizations: false, + supportLinks: [ + { + name: "This is a bug", + icon: "bug", + target: "#", + }, + { + name: "This is a star", + icon: "star", + target: "#", + location: "navbar", + }, + { + name: "This is a chat", + icon: "chat", + target: "#", + location: "navbar", + }, + { + name: "No icon here", + icon: "", + target: "#", + location: "navbar", + }, + { + name: "No icon here too", + icon: "", + target: "#", + }, + ], + }, +}; + +export const DefaultSupportLinks: Story = { + args: { + user: MockUserMember, + canViewAuditLog: false, + canViewDeployment: false, + canViewHealth: false, + canViewOrganizations: false, + supportLinks: [ + { icon: "docs", name: "Documentation", target: "" }, + { icon: "bug", name: "Report a bug", target: "" }, + { + icon: "chat", + name: "Join the Coder Discord", + target: "", + location: "navbar", + }, + { icon: "star", name: "Star the Repo", target: "" }, + ], + }, +}; diff --git a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx index 9d011089ba6c5..f313b6aa2b33e 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.test.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.test.tsx @@ -34,6 +34,7 @@ describe("NavbarView", () => { canViewHealth canViewAuditLog canViewConnectionLog + supportLinks={[]} />, ); const workspacesLink = @@ -52,6 +53,7 @@ describe("NavbarView", () => { canViewHealth canViewAuditLog canViewConnectionLog + supportLinks={[]} />, ); const templatesLink = @@ -70,6 +72,7 @@ describe("NavbarView", () => { canViewHealth canViewAuditLog canViewConnectionLog + supportLinks={[]} />, ); const deploymentMenu = await screen.findByText("Admin settings"); @@ -89,6 +92,7 @@ describe("NavbarView", () => { canViewHealth canViewAuditLog canViewConnectionLog + supportLinks={[]} />, ); const deploymentMenu = await screen.findByText("Admin settings"); diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 0cafaa8fdd46f..eb7b8afe5c923 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -21,13 +21,14 @@ import { cn } from "utils/cn"; import { DeploymentDropdown } from "./DeploymentDropdown"; import { MobileMenu } from "./MobileMenu"; import { ProxyMenu } from "./ProxyMenu"; +import { SupportIcon } from "./SupportIcon"; import { UserDropdown } from "./UserDropdown/UserDropdown"; interface NavbarViewProps { logo_url?: string; user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; - supportLinks?: readonly TypesGen.LinkConfig[]; + supportLinks: readonly TypesGen.LinkConfig[]; onSignOut: () => void; canViewDeployment: boolean; canViewOrganizations: boolean; @@ -71,6 +72,16 @@ export const NavbarView: FC = ({
+ {supportLinks.filter(isNavbarLink).map((link) => ( +
+ +
+ ))} + {proxyContextValue && (
@@ -121,7 +132,7 @@ export const NavbarView: FC = ({ !isNavbarLink(link))} onSignOut={onSignOut} />
@@ -189,8 +200,8 @@ const TasksNavItem: FC = ({ user }) => { process.env.NODE_ENV === "development" || process.env.STORYBOOK, ); - const filter = { - username: user.username, + const filter: TypesGen.TasksFilter = { + owner: user.username, }; const { data: idleCount } = useQuery({ queryKey: ["tasks", filter], @@ -200,8 +211,7 @@ const TasksNavItem: FC = ({ user }) => { refetchOnWindowFocus: true, initialData: [], select: (data) => - data.filter((task) => task.workspace.latest_app_status?.state === "idle") - .length, + data.filter((task) => task.current_state?.state === "idle").length, }); if (!canSeeTasks) { @@ -240,3 +250,36 @@ const TasksNavItem: FC = ({ user }) => { function idleTasksLabel(count: number) { return `You have ${count} ${count === 1 ? "task" : "tasks"} waiting for input`; } + +function isNavbarLink(link: TypesGen.LinkConfig): boolean { + return link.location === "navbar"; +} + +interface SupportButtonProps { + name: string; + target: string; + icon: string; + location?: string; +} + +const SupportButton: FC = ({ name, target, icon }) => { + return ( + + ); +}; diff --git a/site/src/modules/dashboard/Navbar/SupportIcon.tsx b/site/src/modules/dashboard/Navbar/SupportIcon.tsx new file mode 100644 index 0000000000000..6c32f03aea67a --- /dev/null +++ b/site/src/modules/dashboard/Navbar/SupportIcon.tsx @@ -0,0 +1,39 @@ +import type { SvgIconProps } from "@mui/material/SvgIcon"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { BookOpenTextIcon, BugIcon, MessageSquareIcon } from "lucide-react"; +import type { FC } from "react"; + +interface SupportIconProps { + icon: string; + className?: string; +} + +export const SupportIcon: FC = ({ icon, className }) => { + switch (icon) { + case "bug": + return ; + case "chat": + return ; + case "docs": + return ; + case "star": + return ; + default: + return ; + } +}; + +const GithubStar: FC = (props) => ( + +); diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx index 6240a68c9509e..4c796ee436d47 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx @@ -11,7 +11,7 @@ import { UserDropdownContent } from "./UserDropdownContent"; interface UserDropdownProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; - supportLinks?: readonly TypesGen.LinkConfig[]; + supportLinks: readonly TypesGen.LinkConfig[]; onSignOut: () => void; } diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx index 70e2f35c941e1..1d25d894eacb6 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx @@ -8,7 +8,11 @@ describe("UserDropdownContent", () => { it("has the correct link for the account item", async () => { render( - + , ); await waitForLoaderToBeRemoved(); @@ -25,7 +29,11 @@ describe("UserDropdownContent", () => { const onSignOut = jest.fn(); render( - + , ); await waitForLoaderToBeRemoved(); diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index fd0636da3d457..b56c1c67deadd 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -6,24 +6,20 @@ import { } from "@emotion/react"; import Divider from "@mui/material/Divider"; import MenuItem from "@mui/material/MenuItem"; -import type { SvgIconProps } from "@mui/material/SvgIcon"; import Tooltip from "@mui/material/Tooltip"; import { PopoverClose } from "@radix-ui/react-popover"; import type * as TypesGen from "api/typesGenerated"; import { CopyButton } from "components/CopyButton/CopyButton"; -import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { Stack } from "components/Stack/Stack"; import { - BookOpenTextIcon, - BugIcon, CircleUserIcon, LogOutIcon, - MessageSquareIcon, MonitorDownIcon, SquareArrowOutUpRightIcon, } from "lucide-react"; -import type { FC, JSX } from "react"; +import type { FC } from "react"; import { Link } from "react-router"; +import { SupportIcon } from "../SupportIcon"; export const Language = { accountLabel: "Account", @@ -34,7 +30,7 @@ export const Language = { interface UserDropdownContentProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; - supportLinks?: readonly TypesGen.LinkConfig[]; + supportLinks: readonly TypesGen.LinkConfig[]; onSignOut: () => void; } @@ -44,26 +40,6 @@ export const UserDropdownContent: FC = ({ supportLinks, onSignOut, }) => { - const renderMenuIcon = (icon: string): JSX.Element => { - switch (icon) { - case "bug": - return ; - case "chat": - return ; - case "docs": - return ; - case "star": - return ; - default: - return ( - - ); - } - }; - return (
@@ -76,7 +52,7 @@ export const UserDropdownContent: FC = ({ - + Install CLI @@ -85,14 +61,14 @@ export const UserDropdownContent: FC = ({ - + {Language.accountLabel} - + {Language.signOutLabel} @@ -109,7 +85,12 @@ export const UserDropdownContent: FC = ({ > - {renderMenuIcon(link.icon)} + {link.icon && ( + + )} {link.name} @@ -152,21 +133,6 @@ export const UserDropdownContent: FC = ({ ); }; -const GithubStar: FC = (props) => ( - -); - const styles = { info: (theme) => [ theme.typography.body2 as CSSObject, @@ -196,11 +162,6 @@ const styles = { transition: background-color 0.3s ease; } `, - menuItemIcon: (theme) => ({ - color: theme.palette.text.secondary, - width: 20, - height: 20, - }), menuItemText: { fontSize: 14, }, diff --git a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx index e595c26a7884e..faf4894ec7a7c 100644 --- a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx +++ b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx @@ -1,4 +1,4 @@ -import { MockTasks, MockWorkspace } from "testHelpers/entities"; +import { MockTask } from "testHelpers/entities"; import { withGlobalSnackbar } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; @@ -18,7 +18,7 @@ export const DeleteTaskSuccess: Story = { decorators: [withGlobalSnackbar], args: { open: true, - task: { prompt: "My Task", workspace: MockWorkspace }, + task: MockTask, onClose: () => {}, }, parameters: { @@ -40,8 +40,8 @@ export const DeleteTaskSuccess: Story = { await step("Confirm delete", async () => { await waitFor(() => { expect(API.experimental.deleteTask).toHaveBeenCalledWith( - MockTasks[0].workspace.owner_name, - MockTasks[0].workspace.id, + MockTask.owner_name, + MockTask.id, ); }); }); diff --git a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx index b5bac134a66e4..2e8dc14ce7b3c 100644 --- a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx +++ b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx @@ -1,10 +1,10 @@ import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; +import type { Task } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import type { FC } from "react"; import { QueryClient, useMutation } from "react-query"; -import type { Task } from "../tasks"; type TaskDeleteDialogProps = { open: boolean; @@ -20,8 +20,7 @@ export const TaskDeleteDialog: FC = ({ }) => { const queryClient = new QueryClient(); const deleteTaskMutation = useMutation({ - mutationFn: () => - API.experimental.deleteTask(task.workspace.owner_name, task.workspace.id), + mutationFn: () => API.experimental.deleteTask(task.owner_name, task.id), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["tasks"] }); }, diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx index bac6c41aab999..9572c68c75259 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx @@ -1,6 +1,5 @@ import { MockAIPromptPresets, - MockNewTaskData, MockPresets, MockTask, MockTasks, @@ -14,10 +13,19 @@ import { import { withAuthProvider, withGlobalSnackbar } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; +import type { Task } from "api/typesGenerated"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import type TasksPage from "../../../pages/TasksPage/TasksPage"; import { TaskPrompt } from "./TaskPrompt"; +const MockNewTaskData: Task = { + ...MockTask, + current_state: { + ...MockTask.current_state, + message: "Task created successfully!", + }, +}; + const meta: Meta = { title: "modules/tasks/TaskPrompt", component: TaskPrompt, @@ -77,7 +85,7 @@ export const SubmitEnabledWhenPromptNotEmpty: Story = { const canvas = within(canvasElement); const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.prompt); + await userEvent.type(prompt, MockNewTaskData.initial_prompt); const submitButton = canvas.getByRole("button", { name: /run task/i }); expect(submitButton).toBeEnabled(); @@ -152,7 +160,7 @@ export const OnSuccess: Story = { await step("Run task", async () => { const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.prompt); + await userEvent.type(prompt, MockNewTaskData.initial_prompt); const submitButton = canvas.getByRole("button", { name: /run task/i }); await waitFor(() => expect(submitButton).toBeEnabled()); await userEvent.click(submitButton); @@ -162,7 +170,7 @@ export const OnSuccess: Story = { expect(API.experimental.createTask).toHaveBeenCalledWith( MockUserOwner.id, { - input: MockNewTaskData.prompt, + input: MockNewTaskData.initial_prompt, template_version_id: `${MockTemplate.active_version_id}-latest`, template_version_preset_id: undefined, }, @@ -267,7 +275,7 @@ export const SelectTemplateVersion: Story = { await step("Fill prompt", async () => { const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.prompt); + await userEvent.type(prompt, MockNewTaskData.initial_prompt); }); await step("Select version", async () => { @@ -290,7 +298,7 @@ export const SelectTemplateVersion: Story = { expect(API.experimental.createTask).toHaveBeenCalledWith( MockUserOwner.id, { - input: MockNewTaskData.prompt, + input: MockNewTaskData.initial_prompt, template_version_id: "test-template-version-2", template_version_preset_id: undefined, }, @@ -375,7 +383,7 @@ export const MissingExternalAuth: Story = { await step("Submit is disabled", async () => { const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.prompt); + await userEvent.type(prompt, MockNewTaskData.initial_prompt); const submitButton = canvas.getByRole("button", { name: /run task/i }); expect(submitButton).toBeDisabled(); }); @@ -403,7 +411,7 @@ export const ExternalAuthError: Story = { await step("Submit is disabled", async () => { const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.prompt); + await userEvent.type(prompt, MockNewTaskData.initial_prompt); const submitButton = canvas.getByRole("button", { name: /run task/i }); expect(submitButton).toBeDisabled(); }); diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx index 580b520d2c858..b40f3253a8e93 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx @@ -29,7 +29,6 @@ import { import { useAuthenticated } from "hooks/useAuthenticated"; import { useExternalAuth } from "hooks/useExternalAuth"; import { ArrowUpIcon, RedoIcon, RotateCcwIcon } from "lucide-react"; -import { AI_PROMPT_PARAMETER_NAME } from "modules/tasks/tasks"; import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import TextareaAutosize, { @@ -39,6 +38,8 @@ import { docs } from "utils/docs"; import { PromptSelectTrigger } from "./PromptSelectTrigger"; import { TemplateVersionSelect } from "./TemplateVersionSelect"; +const AI_PROMPT_PARAMETER_NAME = "AI Prompt"; + type TaskPromptProps = { templates: Template[] | undefined; error: unknown; diff --git a/site/src/modules/tasks/TaskStatus/TaskStatus.stories.tsx b/site/src/modules/tasks/TaskStatus/TaskStatus.stories.tsx new file mode 100644 index 0000000000000..9d20b357dd7f6 --- /dev/null +++ b/site/src/modules/tasks/TaskStatus/TaskStatus.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TaskStatus } from "./TaskStatus"; + +const meta: Meta = { + title: "modules/tasks/TaskStatus", + component: TaskStatus, +}; + +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { + status: "active", + stateMessage: "Task is running smoothly", + }, +}; + +export const Failed: Story = { + args: { + status: "error", + stateMessage: "Task encountered an error", + }, +}; + +export const Initializing: Story = { + args: { + status: "initializing", + stateMessage: "Task is initializing", + }, +}; + +export const Pending: Story = { + args: { + status: "pending", + stateMessage: "Task is pending", + }, +}; + +export const Paused: Story = { + args: { + status: "paused", + stateMessage: "Task is paused", + }, +}; + +export const Unknown: Story = { + args: { + status: "unknown", + stateMessage: "Task status is unknown", + }, +}; diff --git a/site/src/modules/tasks/TaskStatus/TaskStatus.tsx b/site/src/modules/tasks/TaskStatus/TaskStatus.tsx new file mode 100644 index 0000000000000..81204dee44536 --- /dev/null +++ b/site/src/modules/tasks/TaskStatus/TaskStatus.tsx @@ -0,0 +1,41 @@ +import type * as TypesGen from "api/typesGenerated"; +import { + StatusIndicator, + StatusIndicatorDot, + type StatusIndicatorProps, +} from "components/StatusIndicator/StatusIndicator"; +import type { FC } from "react"; + +type TaskStatusProps = { + status: TypesGen.TaskStatus; + stateMessage: string; +}; + +export const taskStatusToStatusIndicatorVariant: Record< + TypesGen.TaskStatus, + StatusIndicatorProps["variant"] +> = { + active: "success", + error: "failed", + initializing: "pending", + pending: "pending", + paused: "inactive", + unknown: "warning", +}; + +export const TaskStatus: FC = ({ status, stateMessage }) => { + return ( + + +
+ {status} + + {stateMessage} + +
+
+ ); +}; diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx index 11fb2c54bbbb1..7af5fa6b471c0 100644 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx @@ -19,13 +19,14 @@ const meta: Meta = { }, reactRouter: reactRouterParameters({ location: { - path: `/tasks/${MockTasks[0].workspace.name}`, + path: `/tasks/${MockTasks[0].owner_name}/${MockTasks[0].id}`, pathParams: { - workspace: MockTasks[0].workspace.name, + owner_name: MockTasks[0].owner_name, + taskId: MockTasks[0].id, }, }, routing: [ - { path: "/tasks/:workspace", useStoryElement: true }, + { path: "/tasks/:username/:taskId", useStoryElement: true }, { path: "/tasks", element:
Tasks Index Page
}, ], }), diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx index b63366e1b98cc..eddbfa6f3f55e 100644 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx @@ -1,6 +1,6 @@ import { API } from "api/api"; import { getErrorMessage } from "api/errors"; -import { cva } from "class-variance-authority"; +import type { Task, TasksFilter } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { DropdownMenu, @@ -12,6 +12,7 @@ import { import { CoderIcon } from "components/Icons/CoderIcon"; import { ScrollArea } from "components/ScrollArea/ScrollArea"; import { Skeleton } from "components/Skeleton/Skeleton"; +import { StatusIndicatorDot } from "components/StatusIndicator/StatusIndicator"; import { Tooltip, TooltipContent, @@ -21,18 +22,18 @@ import { import { useAuthenticated } from "hooks"; import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import { EditIcon, EllipsisIcon, PanelLeftIcon, TrashIcon } from "lucide-react"; -import type { Task } from "modules/tasks/tasks"; import { type FC, useState } from "react"; import { useQuery } from "react-query"; import { Link as RouterLink, useNavigate, useParams } from "react-router"; import { cn } from "utils/cn"; import { TaskDeleteDialog } from "../TaskDeleteDialog/TaskDeleteDialog"; +import { taskStatusToStatusIndicatorVariant } from "../TaskStatus/TaskStatus"; import { UserCombobox } from "./UserCombobox"; export const TasksSidebar: FC = () => { const { user, permissions } = useAuthenticated(); - const usernameParam = useSearchParamsKey({ - key: "username", + const ownerParam = useSearchParamsKey({ + key: "owner", defaultValue: user.username, }); @@ -42,11 +43,11 @@ export const TasksSidebar: FC = () => {
-
+
{!isCollapsed && (
- {!isCollapsed && } + {!isCollapsed && }
); }; type TasksSidebarGroupProps = { - username: string; + owner: string; }; -const TasksSidebarGroup: FC = ({ username }) => { - const filter = { username }; +const TasksSidebarGroup: FC = ({ owner }) => { + const filter: TasksFilter = { owner }; const tasksQuery = useQuery({ queryKey: ["tasks", filter], queryFn: () => API.experimental.getTasks(filter), @@ -139,14 +140,14 @@ const TasksSidebarGroup: FC = ({ username }) => { }); return ( - +
Tasks
{tasksQuery.data ? ( tasksQuery.data.length > 0 ? ( - tasksQuery.data.map((t) => ( - + tasksQuery.data.map((task) => ( + )) ) : (
@@ -175,8 +176,8 @@ type TaskSidebarMenuItemProps = { }; const TaskSidebarMenuItem: FC = ({ task }) => { - const { workspace } = useParams<{ workspace: string }>(); - const isActive = task.workspace.name === workspace; + const { taskId } = useParams<{ taskId: string }>(); + const isActive = task.id === taskId; const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const navigate = useNavigate(); @@ -197,12 +198,12 @@ const TaskSidebarMenuItem: FC = ({ task }) => { > - {task.workspace.name} + {task.name}