Compare commits

...

89 Commits

Author SHA1 Message Date
Spike Curtis 097e085fcb fix: use correct slog arguments (#20721) (#20723)
Fixes a bad slog.Error() command that didn't wrap the error in
`slog.Error`

(cherry pick form https://github.com/coder/coder/pull/20721)
2025-11-12 00:30:09 +04:00
Cian Johnston b8ab2d351f chore: update Go to 1.24.10 (#20684) (#20688)
(cherry picked from commit 81c3375670)

Signed-off-by: Danny Kopping <danny@coder.com>
Co-authored-by: Danny Kopping <danny@coder.com>
2025-11-10 10:56:18 -06:00
Dean Sheather 1b1e3cb706 chore: change managed agent limit (#20664)
(cherry picked from commit b3f651d62f)
2025-11-04 12:11:37 -05:00
Mathias Fredriksson ea0aca0f26 fix(coderd): fix template ai task check error message (#20651) (#20659)
Create task was still mentioning magic prompt parameter when checking
template task validity. This change updates it to only mention validity
of `coder_ai_task` resource.

The previous message is incorrect, would lead to confusion and is
unhelpful.

(cherry picked from commit daad93967a)
2025-11-04 15:25:58 +02:00
Cian Johnston 563612eb3b fix: delete related task when deleting workspace (#20567) (#20585)
* Instead of prompting the user to start a deleted workspace (which is
silly), prompt them to create a new task instead.
* Adds a warning dialog when deleting a workspace related to a task
* Updates provisionerdserver to delete the related task if a workspace
is related to a task

(cherry picked from commit 73dedcc765)
2025-11-03 10:04:41 +00:00
Cian Johnston fa43ea8e68 chore: remove brazil fly.io proxy (#20601) (#20645)
(cherry picked from commit 7182c53df7)

Co-authored-by: Dean Sheather <dean@deansheather.com>
2025-11-03 09:41:16 +00:00
Cian Johnston d82ba7e3a4 fix(coderd): disallow POSTing a workspace build on a deleted workspace (#20584) (#20586)
- Adds a check on /api/v2/workspacebuilds to disallow creating a START
or STOP build if the workspace is deleted.
- DELETEs are still allowed.

(cherry picked from commit 38017010ce)
2025-11-03 09:01:57 +00:00
Cian Johnston cb4ea1f397 fix(coderd): fix audit log resource link for tasks (#20545) (#20547)
Existing task audit log links were incorrect. As audit log links are
generated on-the-fly, this does not require backfill.

(cherry picked from commit 566146af72)
2025-11-03 09:00:42 +00:00
Danielle Maywood effbe4e52e refactor: remove TaskAppID from codersdk.WorkspaceBuild (#20583) (#20592)
`TaskAppID` has not yet been shipped. We're dropping this field in favor
of using the same information but from `codersdk.Task`.

---

Cherry picked from d80b5fc8ed
https://github.com/coder/coder/pull/20583
2025-10-30 17:02:25 +00:00
Susana Ferreira 6424093146 feat: add prebuilds reconciliation duration metric (#20535) (#20581)
Related to PR: https://github.com/coder/coder/pull/20535

(cherry picked from commit aad1b401c1)
2025-10-30 12:16:19 +00:00
Susana Ferreira 2cf4b5c5a2 perf: optimize prebuilds membership reconciliation to check orgs not presets (#20493) (#20555)
Related to PR: https://github.com/coder/coder/pull/20493

(cherry picked from commit 7e8fcb4b0f)
2025-10-30 10:50:38 +00:00
Susana Ferreira a7b3efb540 feat: delete pending canceled prebuilds (#20499) (#20554)
Related to PR: https://github.com/coder/coder/pull/20499

(cherry picked from commit c3e3bb58f2)
2025-10-30 10:38:14 +00:00
Cian Johnston 0b5542f933 fix: update task link AppStatus using task_id (#20543) (#20551)
Fixes https://github.com/coder/coder/issues/20515

Alternative to https://github.com/coder/coder/pull/20519

Adds `task_id` to `workspaces_expanded` view and updates the "View Task"
link in `AppStatuses` component.

NOTE: this contains a migration
(cherry picked from commit 1ebc217624)

<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->
2025-10-29 21:45:43 +00:00
Danielle Maywood ba14acf4e8 fix(site): fix disappearing preset selector when switching task template (#20514) (#20564)
Ensure we set `selectedPresetId` to `undefined` when we change
`selectedTemplateId` to ensure we don't end up breaking the `<Select>`
component by giving it an invalid preset id.

---

Cherry picked from 9629d873fb (#20514)
2025-10-29 21:29:54 +00:00
Danielle Maywood d0a2e6d603 fix: ensure lifecycle executor has sufficient task permissions (#20539) (#20560)
We recently made a change to the `wsbuilder` to handle task related
logic. Our test coverage for the lifecycle executor didn't handle this
scenario and so we missed that it had insufficient permissions.

This PR adds `Update` and `Read` permissions for `Task`s in the
lifecycle executor, as well as an autostart/autostop test tailored to
task workspaces to verify the change.

---

This is cherry picked from
https://github.com/coder/coder/commit/06dbadab11760fe5fbf88c5bfcac2c48e11f7862
https://github.com/coder/coder/pull/20539
2025-10-29 21:08:26 +00:00
Danny Kopping 2a22440b0e chore!: patch release v2.28 to remove aibridge experiment (#20544)
Includes stack of PRs from https://github.com/coder/coder/pull/20520

---------

Signed-off-by: Danny Kopping <danny@coder.com>
2025-10-29 15:38:30 -04:00
Asher 8b6f55c312 chore: improve remote mcp workspace and file prompts (#20475)
I have been experimenting (via blink) and these seem to have made the
LLM behave more intelligently and consistently when it comes to creating
workspaces and manipulating files.

Partially addresses https://github.com/coder/internal/issues/1047
2025-10-28 15:20:20 -08:00
Danielle Maywood 40fc337659 fix(site): fix react state violation in filetree create/update utils (#20483) 2025-10-28 21:41:02 +00:00
david-fraley f6df4c0ed8 docs: update release calendar for new patches (#20526) 2025-10-28 14:24:03 -07:00
Steven Masley 924afb753f feat: add more detailed init timings in build timeline (#20503)
This uses the `terraform init -json` logs to add more visibility into
the `init` phase of `plan`. The `init` logs omit `ms` precision, so we
have to use `time.Now()` 😢

Open TF PR: https://github.com/hashicorp/terraform/pull/37818
2025-10-28 15:47:52 -05:00
Callum Styan 45c43d4ec4 fix: refactor agent resource monitoring API to avoid excessive calls to DB (#20430)
This should resolve https://github.com/coder/internal/issues/728 by
refactoring the ResourceMonitorAPI struct to only require querying the
resource monitor once for memory and once for volumes, then using the
stored monitors on the API struct from that point on. This should
eliminate the vast majority of calls to `GetWorkspaceByAgentID` and
`FetchVolumesResourceMonitorsUpdatedAfter`/`FetchMemoryResourceMonitorsUpdatedAfter`
(millions of calls per week).

Tests passed, and I ran an instance of coder via a workspace with a
template that added resource monitoring every 10s. Note that this is the
default docker container, so there are other sources of
`GetWorkspaceByAgentID` db queries. Note that this workspace was running
for ~15 minutes at the time I gathered this data.

Over 30s for the `ResourceMonitor` calls:
```
coder@callum-coder-2:~/coder$ curl localhost:19090/metrics | grep ResourceMonitor | grep count
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0coderd_db_query_latencies_seconds_count{query="FetchMemoryResourceMonitorsByAgentID"} 2
coderd_db_query_latencies_seconds_count{query="FetchMemoryResourceMonitorsUpdatedAfter"} 2
100  288k    0  288k    0     0  58.3M      0 --:--:-- --:--:-- --:--:-- 70.4M
coderd_db_query_latencies_seconds_count{query="FetchVolumesResourceMonitorsByAgentID"} 2
coderd_db_query_latencies_seconds_count{query="FetchVolumesResourceMonitorsUpdatedAfter"} 2
coderd_db_query_latencies_seconds_count{query="UpdateMemoryResourceMonitor"} 155
coderd_db_query_latencies_seconds_count{query="UpdateVolumeResourceMonitor"} 155
coder@callum-coder-2:~/coder$ curl localhost:19090/metrics | grep ResourceMonitor | grep count
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0coderd_db_query_latencies_seconds_count{query="FetchMemoryResourceMonitorsByAgentID"} 2
coderd_db_query_latencies_seconds_count{query="FetchMemoryResourceMonitorsUpdatedAfter"} 2
100  288k    0  288k    0     0  34.7M      0 --:--:-- --:--:-- --:--:-- 40.2M
coderd_db_query_latencies_seconds_count{query="FetchVolumesResourceMonitorsByAgentID"} 2
coderd_db_query_latencies_seconds_count{query="FetchVolumesResourceMonitorsUpdatedAfter"} 2
coderd_db_query_latencies_seconds_count{query="UpdateMemoryResourceMonitor"} 158
coderd_db_query_latencies_seconds_count{query="UpdateVolumeResourceMonitor"} 158
```

And over 1m for the `GetWorkspaceAgentByID` calls, the majority are from
the workspace metadata stats updates:
```
coder@callum-coder-2:~/coder$ curl localhost:19090/metrics | grep GetWorkspaceByAgentID | grep count
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  284k    0  284k    0     0  42.4M      0 --:--:-- --:--:-- --:--:-- 46.3M
coderd_db_query_latencies_seconds_count{query="GetWorkspaceByAgentID"} 876
coder@callum-coder-2:~/coder$ curl localhost:19090/metrics | grep GetWorkspaceByAgentID | grep count
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  284k    0  284k    0     0  75.4M      0 --:--:-- --:--:-- --:--:-- 92.7M
coderd_db_query_latencies_seconds_count{query="GetWorkspaceByAgentID"} 918
```

---------

Signed-off-by: Callum Styan <callumstyan@gmail.com>
2025-10-28 13:38:16 -07:00
Danielle Maywood a1e7e105a4 chore: disable task notifications by default (#20518)
Relates to https://github.com/coder/internal/issues/1098

Currently task notifications are incredibly noisy. We should disable
them by default for the upcoming release whilst we iron them out.
2025-10-28 17:21:23 +00:00
david-fraley cf93c34172 docs: update coder_token_lifetime description to include units and examples (#20516) 2025-10-28 15:15:57 +00:00
Cian Johnston 659f89e079 feat(coderd): add owner-related fields to tasks_with_status view (#20471)
Relates to
https://github.com/coder/coder/pull/20431/files#diff-9cfc826a6ce7e77d977b2025482474dd263d12965b2a94479a74c7f1d872b782

If the workspace relating to a task was deleted, most of the
workspace-related fields in `taskFromDBTaskAndWorkspace` will be
zero-valued. However, we can still get information relating to the owner
so that "created by" shows up correctly in the UI.

Updates the `tasks_with_status` view with a join on `visible_users` to
get owner-related info.
2025-10-28 14:29:29 +00:00
Danielle Maywood e4e4669feb fix(agent/agentcontainers): remove unneeded default branch (#20511)
Closes https://github.com/coder/internal/issues/769

According to the `time.NewTicker` documentation [^1] (which is used
under the hood by https://github.com/coder/quartz) it will automatically
adjust the time interval to make up for slow receivers. This means we
should be safe to drop the default branch.

> NewTicker returns a new Ticker containing a channel that will send the
current time on the channel after each tick. The period of the ticks is
specified by the duration argument. The ticker will adjust the time
interval or drop ticks to make up for slow receivers. The duration d
must be greater than zero; if not, NewTicker will panic.

[^1]: https://pkg.go.dev/time#Ticker
2025-10-28 12:16:42 +00:00
Mathias Fredriksson a1fa58ac17 fix: update dbgen and dbfake task creation and toolsdk test fixtures (#20508)
Depends on #20506
Fixes coder/internal#1103
2025-10-28 14:15:58 +02:00
Hugo Dutka 88b7372e7f chore: remove custom go cache download step from CI (#20510)
Since depot added [native support for go
cache](https://depot.dev/docs/cache/reference/gocache), custom cache
download and upload steps are not necessary anymore.
2025-10-28 12:58:25 +01:00
Dean Sheather dec6d310a8 fix: avoid bad switch statement in license code (#20509)
Noticed this while trying to investigate a flake.

Relates to https://github.com/coder/internal/issues/788
2025-10-28 06:19:52 +00:00
Spike Curtis e720afa9d0 docs: add description of dynamic parameters test (#20488)
## Add Dynamic Parameters test procedure to 10k users validated architecture

This PR adds a new test procedure for Dynamic Parameters to the 10k users validated architecture documentation. No changes to the recommended hardware specs as this test case succeeded with no issues.
2025-10-28 10:11:25 +04:00
Danny Kopping d18441debe feat: add AWS Bedrock support (#20507)
Depends on https://github.com/coder/aibridge/pull/44

Closes https://github.com/coder/aibridge/issues/28

---------

Signed-off-by: Danny Kopping <danny@coder.com>
2025-10-28 03:38:14 +00:00
ケイラ 4f7b279fd8 feat: add an organization member permission level (#19953) 2025-10-27 17:14:16 -06:00
Mathias Fredriksson c3cbd977f1 fix(coderd/database/dbfake): use transaction for workspace builder (#20506)
While investigating a flake I noticed that the dbfake workspace builder
executes all database inserts without a transaction. Since our real
wsbuilder implementation utilizes one it makes sense to do here as well.

For example, our normal workspace <-> build relationship is such that a
workspace cannot exist with at least one build. However, our
GetWorkspaces query left joins workspace builds but has types that are
non-nullable, leading to flakes like coder/internal#1103.
2025-10-28 01:06:52 +02:00
ケイラ d8b1ca70d6 chore: remove @Parkreiner from CODEOWNERS (#20504) 2025-10-27 11:55:33 -06:00
Dean Sheather 5a3ceb38f0 chore: add aibridge data to telemetry (#20449)
- Adds a new table to keep track of which payloads have already been
reported since we only report for the last clock hour
- Adds a query to gather and aggregate all the data by
provider/model/client

Relates to https://github.com/coder/coder-telemetry-server/issues/27
2025-10-28 03:16:41 +11:00
Thomas Kosiewski cadf1352b4 feat: add scoped token support to CLI (#19985)
<!--

If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting.

-->

Add support for scoped API tokens in CLI

This PR adds CLI support for creating and viewing API tokens with scopes and allow lists. It includes:

- New `--scope` and `--allow` flags for the `tokens create` command
- A new `tokens view` command to display detailed information about a token
- Updated table columns in `tokens list` to show scopes and allow list entries
- Updated help text and examples

These changes enable users to create tokens with limited permissions through the CLI, similar to the existing functionality in the web UI.
2025-10-27 17:07:25 +01:00
jesmine ed3d6fa9e3 fix(site): preserve file path when building template version (#20481)
Fixes issue where clicking Build in the template editor would always
redirect to main.tf instead of keeping the currently open file.

Closes #14130

<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->
2025-10-27 13:03:15 -03:00
ケイラ d9c40d61c2 refactor: clean up policy.rego (#20366) 2025-10-27 10:01:30 -06:00
Bruno Quaresma 90b64c5e04 chore: remove unecessary API.getWorkspaceParameters (#20462)
I initially created `API.getWorkspaceParameters` to group two related
requests, but after revisiting the implementation, I realized this
abstraction doesn’t add much value. It also prevents us from taking full
advantage of React Query’s built-in caching and invalidation.

So instead of grouping them, I removed the helper and replaced it with
separate queries — this simplifies the flow and lets React Query handle
caching more efficiently.

Related to
https://github.com/coder/coder/pull/20431#discussion_r2457010137
2025-10-27 12:47:44 -03:00
Paweł Banaszewski d0fb4599f0 feat: add RecordInterceptionEnded rpc (#20494)
Adds RPC that marks interception as completed.
Added to aibridge in https://github.com/coder/aibridge/pull/43

fixes https://github.com/coder/internal/issues/1051
2025-10-27 16:38:00 +01:00
Steven Masley ffe22a0ffc chore: show build timeline regardless of agent scripts count (#20470) 2025-10-27 10:09:52 -05:00
dependabot[bot] a1161b79a7 ci: bump the github-actions group with 7 updates (#20498)
Bumps the github-actions group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/upload-artifact](https://github.com/actions/upload-artifact)
| `4.6.2` | `5.0.0` |
| [chromaui/action](https://github.com/chromaui/action) | `13.3.0` |
`13.3.2` |
|
[actions/download-artifact](https://github.com/actions/download-artifact)
| `5.0.0` | `6.0.0` |
|
[tj-actions/changed-files](https://github.com/tj-actions/changed-files)
| `d03a93c0dbfac6d6dd6a0d8a5e7daff992b07449` |
`dbf178ceecb9304128c8e0648591d71208c6e2c9` |
|
[nixbuild/nix-quick-install-action](https://github.com/nixbuild/nix-quick-install-action)
| `33` | `34` |
| [github/codeql-action](https://github.com/github/codeql-action) |
`4.30.9` | `4.31.0` |
|
[Mattraks/delete-workflow-runs](https://github.com/mattraks/delete-workflow-runs)
| `2.0.6` | `2.1.0` |

Updates `actions/upload-artifact` from 4.6.2 to 5.0.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/upload-artifact/releases">actions/upload-artifact's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.0</h2>
<h2>What's Changed</h2>
<p><strong>BREAKING CHANGE:</strong> this update supports Node
<code>v24.x</code>. This is not a breaking change per-se but we're
treating it as such.</p>
<ul>
<li>Update README.md by <a
href="https://github.com/GhadimiR"><code>@​GhadimiR</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/681">actions/upload-artifact#681</a></li>
<li>Update README.md by <a
href="https://github.com/nebuk89"><code>@​nebuk89</code></a> in <a
href="https://redirect.github.com/actions/upload-artifact/pull/712">actions/upload-artifact#712</a></li>
<li>Readme: spell out the first use of GHES by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/727">actions/upload-artifact#727</a></li>
<li>Update GHES guidance to include reference to Node 20 version by <a
href="https://github.com/patrikpolyak"><code>@​patrikpolyak</code></a>
in <a
href="https://redirect.github.com/actions/upload-artifact/pull/725">actions/upload-artifact#725</a></li>
<li>Bump <code>@actions/artifact</code> to <code>v4.0.0</code></li>
<li>Prepare <code>v5.0.0</code> by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/734">actions/upload-artifact#734</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/GhadimiR"><code>@​GhadimiR</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/upload-artifact/pull/681">actions/upload-artifact#681</a></li>
<li><a href="https://github.com/nebuk89"><code>@​nebuk89</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/upload-artifact/pull/712">actions/upload-artifact#712</a></li>
<li><a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/upload-artifact/pull/727">actions/upload-artifact#727</a></li>
<li><a
href="https://github.com/patrikpolyak"><code>@​patrikpolyak</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/upload-artifact/pull/725">actions/upload-artifact#725</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/upload-artifact/compare/v4...v5.0.0">https://github.com/actions/upload-artifact/compare/v4...v5.0.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/upload-artifact/commit/330a01c490aca151604b8cf639adc76d48f6c5d4"><code>330a01c</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/734">#734</a>
from actions/danwkennedy/prepare-5.0.0</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/03f282445299bbefc96171af272a984663b63a26"><code>03f2824</code></a>
Update <code>github.dep.yml</code></li>
<li><a
href="https://github.com/actions/upload-artifact/commit/905a1ecb5915b264cbc519e4eb415b5d82916018"><code>905a1ec</code></a>
Prepare <code>v5.0.0</code></li>
<li><a
href="https://github.com/actions/upload-artifact/commit/2d9f9cdfa99fedaddba68e9b5b5c281eca26cc63"><code>2d9f9cd</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/725">#725</a>
from patrikpolyak/patch-1</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/9687587dec67f2a8bc69104e183d311c42af6d6f"><code>9687587</code></a>
Merge branch 'main' into patch-1</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/2848b2cda0e5190984587ec6bb1f36730ca78d50"><code>2848b2c</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/727">#727</a>
from danwkennedy/patch-1</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/9b511775fd9ce8c5710b38eea671f856de0e70a7"><code>9b51177</code></a>
Spell out the first use of GHES</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/cd231ca1eda77976a84805c4194a1954f56b0727"><code>cd231ca</code></a>
Update GHES guidance to include reference to Node 20 version</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/de65e23aa2b7e23d713bb51fbfcb6d502f8667d8"><code>de65e23</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/712">#712</a>
from actions/nebuk89-patch-1</li>
<li><a
href="https://github.com/actions/upload-artifact/commit/8747d8cd7632611ad6060b528f3e0f654c98869c"><code>8747d8c</code></a>
Update README.md</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...330a01c490aca151604b8cf639adc76d48f6c5d4">compare
view</a></li>
</ul>
</details>
<br />

Updates `chromaui/action` from 13.3.0 to 13.3.2
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/chromaui/action/commit/bc2d84ad2b60813a67d995c5582d696104a19383"><code>bc2d84a</code></a>
v13.3.2</li>
<li><a
href="https://github.com/chromaui/action/commit/1c807fb41f4db007b022d0806c09a94dce7b5ff6"><code>1c807fb</code></a>
v13.3.1</li>
<li>See full diff in <a
href="https://github.com/chromaui/action/compare/4ffe736a2a8262ea28067ff05a13b635ba31ec05...bc2d84ad2b60813a67d995c5582d696104a19383">compare
view</a></li>
</ul>
</details>
<br />

Updates `actions/download-artifact` from 5.0.0 to 6.0.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/download-artifact/releases">actions/download-artifact's
releases</a>.</em></p>
<blockquote>
<h2>v6.0.0</h2>
<h2>What's Changed</h2>
<p><strong>BREAKING CHANGE:</strong> this update supports Node
<code>v24.x</code>. This is not a breaking change per-se but we're
treating it as such.</p>
<ul>
<li>Update README for download-artifact v5 changes by <a
href="https://github.com/yacaovsnc"><code>@​yacaovsnc</code></a> in <a
href="https://redirect.github.com/actions/download-artifact/pull/417">actions/download-artifact#417</a></li>
<li>Update README with artifact extraction details by <a
href="https://github.com/yacaovsnc"><code>@​yacaovsnc</code></a> in <a
href="https://redirect.github.com/actions/download-artifact/pull/424">actions/download-artifact#424</a></li>
<li>Readme: spell out the first use of GHES by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/download-artifact/pull/431">actions/download-artifact#431</a></li>
<li>Bump <code>@actions/artifact</code> to <code>v4.0.0</code></li>
<li>Prepare <code>v6.0.0</code> by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/download-artifact/pull/438">actions/download-artifact#438</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/download-artifact/pull/431">actions/download-artifact#431</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/download-artifact/compare/v5...v6.0.0">https://github.com/actions/download-artifact/compare/v5...v6.0.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/download-artifact/commit/018cc2cf5baa6db3ef3c5f8a56943fffe632ef53"><code>018cc2c</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/download-artifact/issues/438">#438</a>
from actions/danwkennedy/prepare-6.0.0</li>
<li><a
href="https://github.com/actions/download-artifact/commit/815651c680ffe1c95719d0ed08aba1a2f9d5c177"><code>815651c</code></a>
Revert &quot;Remove <code>github.dep.yml</code>&quot;</li>
<li><a
href="https://github.com/actions/download-artifact/commit/bb3a066a8babc8ed7b3e4218896c548fe34e7115"><code>bb3a066</code></a>
Remove <code>github.dep.yml</code></li>
<li><a
href="https://github.com/actions/download-artifact/commit/fa1ce46bbd11b8387539af12741055a76dfdf804"><code>fa1ce46</code></a>
Prepare <code>v6.0.0</code></li>
<li><a
href="https://github.com/actions/download-artifact/commit/4a24838f3d5601fd639834081e118c2995d51e1c"><code>4a24838</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/download-artifact/issues/431">#431</a>
from danwkennedy/patch-1</li>
<li><a
href="https://github.com/actions/download-artifact/commit/5e3251c4ff5a32e4cf8dd4adaee0e692365237ae"><code>5e3251c</code></a>
Readme: spell out the first use of GHES</li>
<li><a
href="https://github.com/actions/download-artifact/commit/abefc31eafcfbdf6c5336127c1346fdae79ff41c"><code>abefc31</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/download-artifact/issues/424">#424</a>
from actions/yacaovsnc/update_readme</li>
<li><a
href="https://github.com/actions/download-artifact/commit/ac43a6070aa7db8a41e756e7a2846221edca7027"><code>ac43a60</code></a>
Update README with artifact extraction details</li>
<li><a
href="https://github.com/actions/download-artifact/commit/de96f4613b77ec03b5cf633e7c350c32bd3c5660"><code>de96f46</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/download-artifact/issues/417">#417</a>
from actions/yacaovsnc/update_readme</li>
<li><a
href="https://github.com/actions/download-artifact/commit/7993cb44e9052f2f08f9b828ae5ef3ecca7d2ac7"><code>7993cb4</code></a>
Remove migration guide for artifact download changes</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/download-artifact/compare/634f93cb2916e3fdff6788551b99b062d0335ce0...018cc2cf5baa6db3ef3c5f8a56943fffe632ef53">compare
view</a></li>
</ul>
</details>
<br />

Updates `tj-actions/changed-files` from
d03a93c0dbfac6d6dd6a0d8a5e7daff992b07449 to
dbf178ceecb9304128c8e0648591d71208c6e2c9
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/tj-actions/changed-files/blob/main/HISTORY.md">tj-actions/changed-files's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h1><a
href="https://github.com/tj-actions/changed-files/compare/v46.0.5...v47.0.0">47.0.0</a>
- (2025-09-13)</h1>
<h2><!-- raw HTML omitted -->🚀 Features</h2>
<ul>
<li>Add any_added to outputs (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2567">#2567</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/c260d49a827b5eb266673bed7871c5d3ee9b5aef">c260d49</a>)
- (Jellyfrog)</li>
</ul>
<h2><!-- raw HTML omitted --> Remove</h2>
<ul>
<li>Commit and push step from build job (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2538">#2538</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/be393a90381e27c9fec2c8c2e02b00f005710145">be393a9</a>)
- (Tonye Jack)</li>
</ul>
<h2><!-- raw HTML omitted -->🔄 Update</h2>
<ul>
<li>Updated README.md (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2592">#2592</a>)</li>
</ul>
<p>Co-authored-by: github-actions[bot]
&lt;41898282+github-actions[bot]<a
href="https://github.com/users"><code>@​users</code></a>.noreply.github.com&gt;
(<a
href="https://github.com/tj-actions/changed-files/commit/3dbc1e181273d808ccff822a6e00cf18b6628ef0">3dbc1e1</a>)
- (github-actions[bot])</p>
<ul>
<li>Updated README.md (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2591">#2591</a>)</li>
</ul>
<p>Co-authored-by: github-actions[bot]
&lt;41898282+github-actions[bot]<a
href="https://github.com/users"><code>@​users</code></a>.noreply.github.com&gt;
(<a
href="https://github.com/tj-actions/changed-files/commit/b1ccff8c0892ad141d7d2de6f31e526a9dad931f">b1ccff8</a>)
- (github-actions[bot])</p>
<ul>
<li>Updated README.md (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2574">#2574</a>)</li>
</ul>
<p>Co-authored-by: github-actions[bot]
&lt;41898282+github-actions[bot]<a
href="https://github.com/users"><code>@​users</code></a>.noreply.github.com&gt;
(<a
href="https://github.com/tj-actions/changed-files/commit/050a3d3360d29711ee9d8210fc639d902d23ad07">050a3d3</a>)
- (github-actions[bot])</p>
<h2><!-- raw HTML omitted -->📚 Documentation</h2>
<ul>
<li>Update link to glob patterns (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2590">#2590</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/a892f50f7a7187bc288633c09230b09ce7ad8fd0">a892f50</a>)
- (Tonye Jack)</li>
<li>Add Jellyfrog as a contributor for code, and doc (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2573">#2573</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/f000a9b97f254f9590ff26f651cccde827ad36da">f000a9b</a>)
- (allcontributors[bot])</li>
</ul>
<h2><!-- raw HTML omitted -->🧪 Testing</h2>
<ul>
<li>Manual triggered workflows (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2637">#2637</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/c2ca2493190021783138cb8aac49bcee14b4bb89">c2ca249</a>)
- (Tonye Jack)</li>
</ul>
<h2><!-- raw HTML omitted -->⚙️ Miscellaneous Tasks</h2>
<ul>
<li><strong>deps-dev:</strong> Bump jest from 30.0.5 to 30.1.3 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2655">#2655</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/9a6755550a331fdcc8ec45443738933f8fa22eea">9a67555</a>)
- (dependabot[bot])</li>
<li><strong>deps:</strong> Bump tj-actions/git-cliff from 2.1.0 to 2.2.0
(<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2660">#2660</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/b67e30df88f43e244f4e83775e5ad8335114fb95">b67e30d</a>)
- (dependabot[bot])</li>
<li><strong>deps:</strong> Bump github/codeql-action from 3.30.2 to
3.30.3 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2661">#2661</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/62aef422ffa195474d80d73387535cf4622b2824">62aef42</a>)
- (dependabot[bot])</li>
<li><strong>deps:</strong> Bump github/codeql-action from 3.29.11 to
3.30.2 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2659">#2659</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/e874f3cddd0f54ae776e6995ae6dae4cf40fd3d3">e874f3c</a>)
- (dependabot[bot])</li>
<li><strong>deps:</strong> Bump actions/setup-node from 4.4.0 to 5.0.0
(<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2656">#2656</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/8c14441336bb3d84fd6b7fa83b6d7201c740baf5">8c14441</a>)
- (dependabot[bot])</li>
<li><strong>deps-dev:</strong> Bump <code>@​types/node</code> from
24.3.0 to 24.3.1 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2657">#2657</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/e995ac4be5be2bcb6e29556edc51fb63aca6b49b">e995ac4</a>)
- (dependabot[bot])</li>
<li><strong>deps-dev:</strong> Bump <code>@​types/node</code> from
24.2.1 to 24.3.0 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2649">#2649</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/3b04099b21072562f07469c10deb182b24236ca9">3b04099</a>)
- (dependabot[bot])</li>
<li><strong>deps:</strong> Bump github/codeql-action from 3.29.9 to
3.29.11 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2651">#2651</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/e7b6c977e51984988e3cc1d6b18abe2a3ba8daaa">e7b6c97</a>)
- (dependabot[bot])</li>
<li><strong>deps:</strong> Bump tj-actions/git-cliff from 2.0.2 to 2.1.0
(<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2648">#2648</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/765d62bc041415a5b494ef13d02d566128b25973">765d62b</a>)
- (dependabot[bot])</li>
<li><strong>deps:</strong> Bump github/codeql-action from 3.29.8 to
3.29.9 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2647">#2647</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/2036da178f85576f1940fedb74bb93a36cd89ab7">2036da1</a>)
- (dependabot[bot])</li>
<li><strong>deps:</strong> Bump github/codeql-action from 3.29.7 to
3.29.8 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2644">#2644</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/239aef84a5502c79a1cea96e495d17588c66c659">239aef8</a>)
- (dependabot[bot])</li>
<li><strong>deps-dev:</strong> Bump <code>@​types/node</code> from
24.2.0 to 24.2.1 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2645">#2645</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/a7d5f5f4919b6dbc6d3a3689887964361e8dd88f">a7d5f5f</a>)
- (dependabot[bot])</li>
<li><strong>deps:</strong> Bump actions/checkout from 4.2.2 to 5.0.0 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2646">#2646</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/5107f3abcc0c3737db51e2949f181e2c197d4d5b">5107f3a</a>)
- (dependabot[bot])</li>
<li><strong>deps-dev:</strong> Bump <code>@​types/node</code> from
24.1.0 to 24.2.0 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2640">#2640</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/f963b3f3562b00b6d2dd25efc390eb04e51ef6c6">f963b3f</a>)
- (dependabot[bot])</li>
<li><strong>deps:</strong> Bump actions/download-artifact from 4.3.0 to
5.0.0 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2641">#2641</a>)
(<a
href="https://github.com/tj-actions/changed-files/commit/f956744105e18d78bba3844a1199ce43d6503017">f956744</a>)
- (dependabot[bot])</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/tj-actions/changed-files/commit/dbf178ceecb9304128c8e0648591d71208c6e2c9"><code>dbf178c</code></a>
chore(deps): bump actions/setup-node from 5.0.0 to 6.0.0 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2690">#2690</a>)</li>
<li><a
href="https://github.com/tj-actions/changed-files/commit/19002623031eba72900680c5deed5ee6333dbc12"><code>1900262</code></a>
chore(deps): bump github/codeql-action from 3.30.6 to 4.30.9 (<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2693">#2693</a>)</li>
<li><a
href="https://github.com/tj-actions/changed-files/commit/27e5d78f9b6a61e3160a2fe263cca91675c08fa0"><code>27e5d78</code></a>
chore(deps-dev): bump <code>@​types/node</code> from 24.6.2 to 24.9.1
(<a
href="https://redirect.github.com/tj-actions/changed-files/issues/2695">#2695</a>)</li>
<li>See full diff in <a
href="https://github.com/tj-actions/changed-files/compare/d03a93c0dbfac6d6dd6a0d8a5e7daff992b07449...dbf178ceecb9304128c8e0648591d71208c6e2c9">compare
view</a></li>
</ul>
</details>
<br />

Updates `nixbuild/nix-quick-install-action` from 33 to 34
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/nixbuild/nix-quick-install-action/releases">nixbuild/nix-quick-install-action's
releases</a>.</em></p>
<blockquote>
<h2>nixbuild/nix-quick-install-action@v34</h2>
<h2>Changes</h2>
<ul>
<li>
<p>Update Nix versions: 2.31.0 -&gt; 2.31.2, 2.30.0 -&gt; 2.30.3, 2.29.1
-&gt; 2.29.2, 2.28.4 -&gt; 2.28.5.</p>
</li>
<li>
<p>Bump default Nix version: 2.29.1 -&gt; 2.29.2</p>
</li>
</ul>
<h2>Supported Nix Versions on x86_64-linux runners</h2>
<ul>
<li>2.31.2</li>
<li>2.30.3</li>
<li>2.29.2</li>
<li>2.28.5</li>
<li>2.26.4</li>
<li>2.24.15</li>
<li>2.3.18</li>
</ul>
<h2>Supported Nix Versions on aarch64-linux runners</h2>
<ul>
<li>2.31.2</li>
<li>2.30.3</li>
<li>2.29.2</li>
<li>2.28.5</li>
<li>2.26.4</li>
<li>2.24.15</li>
</ul>
<h2>Supported Nix Versions on x86_64-darwin runners</h2>
<ul>
<li>2.31.2</li>
<li>2.30.3</li>
<li>2.29.2</li>
<li>2.28.5</li>
<li>2.26.4</li>
<li>2.24.15</li>
<li>2.3.18</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nixbuild/nix-quick-install-action/blob/master/RELEASE">nixbuild/nix-quick-install-action's
changelog</a>.</em></p>
<blockquote>
<p>v34</p>
<h2>Changes</h2>
<ul>
<li>
<p>Update Nix versions: 2.31.0 -&gt; 2.31.2, 2.30.0 -&gt; 2.30.3, 2.29.1
-&gt; 2.29.2, 2.28.4 -&gt; 2.28.5.</p>
</li>
<li>
<p>Bump default Nix version: 2.29.1 -&gt; 2.29.2</p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/nixbuild/nix-quick-install-action/commit/2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035"><code>2c9db80</code></a>
Release v34</li>
<li><a
href="https://github.com/nixbuild/nix-quick-install-action/commit/6dd8039259767bef94d598f79b506a92ee991ff8"><code>6dd8039</code></a>
ci: Fix versions</li>
<li><a
href="https://github.com/nixbuild/nix-quick-install-action/commit/a7214c23df4e59da80e6e71503e1f766444fb1e2"><code>a7214c2</code></a>
Fix default version</li>
<li><a
href="https://github.com/nixbuild/nix-quick-install-action/commit/efda085bcade238a03bdbd41f224f08d4ef362e9"><code>efda085</code></a>
Bump Nix versions</li>
<li><a
href="https://github.com/nixbuild/nix-quick-install-action/commit/b644e5e09df2afc194ad86b2a8467b701d15c606"><code>b644e5e</code></a>
Update README and workflows for v33</li>
<li>See full diff in <a
href="https://github.com/nixbuild/nix-quick-install-action/compare/1f095fee853b33114486cfdeae62fa099cda35a9...2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action` from 4.30.9 to 4.31.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.31.0</h2>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>4.31.0 - 24 Oct 2025</h2>
<ul>
<li>Bump minimum CodeQL bundle version to 2.17.6. <a
href="https://redirect.github.com/github/codeql-action/pull/3223">#3223</a></li>
<li>When SARIF files are uploaded by the <code>analyze</code> or
<code>upload-sarif</code> actions, the CodeQL Action automatically
performs post-processing steps to prepare the data for the upload.
Previously, these post-processing steps were only performed before an
upload took place. We are now changing this so that the post-processing
steps will always be performed, even when the SARIF files are not
uploaded. This does not change anything for the
<code>upload-sarif</code> action. For <code>analyze</code>, this may
affect Advanced Setup for CodeQL users who specify a value other than
<code>always</code> for the <code>upload</code> input. <a
href="https://redirect.github.com/github/codeql-action/pull/3222">#3222</a></li>
</ul>
<p>See the full <a
href="https://github.com/github/codeql-action/blob/v4.31.0/CHANGELOG.md">CHANGELOG.md</a>
for more information.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.31.0 - 24 Oct 2025</h2>
<ul>
<li>Bump minimum CodeQL bundle version to 2.17.6. <a
href="https://redirect.github.com/github/codeql-action/pull/3223">#3223</a></li>
<li>When SARIF files are uploaded by the <code>analyze</code> or
<code>upload-sarif</code> actions, the CodeQL Action automatically
performs post-processing steps to prepare the data for the upload.
Previously, these post-processing steps were only performed before an
upload took place. We are now changing this so that the post-processing
steps will always be performed, even when the SARIF files are not
uploaded. This does not change anything for the
<code>upload-sarif</code> action. For <code>analyze</code>, this may
affect Advanced Setup for CodeQL users who specify a value other than
<code>always</code> for the <code>upload</code> input. <a
href="https://redirect.github.com/github/codeql-action/pull/3222">#3222</a></li>
</ul>
<h2>4.30.9 - 17 Oct 2025</h2>
<ul>
<li>Update default CodeQL bundle version to 2.23.3. <a
href="https://redirect.github.com/github/codeql-action/pull/3205">#3205</a></li>
<li>Experimental: A new <code>setup-codeql</code> action has been added
which is similar to <code>init</code>, except it only installs the
CodeQL CLI and does not initialize a database. Do not use this in
production as it is part of an internal experiment and subject to change
at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3204">#3204</a></li>
</ul>
<h2>4.30.8 - 10 Oct 2025</h2>
<p>No user facing changes.</p>
<h2>4.30.7 - 06 Oct 2025</h2>
<ul>
<li>[v4+ only] The CodeQL Action now runs on Node.js v24. <a
href="https://redirect.github.com/github/codeql-action/pull/3169">#3169</a></li>
</ul>
<h2>3.30.6 - 02 Oct 2025</h2>
<ul>
<li>Update default CodeQL bundle version to 2.23.2. <a
href="https://redirect.github.com/github/codeql-action/pull/3168">#3168</a></li>
</ul>
<h2>3.30.5 - 26 Sep 2025</h2>
<ul>
<li>We fixed a bug that was introduced in <code>3.30.4</code> with
<code>upload-sarif</code> which resulted in files without a
<code>.sarif</code> extension not getting uploaded. <a
href="https://redirect.github.com/github/codeql-action/pull/3160">#3160</a></li>
</ul>
<h2>3.30.4 - 25 Sep 2025</h2>
<ul>
<li>We have improved the CodeQL Action's ability to validate that the
workflow it is used in does not use different versions of the CodeQL
Action for different workflow steps. Mixing different versions of the
CodeQL Action in the same workflow is unsupported and can lead to
unpredictable results. A warning will now be emitted from the
<code>codeql-action/init</code> step if different versions of the CodeQL
Action are detected in the workflow file. Additionally, an error will
now be thrown by the other CodeQL Action steps if they load a
configuration file that was generated by a different version of the
<code>codeql-action/init</code> step. <a
href="https://redirect.github.com/github/codeql-action/pull/3099">#3099</a>
and <a
href="https://redirect.github.com/github/codeql-action/pull/3100">#3100</a></li>
<li>We added support for reducing the size of dependency caches for Java
analyses, which will reduce cache usage and speed up workflows. This
will be enabled automatically at a later time. <a
href="https://redirect.github.com/github/codeql-action/pull/3107">#3107</a></li>
<li>You can now run the latest CodeQL nightly bundle by passing
<code>tools: nightly</code> to the <code>init</code> action. In general,
the nightly bundle is unstable and we only recommend running it when
directed by GitHub staff. <a
href="https://redirect.github.com/github/codeql-action/pull/3130">#3130</a></li>
<li>Update default CodeQL bundle version to 2.23.1. <a
href="https://redirect.github.com/github/codeql-action/pull/3118">#3118</a></li>
</ul>
<h2>3.30.3 - 10 Sep 2025</h2>
<p>No user facing changes.</p>
<h2>3.30.2 - 09 Sep 2025</h2>
<ul>
<li>Fixed a bug which could cause language autodetection to fail. <a
href="https://redirect.github.com/github/codeql-action/pull/3084">#3084</a></li>
<li>Experimental: The <code>quality-queries</code> input that was added
in <code>3.29.2</code> as part of an internal experiment is now
deprecated and will be removed in an upcoming version of the CodeQL
Action. It has been superseded by a new <code>analysis-kinds</code>
input, which is part of the same internal experiment. Do not use this in
production as it is subject to change at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3064">#3064</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/4e94bd11f71e507f7f87df81788dff88d1dacbfb"><code>4e94bd1</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3235">#3235</a>
from github/update-v4.31.0-1d36546c1</li>
<li><a
href="https://github.com/github/codeql-action/commit/8f11182164f2181cc5608a575e3c7ef3bc4a9cd1"><code>8f11182</code></a>
Update changelog for v4.31.0</li>
<li><a
href="https://github.com/github/codeql-action/commit/1d36546c1419dc613cdb4b7fde46b1c81643ccbe"><code>1d36546</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3234">#3234</a>
from github/mbg/changelog/post-processing</li>
<li><a
href="https://github.com/github/codeql-action/commit/08ada26e6a4768939d6da6a5e23ae69052948fd7"><code>08ada26</code></a>
Add changelog entry for post-processing change</li>
<li><a
href="https://github.com/github/codeql-action/commit/b843cbeed03550ed4937992fa96258262e955178"><code>b843cbe</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3233">#3233</a>
from github/mbg/getOptionalEnvVar</li>
<li><a
href="https://github.com/github/codeql-action/commit/1ecd56391940567d00fd07e34b4ca7b75dadd92a"><code>1ecd563</code></a>
Use <code>getOptionalEnvVar</code> in
<code>writePostProcessedFiles</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/e57680792076a32e6f147ccf58374517ea645a31"><code>e576807</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3223">#3223</a>
from github/henrymercer/bump-minimum</li>
<li><a
href="https://github.com/github/codeql-action/commit/ad3567666919ea4249d02a26c230ea8e0daef410"><code>ad35676</code></a>
Add <code>getOptionalEnvVar</code> function</li>
<li><a
href="https://github.com/github/codeql-action/commit/d75645b13f453e29a7f3c3f316babb725e644d0a"><code>d75645b</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3222">#3222</a>
from github/mbg/upload-lib/post-process</li>
<li><a
href="https://github.com/github/codeql-action/commit/710606cc35e2444ba84bdf7702dcb481f7380ae7"><code>710606c</code></a>
Check that <code>outputPath</code> is non-empty</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/16140ae1a102900babc80a33c44059580f687047...4e94bd11f71e507f7f87df81788dff88d1dacbfb">compare
view</a></li>
</ul>
</details>
<br />

Updates `Mattraks/delete-workflow-runs` from 2.0.6 to 2.1.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/mattraks/delete-workflow-runs/releases">Mattraks/delete-workflow-runs's
releases</a>.</em></p>
<blockquote>
<h2>v2.1.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Update README.md with contents: read permission by <a
href="https://github.com/jonaslindstr"><code>@​jonaslindstr</code></a>
in <a
href="https://redirect.github.com/Mattraks/delete-workflow-runs/pull/19">Mattraks/delete-workflow-runs#19</a></li>
<li>Deletes workflow runs that do not have an existing workflow by <a
href="https://github.com/watercable76"><code>@​watercable76</code></a>
in <a
href="https://redirect.github.com/Mattraks/delete-workflow-runs/pull/20">Mattraks/delete-workflow-runs#20</a></li>
<li>Quick note about GHE <code>baseUrl</code> config by <a
href="https://github.com/kquinsland"><code>@​kquinsland</code></a> in <a
href="https://redirect.github.com/Mattraks/delete-workflow-runs/pull/32">Mattraks/delete-workflow-runs#32</a></li>
<li>Added try/catch blocks to catch each error individually by <a
href="https://github.com/marcelovani"><code>@​marcelovani</code></a> in
<a
href="https://redirect.github.com/Mattraks/delete-workflow-runs/pull/35">Mattraks/delete-workflow-runs#35</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/jonaslindstr"><code>@​jonaslindstr</code></a>
made their first contribution in <a
href="https://redirect.github.com/Mattraks/delete-workflow-runs/pull/19">Mattraks/delete-workflow-runs#19</a></li>
<li><a
href="https://github.com/watercable76"><code>@​watercable76</code></a>
made their first contribution in <a
href="https://redirect.github.com/Mattraks/delete-workflow-runs/pull/20">Mattraks/delete-workflow-runs#20</a></li>
<li><a
href="https://github.com/kquinsland"><code>@​kquinsland</code></a> made
their first contribution in <a
href="https://redirect.github.com/Mattraks/delete-workflow-runs/pull/32">Mattraks/delete-workflow-runs#32</a></li>
<li><a
href="https://github.com/marcelovani"><code>@​marcelovani</code></a>
made their first contribution in <a
href="https://redirect.github.com/Mattraks/delete-workflow-runs/pull/35">Mattraks/delete-workflow-runs#35</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/Mattraks/delete-workflow-runs/compare/v2.0.6...v2.1.0">https://github.com/Mattraks/delete-workflow-runs/compare/v2.0.6...v2.1.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/Mattraks/delete-workflow-runs/commit/ab482449ba468316e9a8801e092d0405715c5e6d"><code>ab48244</code></a>
version v2.1.0</li>
<li><a
href="https://github.com/Mattraks/delete-workflow-runs/commit/feeb82053ea847a97fe4ee2aa314c94eac0eff04"><code>feeb820</code></a>
Undo previous changes.</li>
<li><a
href="https://github.com/Mattraks/delete-workflow-runs/commit/c61e04ec70740b989bdb168af9c948f2172856d8"><code>c61e04e</code></a>
Added another try/catch block</li>
<li><a
href="https://github.com/Mattraks/delete-workflow-runs/commit/f93a693640fbced9f6b9026397406572c0dddcec"><code>f93a693</code></a>
Added another try/catch block</li>
<li><a
href="https://github.com/Mattraks/delete-workflow-runs/commit/f40e9cd01093f8470cb92e70a971329a1c6b5ca1"><code>f40e9cd</code></a>
Removed condition that is limiting deletion</li>
<li><a
href="https://github.com/Mattraks/delete-workflow-runs/commit/0a28a6b9d4f62e5b211056ceead85cc6776403eb"><code>0a28a6b</code></a>
Display debug messages</li>
<li><a
href="https://github.com/Mattraks/delete-workflow-runs/commit/cb572387c439bdadffcac5fdba1f5fae3374309f"><code>cb57238</code></a>
Display all workflows</li>
<li><a
href="https://github.com/Mattraks/delete-workflow-runs/commit/8859caa93697386bdb136521cca778eb9427e800"><code>8859caa</code></a>
Quick note about GHE <code>baseUrl</code> config</li>
<li><a
href="https://github.com/Mattraks/delete-workflow-runs/commit/4c9f24749b7996562658e3d6e10662489e22caca"><code>4c9f247</code></a>
Added better check to verify runs to be deleted, and some logging</li>
<li><a
href="https://github.com/Mattraks/delete-workflow-runs/commit/20682956720600a468c346b81c1965bfc2312d70"><code>2068295</code></a>
Deletes workflow runs that do not have an existing workflow</li>
<li>Additional commits viewable in <a
href="https://github.com/mattraks/delete-workflow-runs/compare/39f0bbed25d76b34de5594dceab824811479e5de...ab482449ba468316e9a8801e092d0405715c5e6d">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 12:25:24 +00:00
dependabot[bot] dd92fbc83c chore: bump google.golang.org/api from 0.252.0 to 0.253.0 (#20497)
Bumps
[google.golang.org/api](https://github.com/googleapis/google-api-go-client)
from 0.252.0 to 0.253.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/googleapis/google-api-go-client/releases">google.golang.org/api's
releases</a>.</em></p>
<blockquote>
<h2>v0.253.0</h2>
<h2><a
href="https://github.com/googleapis/google-api-go-client/compare/v0.252.0...v0.253.0">0.253.0</a>
(2025-10-22)</h2>
<h3>Features</h3>
<ul>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3337">#3337</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/40f2752ec3e2075ceada92b8dcc3f6b2e465bb8d">40f2752</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3339">#3339</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/d1ef9766c46dcf687ee1711cc921b8162af47275">d1ef976</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3340">#3340</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/580c65f00246810dadf22d103614c9dbaaaf93af">580c65f</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3341">#3341</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/9d031c42291eef4e164462c979beafb7b2133155">9d031c4</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3342">#3342</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/ca2b9ad85a1e1889fbed2c33f3e1e6cd7dac014b">ca2b9ad</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3344">#3344</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/844753e402286005aa20ad8defb7eece1d4aea0c">844753e</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3345">#3345</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/3de8a5b1f08d0b14773636ed984e9f4c62d097cc">3de8a5b</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3346">#3346</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/a590b9a89840c418a5dd1a426ef91ab740ac536e">a590b9a</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3347">#3347</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/40ca8fedda58c6b2f68a0e8b5be325628f4a0c09">40ca8fe</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md">google.golang.org/api's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/googleapis/google-api-go-client/compare/v0.252.0...v0.253.0">0.253.0</a>
(2025-10-22)</h2>
<h3>Features</h3>
<ul>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3337">#3337</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/40f2752ec3e2075ceada92b8dcc3f6b2e465bb8d">40f2752</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3339">#3339</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/d1ef9766c46dcf687ee1711cc921b8162af47275">d1ef976</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3340">#3340</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/580c65f00246810dadf22d103614c9dbaaaf93af">580c65f</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3341">#3341</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/9d031c42291eef4e164462c979beafb7b2133155">9d031c4</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3342">#3342</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/ca2b9ad85a1e1889fbed2c33f3e1e6cd7dac014b">ca2b9ad</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3344">#3344</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/844753e402286005aa20ad8defb7eece1d4aea0c">844753e</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3345">#3345</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/3de8a5b1f08d0b14773636ed984e9f4c62d097cc">3de8a5b</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3346">#3346</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/a590b9a89840c418a5dd1a426ef91ab740ac536e">a590b9a</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3347">#3347</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/40ca8fedda58c6b2f68a0e8b5be325628f4a0c09">40ca8fe</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/bf0e302fbeb1339d0ee44171a3db421fe4a50eb2"><code>bf0e302</code></a>
chore(main): release 0.253.0 (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3338">#3338</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/40ca8fedda58c6b2f68a0e8b5be325628f4a0c09"><code>40ca8fe</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3347">#3347</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/a590b9a89840c418a5dd1a426ef91ab740ac536e"><code>a590b9a</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3346">#3346</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/3de8a5b1f08d0b14773636ed984e9f4c62d097cc"><code>3de8a5b</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3345">#3345</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/8012a7b9bfe04c4089d1f7f3fac4f553221f3067"><code>8012a7b</code></a>
chore(all): update all (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3343">#3343</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/844753e402286005aa20ad8defb7eece1d4aea0c"><code>844753e</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3344">#3344</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/ca2b9ad85a1e1889fbed2c33f3e1e6cd7dac014b"><code>ca2b9ad</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3342">#3342</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/9d031c42291eef4e164462c979beafb7b2133155"><code>9d031c4</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3341">#3341</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/580c65f00246810dadf22d103614c9dbaaaf93af"><code>580c65f</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3340">#3340</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/d1ef9766c46dcf687ee1711cc921b8162af47275"><code>d1ef976</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3339">#3339</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/googleapis/google-api-go-client/compare/v0.252.0...v0.253.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google.golang.org/api&package-manager=go_modules&previous-version=0.252.0&new-version=0.253.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 12:21:34 +00:00
dependabot[bot] 10d2844cce chore: bump github.com/valyala/fasthttp from 1.67.0 to 1.68.0 (#20496)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp)
from 1.67.0 to 1.68.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/valyala/fasthttp/releases">github.com/valyala/fasthttp's
releases</a>.</em></p>
<blockquote>
<h2>v1.68.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix named return bugs by <a
href="https://github.com/erikdubbelboer"><code>@​erikdubbelboer</code></a>
in <a
href="https://github.com/valyala/fasthttp/commit/1b8c5593da699309522dee00ad1d6c913482a0f3">https://github.com/valyala/fasthttp/commit/1b8c5593da699309522dee00ad1d6c913482a0f3</a></li>
<li>chore(deps): bump golang.org/x/sys from 0.36.0 to 0.37.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/valyala/fasthttp/pull/2087">valyala/fasthttp#2087</a></li>
<li>chore(deps): bump golang.org/x/crypto from 0.42.0 to 0.43.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/valyala/fasthttp/pull/2086">valyala/fasthttp#2086</a></li>
<li>chore(deps): bump golang.org/x/net from 0.45.0 to 0.46.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/valyala/fasthttp/pull/2085">valyala/fasthttp#2085</a></li>
<li>chore(deps): bump securego/gosec from 2.22.9 to 2.22.10 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/valyala/fasthttp/pull/2088">valyala/fasthttp#2088</a></li>
<li>chore(deps): bump github.com/klauspost/compress from 1.18.0 to
1.18.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/valyala/fasthttp/pull/2089">valyala/fasthttp#2089</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/valyala/fasthttp/compare/v1.67.0...v1.68.0">https://github.com/valyala/fasthttp/compare/v1.67.0...v1.68.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/valyala/fasthttp/commit/1b8c5593da699309522dee00ad1d6c913482a0f3"><code>1b8c559</code></a>
Fix named return bugs</li>
<li><a
href="https://github.com/valyala/fasthttp/commit/9ca62939848ed5b186d9b1a696c9de3d76e037ce"><code>9ca6293</code></a>
chore(deps): bump github.com/klauspost/compress from 1.18.0 to 1.18.1
(<a
href="https://redirect.github.com/valyala/fasthttp/issues/2089">#2089</a>)</li>
<li><a
href="https://github.com/valyala/fasthttp/commit/77468f66c6618d8ca3d5d183d1aaf55cc5395f2c"><code>77468f6</code></a>
chore(deps): bump securego/gosec from 2.22.9 to 2.22.10 (<a
href="https://redirect.github.com/valyala/fasthttp/issues/2088">#2088</a>)</li>
<li><a
href="https://github.com/valyala/fasthttp/commit/3a2fdec290176325d5432245facf04ef25674f46"><code>3a2fdec</code></a>
chore(deps): bump golang.org/x/net from 0.45.0 to 0.46.0 (<a
href="https://redirect.github.com/valyala/fasthttp/issues/2085">#2085</a>)</li>
<li><a
href="https://github.com/valyala/fasthttp/commit/59f58c07bec79a5eb65928defdf37ec247504e65"><code>59f58c0</code></a>
chore(deps): bump golang.org/x/crypto from 0.42.0 to 0.43.0 (<a
href="https://redirect.github.com/valyala/fasthttp/issues/2086">#2086</a>)</li>
<li><a
href="https://github.com/valyala/fasthttp/commit/dbfb82aabe6074ab83033941103f890ae09dabbd"><code>dbfb82a</code></a>
chore(deps): bump golang.org/x/sys from 0.36.0 to 0.37.0 (<a
href="https://redirect.github.com/valyala/fasthttp/issues/2087">#2087</a>)</li>
<li>See full diff in <a
href="https://github.com/valyala/fasthttp/compare/v1.67.0...v1.68.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/valyala/fasthttp&package-manager=go_modules&previous-version=1.67.0&new-version=1.68.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 12:14:51 +00:00
dependabot[bot] 277b2d21ca chore: bump github.com/gohugoio/hugo from 0.151.2 to 0.152.2 (#20495)
Bumps [github.com/gohugoio/hugo](https://github.com/gohugoio/hugo) from
0.151.2 to 0.152.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/gohugoio/hugo/releases">github.com/gohugoio/hugo's
releases</a>.</em></p>
<blockquote>
<h2>v0.152.2</h2>
<p>In <code>v0.152.0</code> we tightened the source validation for <a
href="https://gohugo.io/configuration/module/#mounts">file mounts</a>.
We always said that <em>project mounts can mount with absolute
file/directorynames, modules/themes are restricted to relative</em>. In
<code>v0.152.0</code> we narrowed module/themes mounts to be local,
which made the setup in the bug report listed below fail:</p>
<pre lang="toml"><code>[[module.mounts]]
source = '../../node_modules/bootstrap'
target = 'assets/vendor/bootstrap'
</code></pre>
<p>One part of this is security. But the construct above is
<em>usually</em> very odd (the project uses files in a theme/module, not
the other way around) and not very portable. But the example above
demonstrates a valid exception, that we now have added support for in a
portable way. The above example now works as it did before
<code>v0.152.0</code>, but going forward you can also write:</p>
<pre lang="toml"><code>[[module.mounts]]
source = 'node_modules/bootstrap'
target = 'assets/vendor/bootstrap'
</code></pre>
<p>We now have the <code>node_modules</code> as a special case: For
themes/modules we first check if the mounted source exists locally, if
not we try relative to the project root.</p>
<h2>What's Changed</h2>
<ul>
<li>deps: Update github.com/tdewolff/minify v2.24.4 =&gt; v2.24.5
1c8c21e45 <a
href="https://github.com/jmooring"><code>@​jmooring</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/14086">#14086</a></li>
<li>hugofs: Make node_modules a &quot;special case&quot; mount 809ebe01f
<a href="https://github.com/bep"><code>@​bep</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/14089">#14089</a></li>
<li>github: Fix typo in stale PR message 08a0679a8 <a
href="https://github.com/jordelver"><code>@​jordelver</code></a></li>
</ul>
<h2>v0.152.1</h2>
<p>These fixes are are all related to the YAML library upgrade in <a
href="https://github.com/gohugoio/hugo/releases/tag/v0.152.0">v0.152.0</a>.</p>
<ul>
<li>Expand the numeric conversions to template funcs/methods e08278d16
<a href="https://github.com/bep"><code>@​bep</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/14079">#14079</a></li>
<li>Fix where with uint64 df4f80d54 <a
href="https://github.com/bep"><code>@​bep</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/14081">#14081</a></li>
<li>Fix it so YAML integer types can be used where Go int types are
expected d4c78885a <a
href="https://github.com/bep"><code>@​bep</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/14079">#14079</a></li>
<li>tpl/compare: Fix compare/sort of uint64s 29e2c2fa9 <a
href="https://github.com/bep"><code>@​bep</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/14078">#14078</a></li>
<li>Fix &quot;assignment to entry in nil map&quot; on empty YAML config
files 0579afc3c <a href="https://github.com/bep"><code>@​bep</code></a>
<a
href="https://redirect.github.com/gohugoio/hugo/issues/14074">#14074</a></li>
</ul>
<h2>v0.152.0</h2>
<p>The big new thing and the motivation behind this release is the
upgrade to a more modern YAML library in <a
href="https://github.com/goccy"><code>@​goccy</code></a> 's <a
href="https://github.com/goccy/go-yaml">github.com/goccy/go-yaml</a>.
It's been a surprisingly long and winding road to get here. <strong>Note
that this upgrade comes with some minor breaking changes, most notably
that the old YAML 1.1 spec listed a set of strings that, when unquoted,
were treated as boolean <code>true</code> or
<code>false</code>.</strong> So if you're using any of the values in the
table below as booleans, you need to adjust your YAML, but I suspect
that fixing this very surprising behavior will fix more issues than it
introduces. A big new thing with this new YAML library is the support
for <a
href="https://www.linode.com/docs/guides/yaml-anchors-aliases-overrides-extensions/">YAML
anchors and aliases</a> which helps to reduce duplication in e.g. your
configuration. There are some examples in Hugo's <a
href="https://github.com/gohugoio/hugo/blob/master/hugoreleaser.yaml">release
build configuration</a> and in the <a
href="https://github.com/gohugoio/hugo/blob/master/.circleci/config.yml">Hugo's
CI release setup</a>.</p>
<table>
<thead>
<tr>
<th align="left">Values</th>
<th align="left">Old meaning</th>
<th align="left">New meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td align="left"><code>yes</code>, <code>Yes</code>, <code>YES</code>,
<code>y</code>, <code>Y</code>, <code>on</code>, <code>On</code>,
<code>ON</code></td>
<td align="left"><code>true</code> (bool)</td>
<td align="left"><code>yes</code>, <code>Yes</code>, <code>YES</code>,
<code>y</code>, <code>Y</code>, <code>on</code>, <code>On</code>,
<code>ON</code> (string)</td>
</tr>
<tr>
<td align="left"><code>no</code>, <code>No</code>, <code>NO</code>,
<code>n</code>, <code>N</code>, <code>off</code>, <code>Off</code>,
<code>OFF</code></td>
<td align="left"><code>false</code> (bool)</td>
<td align="left"><code>no</code>, <code>No</code>, <code>NO</code>,
<code>n</code>, <code>N</code>, <code>off</code>, <code>Off</code>,
<code>OFF</code> (string)</td>
</tr>
</tbody>
</table>
<h2>Note</h2>
<ul>
<li>Replace to gopkg.in/yaml with github.com/goccy/go-yaml (note)
a3d954846 <a href="https://github.com/bep"><code>@​bep</code></a> <a
href="https://redirect.github.com/gohugoio/hugo/issues/8822">#8822</a>
<a
href="https://redirect.github.com/gohugoio/hugo/issues/13043">#13043</a>
<a
href="https://redirect.github.com/gohugoio/hugo/issues/14053">#14053</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/gohugoio/hugo/commit/6abdacad3f3fe944ea42177844469139e81feda6"><code>6abdaca</code></a>
releaser: Bump versions for release of 0.152.2</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/1c8c21e45eb7d87c1038b3f3b9ea87b82a11c867"><code>1c8c21e</code></a>
deps: Update github.com/tdewolff/minify v2.24.4 =&gt; v2.24.5</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/809ebe01fa954a44a48c265f0c9a4e4845b9a0f8"><code>809ebe0</code></a>
hugofs: Make node_modules a &quot;special case&quot; mount</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/08a0679a895678b38f1324ae78503fd32ec22996"><code>08a0679</code></a>
github: Fix typo in stale PR message</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/524b98665b64f206f14879ffe29c64f413592a2f"><code>524b986</code></a>
releaser: Prepare repository for 0.153.0-DEV</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/5869cbddd88590563c2b7b400e804ccc7d2cb697"><code>5869cbd</code></a>
releaser: Bump versions for release of 0.152.1</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/e08278d165399bd2d7d7331d966129a2faa268b2"><code>e08278d</code></a>
Expand the numeric conversions to template funcs/methods</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/df4f80d54f389407d935a0388707be2bf888d882"><code>df4f80d</code></a>
Fix where with uint64</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/d4c78885ae4cb7c488ea2ed399aa57f0861f44cb"><code>d4c7888</code></a>
Fix it so YAML integer types can be used where Go int types are
expected</li>
<li><a
href="https://github.com/gohugoio/hugo/commit/29e2c2fa92b10c1250376913558448e838e390fe"><code>29e2c2f</code></a>
tpl/compare: Fix compare/sort of uint64s</li>
<li>Additional commits viewable in <a
href="https://github.com/gohugoio/hugo/compare/v0.151.2...v0.152.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/gohugoio/hugo&package-manager=go_modules&previous-version=0.151.2&new-version=0.152.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 12:08:59 +00:00
Spike Curtis af3ff825a1 test: track postgres database creation by package and test name (#20492)
Adds columns to track package and test name to test_databases table, and populates them as databases are created using the Broker.

In order to seamlessly work with existing `coder_database` databases with the old schema, the SQL that creates the table and columns is additive and idempotent, so we run it every time we initialize the Broker (once per test binary execution).

We include a transaction level advisorly lock to prevent deadlocks before attempting to alter the schema. I was seeing deadlocks without this.
2025-10-27 14:31:32 +04:00
Paweł Banaszewski 50ba223aa1 feat: add db query for setting interception ended_at field (#20437)
Adds UpdateAIBridgeInterceptionEnded query to mark interceptions as
done.
Needed for https://github.com/coder/internal/issues/1051
2025-10-27 09:51:37 +01:00
Ethan 6318520501 fix(cli/ssh): avoid swallowing unexpected http status codes (#20487)
If you were to somehow get a 401, or some other unexpected HTTP status code when following a workspace's build logs, `coder ssh` would swallow the error, and give you a different error that didn't make sense. 
In this case I was getting a 401 on `/templateversions/{templateversion}/dry-run/{jobID}/logs` , but the CLI error would say the workspace had no agents.

I ran into the 401 when running a scaletest, and then whilst attempting to reproduce the issue locally, I ran  `coder ssh` from one build of Coder that used `coder_session_token` as the session token cookie name, whilst the other build used `dev_coder_session_token` (as set by `develop.sh`). For reference, the CLI uses cookies when following the build logs.
2025-10-27 16:59:31 +11:00
dependabot[bot] f3f83540df chore: bump coder/claude-code/coder from 3.1.1 to 3.3.2 in /examples/templates/tasks-docker (#20486)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 04:23:07 +00:00
Max Bretschneider 10ef5933f4 chore(examples/templates): remove deleted vscode-server-template (#20480)
The list of community templates contained a template called
vscode-server-template which links to a hijacked accounts repository.
https://github.com/KozmikNano/vscode-server-template

I removed it from the list
2025-10-27 11:51:42 +11:00
dependabot[bot] c8538769a2 chore: bump coder/claude-code/coder from 3.3.1 to 3.3.2 in /dogfood/coder (#20484)
[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=coder/claude-code/coder&package-manager=terraform&previous-version=3.3.1&new-version=3.3.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 00:27:07 +00:00
dependabot[bot] 5743149396 chore: bump coder/zed/coder from 1.1.0 to 1.1.1 in /dogfood/coder (#20485)
[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=coder/zed/coder&package-manager=terraform&previous-version=1.1.0&new-version=1.1.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 00:27:02 +00:00
Atif Ali 9780d0295c feat(cli): add dynamic completions for ssh command (#20171)
Adds CompletionHandler to the ssh command that dynamically suggests
workspace and agent targets based on the user's running workspaces.

Features:
- Suggests workspace name for single-agent workspaces
- Suggests agent.workspace format for all agents in multi-agent
workspaces
- Only shows running workspaces (matches immediate availability)
- Alphabetically sorted completions for better UX

Tests cover single-agent, multi-agent, and network error scenarios.

Amp-Thread-ID:
https://ampcode.com/threads/T-d137d343-53f3-4ece-be5a-584249bbd9e8

<!--

If you have used AI to produce some or all of this PR, please ensure you
have read our [AI Contribution
guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING)
before submitting.

-->

closes #20158

Demo:


https://github.com/user-attachments/assets/e1000463-ded6-4bc9-b013-61780453f019

---------

Co-authored-by: Ethan Dickson <ethan@coder.com>
2025-10-27 11:13:26 +11:00
Faur Ioan-Aurel b89093031e fix: initialize pseudo console with default size for SSH sessions (#20472)
Resolved an invalid parameter error (-2147024809) during PTY creation on
Windows 11 22H2 (but not only) when connecting via JetBrains Toolbox
which spawns the native SSH client with `-tt` forcing PTY allocation
even though there is no "terminal" on the client side to query its size.

CreatePseudoConsole doesn't accept a 0x0 (zero width and zero height)
console size and unfortunately, there is NO explicit documentation in
the official Microsoft documentation that states the minimum valid
values or explicitly prohibits 0x0.

Looking at real-world implementations in the search results, all
examples use reasonable non-zero values.

I tested this with a local Windows VM registered to dev.coder.com i.e.
externally managed workspace.

Fixes: #20468
2025-10-25 14:05:03 +11:00
Atif Ali 87045fc27b chore(dogfood): update claude-code module version to 3.3.1 (#20467) 2025-10-25 00:03:16 +05:00
Cian Johnston b8a0f97cab chore(coderd): add test for deleting task with no workspace (#20466) 2025-10-24 18:19:05 +01:00
Jaayden Halko 7bad7e35ae chore: update claude markdown docs (#20446)
Suggesting some improvements for claude code and tasks usage. See
comments inline.

---------

Co-authored-by: Dean Sheather <dean@deansheather.com>
2025-10-24 16:05:18 +01:00
Bruno Quaresma cd0a2849d0 fix: fix URL parameter for task (#20463) 2025-10-24 11:56:57 -03:00
Susana Ferreira f6e86c6fdb feat: cancel pending prebuilds from non-active template versions (#20387)
## Description

This PR introduces an optimization to automatically cancel pending
prebuild-related jobs from non-active template versions in the
reconciliation loop.

## Problem

Currently, when a template is configured with more prebuild instances
than available provisioners, the provisioner queue can become flooded
with pending prebuild jobs. This issue is worsened when
provisioning/deprovisioning operations take a long time.

When the prebuild reconciliation loop generates jobs faster than
provisioners can process them, pending jobs accumulate in the queue.
Since prebuilt workspaces should always run the latest active template
version, pending prebuild jobs from non-active versions become obsolete
once a new version is promoted.

## Solution

The reconciliation loop cancels pending prebuild-related jobs from
non-active template versions that match the following criteria:

* Build number: 1 (initial build created by the reconciliation loop)
* Job status: `pending`
* Not yet picked up by a provisioner (`worker_id` is `NULL`)
* Owned by the prebuilds system user
* Workspace transition: `start`

This prevents the queue from being cluttered with stale prebuild jobs
that would provision workspaces on an outdated template version that
would consequently need to be deprovisioned.

## Changes

* Added new SQL query `CountPendingNonActivePrebuilds` to identify
presets with pending jobs from non-active versions
* Added new SQL query `UpdatePrebuildProvisionerJobWithCancel` to cancel
jobs for a specific preset
* New reconciliation action type `ActionTypeCancelPending` handles the
cancellation logic
* Cancellation is non-blocking: failures to cancel prebuild jobs are
logged as errors and don't prevent other reconciliation actions

## Follow-up PR

Canceling pending prebuild jobs leaves workspaces in a Canceled state.
While no Terraform resources need to be destroyed (since jobs were
canceled before provisioning started), these database records should
still be cleaned up. This will be addressed in a follow-up PR.

Closes: https://github.com/coder/coder/issues/20242
2025-10-24 15:27:49 +01:00
Marcin Tojek c301a0d804 docs: add comprehensive Web Terminal documentation (#20458)
Fixes: https://github.com/coder/coder/issues/19119
2025-10-24 16:24:53 +02:00
Sas Swart 6c621364f8 feat: add a dependency management graph for agents (#20208)
Relates to https://github.com/coder/internal/issues/1093

This is the first of N pull requests to allow coder script ordering.
It introduces what is for now dead code, but paves the way for various
interfaces that allow coder scripts and other processes to depend on one
another via CLI commands and terraform configurations.

The next step is to add reactivity to the graph, such that changes in
the status of one vertex will propagate and allow other vertices to
change their own statuses.

Concurrency and stress testing yield the following:

CPU Profile:
<img width="1512" height="862" alt="Screenshot 2025-10-17 at 10 38 52"
src="https://github.com/user-attachments/assets/f46cf1a2-a0b2-4c02-81a0-069798108ee5"
/>

Mem Profile:
<img width="1512" height="862" alt="Screenshot 2025-10-17 at 10 38 01"
src="https://github.com/user-attachments/assets/45be1235-fff6-45ba-a50d-db9880377bd0"
/>

Predictably, lock contention and memory allocation are the largest
components of this system under stress. Nothing seems untoward.
2025-10-24 16:18:16 +02:00
Mathias Fredriksson 51d3abb904 feat(site): use new task data model and endpoints (#20431)
Updates the UI to use the new API endpoints for tasks and use its new
data model.

Disclaimer: Since the base data model for tasks changed, we had to do a
quite large refactor and I'm sorry for that 🙏, but you'll notice most of
the changes are to adjust the types.

Closes coder/internal#976

---------

Co-authored-by: Bruno Quaresma <bruno_nonato_quaresma@hotmail.com>
2025-10-24 10:45:19 -03:00
Thomas Kosiewski c6e551f538 fix: renumber api key allow list migration (#20457) 2025-10-24 11:54:51 +00:00
Thomas Kosiewski f684831f56 feat: add allow list to API keys (#19972)
Add API key allow list to the SDK

This PR adds an allow list to API keys in the SDK. The allow list is a list of targets that the API key is allowed to access. If the allow list is empty, a default allow list with a single entry that allows access to all resources is created.

The changes include:

- Adding a default allow list when generating an API key if none is provided
- Adding allow list to the API key response in the SDK
- Converting database allow list entries to SDK format in the API response
- Adding tests to verify the default allow list behavior



Fixes #19854
2025-10-24 12:33:56 +01:00
dependabot[bot] f947a34103 ci: bump the github-actions group across 1 directory with 15 updates (#20384)
Co-authored-by: github-actions[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: M Atif Ali <atif@coder.com>
Co-authored-by: Ethan Dickson <ethan@coder.com>
2025-10-24 16:06:44 +05:00
Marcin Tojek fb9d8e3030 feat(cli): warn user if setting autostart on workspace with template-level autostart (#20454)
Fixes: https://github.com/coder/coder/issues/15619
2025-10-24 10:18:19 +00:00
Danielle Maywood e60112e54f chore(coderd): introduce TaskAppID and deprecate AITaskSidebarAppID (#20336)
As we're moving away from the SidebarAppID nomenclature, this PR
introduces a new `TaskAppID` field to `codersdk.WorkspaceBuild` and
deprecates the `AITaskSidebarAppID` field. They both contain the same
value.
2025-10-24 10:57:32 +01:00
Mathias Fredriksson e8e31dcb2c fix(cli): use correct task status in list/status output (#20453) 2025-10-24 12:38:47 +03:00
Marcin Tojek 40e1784846 feat(provisioner): warn when .terraform.lock.hcl is modified during init (#20451)
Fixes: https://github.com/coder/coder/pull/20451
2025-10-24 10:44:46 +02:00
Danielle Maywood 5a31c590e6 fix(coderd/provisionerdserver): pipe through task id and prompt (#20408)
Pipes through the Task's ID and prompt into the provisioner. This is
required to support the new `coder_ai_task.prompt` field and modified
`coder_ai_task.id` field.
2025-10-24 09:43:48 +01:00
Ethan e13a34c145 chore: bump coder/serpent to fix shell completions bug (#20448)
Brings in the fix described in https://github.com/coder/serpent/pull/27
2025-10-24 17:53:18 +11:00
Ethan 33b42fca7a test: fix flake in TestAgent_Metrics_SSH (#20450)
Second flake for this test today 😮‍💨.

Flake seen here, though I couldn't replicate this locally, some CI
exclusive networking issue.

https://github.com/coder/coder/actions/runs/18770305895/job/53553517887?pr=20448
```
    agent_test.go:3619: 
        	Error Trace:	/home/runner/work/coder/coder/agent/agent_test.go:3619
        	Error:      	Received unexpected error:
        	            	expected 1, got 0.000000:
        	            	    github.com/coder/coder/v2/agent_test.TestAgent_Metrics_SSH.func7
        	            	        /home/runner/work/coder/coder/agent/agent_test.go:3557
        	Test:       	TestAgent_Metrics_SSH
        	Messages:   	check fn for coderd_agentstats_currently_reachable_peers failed
```
This value is incremented by a successful ping to the peer from the
agent, which is dependent on all the networking code, which I think is
definitely out of scope of this test for agent metrics. So, we'll just
assert that the metrics exist with the correct labels (`derp`, `p2p`)
2025-10-24 17:28:57 +11:00
Ethan 86ef3fb497 test: fix flake in TestAgent_Metrics_SSH (#20447)
Closes https://github.com/coder/internal/issues/921

The flake in the linked issue was caused by the startup script taking longer than 1 second in CI. The existing conditional, that the startup script duration was under a second, was incorrect; the correct conditional is that the metric exists with the `success` label set to `true`.
2025-10-24 14:06:25 +11:00
Steven Masley 13ca9ead3a chore!: ensure consistent secret token generation and hashing (#20388)
This PR uses the same sha256 hashing technique as we use for APIKeys. So
now all randomly generated secrets will be hashed with sha256 for
consistency.

This is a breaking change for the oauth tokens. Since oauth is only
allowed for dev builds and experimental, this is ok.
2025-10-23 15:38:49 -05:00
Marcin Tojek 906149317d docs: document location property for support links (#20445) 2025-10-23 22:26:21 +02:00
Mathias Fredriksson 6187acff8a chore(codersdk): document TaskStatus and TaskState (#20441)
Updates coder/internal#976
2025-10-23 20:28:27 +03:00
Mathias Fredriksson a106d67c07 feat(coderd): use task data model for list (#20394)
Updates coder/internal#976
2025-10-23 20:22:51 +03:00
Mathias Fredriksson 2c6cbf15e2 feat(coderd): use task data model for send/logs (#20381)
Updates coder/internal#976
2025-10-23 20:10:50 +03:00
Atif Ali 1cb2ac65e5 chore: remove a redundant letter from docs (#20443) 2025-10-23 22:02:34 +05:00
Mathias Fredriksson c6f63990cf feat(coderd): use task data model when fetching a task (#20380)
Updates coder/internal#976
2025-10-23 19:58:47 +03:00
Mathias Fredriksson 9855460524 feat(coderd): use new data model for task delete (#20334)
Updates coder/internal#976
2025-10-23 19:45:18 +03:00
Mathias Fredriksson 79728c30fa chore(coderd/database/migrations): migrate tasks to new data model (#20434)
Updates coder/internal#976
Closes coder/internal#1078
2025-10-23 19:29:23 +03:00
DevCats 8daf4f35b1 feat: add copyparty icon (#20440)
copy of #20415 since ci wont run on fork currently
2025-10-23 11:14:35 -05:00
Mathias Fredriksson 5c802c2627 feat(coderd): use task data model when creating a new task (#20275)
Updates coder/internal#976
2025-10-23 19:12:09 +03:00
Spike Curtis 0f342ecc04 feat: add provisioner tags to dynamic-parameters scaletest (#20435)
Since `coder exp scaletest dynamic-parameters` ends up creating template versions, provisioner tags may be required to create the template versions.

On our own scaletest clusters, we tag every provisioner, so an untagged template version won't build and won't get imported.
2025-10-23 16:49:09 +04:00
Hugo Dutka e62c5db678 chore: remove references to dbtestutil.WillUsePostgres (#20436)
Addresses https://github.com/coder/internal/issues/758.

This PR only cleans up dead code, it makes no changes to test logic.
2025-10-23 14:24:54 +02:00
Paweł Banaszewski 4244b20823 feat: add ended_at column to aibridge_interceptions table (#20432)
Needed for marking interceptions as done
(https://github.com/coder/internal/issues/1051).
2025-10-23 13:29:05 +02:00
Cian Johnston 70cc3dd14a ci(.github/workflows/traiage.yaml): use coder template with no preset (#20428)
Standardize on using the Write Coder on Coder template for the traiage
workflow.
2025-10-23 09:55:19 +01:00
Jake Howell d455f6ea2b fix: rename total to count in AIBridgeListInterceptionsResponse (#20410)
Thanks to the great work in #20393, we’ve successfully introduced
offset-based pagination for this endpoint. However, the frontend expects
a `count` field in the response rather than `total`. This PR updates the
response payload to rename the returned key to `count` for consistency
with frontend expectations and existing API patterns.

This is necessary to unblock the work in #20331
2025-10-23 13:19:12 +11:00
Steven Masley 4bd7c7b7e0 feat: implement oauth2 RFC 7009 token revocation endpoint (#20362)
Adds RFC 7009 token revocation endpoint
2025-10-22 15:18:42 -05:00
339 changed files with 14760 additions and 5733 deletions
+10 -2
View File
@@ -91,6 +91,9 @@
## Systematic Debugging Approach
YOU MUST ALWAYS find the root cause of any issue you are debugging
YOU MUST NEVER fix a symptom or add a workaround instead of finding a root cause, even if it is faster.
### Multi-Issue Problem Solving
When facing multiple failing tests or complex integration issues:
@@ -98,16 +101,21 @@ When facing multiple failing tests or complex integration issues:
1. **Identify Root Causes**:
- Run failing tests individually to isolate issues
- Use LSP tools to trace through call chains
- Check both compilation and runtime errors
- Read Error Messages Carefully: Check both compilation and runtime errors
- Reproduce Consistently: Ensure you can reliably reproduce the issue before investigating
- Check Recent Changes: What changed that could have caused this? Git diff, recent commits, etc.
- When You Don't Know: Say "I don't understand X" rather than pretending to know
2. **Fix in Logical Order**:
- Address compilation issues first (imports, syntax)
- Fix authorization and RBAC issues next
- Resolve business logic and validation issues
- Handle edge cases and race conditions last
- IF your first fix doesn't work, STOP and re-analyze rather than adding more fixes
3. **Verification Strategy**:
- Test each fix individually before moving to next issue
- Always Test each fix individually before moving to next issue
- Verify Before Continuing: Did your test work? If not, form new hypothesis - don't add more fixes
- Use `make lint` and `make gen` after database changes
- Verify RFC compliance with actual specifications
- Run comprehensive test suites before considering complete
+7 -3
View File
@@ -40,11 +40,15 @@
- Use proper error types
- Pattern: `xerrors.Errorf("failed to X: %w", err)`
### Naming Conventions
## Naming Conventions
- Use clear, descriptive names
- Abbreviate only when obvious
- Names MUST tell what code does, not how it's implemented or its history
- Follow Go and TypeScript naming conventions
- When changing code, never document the old behavior or the behavior change
- NEVER use implementation details in names (e.g., "ZodValidator", "MCPWrapper", "JSONParser")
- NEVER use temporal/historical context in names (e.g., "LegacyHandler", "UnifiedTool", "ImprovedInterface", "EnhancedParser")
- NEVER use pattern names unless they add clarity (e.g., prefer "Tool" over "ToolFactory")
- Abbreviate only when obvious
### Comments
+1 -1
View File
@@ -4,7 +4,7 @@ description: |
inputs:
version:
description: "The Go version to use."
default: "1.24.6"
default: "1.24.10"
use-preinstalled-go:
description: "Whether to use preinstalled Go."
default: "false"
@@ -1,34 +0,0 @@
app = "sao-paulo-coder"
primary_region = "gru"
[experimental]
entrypoint = ["/bin/sh", "-c", "CODER_DERP_SERVER_RELAY_URL=\"http://[${FLY_PRIVATE_IP}]:3000\" /opt/coder wsproxy server"]
auto_rollback = true
[build]
image = "ghcr.io/coder/coder-preview:main"
[env]
CODER_ACCESS_URL = "https://sao-paulo.fly.dev.coder.com"
CODER_HTTP_ADDRESS = "0.0.0.0:3000"
CODER_PRIMARY_ACCESS_URL = "https://dev.coder.com"
CODER_WILDCARD_ACCESS_URL = "*--apps.sao-paulo.fly.dev.coder.com"
CODER_VERBOSE = "true"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
# Ref: https://fly.io/docs/reference/configuration/#http_service-concurrency
[http_service.concurrency]
type = "requests"
soft_limit = 50
hard_limit = 100
[[vm]]
cpu_kind = "shared"
cpus = 2
memory_mb = 512
+12 -26
View File
@@ -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
@@ -376,13 +376,6 @@ jobs:
id: go-paths
uses: ./.github/actions/setup-go-paths
- name: Download Go Build Cache
id: download-go-build-cache
uses: ./.github/actions/test-cache/download
with:
key-prefix: test-go-build-${{ runner.os }}-${{ runner.arch }}
cache-path: ${{ steps.go-paths.outputs.cached-dirs }}
- name: Setup Go
uses: ./.github/actions/setup-go
with:
@@ -390,8 +383,7 @@ jobs:
# download the toolchain configured in go.mod, so we don't
# need to reinstall it. It's faster on Windows runners.
use-preinstalled-go: ${{ runner.os == 'Windows' }}
# Cache is already downloaded above
use-cache: false
use-cache: true
- name: Setup Terraform
uses: ./.github/actions/setup-tf
@@ -500,17 +492,11 @@ jobs:
make test
- name: Upload failed test db dumps
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: failed-test-db-dump-${{matrix.os}}
path: "**/*.test.sql"
- name: Upload Go Build Cache
uses: ./.github/actions/test-cache/upload
with:
cache-key: ${{ steps.download-go-build-cache.outputs.cache-key }}
cache-path: ${{ steps.go-paths.outputs.cached-dirs }}
- name: Upload Test Cache
uses: ./.github/actions/test-cache/upload
with:
@@ -762,7 +748,7 @@ jobs:
- name: Upload Playwright Failed Tests
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: failed-test-videos${{ matrix.variant.premium && '-premium' || '' }}
path: ./site/test-results/**/*.webm
@@ -770,7 +756,7 @@ jobs:
- name: Upload pprof dumps
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: debug-pprof-dumps${{ matrix.variant.premium && '-premium' || '' }}
path: ./site/test-results/**/debug-pprof-*.txt
@@ -806,7 +792,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@bc2d84ad2b60813a67d995c5582d696104a19383 # v13.3.2
env:
NODE_OPTIONS: "--max_old_space_size=4096"
STORYBOOK: true
@@ -838,7 +824,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@bc2d84ad2b60813a67d995c5582d696104a19383 # v13.3.2
env:
NODE_OPTIONS: "--max_old_space_size=4096"
STORYBOOK: true
@@ -1036,7 +1022,7 @@ jobs:
- name: Upload build artifacts
if: ${{ github.repository_owner == 'coder' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: dylibs
path: |
@@ -1123,7 +1109,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 }}
@@ -1201,7 +1187,7 @@ jobs:
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
- name: Download dylibs
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: dylibs
path: ./build
@@ -1468,7 +1454,7 @@ jobs:
- name: Upload build artifacts
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: coder
path: |
+2 -4
View File
@@ -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"
@@ -163,12 +163,10 @@ jobs:
run: |
flyctl deploy --image "$IMAGE" --app paris-coder --config ./.github/fly-wsproxies/paris-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_PARIS" --yes
flyctl deploy --image "$IMAGE" --app sydney-coder --config ./.github/fly-wsproxies/sydney-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_SYDNEY" --yes
flyctl deploy --image "$IMAGE" --app sao-paulo-coder --config ./.github/fly-wsproxies/sao-paulo-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_SAO_PAULO" --yes
flyctl deploy --image "$IMAGE" --app jnb-coder --config ./.github/fly-wsproxies/jnb-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_JNB" --yes
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
IMAGE: ${{ inputs.image }}
TOKEN_PARIS: ${{ secrets.FLY_PARIS_CODER_PROXY_SESSION_TOKEN }}
TOKEN_SYDNEY: ${{ secrets.FLY_SYDNEY_CODER_PROXY_SESSION_TOKEN }}
TOKEN_SAO_PAULO: ${{ secrets.FLY_SAO_PAULO_CODER_PROXY_SESSION_TOKEN }}
TOKEN_JNB: ${{ secrets.FLY_JNB_CODER_PROXY_SESSION_TOKEN }}
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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@dbf178ceecb9304128c8e0648591d71208c6e2c9 # v45.0.7
id: changed-files
with:
files: |
+3 -3
View File
@@ -36,11 +36,11 @@ jobs:
persist-credentials: false
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@1f095fee853b33114486cfdeae62fa099cda35a9 # v33
uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
with:
# Pinning to 2.28 here, as Nix gets a "error: [json.exception.type_error.302] type must be array, but is string"
# on version 2.29 and above.
nix_version: "2.28.4"
nix_version: "2.28.5"
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
with:
@@ -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 }}
+5 -5
View File
@@ -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:
+6 -6
View File
@@ -131,7 +131,7 @@ jobs:
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: dylibs
path: |
@@ -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 }}
@@ -327,7 +327,7 @@ jobs:
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
- name: Download dylibs
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: dylibs
path: ./build
@@ -761,7 +761,7 @@ jobs:
- name: Upload artifacts to actions (if dry-run)
if: ${{ inputs.dry_run }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: release-artifacts
path: |
@@ -777,7 +777,7 @@ jobs:
- name: Upload latest sbom artifact to actions (if dry-run)
if: inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: latest-sbom-artifact
path: ./coder_latest_sbom.spdx.json
@@ -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
+3 -3
View File
@@ -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
@@ -39,7 +39,7 @@ jobs:
# Upload the results as artifacts.
- name: "Upload artifact"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: SARIF file
path: results.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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v3.29.5
with:
sarif_file: results.sarif
+4 -4
View File
@@ -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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # 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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v3.29.5
- name: Send Slack notification on failure
if: ${{ failure() }}
@@ -154,13 +154,13 @@ 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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v3.29.5
with:
sarif_file: trivy-results.sarif
category: "Trivy"
- name: Upload Trivy scan results as an artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: trivy
path: trivy-results.sarif
+3 -3
View File
@@ -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"
@@ -125,7 +125,7 @@ jobs:
egress-policy: audit
- name: Delete PR Cleanup workflow runs
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
uses: Mattraks/delete-workflow-runs@ab482449ba468316e9a8801e092d0405715c5e6d # v2.1.0
with:
token: ${{ github.token }}
repository: ${{ github.repository }}
@@ -134,7 +134,7 @@ jobs:
delete_workflow_pattern: pr-cleanup.yaml
- name: Delete PR Deploy workflow skipped runs
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
uses: Mattraks/delete-workflow-runs@ab482449ba468316e9a8801e092d0405715c5e6d # v2.1.0
with:
token: ${{ github.token }}
repository: ${{ github.repository }}
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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:
+5
View File
@@ -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.
@@ -86,3 +89,5 @@ result
__debug_bin*
**/.claude/settings.local.json
/.env
+40 -19
View File
@@ -1,11 +1,41 @@
# Coder Development Guidelines
You are an experienced, pragmatic software engineer. You don't over-engineer a solution when a simple one is possible.
Rule #1: If you want exception to ANY rule, YOU MUST STOP and get explicit permission first. BREAKING THE LETTER OR SPIRIT OF THE RULES IS FAILURE.
## Foundational rules
- Doing it right is better than doing it fast. You are not in a rush. NEVER skip steps or take shortcuts.
- Tedious, systematic work is often the correct solution. Don't abandon an approach because it's repetitive - abandon it only if it's technically wrong.
- Honesty is a core value.
## Our relationship
- Act as a critical peer reviewer. Your job is to disagree with me when I'm wrong, not to please me. Prioritize accuracy and reasoning over agreement.
- YOU MUST speak up immediately when you don't know something or we're in over our heads
- YOU MUST call out bad ideas, unreasonable expectations, and mistakes - I depend on this
- NEVER be agreeable just to be nice - I NEED your HONEST technical judgment
- NEVER write the phrase "You're absolutely right!" You are not a sycophant. We're working together because I value your opinion. Do not agree with me unless you can justify it with evidence or reasoning.
- YOU MUST ALWAYS STOP and ask for clarification rather than making assumptions.
- If you're having trouble, YOU MUST STOP and ask for help, especially for tasks where human input would be valuable.
- When you disagree with my approach, YOU MUST push back. Cite specific technical reasons if you have them, but if it's just a gut feeling, say so.
- If you're uncomfortable pushing back out loud, just say "Houston, we have a problem". I'll know what you mean
- We discuss architectutral decisions (framework changes, major refactoring, system design) together before implementation. Routine fixes and clear implementations don't need discussion.
## Proactiveness
When asked to do something, just do it - including obvious follow-up actions needed to complete the task properly.
Only pause to ask for confirmation when:
- Multiple valid approaches exist and the choice matters
- The action would delete or significantly restructure existing code
- You genuinely don't understand what's being asked
- Your partner asked a question (answer the question, don't jump to implementation)
@.claude/docs/WORKFLOWS.md
@.cursorrules
@README.md
@package.json
## 🚀 Essential Commands
## Essential Commands
| Task | Command | Notes |
|-------------------|--------------------------|----------------------------------|
@@ -21,22 +51,13 @@
| **Format** | `make fmt` | Auto-format code |
| **Clean** | `make clean` | Clean build artifacts |
### Frontend Commands (site directory)
- `pnpm build` - Build frontend
- `pnpm dev` - Run development server
- `pnpm check` - Run code checks
- `pnpm format` - Format frontend code
- `pnpm lint` - Lint frontend code
- `pnpm test` - Run frontend tests
### Documentation Commands
- `pnpm run format-docs` - Format markdown tables in docs
- `pnpm run lint-docs` - Lint and fix markdown files
- `pnpm run storybook` - Run Storybook (from site directory)
## 🔧 Critical Patterns
## Critical Patterns
### Database Changes (ALWAYS FOLLOW)
@@ -78,7 +99,7 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestrict
app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
```
## 📋 Quick Reference
## Quick Reference
### Full workflows available in imported WORKFLOWS.md
@@ -88,14 +109,14 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
- [ ] Check if feature touches database - you'll need migrations
- [ ] Check if feature touches audit logs - update `enterprise/audit/table.go`
## 🏗️ Architecture
## Architecture
- **coderd**: Main API service
- **provisionerd**: Infrastructure provisioning
- **Agents**: Workspace services (SSH, port forwarding)
- **Database**: PostgreSQL with `dbauthz` authorization
## 🧪 Testing
## Testing
### Race Condition Prevention
@@ -112,21 +133,21 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
NEVER use `time.Sleep` to mitigate timing issues. If an issue
seems like it should use `time.Sleep`, read through https://github.com/coder/quartz and specifically the [README](https://github.com/coder/quartz/blob/main/README.md) to better understand how to handle timing issues.
## 🎯 Code Style
## Code Style
### Detailed guidelines in imported WORKFLOWS.md
- Follow [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md)
- Commit format: `type(scope): message`
## 📚 Detailed Development Guides
## Detailed Development Guides
@.claude/docs/OAUTH2.md
@.claude/docs/TESTING.md
@.claude/docs/TROUBLESHOOTING.md
@.claude/docs/DATABASE.md
## 🚨 Common Pitfalls
## Common Pitfalls
1. **Audit table errors** → Update `enterprise/audit/table.go`
2. **OAuth2 errors** → Return RFC-compliant format
-12
View File
@@ -18,18 +18,6 @@ coderd/rbac/ @Emyrk
scripts/apitypings/ @Emyrk
scripts/gensite/ @aslilac
site/ @aslilac @Parkreiner
site/src/hooks/ @Parkreiner
# These rules intentionally do not specify any owners. More specific rules
# override less specific rules, so these files are "ignored" by the site/ rule.
site/e2e/google/protobuf/timestampGenerated.ts
site/e2e/provisionerGenerated.ts
site/src/api/countriesGenerated.ts
site/src/api/rbacresourcesGenerated.ts
site/src/api/typesGenerated.ts
site/src/testHelpers/entities.ts
site/CLAUDE.md
# The blood and guts of the autostop algorithm, which is quite complex and
# requires elite ball knowledge of most of the scheduling code to make changes
# without inadvertently affecting other parts of the codebase.
+18 -8
View File
@@ -636,8 +636,8 @@ TAILNETTEST_MOCKS := \
tailnet/tailnettest/subscriptionmock.go
AIBRIDGED_MOCKS := \
enterprise/x/aibridged/aibridgedmock/clientmock.go \
enterprise/x/aibridged/aibridgedmock/poolmock.go
enterprise/aibridged/aibridgedmock/clientmock.go \
enterprise/aibridged/aibridgedmock/poolmock.go
GEN_FILES := \
tailnet/proto/tailnet.pb.go \
@@ -645,7 +645,7 @@ GEN_FILES := \
provisionersdk/proto/provisioner.pb.go \
provisionerd/proto/provisionerd.pb.go \
vpn/vpn.pb.go \
enterprise/x/aibridged/proto/aibridged.pb.go \
enterprise/aibridged/proto/aibridged.pb.go \
$(DB_GEN_FILES) \
$(SITE_GEN_FILES) \
coderd/rbac/object_gen.go \
@@ -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 \
@@ -696,7 +697,7 @@ gen/mark-fresh:
provisionersdk/proto/provisioner.pb.go \
provisionerd/proto/provisionerd.pb.go \
vpn/vpn.pb.go \
enterprise/x/aibridged/proto/aibridged.pb.go \
enterprise/aibridged/proto/aibridged.pb.go \
coderd/database/dump.sql \
$(DB_GEN_FILES) \
site/src/api/typesGenerated.ts \
@@ -767,8 +768,8 @@ codersdk/workspacesdk/agentconnmock/agentconnmock.go: codersdk/workspacesdk/agen
go generate ./codersdk/workspacesdk/agentconnmock/
touch "$@"
$(AIBRIDGED_MOCKS): enterprise/x/aibridged/client.go enterprise/x/aibridged/pool.go
go generate ./enterprise/x/aibridged/aibridgedmock/
$(AIBRIDGED_MOCKS): enterprise/aibridged/client.go enterprise/aibridged/pool.go
go generate ./enterprise/aibridged/aibridgedmock/
touch "$@"
agent/agentcontainers/dcspec/dcspec_gen.go: \
@@ -821,13 +822,13 @@ vpn/vpn.pb.go: vpn/vpn.proto
--go_opt=paths=source_relative \
./vpn/vpn.proto
enterprise/x/aibridged/proto/aibridged.pb.go: enterprise/x/aibridged/proto/aibridged.proto
enterprise/aibridged/proto/aibridged.pb.go: enterprise/aibridged/proto/aibridged.proto
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-drpc_out=. \
--go-drpc_opt=paths=source_relative \
./enterprise/x/aibridged/proto/aibridged.proto
./enterprise/aibridged/proto/aibridged.proto
site/src/api/typesGenerated.ts: site/node_modules/.installed $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
# -C sets the directory for the go run command
@@ -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 "$@"
@@ -1177,3 +1182,8 @@ endif
dogfood/coder/nix.hash: flake.nix flake.lock
sha256sum flake.nix flake.lock >./dogfood/coder/nix.hash
# Count the number of test databases created per test package.
count-test-databases:
PGPASSWORD=postgres psql -h localhost -U postgres -d coder_testing -P pager=off -c 'SELECT test_package, count(*) as count from test_databases GROUP BY test_package ORDER BY count DESC'
.PHONY: count-test-databases
+76 -33
View File
@@ -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{
-2
View File
@@ -682,8 +682,6 @@ func (api *API) updaterLoop() {
} else {
prevErr = nil
}
default:
api.logger.Debug(api.ctx, "updater loop ticker skipped, update in progress")
}
return nil // Always nil to keep the ticker going.
+174
View File
@@ -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
}
+454
View File
@@ -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()
}
}
+8
View File
@@ -0,0 +1,8 @@
strict digraph Cycle {
// Node definitions.
1;
2;
// Edge definitions.
1 -> 2;
}
+10
View File
@@ -0,0 +1,10 @@
strict digraph ForwardAndReverseEdges {
// Node definitions.
1;
2;
3;
// Edge definitions.
1 -> 2;
1 -> 3;
}
@@ -0,0 +1,12 @@
strict digraph MultipleDependenciesSameStatus {
// Node definitions.
1;
2;
3;
4;
// Edge definitions.
1 -> 2;
1 -> 3;
1 -> 4;
}
+4
View File
@@ -0,0 +1,4 @@
strict digraph SelfReference {
// Node definitions.
1;
}
+78
View File
@@ -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
}
+18 -9
View File
@@ -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
+72
View File
@@ -400,6 +400,78 @@ foo <nil> 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 <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
-6
View File
@@ -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())
+12 -1
View File
@@ -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)
+10 -34
View File
@@ -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()),
)
}
+41 -17
View File
@@ -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"))
+4 -3
View File
@@ -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)
+36 -15
View File
@@ -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
+7 -15
View File
@@ -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)
}
+13 -13
View File
@@ -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()
+7 -15
View File
@@ -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)
}
+13 -13
View File
@@ -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)
+22 -31
View File
@@ -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
}
+149 -69
View File
@@ -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)
}
+14 -9
View File
@@ -17,7 +17,6 @@ import (
"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"
@@ -95,8 +94,7 @@ func Test_Tasks(t *testing.T) {
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")
// NOTE: task status changes type, this is so this test works with both old and new model
require.Contains(t, []string{"active", "running"}, string(task.Status), "task should be active")
require.Equal(t, codersdk.TaskStatusActive, task.Status, "task should be active")
},
},
{
@@ -239,7 +237,7 @@ func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Mes
// 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})
@@ -252,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))
@@ -267,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.
+16
View File
@@ -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{
+64
View File
@@ -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")
})
}
@@ -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()
-11
View File
@@ -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)
+47
View File
@@ -109,6 +109,51 @@ func (r *RootCmd) ssh() *serpent.Command {
}
},
),
CompletionHandler: func(inv *serpent.Invocation) []string {
client, err := r.InitClient(inv)
if err != nil {
return []string{}
}
res, err := client.Workspaces(inv.Context(), codersdk.WorkspaceFilter{
Owner: codersdk.Me,
})
if err != nil {
return []string{}
}
var mu sync.Mutex
var completions []string
var wg sync.WaitGroup
for _, ws := range res.Workspaces {
wg.Add(1)
go func() {
defer wg.Done()
resources, err := client.TemplateVersionResources(inv.Context(), ws.LatestBuild.TemplateVersionID)
if err != nil {
return
}
var agents []codersdk.WorkspaceAgent
for _, resource := range resources {
agents = append(agents, resource.Agents...)
}
mu.Lock()
defer mu.Unlock()
if len(agents) == 1 {
completions = append(completions, ws.Name)
} else {
for _, agent := range agents {
completions = append(completions, fmt.Sprintf("%s.%s", ws.Name, agent.Name))
}
}
}()
}
wg.Wait()
slices.Sort(completions)
return completions
},
Handler: func(inv *serpent.Invocation) (retErr error) {
client, err := r.InitClient(inv)
if err != nil {
@@ -906,6 +951,8 @@ func GetWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("start workspace with active template version: %w", err)
}
_, _ = fmt.Fprintln(inv.Stdout, "Unable to start the workspace with template version from last build. Your workspace has been updated to the current active template version.")
default:
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("start workspace with current template version: %w", err)
}
} else if err != nil {
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, nil, xerrors.Errorf("start workspace with current template version: %w", err)
+96
View File
@@ -2447,3 +2447,99 @@ func tempDirUnixSocket(t *testing.T) string {
return t.TempDir()
}
func TestSSH_Completion(t *testing.T) {
t.Parallel()
t.Run("SingleAgent", func(t *testing.T) {
t.Parallel()
client, workspace, agentToken := setupWorkspaceForAgent(t)
_ = agenttest.New(t, client.URL, agentToken)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
var stdout bytes.Buffer
inv, root := clitest.New(t, "ssh", "")
inv.Stdout = &stdout
inv.Environ.Set("COMPLETION_MODE", "1")
clitest.SetupConfig(t, client, root)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
defer cancel()
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
// For single-agent workspaces, the only completion should be the
// bare workspace name.
output := stdout.String()
t.Logf("Completion output: %q", output)
require.Contains(t, output, workspace.Name)
})
t.Run("MultiAgent", func(t *testing.T) {
t.Parallel()
client, store := coderdtest.NewWithDatabase(t, nil)
first := coderdtest.CreateFirstUser(t, client)
userClient, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
r.Username = "multiuser"
})
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
Name: "multiworkspace",
OrganizationID: first.OrganizationID,
OwnerID: user.ID,
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
return []*proto.Agent{
{
Name: "agent1",
Auth: &proto.Agent_Token{},
},
{
Name: "agent2",
Auth: &proto.Agent_Token{},
},
}
}).Do()
var stdout bytes.Buffer
inv, root := clitest.New(t, "ssh", "")
inv.Stdout = &stdout
inv.Environ.Set("COMPLETION_MODE", "1")
clitest.SetupConfig(t, userClient, root)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
defer cancel()
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
// For multi-agent workspaces, completions should include the
// workspace.agent format but NOT the bare workspace name.
output := stdout.String()
t.Logf("Completion output: %q", output)
lines := strings.Split(strings.TrimSpace(output), "\n")
require.NotContains(t, lines, r.Workspace.Name)
require.Contains(t, output, r.Workspace.Name+".agent1")
require.Contains(t, output, r.Workspace.Name+".agent2")
})
t.Run("NetworkError", func(t *testing.T) {
t.Parallel()
var stdout bytes.Buffer
inv, _ := clitest.New(t, "ssh", "")
inv.Stdout = &stdout
inv.Environ.Set("COMPLETION_MODE", "1")
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
output := stdout.String()
require.Empty(t, output)
})
}
+2 -1
View File
@@ -90,6 +90,7 @@
"allow_renames": false,
"favorite": false,
"next_start_at": "====[timestamp]=====",
"is_prebuild": false
"is_prebuild": false,
"task_id": null
}
]
+35
View File
@@ -80,6 +80,41 @@ OPTIONS:
Periodically check for new releases of Coder and inform the owner. The
check is performed once per day.
AIBRIDGE OPTIONS:
--aibridge-anthropic-base-url string, $CODER_AIBRIDGE_ANTHROPIC_BASE_URL (default: https://api.anthropic.com/)
The base URL of the Anthropic API.
--aibridge-anthropic-key string, $CODER_AIBRIDGE_ANTHROPIC_KEY
The key to authenticate against the Anthropic API.
--aibridge-bedrock-access-key string, $CODER_AIBRIDGE_BEDROCK_ACCESS_KEY
The access key to authenticate against the AWS Bedrock API.
--aibridge-bedrock-access-key-secret string, $CODER_AIBRIDGE_BEDROCK_ACCESS_KEY_SECRET
The access key secret to use with the access key to authenticate
against the AWS Bedrock API.
--aibridge-bedrock-model string, $CODER_AIBRIDGE_BEDROCK_MODEL (default: global.anthropic.claude-sonnet-4-5-20250929-v1:0)
The model to use when making requests to the AWS Bedrock API.
--aibridge-bedrock-region string, $CODER_AIBRIDGE_BEDROCK_REGION
The AWS Bedrock API region.
--aibridge-bedrock-small-fastmodel string, $CODER_AIBRIDGE_BEDROCK_SMALL_FAST_MODEL (default: global.anthropic.claude-haiku-4-5-20251001-v1:0)
The small fast model to use when making requests to the AWS Bedrock
API. Claude Code uses Haiku-class models to perform background tasks.
See
https://docs.claude.com/en/docs/claude-code/settings#environment-variables.
--aibridge-enabled bool, $CODER_AIBRIDGE_ENABLED (default: false)
Whether to start an in-memory aibridged instance.
--aibridge-openai-base-url string, $CODER_AIBRIDGE_OPENAI_BASE_URL (default: https://api.openai.com/v1/)
The base URL of the OpenAI API.
--aibridge-openai-key string, $CODER_AIBRIDGE_OPENAI_KEY
The key to authenticate against the OpenAI API.
CLIENT OPTIONS:
These options change the behavior of how clients interact with the Coder.
Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
+5
View File
@@ -16,6 +16,10 @@ USAGE:
$ coder tokens ls
- Create a scoped token:
$ coder tokens create --scope workspace:read --allow workspace:<uuid>
- 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.
+9 -1
View File
@@ -6,12 +6,20 @@ USAGE:
Create a token
OPTIONS:
--allow allow-list
Repeatable allow-list entry (<type>:<uuid>, e.g. workspace:1234-...).
--lifetime string, $CODER_TOKEN_LIFETIME
Specify a duration for the lifetime of the token.
Duration for the token lifetime. Supports standard Go duration units
(ns, us, ms, s, m, h) plus d (days) and y (years). Examples: 8h, 30d,
1y, 1d12h30m.
-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).
+1 -1
View File
@@ -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)
+16
View File
@@ -0,0 +1,16 @@
coder v0.0.0-devel
USAGE:
coder tokens view [flags] <name|id>
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.
+21 -4
View File
@@ -714,8 +714,7 @@ workspace_prebuilds:
# (default: 3, type: int)
failure_hard_limit: 3
aibridge:
# Whether to start an in-memory aibridged instance ("aibridge" experiment must be
# enabled, too).
# Whether to start an in-memory aibridged instance.
# (default: false, type: bool)
enabled: false
# The base URL of the OpenAI API.
@@ -726,7 +725,25 @@ aibridge:
openai_key: ""
# The base URL of the Anthropic API.
# (default: https://api.anthropic.com/, type: string)
base_url: https://api.anthropic.com/
anthropic_base_url: https://api.anthropic.com/
# The key to authenticate against the Anthropic API.
# (default: <unset>, type: string)
key: ""
anthropic_key: ""
# The AWS Bedrock API region.
# (default: <unset>, type: string)
bedrock_region: ""
# The access key to authenticate against the AWS Bedrock API.
# (default: <unset>, type: string)
bedrock_access_key: ""
# The access key secret to use with the access key to authenticate against the AWS
# Bedrock API.
# (default: <unset>, type: string)
bedrock_access_key_secret: ""
# The model to use when making requests to the AWS Bedrock API.
# (default: global.anthropic.claude-sonnet-4-5-20250929-v1:0, type: string)
bedrock_model: global.anthropic.claude-sonnet-4-5-20250929-v1:0
# The small fast model to use when making requests to the AWS Bedrock API. Claude
# Code uses Haiku-class models to perform background tasks. See
# https://docs.claude.com/en/docs/claude-code/settings#environment-variables.
# (default: global.anthropic.claude-haiku-4-5-20251001-v1:0, type: string)
bedrock_small_fast_model: global.anthropic.claude-haiku-4-5-20251001-v1:0
+104 -6
View File
@@ -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:<uuid>",
},
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)
}
@@ -106,7 +123,7 @@ func (r *RootCmd) createToken() *serpent.Command {
{
Flag: "lifetime",
Env: "CODER_TOKEN_LIFETIME",
Description: "Specify a duration for the lifetime of the token.",
Description: "Duration for the token lifetime. Supports standard Go duration units (ns, us, ms, s, m, h) plus d (days) and y (years). Examples: 8h, 30d, 1y, 1d12h30m.",
Value: serpent.StringOf(&tokenLifetime),
},
{
@@ -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 (<type>:<uuid>, 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,47 @@ 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 := slice.ToStrings(scopes)
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 +282,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 <name|id>",
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 <name|id|token>",
+56 -3
View File
@@ -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)
+4
View File
@@ -239,6 +239,10 @@ func (a *API) Serve(ctx context.Context, l net.Listener) error {
return xerrors.Errorf("create agent API server: %w", err)
}
if err := a.ResourcesMonitoringAPI.InitMonitors(ctx); err != nil {
return xerrors.Errorf("initialize resource monitoring: %w", err)
}
return server.Serve(ctx, l)
}
+52 -35
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"sync"
"time"
"golang.org/x/xerrors"
@@ -33,42 +34,60 @@ type ResourcesMonitoringAPI struct {
Debounce time.Duration
Config resourcesmonitor.Config
// Cache resource monitors on first call to avoid millions of DB queries per day.
memoryMonitor database.WorkspaceAgentMemoryResourceMonitor
volumeMonitors []database.WorkspaceAgentVolumeResourceMonitor
monitorsLock sync.RWMutex
}
func (a *ResourcesMonitoringAPI) GetResourcesMonitoringConfiguration(ctx context.Context, _ *proto.GetResourcesMonitoringConfigurationRequest) (*proto.GetResourcesMonitoringConfigurationResponse, error) {
memoryMonitor, memoryErr := a.Database.FetchMemoryResourceMonitorsByAgentID(ctx, a.AgentID)
if memoryErr != nil && !errors.Is(memoryErr, sql.ErrNoRows) {
return nil, xerrors.Errorf("failed to fetch memory resource monitor: %w", memoryErr)
// InitMonitors fetches resource monitors from the database and caches them.
// This must be called once after creating a ResourcesMonitoringAPI, the context should be
// the agent per-RPC connection context. If fetching fails with a real error (not sql.ErrNoRows), the
// connection should be torn down.
func (a *ResourcesMonitoringAPI) InitMonitors(ctx context.Context) error {
memMon, err := a.Database.FetchMemoryResourceMonitorsByAgentID(ctx, a.AgentID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("fetch memory resource monitor: %w", err)
}
// If sql.ErrNoRows, memoryMonitor stays as zero value (CreatedAt.IsZero() = true).
// Otherwise, store the fetched monitor.
if err == nil {
a.memoryMonitor = memMon
}
volumeMonitors, err := a.Database.FetchVolumesResourceMonitorsByAgentID(ctx, a.AgentID)
volMons, err := a.Database.FetchVolumesResourceMonitorsByAgentID(ctx, a.AgentID)
if err != nil {
return nil, xerrors.Errorf("failed to fetch volume resource monitors: %w", err)
return xerrors.Errorf("fetch volume resource monitors: %w", err)
}
// 0 length is valid, indicating none configured, since the volume monitors in the DB can be many.
a.volumeMonitors = volMons
return nil
}
func (a *ResourcesMonitoringAPI) GetResourcesMonitoringConfiguration(_ context.Context, _ *proto.GetResourcesMonitoringConfigurationRequest) (*proto.GetResourcesMonitoringConfigurationResponse, error) {
return &proto.GetResourcesMonitoringConfigurationResponse{
Config: &proto.GetResourcesMonitoringConfigurationResponse_Config{
CollectionIntervalSeconds: int32(a.Config.CollectionInterval.Seconds()),
NumDatapoints: a.Config.NumDatapoints,
},
Memory: func() *proto.GetResourcesMonitoringConfigurationResponse_Memory {
if memoryErr != nil {
if a.memoryMonitor.CreatedAt.IsZero() {
return nil
}
return &proto.GetResourcesMonitoringConfigurationResponse_Memory{
Enabled: memoryMonitor.Enabled,
Enabled: a.memoryMonitor.Enabled,
}
}(),
Volumes: func() []*proto.GetResourcesMonitoringConfigurationResponse_Volume {
volumes := make([]*proto.GetResourcesMonitoringConfigurationResponse_Volume, 0, len(volumeMonitors))
for _, monitor := range volumeMonitors {
volumes := make([]*proto.GetResourcesMonitoringConfigurationResponse_Volume, 0, len(a.volumeMonitors))
for _, monitor := range a.volumeMonitors {
volumes = append(volumes, &proto.GetResourcesMonitoringConfigurationResponse_Volume{
Enabled: monitor.Enabled,
Path: monitor.Path,
})
}
return volumes
}(),
}, nil
@@ -77,6 +96,10 @@ func (a *ResourcesMonitoringAPI) GetResourcesMonitoringConfiguration(ctx context
func (a *ResourcesMonitoringAPI) PushResourcesMonitoringUsage(ctx context.Context, req *proto.PushResourcesMonitoringUsageRequest) (*proto.PushResourcesMonitoringUsageResponse, error) {
var err error
// Lock for the entire push operation since calls are sequential from the agent
a.monitorsLock.Lock()
defer a.monitorsLock.Unlock()
if memoryErr := a.monitorMemory(ctx, req.Datapoints); memoryErr != nil {
err = errors.Join(err, xerrors.Errorf("monitor memory: %w", memoryErr))
}
@@ -89,18 +112,7 @@ func (a *ResourcesMonitoringAPI) PushResourcesMonitoringUsage(ctx context.Contex
}
func (a *ResourcesMonitoringAPI) monitorMemory(ctx context.Context, datapoints []*proto.PushResourcesMonitoringUsageRequest_Datapoint) error {
monitor, err := a.Database.FetchMemoryResourceMonitorsByAgentID(ctx, a.AgentID)
if err != nil {
// It is valid for an agent to not have a memory monitor, so we
// do not want to treat it as an error.
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return xerrors.Errorf("fetch memory resource monitor: %w", err)
}
if !monitor.Enabled {
if !a.memoryMonitor.Enabled {
return nil
}
@@ -109,15 +121,15 @@ func (a *ResourcesMonitoringAPI) monitorMemory(ctx context.Context, datapoints [
usageDatapoints = append(usageDatapoints, datapoint.Memory)
}
usageStates := resourcesmonitor.CalculateMemoryUsageStates(monitor, usageDatapoints)
usageStates := resourcesmonitor.CalculateMemoryUsageStates(a.memoryMonitor, usageDatapoints)
oldState := monitor.State
oldState := a.memoryMonitor.State
newState := resourcesmonitor.NextState(a.Config, oldState, usageStates)
debouncedUntil, shouldNotify := monitor.Debounce(a.Debounce, a.Clock.Now(), oldState, newState)
debouncedUntil, shouldNotify := a.memoryMonitor.Debounce(a.Debounce, a.Clock.Now(), oldState, newState)
//nolint:gocritic // We need to be able to update the resource monitor here.
err = a.Database.UpdateMemoryResourceMonitor(dbauthz.AsResourceMonitor(ctx), database.UpdateMemoryResourceMonitorParams{
err := a.Database.UpdateMemoryResourceMonitor(dbauthz.AsResourceMonitor(ctx), database.UpdateMemoryResourceMonitorParams{
AgentID: a.AgentID,
State: newState,
UpdatedAt: dbtime.Time(a.Clock.Now()),
@@ -127,6 +139,11 @@ func (a *ResourcesMonitoringAPI) monitorMemory(ctx context.Context, datapoints [
return xerrors.Errorf("update workspace monitor: %w", err)
}
// Update cached state
a.memoryMonitor.State = newState
a.memoryMonitor.DebouncedUntil = dbtime.Time(debouncedUntil)
a.memoryMonitor.UpdatedAt = dbtime.Time(a.Clock.Now())
if !shouldNotify {
return nil
}
@@ -143,7 +160,7 @@ func (a *ResourcesMonitoringAPI) monitorMemory(ctx context.Context, datapoints [
notifications.TemplateWorkspaceOutOfMemory,
map[string]string{
"workspace": workspace.Name,
"threshold": fmt.Sprintf("%d%%", monitor.Threshold),
"threshold": fmt.Sprintf("%d%%", a.memoryMonitor.Threshold),
},
map[string]any{
// NOTE(DanielleMaywood):
@@ -169,14 +186,9 @@ func (a *ResourcesMonitoringAPI) monitorMemory(ctx context.Context, datapoints [
}
func (a *ResourcesMonitoringAPI) monitorVolumes(ctx context.Context, datapoints []*proto.PushResourcesMonitoringUsageRequest_Datapoint) error {
volumeMonitors, err := a.Database.FetchVolumesResourceMonitorsByAgentID(ctx, a.AgentID)
if err != nil {
return xerrors.Errorf("get or insert volume monitor: %w", err)
}
outOfDiskVolumes := make([]map[string]any, 0)
for _, monitor := range volumeMonitors {
for i, monitor := range a.volumeMonitors {
if !monitor.Enabled {
continue
}
@@ -219,6 +231,11 @@ func (a *ResourcesMonitoringAPI) monitorVolumes(ctx context.Context, datapoints
}); err != nil {
return xerrors.Errorf("update workspace monitor: %w", err)
}
// Update cached state
a.volumeMonitors[i].State = newState
a.volumeMonitors[i].DebouncedUntil = dbtime.Time(debouncedUntil)
a.volumeMonitors[i].UpdatedAt = dbtime.Time(a.Clock.Now())
}
if len(outOfDiskVolumes) == 0 {
@@ -101,6 +101,9 @@ func TestMemoryResourceMonitorDebounce(t *testing.T) {
Threshold: 80,
})
// Initialize API to fetch and cache the monitors
require.NoError(t, api.InitMonitors(context.Background()))
// When: The monitor is given a state that will trigger NOK
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
Datapoints: []*agentproto.PushResourcesMonitoringUsageRequest_Datapoint{
@@ -304,6 +307,9 @@ func TestMemoryResourceMonitor(t *testing.T) {
Threshold: 80,
})
// Initialize API to fetch and cache the monitors
require.NoError(t, api.InitMonitors(context.Background()))
clock.Set(collectedAt)
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
Datapoints: datapoints,
@@ -337,6 +343,8 @@ func TestMemoryResourceMonitorMissingData(t *testing.T) {
State: database.WorkspaceAgentMonitorStateOK,
Threshold: 80,
})
// Initialize API to fetch and cache the monitors
require.NoError(t, api.InitMonitors(context.Background()))
// When: A datapoint is missing, surrounded by two NOK datapoints.
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
@@ -387,6 +395,9 @@ func TestMemoryResourceMonitorMissingData(t *testing.T) {
Threshold: 80,
})
// Initialize API to fetch and cache the monitors
require.NoError(t, api.InitMonitors(context.Background()))
// When: A datapoint is missing, surrounded by two OK datapoints.
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
Datapoints: []*agentproto.PushResourcesMonitoringUsageRequest_Datapoint{
@@ -466,6 +477,9 @@ func TestVolumeResourceMonitorDebounce(t *testing.T) {
Threshold: 80,
})
// Initialize API to fetch and cache the monitors
require.NoError(t, api.InitMonitors(context.Background()))
// When:
// - First monitor is in a NOK state
// - Second monitor is in an OK state
@@ -742,6 +756,9 @@ func TestVolumeResourceMonitor(t *testing.T) {
Threshold: tt.thresholdPercent,
})
// Initialize API to fetch and cache the monitors
require.NoError(t, api.InitMonitors(context.Background()))
clock.Set(collectedAt)
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
Datapoints: datapoints,
@@ -780,6 +797,9 @@ func TestVolumeResourceMonitorMultiple(t *testing.T) {
Threshold: 80,
})
// Initialize API to fetch and cache the monitors
require.NoError(t, api.InitMonitors(context.Background()))
// When: both of them move to a NOK state
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
Datapoints: []*agentproto.PushResourcesMonitoringUsageRequest_Datapoint{
@@ -832,6 +852,9 @@ func TestVolumeResourceMonitorMissingData(t *testing.T) {
Threshold: 80,
})
// Initialize API to fetch and cache the monitors
require.NoError(t, api.InitMonitors(context.Background()))
// When: A datapoint is missing, surrounded by two NOK datapoints.
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
Datapoints: []*agentproto.PushResourcesMonitoringUsageRequest_Datapoint{
@@ -891,6 +914,9 @@ func TestVolumeResourceMonitorMissingData(t *testing.T) {
Threshold: 80,
})
// Initialize API to fetch and cache the monitors
require.NoError(t, api.InitMonitors(context.Background()))
// When: A datapoint is missing, surrounded by two OK datapoints.
_, err := api.PushResourcesMonitoringUsage(context.Background(), &agentproto.PushResourcesMonitoringUsageRequest{
Datapoints: []*agentproto.PushResourcesMonitoringUsageRequest_Datapoint{
+305 -377
View File
@@ -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,33 +94,56 @@ 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),
Message: `Template does not have a valid "coder_ai_task" resource.`,
})
return
}
@@ -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,113 @@ 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{
ID: uuid.New(),
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 +335,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,
OwnerName: ws.OwnerName,
Name: ws.Name,
ID: dbTask.ID,
OrganizationID: dbTask.OrganizationID,
OwnerID: dbTask.OwnerID,
OwnerName: dbTask.OwnerUsername,
OwnerAvatarURL: dbTask.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:<username/uuid/me>, organization:<org-name/uuid>, status:<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
}
if len(workspaceRows) == 1 {
httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{
Tasks: []codersdk.Task{},
Count: 0,
})
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
}
// Skip summary row.
workspaceRows = workspaceRows[:len(workspaceRows)-1]
// 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)
}
// 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
}
apiWorkspaces, err := convertWorkspaces(apiKey.UserID, 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("fetch workspace data: %w", err)
}
tasks, err := api.tasksFromWorkspaces(ctx, apiWorkspaces)
apiWorkspaces, err := convertWorkspaces(requesterID, workspaces, data)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching task prompts and states.",
Detail: err.Error(),
})
return
return nil, xerrors.Errorf("convert workspaces: %w", err)
}
httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{
Tasks: tasks,
Count: len(tasks),
})
workspacesByID := make(map[uuid.UUID]codersdk.Workspace)
for _, ws := range apiWorkspaces {
workspacesByID[ws.ID] = ws
}
// 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 +472,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 +483,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 +518,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 +540,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 +550,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()
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
}
}
// 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
}
_, 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.",
Message: "Failed to delete task",
Detail: err.Error(),
})
return
}
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace resources.",
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 +624,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 +648,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 +698,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 +758,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 +807,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 +845,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.",
+530 -258
View File
File diff suppressed because it is too large Load Diff
+192 -66
View File
@@ -85,7 +85,7 @@ const docTemplate = `{
}
}
},
"/api/experimental/aibridge/interceptions": {
"/aibridge/interceptions": {
"get": {
"security": [
{
@@ -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": [
@@ -11624,20 +11640,6 @@ const docTemplate = `{
}
}
},
"coderd.tasksListResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"tasks": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Task"
}
}
}
},
"codersdk.ACLAvailable": {
"type": "object",
"properties": {
@@ -11666,12 +11668,35 @@ const docTemplate = `{
}
}
},
"codersdk.AIBridgeBedrockConfig": {
"type": "object",
"properties": {
"access_key": {
"type": "string"
},
"access_key_secret": {
"type": "string"
},
"model": {
"type": "string"
},
"region": {
"type": "string"
},
"small_fast_model": {
"type": "string"
}
}
},
"codersdk.AIBridgeConfig": {
"type": "object",
"properties": {
"anthropic": {
"$ref": "#/definitions/codersdk.AIBridgeAnthropicConfig"
},
"bedrock": {
"$ref": "#/definitions/codersdk.AIBridgeBedrockConfig"
},
"enabled": {
"type": "boolean"
},
@@ -11683,6 +11708,10 @@ const docTemplate = `{
"codersdk.AIBridgeInterception": {
"type": "object",
"properties": {
"ended_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
@@ -11727,14 +11756,14 @@ const docTemplate = `{
"codersdk.AIBridgeListInterceptionsResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"results": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeInterception"
}
},
"total": {
"type": "integer"
}
}
},
@@ -11878,6 +11907,12 @@ const docTemplate = `{
"user_id"
],
"properties": {
"allow_list": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIAllowListTarget"
}
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -12488,6 +12523,13 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"organization_member_permissions": {
"description": "OrganizationMemberPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"organization_permissions": {
"description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
@@ -13711,6 +13753,13 @@ const docTemplate = `{
"name": {
"type": "string"
},
"organization_member_permissions": {
"description": "OrganizationMemberPermissions are specific to the organization the role belongs to.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"organization_permissions": {
"description": "OrganizationPermissions are specific to the organization the role belongs to.",
"type": "array",
@@ -14267,11 +14316,9 @@ const docTemplate = `{
"web-push",
"oauth2",
"mcp-server-http",
"workspace-sharing",
"aibridge"
"workspace-sharing"
],
"x-enum-comments": {
"ExperimentAIBridge": "Enables AI Bridge functionality.",
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
"ExperimentExample": "This isn't used for anything.",
"ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.",
@@ -14289,8 +14336,7 @@ const docTemplate = `{
"ExperimentWebPush",
"ExperimentOAuth2",
"ExperimentMCPServerHTTP",
"ExperimentWorkspaceSharing",
"ExperimentAIBridge"
"ExperimentWorkspaceSharing"
]
},
"codersdk.ExternalAPIKeyScopes": {
@@ -15353,6 +15399,9 @@ const docTemplate = `{
},
"token": {
"type": "string"
},
"token_revoke": {
"type": "string"
}
}
},
@@ -15452,7 +15501,10 @@ const docTemplate = `{
}
},
"registration_access_token": {
"type": "string"
"type": "array",
"items": {
"type": "integer"
}
},
"registration_client_uri": {
"type": "string"
@@ -17472,6 +17524,13 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"organization_member_permissions": {
"description": "OrganizationMemberPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"organization_permissions": {
"description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
@@ -17716,6 +17775,9 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"owner_avatar_url": {
"type": "string"
},
"owner_id": {
"type": "string",
"format": "uuid"
@@ -17726,19 +17788,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"
}
]
},
@@ -17755,6 +17813,10 @@ const docTemplate = `{
"template_name": {
"type": "string"
},
"template_version_id": {
"type": "string",
"format": "uuid"
},
"updated_at": {
"type": "string",
"format": "date-time"
@@ -17791,6 +17853,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"
}
]
}
}
},
@@ -17875,6 +17959,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": {
@@ -19595,6 +19712,14 @@ const docTemplate = `{
"description": "OwnerName is the username of the owner of the workspace.",
"type": "string"
},
"task_id": {
"description": "TaskID, if set, indicates that the workspace is relevant to the given codersdk.Task.",
"allOf": [
{
"$ref": "#/definitions/uuid.NullUUID"
}
]
},
"template_active_version_id": {
"type": "string",
"format": "uuid"
@@ -20402,6 +20527,7 @@ const docTemplate = `{
"type": "object",
"properties": {
"ai_task_sidebar_app_id": {
"description": "Deprecated: This field has been replaced with ` + "`" + `Task.WorkspaceAppID` + "`" + `",
"type": "string",
"format": "uuid"
},
+188 -66
View File
@@ -65,7 +65,7 @@
}
}
},
"/api/experimental/aibridge/interceptions": {
"/aibridge/interceptions": {
"get": {
"security": [
{
@@ -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"],
@@ -10324,20 +10336,6 @@
}
}
},
"coderd.tasksListResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"tasks": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Task"
}
}
}
},
"codersdk.ACLAvailable": {
"type": "object",
"properties": {
@@ -10366,12 +10364,35 @@
}
}
},
"codersdk.AIBridgeBedrockConfig": {
"type": "object",
"properties": {
"access_key": {
"type": "string"
},
"access_key_secret": {
"type": "string"
},
"model": {
"type": "string"
},
"region": {
"type": "string"
},
"small_fast_model": {
"type": "string"
}
}
},
"codersdk.AIBridgeConfig": {
"type": "object",
"properties": {
"anthropic": {
"$ref": "#/definitions/codersdk.AIBridgeAnthropicConfig"
},
"bedrock": {
"$ref": "#/definitions/codersdk.AIBridgeBedrockConfig"
},
"enabled": {
"type": "boolean"
},
@@ -10383,6 +10404,10 @@
"codersdk.AIBridgeInterception": {
"type": "object",
"properties": {
"ended_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
@@ -10427,14 +10452,14 @@
"codersdk.AIBridgeListInterceptionsResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"results": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeInterception"
}
},
"total": {
"type": "integer"
}
}
},
@@ -10578,6 +10603,12 @@
"user_id"
],
"properties": {
"allow_list": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIAllowListTarget"
}
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -11174,6 +11205,13 @@
"type": "string",
"format": "uuid"
},
"organization_member_permissions": {
"description": "OrganizationMemberPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"organization_permissions": {
"description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
@@ -12329,6 +12367,13 @@
"name": {
"type": "string"
},
"organization_member_permissions": {
"description": "OrganizationMemberPermissions are specific to the organization the role belongs to.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"organization_permissions": {
"description": "OrganizationPermissions are specific to the organization the role belongs to.",
"type": "array",
@@ -12878,11 +12923,9 @@
"web-push",
"oauth2",
"mcp-server-http",
"workspace-sharing",
"aibridge"
"workspace-sharing"
],
"x-enum-comments": {
"ExperimentAIBridge": "Enables AI Bridge functionality.",
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
"ExperimentExample": "This isn't used for anything.",
"ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.",
@@ -12900,8 +12943,7 @@
"ExperimentWebPush",
"ExperimentOAuth2",
"ExperimentMCPServerHTTP",
"ExperimentWorkspaceSharing",
"ExperimentAIBridge"
"ExperimentWorkspaceSharing"
]
},
"codersdk.ExternalAPIKeyScopes": {
@@ -13911,6 +13953,9 @@
},
"token": {
"type": "string"
},
"token_revoke": {
"type": "string"
}
}
},
@@ -14010,7 +14055,10 @@
}
},
"registration_access_token": {
"type": "string"
"type": "array",
"items": {
"type": "integer"
}
},
"registration_client_uri": {
"type": "string"
@@ -15968,6 +16016,13 @@
"type": "string",
"format": "uuid"
},
"organization_member_permissions": {
"description": "OrganizationMemberPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.Permission"
}
},
"organization_permissions": {
"description": "OrganizationPermissions are specific for the organization in the field 'OrganizationID' above.",
"type": "array",
@@ -16208,6 +16263,9 @@
"type": "string",
"format": "uuid"
},
"owner_avatar_url": {
"type": "string"
},
"owner_id": {
"type": "string",
"format": "uuid"
@@ -16218,19 +16276,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"
}
]
},
@@ -16247,6 +16301,10 @@
"template_name": {
"type": "string"
},
"template_version_id": {
"type": "string",
"format": "uuid"
},
"updated_at": {
"type": "string",
"format": "date-time"
@@ -16283,6 +16341,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"
}
]
}
}
},
@@ -16356,6 +16436,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": {
@@ -17985,6 +18098,14 @@
"description": "OwnerName is the username of the owner of the workspace.",
"type": "string"
},
"task_id": {
"description": "TaskID, if set, indicates that the workspace is relevant to the given codersdk.Task.",
"allOf": [
{
"$ref": "#/definitions/uuid.NullUUID"
}
]
},
"template_active_version_id": {
"type": "string",
"format": "uuid"
@@ -18740,6 +18861,7 @@
"type": "object",
"properties": {
"ai_task_sidebar_app_id": {
"description": "Deprecated: This field has been replaced with `Task.WorkspaceAppID`",
"type": "string",
"format": "uuid"
},
+28 -15
View File
@@ -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)
func GenerateSecret(length int) (secret string, hashed []byte, err error) {
secret, err = cryptorand.String(length)
if err != nil {
return "", "", err
return "", nil, err
}
// Length of an API Key secret.
secret, err = cryptorand.String(22)
if err != nil {
return "", "", 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[:]
}
+16 -3
View File
@@ -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")
}
+6
View File
@@ -51,6 +51,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 +88,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,6 +136,8 @@ 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())
})
}
}
+2 -2
View File
@@ -509,11 +509,11 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
if err != nil {
return ""
}
workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID)
user, err := api.Database.GetUserByID(ctx, task.OwnerID)
if err != nil {
return ""
}
return fmt.Sprintf("/tasks/%s/%s", workspace.OwnerName, task.Name)
return fmt.Sprintf("/tasks/%s/%s", user.Username, task.ID)
default:
return ""
+11 -10
View File
@@ -50,6 +50,13 @@ func TestCheckPermissions(t *testing.T) {
},
Action: "read",
},
readOrgWorkspaces: {
Object: codersdk.AuthorizationObject{
ResourceType: codersdk.ResourceWorkspace,
OrganizationID: adminUser.OrganizationID.String(),
},
Action: "read",
},
readMyself: {
Object: codersdk.AuthorizationObject{
ResourceType: codersdk.ResourceUser,
@@ -58,16 +65,10 @@ func TestCheckPermissions(t *testing.T) {
Action: "read",
},
readOwnWorkspaces: {
Object: codersdk.AuthorizationObject{
ResourceType: codersdk.ResourceWorkspace,
OwnerID: "me",
},
Action: "read",
},
readOrgWorkspaces: {
Object: codersdk.AuthorizationObject{
ResourceType: codersdk.ResourceWorkspace,
OrganizationID: adminUser.OrganizationID.String(),
OwnerID: "me",
},
Action: "read",
},
@@ -92,9 +93,9 @@ func TestCheckPermissions(t *testing.T) {
UserID: adminUser.UserID,
Check: map[string]bool{
readAllUsers: true,
readOrgWorkspaces: true,
readMyself: true,
readOwnWorkspaces: true,
readOrgWorkspaces: true,
updateSpecificTemplate: true,
},
},
@@ -104,9 +105,9 @@ func TestCheckPermissions(t *testing.T) {
UserID: orgAdminUser.ID,
Check: map[string]bool{
readAllUsers: true,
readOrgWorkspaces: true,
readMyself: true,
readOwnWorkspaces: true,
readOrgWorkspaces: true,
updateSpecificTemplate: true,
},
},
@@ -116,9 +117,9 @@ func TestCheckPermissions(t *testing.T) {
UserID: memberUser.ID,
Check: map[string]bool{
readAllUsers: false,
readOrgWorkspaces: false,
readMyself: true,
readOwnWorkspaces: true,
readOrgWorkspaces: false,
updateSpecificTemplate: false,
},
},
+172 -8
View File
@@ -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) {
@@ -1772,3 +1764,175 @@ func TestExecutorAutostartSkipsWhenNoProvisionersAvailable(t *testing.T) {
assert.Len(t, stats.Transitions, 1, "should create builds when provisioners are available")
}
func TestExecutorTaskWorkspace(t *testing.T) {
t.Parallel()
createTaskTemplate := func(t *testing.T, client *codersdk.Client, orgID uuid.UUID, ctx context.Context, defaultTTL time.Duration) codersdk.Template {
t.Helper()
taskAppID := uuid.New()
version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{HasAiTasks: true},
},
},
},
ProvisionApply: []*proto.Response{
{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{
{
Agents: []*proto.Agent{
{
Id: uuid.NewString(),
Name: "dev",
Auth: &proto.Agent_Token{
Token: uuid.NewString(),
},
Apps: []*proto.App{
{
Id: taskAppID.String(),
Slug: "task-app",
},
},
},
},
},
},
AiTasks: []*proto.AITask{
{
AppId: taskAppID.String(),
},
},
},
},
},
},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, orgID, version.ID)
if defaultTTL > 0 {
_, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
DefaultTTLMillis: defaultTTL.Milliseconds(),
})
require.NoError(t, err)
}
return template
}
createTaskWorkspace := func(t *testing.T, client *codersdk.Client, template codersdk.Template, ctx context.Context, input string) codersdk.Workspace {
t.Helper()
exp := codersdk.NewExperimentalClient(client)
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Input: input,
})
require.NoError(t, err)
require.True(t, task.WorkspaceID.Valid, "task should have a workspace")
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
return workspace
}
t.Run("Autostart", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *")
tickCh = make(chan time.Time)
statsCh = make(chan autobuild.Stats)
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
AutobuildTicker: tickCh,
IncludeProvisionerDaemon: true,
AutobuildStats: statsCh,
})
admin = coderdtest.CreateFirstUser(t, client)
)
// Given: A task workspace
template := createTaskTemplate(t, client, admin.OrganizationID, ctx, 0)
workspace := createTaskWorkspace(t, client, template, ctx, "test task for autostart")
// Given: The task workspace has an autostart schedule
err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
Schedule: ptr.Ref(sched.String()),
})
require.NoError(t, err)
// Given: That the workspace is in a stopped state.
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, map[string]string{})
require.NoError(t, err)
// When: the autobuild executor ticks after the scheduled time
go func() {
tickTime := sched.Next(workspace.LatestBuild.CreatedAt)
coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime)
tickCh <- tickTime
close(tickCh)
}()
// Then: We expect to see a start transition
stats := <-statsCh
require.Len(t, stats.Transitions, 1, "lifecycle executor should transition the task workspace")
assert.Contains(t, stats.Transitions, workspace.ID, "task workspace should be in transitions")
assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID], "should autostart the workspace")
require.Empty(t, stats.Errors, "should have no errors when managing task workspaces")
})
t.Run("Autostop", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitShort)
tickCh = make(chan time.Time)
statsCh = make(chan autobuild.Stats)
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
AutobuildTicker: tickCh,
IncludeProvisionerDaemon: true,
AutobuildStats: statsCh,
})
admin = coderdtest.CreateFirstUser(t, client)
)
// Given: A task workspace with an 8 hour deadline
template := createTaskTemplate(t, client, admin.OrganizationID, ctx, 8*time.Hour)
workspace := createTaskWorkspace(t, client, template, ctx, "test task for autostop")
// Given: The workspace is currently running
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
require.NotZero(t, workspace.LatestBuild.Deadline, "workspace should have a deadline for autostop")
p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, map[string]string{})
require.NoError(t, err)
// When: the autobuild executor ticks after the deadline
go func() {
tickTime := workspace.LatestBuild.Deadline.Time.Add(time.Minute)
coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime)
tickCh <- tickTime
close(tickCh)
}()
// Then: We expect to see a stop transition
stats := <-statsCh
require.Len(t, stats.Transitions, 1, "lifecycle executor should transition the task workspace")
assert.Contains(t, stats.Transitions, workspace.ID, "task workspace should be in transitions")
assert.Equal(t, database.WorkspaceTransitionStop, stats.Transitions[workspace.ID], "should autostop the workspace")
require.Empty(t, stats.Errors, "should have no errors when managing task workspaces")
})
}
+18 -4
View File
@@ -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) {
+2
View File
@@ -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
@@ -13,6 +14,7 @@ const (
CheckSubsystemsNotNone CheckConstraint = "subsystems_not_none" // workspace_agents
CheckWorkspaceBuildsAiTaskSidebarAppIDRequired CheckConstraint = "workspace_builds_ai_task_sidebar_app_id_required" // workspace_builds
CheckWorkspaceBuildsDeadlineBelowMaxDeadline CheckConstraint = "workspace_builds_deadline_below_max_deadline" // workspace_builds
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
)
+23 -8
View File
@@ -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(),
},
}
}
@@ -704,12 +714,13 @@ func RBACRole(role rbac.Role) codersdk.Role {
orgPerms := role.ByOrgID[slim.OrganizationID]
return codersdk.Role{
Name: slim.Name,
OrganizationID: slim.OrganizationID,
DisplayName: slim.DisplayName,
SitePermissions: List(role.Site, RBACPermission),
OrganizationPermissions: List(orgPerms.Org, RBACPermission),
UserPermissions: List(role.User, RBACPermission),
Name: slim.Name,
OrganizationID: slim.OrganizationID,
DisplayName: slim.DisplayName,
SitePermissions: List(role.Site, RBACPermission),
UserPermissions: List(role.User, RBACPermission),
OrganizationPermissions: List(orgPerms.Org, RBACPermission),
OrganizationMemberPermissions: List(orgPerms.Member, RBACPermission),
}
}
@@ -724,8 +735,8 @@ func Role(role database.CustomRole) codersdk.Role {
OrganizationID: orgID,
DisplayName: role.DisplayName,
SitePermissions: List(role.SitePermissions, Permission),
OrganizationPermissions: List(role.OrgPermissions, Permission),
UserPermissions: List(role.UserPermissions, Permission),
OrganizationPermissions: List(role.OrgPermissions, Permission),
}
}
@@ -952,7 +963,7 @@ func AIBridgeInterception(interception database.AIBridgeInterception, initiator
// created_at ASC
return sdkToolUsages[i].CreatedAt.Before(sdkToolUsages[j].CreatedAt)
})
return codersdk.AIBridgeInterception{
intc := codersdk.AIBridgeInterception{
ID: interception.ID,
Initiator: MinimalUserFromVisibleUser(initiator),
Provider: interception.Provider,
@@ -963,6 +974,10 @@ func AIBridgeInterception(interception database.AIBridgeInterception, initiator
UserPrompts: sdkUserPrompts,
ToolUsages: sdkToolUsages,
}
if interception.EndedAt.Valid {
intc.EndedAt = &interception.EndedAt.Time
}
return intc
}
func AIBridgeTokenUsage(usage database.AIBridgeTokenUsage) codersdk.AIBridgeTokenUsage {
-4
View File
@@ -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)
+164 -19
View File
@@ -219,8 +219,8 @@ var (
rbac.ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal},
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop},
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionCreateAgent},
// Provisionerd needs to read and update tasks associated with workspaces.
rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate},
// Provisionerd needs to read, update, and delete tasks associated with workspaces.
rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
rbac.ResourceApiKey.Type: {policy.WildcardSymbol},
// When org scoped provisioner credentials are implemented,
// this can be reduced to read a specific org.
@@ -254,6 +254,7 @@ var (
rbac.ResourceFile.Type: {policy.ActionRead}, // Required to read terraform files
rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead},
rbac.ResourceSystem.Type: {policy.WildcardSymbol},
rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate},
rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate},
rbac.ResourceUser.Type: {policy.ActionRead},
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop},
@@ -395,11 +396,13 @@ var (
Identifier: rbac.RoleIdentifier{Name: "subagentapi"},
DisplayName: "Sub Agent API",
Site: []rbac.Permission{},
User: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionCreateAgent, policy.ActionDeleteAgent},
}),
User: []rbac.Permission{},
ByOrgID: map[string]rbac.OrgPermissions{
orgID.String(): {},
orgID.String(): {
Member: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionCreateAgent, policy.ActionDeleteAgent},
}),
},
},
},
}),
@@ -446,6 +449,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 +674,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 {
@@ -1256,14 +1293,17 @@ func (q *querier) customRoleCheck(ctx context.Context, role database.CustomRole)
return xerrors.Errorf("invalid role: %w", err)
}
if len(rbacRole.ByOrgID) > 0 && len(rbacRole.Site) > 0 {
// This is a choice to keep roles simple. If we allow mixing site and org scoped perms, then knowing who can
// do what gets more complicated.
return xerrors.Errorf("invalid custom role, cannot assign both org and site permissions at the same time")
if len(rbacRole.ByOrgID) > 0 && (len(rbacRole.Site) > 0 || len(rbacRole.User) > 0) {
// This is a choice to keep roles simple. If we allow mixing site and org
// scoped perms, then knowing who can do what gets more complicated. Roles
// should either be entirely org-scoped or entirely unrelated to
// organizations.
return xerrors.Errorf("invalid custom role, cannot assign both org-scoped and site/user permissions at the same time")
}
if len(rbacRole.ByOrgID) > 1 {
// Again to avoid more complexity in our roles
// Again to avoid more complexity in our roles. Roles are limited to one
// organization.
return xerrors.Errorf("invalid custom role, cannot assign permissions to more than 1 org at a time")
}
@@ -1279,7 +1319,18 @@ func (q *querier) customRoleCheck(ctx context.Context, role database.CustomRole)
for _, orgPerm := range perms.Org {
err := q.customRoleEscalationCheck(ctx, act, orgPerm, rbac.Object{OrgID: orgID, Type: orgPerm.ResourceType})
if err != nil {
return xerrors.Errorf("org=%q: %w", orgID, err)
return xerrors.Errorf("org=%q: org: %w", orgID, err)
}
}
for _, memberPerm := range perms.Member {
// The person giving the permission should still be required to have
// the permissions throughout the org in order to give individuals the
// same permission among their own resources, since the role can be given
// to anyone. The `Owner` is intentionally omitted from the `Object` to
// enforce this.
err := q.customRoleEscalationCheck(ctx, act, memberPerm, rbac.Object{OrgID: orgID, Type: memberPerm.ResourceType})
if err != nil {
return xerrors.Errorf("org=%q: member: %w", orgID, err)
}
}
}
@@ -1297,8 +1348,8 @@ func (q *querier) customRoleCheck(ctx context.Context, role database.CustomRole)
func (q *querier) authorizeProvisionerJob(ctx context.Context, job database.ProvisionerJob) error {
switch job.Type {
case database.ProvisionerJobTypeWorkspaceBuild:
// Authorized call to get workspace build. If we can read the build, we
// can read the job.
// Authorized call to get workspace build. If we can read the build, we can
// read the job.
_, err := q.GetWorkspaceBuildByJobID(ctx, job.ID)
if err != nil {
return xerrors.Errorf("fetch related workspace build: %w", err)
@@ -1341,8 +1392,8 @@ func (q *querier) ActivityBumpWorkspace(ctx context.Context, arg database.Activi
}
func (q *querier) AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error) {
// Although this technically only reads users, only system-related functions should be
// allowed to call this.
// Although this technically only reads users, only system-related functions
// should be allowed to call this.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return nil, err
}
@@ -1361,8 +1412,8 @@ func (q *querier) ArchiveUnusedTemplateVersions(ctx context.Context, arg databas
}
func (q *querier) BatchUpdateWorkspaceLastUsedAt(ctx context.Context, arg database.BatchUpdateWorkspaceLastUsedAtParams) error {
// Could be any workspace and checking auth to each workspace is overkill for the purpose
// of this function.
// Could be any workspace and checking auth to each workspace is overkill for
// the purpose of this function.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceWorkspace.All()); err != nil {
return err
}
@@ -1390,6 +1441,13 @@ func (q *querier) BulkMarkNotificationMessagesSent(ctx context.Context, arg data
return q.db.BulkMarkNotificationMessagesSent(ctx, arg)
}
func (q *querier) CalculateAIBridgeInterceptionsTelemetrySummary(ctx context.Context, arg database.CalculateAIBridgeInterceptionsTelemetrySummaryParams) (database.CalculateAIBridgeInterceptionsTelemetrySummaryRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil {
return database.CalculateAIBridgeInterceptionsTelemetrySummaryRow{}, err
}
return q.db.CalculateAIBridgeInterceptionsTelemetrySummary(ctx, arg)
}
func (q *querier) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) {
empty := database.ClaimPrebuiltWorkspaceRow{}
@@ -1478,6 +1536,13 @@ func (q *querier) CountInProgressPrebuilds(ctx context.Context) ([]database.Coun
return q.db.CountInProgressPrebuilds(ctx)
}
func (q *querier) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace.All()); err != nil {
return nil, err
}
return q.db.CountPendingNonActivePrebuilds(ctx)
}
func (q *querier) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceInboxNotification.WithOwner(userID.String())); err != nil {
return 0, err
@@ -1682,6 +1747,13 @@ func (q *querier) DeleteOldProvisionerDaemons(ctx context.Context) error {
return q.db.DeleteOldProvisionerDaemons(ctx)
}
func (q *querier) DeleteOldTelemetryLocks(ctx context.Context, beforeTime time.Time) error {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
return err
}
return q.db.DeleteOldTelemetryLocks(ctx, beforeTime)
}
func (q *querier) DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) error {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
return err
@@ -1764,6 +1836,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 +2513,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
}
@@ -2564,6 +2649,13 @@ func (q *querier) GetOrganizationsByUserID(ctx context.Context, userID database.
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOrganizationsByUserID)(ctx, userID)
}
func (q *querier) GetOrganizationsWithPrebuildStatus(ctx context.Context, arg database.GetOrganizationsWithPrebuildStatusParams) ([]database.GetOrganizationsWithPrebuildStatusRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOrganization.All()); err != nil {
return nil, err
}
return q.db.GetOrganizationsWithPrebuildStatus(ctx, arg)
}
func (q *querier) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) {
version, err := q.db.GetTemplateVersionByJobID(ctx, jobID)
if err != nil {
@@ -4158,6 +4250,13 @@ func (q *querier) InsertTelemetryItemIfNotExists(ctx context.Context, arg databa
return q.db.InsertTelemetryItemIfNotExists(ctx, arg)
}
func (q *querier) InsertTelemetryLock(ctx context.Context, arg database.InsertTelemetryLockParams) error {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil {
return err
}
return q.db.InsertTelemetryLock(ctx, arg)
}
func (q *querier) InsertTemplate(ctx context.Context, arg database.InsertTemplateParams) error {
obj := rbac.ResourceTemplate.InOrg(arg.OrganizationID)
if err := q.authorizeContext(ctx, policy.ActionCreate, obj); err != nil {
@@ -4469,6 +4568,13 @@ func (q *querier) ListAIBridgeInterceptions(ctx context.Context, arg database.Li
return q.db.ListAuthorizedAIBridgeInterceptions(ctx, arg, prep)
}
func (q *querier) ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg database.ListAIBridgeInterceptionsTelemetrySummariesParams) ([]database.ListAIBridgeInterceptionsTelemetrySummariesRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil {
return nil, err
}
return q.db.ListAIBridgeInterceptionsTelemetrySummaries(ctx, arg)
}
func (q *querier) ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIDs []uuid.UUID) ([]database.AIBridgeTokenUsage, error) {
// This function is a system function until we implement a join for aibridge interceptions.
// Matches the behavior of the workspaces listing endpoint.
@@ -4657,6 +4763,13 @@ func (q *querier) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error {
return update(q.log, q.auth, fetch, q.db.UnfavoriteWorkspace)(ctx, id)
}
func (q *querier) UpdateAIBridgeInterceptionEnded(ctx context.Context, params database.UpdateAIBridgeInterceptionEndedParams) (database.AIBridgeInterception, error) {
if err := q.authorizeAIBridgeInterceptionAction(ctx, policy.ActionUpdate, params.ID); err != nil {
return database.AIBridgeInterception{}, err
}
return q.db.UpdateAIBridgeInterceptionEnded(ctx, params)
}
func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error {
fetch := func(ctx context.Context, arg database.UpdateAPIKeyByIDParams) (database.APIKey, error) {
return q.db.GetAPIKeyByID(ctx, arg.ID)
@@ -4828,6 +4941,14 @@ func (q *querier) UpdateOrganizationDeletedByID(ctx context.Context, arg databas
return deleteQ(q.log, q.auth, q.db.GetOrganizationByID, deleteF)(ctx, arg.ID)
}
func (q *querier) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) {
// Prebuild operation for canceling pending prebuild jobs from non-active template versions
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourcePrebuiltWorkspace); err != nil {
return []database.UpdatePrebuildProvisionerJobWithCancelRow{}, err
}
return q.db.UpdatePrebuildProvisionerJobWithCancel(ctx, arg)
}
func (q *querier) UpdatePresetPrebuildStatus(ctx context.Context, arg database.UpdatePresetPrebuildStatusParams) error {
preset, err := q.db.GetPresetByID(ctx, arg.PresetID)
if err != nil {
@@ -4975,6 +5096,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)
+83 -4
View File
@@ -641,6 +641,19 @@ func (s *MethodTestSuite) TestProvisionerJob() {
dbm.EXPECT().UpdateProvisionerJobWithCancelByID(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(v.RBACObject(tpl), []policy.Action{policy.ActionRead, policy.ActionUpdate}).Returns()
}))
s.Run("UpdatePrebuildProvisionerJobWithCancel", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := database.UpdatePrebuildProvisionerJobWithCancelParams{
PresetID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
Now: dbtime.Now(),
}
canceledJobs := []database.UpdatePrebuildProvisionerJobWithCancelRow{
{ID: uuid.New(), WorkspaceID: uuid.New(), TemplateID: uuid.New(), TemplateVersionPresetID: uuid.NullUUID{UUID: uuid.New(), Valid: true}},
{ID: uuid.New(), WorkspaceID: uuid.New(), TemplateID: uuid.New(), TemplateVersionPresetID: uuid.NullUUID{UUID: uuid.New(), Valid: true}},
}
dbm.EXPECT().UpdatePrebuildProvisionerJobWithCancel(gomock.Any(), arg).Return(canceledJobs, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourcePrebuiltWorkspace, policy.ActionUpdate).Returns(canceledJobs)
}))
s.Run("GetProvisionerJobsByIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
org := testutil.Fake(s.T(), faker, database.Organization{})
org2 := testutil.Fake(s.T(), faker, database.Organization{})
@@ -2362,6 +2375,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 +2418,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 +2983,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 +3225,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()
@@ -3723,6 +3759,14 @@ func (s *MethodTestSuite) TestPrebuilds() {
dbm.EXPECT().GetPrebuildMetrics(gomock.Any()).Return([]database.GetPrebuildMetricsRow{}, nil).AnyTimes()
check.Args().Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead)
}))
s.Run("GetOrganizationsWithPrebuildStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := database.GetOrganizationsWithPrebuildStatusParams{
UserID: uuid.New(),
GroupName: "test",
}
dbm.EXPECT().GetOrganizationsWithPrebuildStatus(gomock.Any(), arg).Return([]database.GetOrganizationsWithPrebuildStatusRow{}, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceOrganization.All(), policy.ActionRead)
}))
s.Run("GetPrebuildsSettings", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetPrebuildsSettings(gomock.Any()).Return("{}", nil).AnyTimes()
check.Args().Asserts()
@@ -3735,6 +3779,10 @@ func (s *MethodTestSuite) TestPrebuilds() {
dbm.EXPECT().CountInProgressPrebuilds(gomock.Any()).Return([]database.CountInProgressPrebuildsRow{}, nil).AnyTimes()
check.Args().Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead)
}))
s.Run("CountPendingNonActivePrebuilds", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().CountPendingNonActivePrebuilds(gomock.Any()).Return([]database.CountPendingNonActivePrebuildsRow{}, nil).AnyTimes()
check.Args().Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead)
}))
s.Run("GetPresetsAtFailureLimit", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetPresetsAtFailureLimit(gomock.Any(), int64(0)).Return([]database.GetPresetsAtFailureLimitRow{}, nil).AnyTimes()
check.Args(int64(0)).Asserts(rbac.ResourceTemplate.All(), policy.ActionViewInsights)
@@ -3902,9 +3950,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)
}))
}
@@ -4580,4 +4628,35 @@ func (s *MethodTestSuite) TestAIBridge() {
db.EXPECT().ListAIBridgeToolUsagesByInterceptionIDs(gomock.Any(), ids).Return([]database.AIBridgeToolUsage{}, nil).AnyTimes()
check.Args(ids).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns([]database.AIBridgeToolUsage{})
}))
s.Run("UpdateAIBridgeInterceptionEnded", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
intcID := uuid.UUID{1}
params := database.UpdateAIBridgeInterceptionEndedParams{ID: intcID}
intc := testutil.Fake(s.T(), faker, database.AIBridgeInterception{ID: intcID})
db.EXPECT().GetAIBridgeInterceptionByID(gomock.Any(), intcID).Return(intc, nil).AnyTimes() // Validation.
db.EXPECT().UpdateAIBridgeInterceptionEnded(gomock.Any(), params).Return(intc, nil).AnyTimes()
check.Args(params).Asserts(intc, policy.ActionUpdate).Returns(intc)
}))
}
func (s *MethodTestSuite) TestTelemetry() {
s.Run("InsertTelemetryLock", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
db.EXPECT().InsertTelemetryLock(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
check.Args(database.InsertTelemetryLockParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate)
}))
s.Run("DeleteOldTelemetryLocks", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
db.EXPECT().DeleteOldTelemetryLocks(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
check.Args(time.Time{}).Asserts(rbac.ResourceSystem, policy.ActionDelete)
}))
s.Run("ListAIBridgeInterceptionsTelemetrySummaries", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
db.EXPECT().ListAIBridgeInterceptionsTelemetrySummaries(gomock.Any(), gomock.Any()).Return([]database.ListAIBridgeInterceptionsTelemetrySummariesRow{}, nil).AnyTimes()
check.Args(database.ListAIBridgeInterceptionsTelemetrySummariesParams{}).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead)
}))
s.Run("CalculateAIBridgeInterceptionsTelemetrySummary", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
db.EXPECT().CalculateAIBridgeInterceptionsTelemetrySummary(gomock.Any(), gomock.Any()).Return(database.CalculateAIBridgeInterceptionsTelemetrySummaryRow{}, nil).AnyTimes()
check.Args(database.CalculateAIBridgeInterceptionsTelemetrySummaryParams{}).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead)
}))
}
-18
View File
@@ -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 {
+161 -21
View File
@@ -41,6 +41,7 @@ type WorkspaceResponse struct {
Build database.WorkspaceBuild
AgentToken string
TemplateVersionResponse
Task database.Task
}
// WorkspaceBuildBuilder generates workspace builds and associated
@@ -55,12 +56,9 @@ type WorkspaceBuildBuilder struct {
resources []*sdkproto.Resource
params []database.WorkspaceBuildParameter
agentToken string
dispo workspaceBuildDisposition
jobStatus database.ProvisionerJobStatus
taskAppID uuid.UUID
}
type workspaceBuildDisposition struct {
starting bool
taskSeed database.TaskTable
}
// WorkspaceBuild generates a workspace build for the provided workspace.
@@ -119,21 +117,28 @@ func (b WorkspaceBuildBuilder) WithAgent(mutations ...func([]*sdkproto.Agent) []
return b
}
func (b WorkspaceBuildBuilder) WithTask(seed *sdkproto.App) WorkspaceBuildBuilder {
//nolint: revive // returns modified struct
b.taskAppID = uuid.New()
if seed == nil {
seed = &sdkproto.App{}
func (b WorkspaceBuildBuilder) WithTask(taskSeed database.TaskTable, appSeed *sdkproto.App) WorkspaceBuildBuilder {
//nolint:revive // returns modified struct
b.taskSeed = taskSeed
if appSeed == nil {
appSeed = &sdkproto.App{}
}
var err error
//nolint: revive // returns modified struct
b.taskAppID, err = uuid.Parse(takeFirst(appSeed.Id, uuid.NewString()))
require.NoError(b.t, err)
return b.Params(database.WorkspaceBuildParameter{
Name: codersdk.AITaskPromptParameterName,
Value: "list me",
Value: b.taskSeed.Prompt,
}).WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent {
a[0].Apps = []*sdkproto.App{
{
Id: takeFirst(seed.Id, b.taskAppID.String()),
Slug: takeFirst(seed.Slug, "vcode"),
Url: takeFirst(seed.Url, ""),
Id: b.taskAppID.String(),
Slug: takeFirst(appSeed.Slug, "task-app"),
Url: takeFirst(appSeed.Url, ""),
},
}
return a
@@ -141,8 +146,17 @@ func (b WorkspaceBuildBuilder) WithTask(seed *sdkproto.App) WorkspaceBuildBuilde
}
func (b WorkspaceBuildBuilder) Starting() WorkspaceBuildBuilder {
//nolint: revive // returns modified struct
b.dispo.starting = true
b.jobStatus = database.ProvisionerJobStatusRunning
return b
}
func (b WorkspaceBuildBuilder) Pending() WorkspaceBuildBuilder {
b.jobStatus = database.ProvisionerJobStatusPending
return b
}
func (b WorkspaceBuildBuilder) Canceled() WorkspaceBuildBuilder {
b.jobStatus = database.ProvisionerJobStatusCanceled
return b
}
@@ -152,6 +166,19 @@ func (b WorkspaceBuildBuilder) Starting() WorkspaceBuildBuilder {
// Workspace will be optionally populated if no ID is set on the provided
// workspace.
func (b WorkspaceBuildBuilder) Do() WorkspaceResponse {
var resp WorkspaceResponse
// Use transaction, like real wsbuilder.
err := b.db.InTx(func(tx database.Store) error {
//nolint:revive // calls do on modified struct
b.db = tx
resp = b.doInTX()
return nil
}, nil)
require.NoError(b.t, err)
return resp
}
func (b WorkspaceBuildBuilder) doInTX() WorkspaceResponse {
b.t.Helper()
jobID := uuid.New()
b.seed.ID = uuid.New()
@@ -195,14 +222,45 @@ 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)
// If a task was requested, ensure it exists and is associated with this
// workspace.
if b.taskAppID != uuid.Nil {
b.logger.Debug(context.Background(), "creating or updating task", "task_id", b.taskSeed.ID)
b.taskSeed.OrganizationID = takeFirst(b.taskSeed.OrganizationID, b.ws.OrganizationID)
b.taskSeed.OwnerID = takeFirst(b.taskSeed.OwnerID, b.ws.OwnerID)
b.taskSeed.Name = takeFirst(b.taskSeed.Name, b.ws.Name)
b.taskSeed.WorkspaceID = uuid.NullUUID{UUID: takeFirst(b.taskSeed.WorkspaceID.UUID, b.ws.ID), Valid: true}
b.taskSeed.TemplateVersionID = takeFirst(b.taskSeed.TemplateVersionID, b.seed.TemplateVersionID)
// Try to fetch existing task and update its workspace ID.
if task, err := b.db.GetTaskByID(ownerCtx, b.taskSeed.ID); err == nil {
if !task.WorkspaceID.Valid {
b.logger.Info(context.Background(), "updating task workspace id", "task_id", b.taskSeed.ID, "workspace_id", b.ws.ID)
_, err = b.db.UpdateTaskWorkspaceID(ownerCtx, database.UpdateTaskWorkspaceIDParams{
ID: b.taskSeed.ID,
WorkspaceID: uuid.NullUUID{UUID: b.ws.ID, Valid: true},
})
require.NoError(b.t, err, "update task workspace id")
} else if task.WorkspaceID.UUID != b.ws.ID {
require.Fail(b.t, "task already has a workspace id, mismatch", task.WorkspaceID.UUID, b.ws.ID)
}
} else if errors.Is(err, sql.ErrNoRows) {
task := dbgen.Task(b.t, b.db, b.taskSeed)
b.taskSeed.ID = task.ID
b.logger.Info(context.Background(), "created new task", "task_id", b.taskSeed.ID)
} else {
require.NoError(b.t, err, "get task by id")
}
}
// Create a provisioner job for the build!
payload, err := json.Marshal(provisionerdserver.WorkspaceProvisionJob{
WorkspaceBuildID: b.seed.ID,
@@ -227,7 +285,11 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse {
require.NoError(b.t, err, "insert job")
b.logger.Debug(context.Background(), "inserted provisioner job", slog.F("job_id", job.ID))
if b.dispo.starting {
switch b.jobStatus {
case database.ProvisionerJobStatusPending:
// Provisioner jobs are created in 'pending' status
b.logger.Debug(context.Background(), "pending the provisioner job")
case database.ProvisionerJobStatusRunning:
// might need to do this multiple times if we got a template version
// import job as well
b.logger.Debug(context.Background(), "looping to acquire provisioner job")
@@ -251,7 +313,23 @@ func (b WorkspaceBuildBuilder) Do() WorkspaceResponse {
break
}
}
} else {
case database.ProvisionerJobStatusCanceled:
// Set provisioner job status to 'canceled'
b.logger.Debug(context.Background(), "canceling the provisioner job")
err = b.db.UpdateProvisionerJobWithCancelByID(ownerCtx, database.UpdateProvisionerJobWithCancelByIDParams{
ID: jobID,
CanceledAt: sql.NullTime{
Time: dbtime.Now(),
Valid: true,
},
CompletedAt: sql.NullTime{
Time: dbtime.Now(),
Valid: true,
},
})
require.NoError(b.t, err, "cancel job")
default:
// By default, consider jobs in 'succeeded' status
b.logger.Debug(context.Background(), "completing the provisioner job")
err = b.db.UpdateProvisionerJobWithCompleteByID(ownerCtx, database.UpdateProvisionerJobWithCompleteByIDParams{
ID: job.ID,
@@ -273,6 +351,35 @@ 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))
// Update task after linking.
task, err = b.db.GetTaskByID(ownerCtx, task.ID)
require.NoError(b.t, err, "get task by id")
resp.Task = task
}
for i := range b.params {
b.params[i].WorkspaceBuildID = resp.Build.ID
}
@@ -543,6 +650,12 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse {
t.params[i] = dbgen.TemplateVersionParameter(t.t, t.db, param)
}
// Update response with template and version
if resp.Template.ID == uuid.Nil && version.TemplateID.Valid {
template, err := t.db.GetTemplateByID(ownerCtx, version.TemplateID.UUID)
require.NoError(t.t, err)
resp.Template = template
}
resp.TemplateVersion = version
return resp
}
@@ -623,3 +736,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.
}
+17 -10
View File
@@ -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(&params)
}
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")
@@ -1496,7 +1495,7 @@ func ClaimPrebuild(
return claimedWorkspace
}
func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertAIBridgeInterceptionParams) database.AIBridgeInterception {
func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertAIBridgeInterceptionParams, endedAt *time.Time) database.AIBridgeInterception {
interception, err := db.InsertAIBridgeInterception(genCtx, database.InsertAIBridgeInterceptionParams{
ID: takeFirst(seed.ID, uuid.New()),
InitiatorID: takeFirst(seed.InitiatorID, uuid.New()),
@@ -1505,6 +1504,13 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA
Metadata: takeFirstSlice(seed.Metadata, json.RawMessage("{}")),
StartedAt: takeFirst(seed.StartedAt, dbtime.Now()),
})
if endedAt != nil {
interception, err = db.UpdateAIBridgeInterceptionEnded(genCtx, database.UpdateAIBridgeInterceptionEndedParams{
ID: interception.ID,
EndedAt: *endedAt,
})
require.NoError(t, err, "insert aibridge interception")
}
require.NoError(t, err, "insert aibridge interception")
return interception
}
@@ -1570,6 +1576,7 @@ func Task(t testing.TB, db database.Store, orig database.TaskTable) database.Tas
}
task, err := db.InsertTask(genCtx, database.InsertTaskParams{
ID: takeFirst(orig.ID, uuid.New()),
OrganizationID: orig.OrganizationID,
OwnerID: orig.OwnerID,
Name: takeFirst(orig.Name, taskname.GenerateFallback()),
+71 -2
View File
@@ -5,7 +5,6 @@ package dbmetrics
import (
"context"
"database/sql"
"slices"
"time"
@@ -159,6 +158,13 @@ func (m queryMetricsStore) BulkMarkNotificationMessagesSent(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) CalculateAIBridgeInterceptionsTelemetrySummary(ctx context.Context, arg database.CalculateAIBridgeInterceptionsTelemetrySummaryParams) (database.CalculateAIBridgeInterceptionsTelemetrySummaryRow, error) {
start := time.Now()
r0, r1 := m.s.CalculateAIBridgeInterceptionsTelemetrySummary(ctx, arg)
m.queryLatencies.WithLabelValues("CalculateAIBridgeInterceptionsTelemetrySummary").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) {
start := time.Now()
r0, r1 := m.s.ClaimPrebuiltWorkspace(ctx, arg)
@@ -215,6 +221,13 @@ func (m queryMetricsStore) CountInProgressPrebuilds(ctx context.Context) ([]data
return r0, r1
}
func (m queryMetricsStore) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) {
start := time.Now()
r0, r1 := m.s.CountPendingNonActivePrebuilds(ctx)
m.queryLatencies.WithLabelValues("CountPendingNonActivePrebuilds").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
start := time.Now()
r0, r1 := m.s.CountUnreadInboxNotificationsByUserID(ctx, userID)
@@ -397,6 +410,13 @@ func (m queryMetricsStore) DeleteOldProvisionerDaemons(ctx context.Context) erro
return r0
}
func (m queryMetricsStore) DeleteOldTelemetryLocks(ctx context.Context, periodEndingAtBefore time.Time) error {
start := time.Now()
r0 := m.s.DeleteOldTelemetryLocks(ctx, periodEndingAtBefore)
m.queryLatencies.WithLabelValues("DeleteOldTelemetryLocks").Observe(time.Since(start).Seconds())
return r0
}
func (m queryMetricsStore) DeleteOldWorkspaceAgentLogs(ctx context.Context, arg time.Time) error {
start := time.Now()
r0 := m.s.DeleteOldWorkspaceAgentLogs(ctx, arg)
@@ -474,6 +494,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 +1124,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())
@@ -1216,6 +1243,13 @@ func (m queryMetricsStore) GetOrganizationsByUserID(ctx context.Context, userID
return organizations, err
}
func (m queryMetricsStore) GetOrganizationsWithPrebuildStatus(ctx context.Context, arg database.GetOrganizationsWithPrebuildStatusParams) ([]database.GetOrganizationsWithPrebuildStatusRow, error) {
start := time.Now()
r0, r1 := m.s.GetOrganizationsWithPrebuildStatus(ctx, arg)
m.queryLatencies.WithLabelValues("GetOrganizationsWithPrebuildStatus").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) {
start := time.Now()
schemas, err := m.s.GetParameterSchemasByJobID(ctx, jobID)
@@ -2504,6 +2538,13 @@ func (m queryMetricsStore) InsertTelemetryItemIfNotExists(ctx context.Context, a
return r0
}
func (m queryMetricsStore) InsertTelemetryLock(ctx context.Context, arg database.InsertTelemetryLockParams) error {
start := time.Now()
r0 := m.s.InsertTelemetryLock(ctx, arg)
m.queryLatencies.WithLabelValues("InsertTelemetryLock").Observe(time.Since(start).Seconds())
return r0
}
func (m queryMetricsStore) InsertTemplate(ctx context.Context, arg database.InsertTemplateParams) error {
start := time.Now()
err := m.s.InsertTemplate(ctx, arg)
@@ -2721,6 +2762,13 @@ func (m queryMetricsStore) ListAIBridgeInterceptions(ctx context.Context, arg da
return r0, r1
}
func (m queryMetricsStore) ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg database.ListAIBridgeInterceptionsTelemetrySummariesParams) ([]database.ListAIBridgeInterceptionsTelemetrySummariesRow, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeInterceptionsTelemetrySummaries(ctx, arg)
m.queryLatencies.WithLabelValues("ListAIBridgeInterceptionsTelemetrySummaries").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]database.AIBridgeTokenUsage, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeTokenUsagesByInterceptionIDs(ctx, interceptionIds)
@@ -2875,6 +2923,13 @@ func (m queryMetricsStore) UnfavoriteWorkspace(ctx context.Context, arg uuid.UUI
return r0
}
func (m queryMetricsStore) UpdateAIBridgeInterceptionEnded(ctx context.Context, id database.UpdateAIBridgeInterceptionEndedParams) (database.AIBridgeInterception, error) {
start := time.Now()
r0, r1 := m.s.UpdateAIBridgeInterceptionEnded(ctx, id)
m.queryLatencies.WithLabelValues("UpdateAIBridgeInterceptionEnded").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error {
start := time.Now()
err := m.s.UpdateAPIKeyByID(ctx, arg)
@@ -2994,6 +3049,13 @@ func (m queryMetricsStore) UpdateOrganizationDeletedByID(ctx context.Context, ar
return r0
}
func (m queryMetricsStore) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) {
start := time.Now()
r0, r1 := m.s.UpdatePrebuildProvisionerJobWithCancel(ctx, arg)
m.queryLatencies.WithLabelValues("UpdatePrebuildProvisionerJobWithCancel").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) UpdatePresetPrebuildStatus(ctx context.Context, arg database.UpdatePresetPrebuildStatusParams) error {
start := time.Now()
r0 := m.s.UpdatePresetPrebuildStatus(ctx, arg)
@@ -3064,6 +3126,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)
+149 -2
View File
@@ -11,7 +11,6 @@ package dbmock
import (
context "context"
sql "database/sql"
reflect "reflect"
time "time"
@@ -191,6 +190,21 @@ func (mr *MockStoreMockRecorder) BulkMarkNotificationMessagesSent(ctx, arg any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BulkMarkNotificationMessagesSent", reflect.TypeOf((*MockStore)(nil).BulkMarkNotificationMessagesSent), ctx, arg)
}
// CalculateAIBridgeInterceptionsTelemetrySummary mocks base method.
func (m *MockStore) CalculateAIBridgeInterceptionsTelemetrySummary(ctx context.Context, arg database.CalculateAIBridgeInterceptionsTelemetrySummaryParams) (database.CalculateAIBridgeInterceptionsTelemetrySummaryRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CalculateAIBridgeInterceptionsTelemetrySummary", ctx, arg)
ret0, _ := ret[0].(database.CalculateAIBridgeInterceptionsTelemetrySummaryRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CalculateAIBridgeInterceptionsTelemetrySummary indicates an expected call of CalculateAIBridgeInterceptionsTelemetrySummary.
func (mr *MockStoreMockRecorder) CalculateAIBridgeInterceptionsTelemetrySummary(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CalculateAIBridgeInterceptionsTelemetrySummary", reflect.TypeOf((*MockStore)(nil).CalculateAIBridgeInterceptionsTelemetrySummary), ctx, arg)
}
// ClaimPrebuiltWorkspace mocks base method.
func (m *MockStore) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) {
m.ctrl.T.Helper()
@@ -353,6 +367,21 @@ func (mr *MockStoreMockRecorder) CountInProgressPrebuilds(ctx any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountInProgressPrebuilds", reflect.TypeOf((*MockStore)(nil).CountInProgressPrebuilds), ctx)
}
// CountPendingNonActivePrebuilds mocks base method.
func (m *MockStore) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountPendingNonActivePrebuilds", ctx)
ret0, _ := ret[0].([]database.CountPendingNonActivePrebuildsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountPendingNonActivePrebuilds indicates an expected call of CountPendingNonActivePrebuilds.
func (mr *MockStoreMockRecorder) CountPendingNonActivePrebuilds(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountPendingNonActivePrebuilds", reflect.TypeOf((*MockStore)(nil).CountPendingNonActivePrebuilds), ctx)
}
// CountUnreadInboxNotificationsByUserID mocks base method.
func (m *MockStore) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
m.ctrl.T.Helper()
@@ -722,6 +751,20 @@ func (mr *MockStoreMockRecorder) DeleteOldProvisionerDaemons(ctx any) *gomock.Ca
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldProvisionerDaemons", reflect.TypeOf((*MockStore)(nil).DeleteOldProvisionerDaemons), ctx)
}
// DeleteOldTelemetryLocks mocks base method.
func (m *MockStore) DeleteOldTelemetryLocks(ctx context.Context, periodEndingAtBefore time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteOldTelemetryLocks", ctx, periodEndingAtBefore)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteOldTelemetryLocks indicates an expected call of DeleteOldTelemetryLocks.
func (mr *MockStoreMockRecorder) DeleteOldTelemetryLocks(ctx, periodEndingAtBefore any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldTelemetryLocks", reflect.TypeOf((*MockStore)(nil).DeleteOldTelemetryLocks), ctx, periodEndingAtBefore)
}
// DeleteOldWorkspaceAgentLogs mocks base method.
func (m *MockStore) DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) error {
m.ctrl.T.Helper()
@@ -880,6 +923,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 +2368,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)
@@ -2564,6 +2622,21 @@ func (mr *MockStoreMockRecorder) GetOrganizationsByUserID(ctx, arg any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationsByUserID", reflect.TypeOf((*MockStore)(nil).GetOrganizationsByUserID), ctx, arg)
}
// GetOrganizationsWithPrebuildStatus mocks base method.
func (m *MockStore) GetOrganizationsWithPrebuildStatus(ctx context.Context, arg database.GetOrganizationsWithPrebuildStatusParams) ([]database.GetOrganizationsWithPrebuildStatusRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOrganizationsWithPrebuildStatus", ctx, arg)
ret0, _ := ret[0].([]database.GetOrganizationsWithPrebuildStatusRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOrganizationsWithPrebuildStatus indicates an expected call of GetOrganizationsWithPrebuildStatus.
func (mr *MockStoreMockRecorder) GetOrganizationsWithPrebuildStatus(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationsWithPrebuildStatus", reflect.TypeOf((*MockStore)(nil).GetOrganizationsWithPrebuildStatus), ctx, arg)
}
// GetParameterSchemasByJobID mocks base method.
func (m *MockStore) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) {
m.ctrl.T.Helper()
@@ -5363,6 +5436,20 @@ func (mr *MockStoreMockRecorder) InsertTelemetryItemIfNotExists(ctx, arg any) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertTelemetryItemIfNotExists", reflect.TypeOf((*MockStore)(nil).InsertTelemetryItemIfNotExists), ctx, arg)
}
// InsertTelemetryLock mocks base method.
func (m *MockStore) InsertTelemetryLock(ctx context.Context, arg database.InsertTelemetryLockParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertTelemetryLock", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// InsertTelemetryLock indicates an expected call of InsertTelemetryLock.
func (mr *MockStoreMockRecorder) InsertTelemetryLock(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertTelemetryLock", reflect.TypeOf((*MockStore)(nil).InsertTelemetryLock), ctx, arg)
}
// InsertTemplate mocks base method.
func (m *MockStore) InsertTemplate(ctx context.Context, arg database.InsertTemplateParams) error {
m.ctrl.T.Helper()
@@ -5818,6 +5905,21 @@ func (mr *MockStoreMockRecorder) ListAIBridgeInterceptions(ctx, arg any) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeInterceptions", reflect.TypeOf((*MockStore)(nil).ListAIBridgeInterceptions), ctx, arg)
}
// ListAIBridgeInterceptionsTelemetrySummaries mocks base method.
func (m *MockStore) ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg database.ListAIBridgeInterceptionsTelemetrySummariesParams) ([]database.ListAIBridgeInterceptionsTelemetrySummariesRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAIBridgeInterceptionsTelemetrySummaries", ctx, arg)
ret0, _ := ret[0].([]database.ListAIBridgeInterceptionsTelemetrySummariesRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAIBridgeInterceptionsTelemetrySummaries indicates an expected call of ListAIBridgeInterceptionsTelemetrySummaries.
func (mr *MockStoreMockRecorder) ListAIBridgeInterceptionsTelemetrySummaries(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeInterceptionsTelemetrySummaries", reflect.TypeOf((*MockStore)(nil).ListAIBridgeInterceptionsTelemetrySummaries), ctx, arg)
}
// ListAIBridgeTokenUsagesByInterceptionIDs mocks base method.
func (m *MockStore) ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]database.AIBridgeTokenUsage, error) {
m.ctrl.T.Helper()
@@ -6187,6 +6289,21 @@ func (mr *MockStoreMockRecorder) UnfavoriteWorkspace(ctx, id any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnfavoriteWorkspace", reflect.TypeOf((*MockStore)(nil).UnfavoriteWorkspace), ctx, id)
}
// UpdateAIBridgeInterceptionEnded mocks base method.
func (m *MockStore) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg database.UpdateAIBridgeInterceptionEndedParams) (database.AIBridgeInterception, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateAIBridgeInterceptionEnded", ctx, arg)
ret0, _ := ret[0].(database.AIBridgeInterception)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateAIBridgeInterceptionEnded indicates an expected call of UpdateAIBridgeInterceptionEnded.
func (mr *MockStoreMockRecorder) UpdateAIBridgeInterceptionEnded(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAIBridgeInterceptionEnded", reflect.TypeOf((*MockStore)(nil).UpdateAIBridgeInterceptionEnded), ctx, arg)
}
// UpdateAPIKeyByID mocks base method.
func (m *MockStore) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error {
m.ctrl.T.Helper()
@@ -6437,6 +6554,21 @@ func (mr *MockStoreMockRecorder) UpdateOrganizationDeletedByID(ctx, arg any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganizationDeletedByID", reflect.TypeOf((*MockStore)(nil).UpdateOrganizationDeletedByID), ctx, arg)
}
// UpdatePrebuildProvisionerJobWithCancel mocks base method.
func (m *MockStore) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdatePrebuildProvisionerJobWithCancel", ctx, arg)
ret0, _ := ret[0].([]database.UpdatePrebuildProvisionerJobWithCancelRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdatePrebuildProvisionerJobWithCancel indicates an expected call of UpdatePrebuildProvisionerJobWithCancel.
func (mr *MockStoreMockRecorder) UpdatePrebuildProvisionerJobWithCancel(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePrebuildProvisionerJobWithCancel", reflect.TypeOf((*MockStore)(nil).UpdatePrebuildProvisionerJobWithCancel), ctx, arg)
}
// UpdatePresetPrebuildStatus mocks base method.
func (m *MockStore) UpdatePresetPrebuildStatus(ctx context.Context, arg database.UpdatePresetPrebuildStatusParams) error {
m.ctrl.T.Helper()
@@ -6578,6 +6710,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()
+10
View File
@@ -24,6 +24,12 @@ const (
// but we won't touch the `connection_logs` table.
maxAuditLogConnectionEventAge = 90 * 24 * time.Hour // 90 days
auditLogConnectionEventBatchSize = 1000
// Telemetry heartbeats are used to deduplicate events across replicas. We
// don't need to persist heartbeat rows for longer than 24 hours, as they
// are only used for deduplication across replicas. The time needs to be
// long enough to cover the maximum interval of a heartbeat event (currently
// 1 hour) plus some buffer.
maxTelemetryHeartbeatAge = 24 * time.Hour
)
// New creates a new periodically purging database instance.
@@ -71,6 +77,10 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, clk quartz.
if err := tx.ExpirePrebuildsAPIKeys(ctx, dbtime.Time(start)); err != nil {
return xerrors.Errorf("failed to expire prebuilds user api keys: %w", err)
}
deleteOldTelemetryLocksBefore := start.Add(-maxTelemetryHeartbeatAge)
if err := tx.DeleteOldTelemetryLocks(ctx, deleteOldTelemetryLocksBefore); err != nil {
return xerrors.Errorf("failed to delete old telemetry locks: %w", err)
}
deleteOldAuditLogConnectionEventsBefore := start.Add(-maxAuditLogConnectionEventAge)
if err := tx.DeleteOldAuditLogConnectionEvents(ctx, database.DeleteOldAuditLogConnectionEventsParams{
+53
View File
@@ -704,3 +704,56 @@ func TestExpireOldAPIKeys(t *testing.T) {
// Out of an abundance of caution, we do not expire explicitly named prebuilds API keys.
assertKeyActive(namedPrebuildsAPIKey.ID)
}
func TestDeleteOldTelemetryHeartbeats(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t, dbtestutil.WithDumpOnFailure())
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
clk := quartz.NewMock(t)
now := clk.Now().UTC()
// Insert telemetry heartbeats.
err := db.InsertTelemetryLock(ctx, database.InsertTelemetryLockParams{
EventType: "aibridge_interceptions_summary",
PeriodEndingAt: now.Add(-25 * time.Hour), // should be purged
})
require.NoError(t, err)
err = db.InsertTelemetryLock(ctx, database.InsertTelemetryLockParams{
EventType: "aibridge_interceptions_summary",
PeriodEndingAt: now.Add(-23 * time.Hour), // should be kept
})
require.NoError(t, err)
err = db.InsertTelemetryLock(ctx, database.InsertTelemetryLockParams{
EventType: "aibridge_interceptions_summary",
PeriodEndingAt: now, // should be kept
})
require.NoError(t, err)
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, db, clk)
defer closer.Close()
<-done // doTick() has now run.
require.Eventuallyf(t, func() bool {
// We use an SQL queries directly here because we don't expose queries
// for deleting heartbeats in the application code.
var totalCount int
err := sqlDB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM telemetry_locks;
`).Scan(&totalCount)
assert.NoError(t, err)
var oldCount int
err = sqlDB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM telemetry_locks WHERE period_ending_at < $1;
`, now.Add(-24*time.Hour)).Scan(&oldCount)
assert.NoError(t, err)
// Expect 2 heartbeats remaining and none older than 24 hours.
t.Logf("eventually: total count: %d, old count: %d", totalCount, oldCount)
return totalCount == 2 && oldCount == 0
}, testutil.WaitShort, testutil.IntervalFast, "it should delete old telemetry heartbeats")
}
@@ -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)
+51 -6
View File
@@ -6,6 +6,8 @@ import (
_ "embed"
"fmt"
"os"
"runtime"
"strings"
"sync"
"time"
@@ -45,6 +47,8 @@ func (b *Broker) Create(t TBSubset, opts ...OpenOption) (ConnectionParams, error
host = defaultConnectionParams.Host
port = defaultConnectionParams.Port
)
packageName := getTestPackageName(t)
testName := t.Name()
// Use a time-based prefix to make it easier to find the database
// when debugging.
@@ -55,9 +59,9 @@ func (b *Broker) Create(t TBSubset, opts ...OpenOption) (ConnectionParams, error
}
dbName := now + "_" + dbSuffix
// TODO: add package and test name
_, err = b.coderTestingDB.Exec(
"INSERT INTO test_databases (name, process_uuid) VALUES ($1, $2)", dbName, b.uuid)
"INSERT INTO test_databases (name, process_uuid, test_package, test_name) VALUES ($1, $2, $3, $4)",
dbName, b.uuid, packageName, testName)
if err != nil {
return ConnectionParams{}, xerrors.Errorf("insert test_database row: %w", err)
}
@@ -104,10 +108,10 @@ func (b *Broker) clean(t TBSubset, dbName string) func() {
func (b *Broker) init(t TBSubset) error {
b.Lock()
defer b.Unlock()
b.refCount++
t.Cleanup(b.decRef)
if b.coderTestingDB != nil {
// already initialized
b.refCount++
t.Cleanup(b.decRef)
return nil
}
@@ -124,8 +128,8 @@ func (b *Broker) init(t TBSubset) error {
return xerrors.Errorf("open postgres connection: %w", err)
}
// creating the db can succeed even if the database doesn't exist. Ping it to find out.
err = coderTestingDB.Ping()
// coderTestingSQLInit is idempotent, so we can run it every time.
_, err = coderTestingDB.Exec(coderTestingSQLInit)
var pqErr *pq.Error
if xerrors.As(err, &pqErr) && pqErr.Code == "3D000" {
// database does not exist.
@@ -145,6 +149,8 @@ func (b *Broker) init(t TBSubset) error {
return xerrors.Errorf("ping '%s' database: %w", CoderTestingDBName, err)
}
b.coderTestingDB = coderTestingDB
b.refCount++
t.Cleanup(b.decRef)
if b.uuid == uuid.Nil {
b.uuid = uuid.New()
@@ -186,3 +192,42 @@ func (b *Broker) decRef() {
b.coderTestingDB = nil
}
}
// getTestPackageName returns the package name of the test that called it.
func getTestPackageName(t TBSubset) string {
packageName := "unknown"
// Ask runtime.Callers for up to 100 program counters, including runtime.Callers itself.
pc := make([]uintptr, 100)
n := runtime.Callers(0, pc)
if n == 0 {
// No PCs available. This can happen if the first argument to
// runtime.Callers is large.
//
// Return now to avoid processing the zero Frame that would
// otherwise be returned by frames.Next below.
t.Logf("could not determine test package name: no PCs available")
return packageName
}
pc = pc[:n] // pass only valid pcs to runtime.CallersFrames
frames := runtime.CallersFrames(pc)
// Loop to get frames.
// A fixed number of PCs can expand to an indefinite number of Frames.
for {
frame, more := frames.Next()
if strings.HasPrefix(frame.Function, "github.com/coder/coder/v2/") {
packageName = strings.SplitN(strings.TrimPrefix(frame.Function, "github.com/coder/coder/v2/"), ".", 2)[0]
}
if strings.HasPrefix(frame.Function, "testing") {
break
}
// Check whether there are more frames to process after this one.
if !more {
break
}
}
return packageName
}
@@ -0,0 +1,13 @@
package dbtestutil
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetTestPackageName(t *testing.T) {
t.Parallel()
packageName := getTestPackageName(t)
require.Equal(t, "coderd/database/dbtestutil", packageName)
}
@@ -1,3 +1,6 @@
BEGIN TRANSACTION;
SELECT pg_advisory_xact_lock(7283699);
CREATE TABLE IF NOT EXISTS test_databases (
name text PRIMARY KEY,
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -6,3 +9,10 @@ CREATE TABLE IF NOT EXISTS test_databases (
);
CREATE INDEX IF NOT EXISTS test_databases_process_uuid ON test_databases (process_uuid, dropped_at);
ALTER TABLE test_databases ADD COLUMN IF NOT EXISTS test_name text;
COMMENT ON COLUMN test_databases.test_name IS 'Name of the test that created the database';
ALTER TABLE test_databases ADD COLUMN IF NOT EXISTS test_package text;
COMMENT ON COLUMN test_databases.test_package IS 'Package of the test that created the database';
COMMIT;
-11
View File
@@ -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
@@ -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)
+46 -17
View File
@@ -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
);
@@ -1826,6 +1828,15 @@ CREATE TABLE tasks (
deleted_at timestamp with time zone
);
CREATE VIEW visible_users AS
SELECT users.id,
users.username,
users.name,
users.avatar_url
FROM users;
COMMENT ON VIEW visible_users IS 'Visible fields of users are allowed to be joined with other tables for including context of other resources.';
CREATE TABLE workspace_agents (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
@@ -1976,8 +1987,16 @@ CREATE VIEW tasks_with_status AS
END AS status,
task_app.workspace_build_number,
task_app.workspace_agent_id,
task_app.workspace_app_id
FROM ((((tasks
task_app.workspace_app_id,
task_owner.owner_username,
task_owner.owner_name,
task_owner.owner_avatar_url
FROM (((((tasks
CROSS JOIN LATERAL ( SELECT vu.username AS owner_username,
vu.name AS owner_name,
vu.avatar_url AS owner_avatar_url
FROM visible_users vu
WHERE (vu.id = tasks.owner_id)) task_owner)
LEFT JOIN LATERAL ( SELECT task_app_1.workspace_build_number,
task_app_1.workspace_agent_id,
task_app_1.workspace_app_id
@@ -2010,6 +2029,18 @@ CREATE TABLE telemetry_items (
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE telemetry_locks (
event_type text NOT NULL,
period_ending_at timestamp with time zone NOT NULL,
CONSTRAINT telemetry_lock_event_type_constraint CHECK ((event_type = 'aibridge_interceptions_summary'::text))
);
COMMENT ON TABLE telemetry_locks IS 'Telemetry lock tracking table for deduplication of heartbeat events across replicas.';
COMMENT ON COLUMN telemetry_locks.event_type IS 'The type of event that was sent.';
COMMENT ON COLUMN telemetry_locks.period_ending_at IS 'The heartbeat period end timestamp.';
CREATE TABLE template_usage_stats (
start_time timestamp with time zone NOT NULL,
end_time timestamp with time zone NOT NULL,
@@ -2196,15 +2227,6 @@ COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of External
COMMENT ON COLUMN template_versions.message IS 'Message describing the changes in this version of the template, similar to a Git commit message. Like a commit message, this should be a short, high-level description of the changes in this version of the template. This message is immutable and should not be updated after the fact.';
CREATE VIEW visible_users AS
SELECT users.id,
users.username,
users.name,
users.avatar_url
FROM users;
COMMENT ON VIEW visible_users IS 'Visible fields of users are allowed to be joined with other tables for including context of other resources.';
CREATE VIEW template_version_with_user AS
SELECT template_versions.id,
template_versions.template_id,
@@ -2900,11 +2922,13 @@ CREATE VIEW workspaces_expanded AS
templates.name AS template_name,
templates.display_name AS template_display_name,
templates.icon AS template_icon,
templates.description AS template_description
FROM (((workspaces
templates.description AS template_description,
tasks.id AS task_id
FROM ((((workspaces
JOIN visible_users ON ((workspaces.owner_id = visible_users.id)))
JOIN organizations ON ((workspaces.organization_id = organizations.id)))
JOIN templates ON ((workspaces.template_id = templates.id)));
JOIN templates ON ((workspaces.template_id = templates.id)))
LEFT JOIN tasks ON ((workspaces.id = tasks.workspace_id)));
COMMENT ON VIEW workspaces_expanded IS 'Joins in the display name information such as username, avatar, and organization name.';
@@ -3088,6 +3112,9 @@ ALTER TABLE ONLY tasks
ALTER TABLE ONLY telemetry_items
ADD CONSTRAINT telemetry_items_pkey PRIMARY KEY (key);
ALTER TABLE ONLY telemetry_locks
ADD CONSTRAINT telemetry_locks_pkey PRIMARY KEY (event_type, period_ending_at);
ALTER TABLE ONLY template_usage_stats
ADD CONSTRAINT template_usage_stats_pkey PRIMARY KEY (start_time, template_id, user_id);
@@ -3313,6 +3340,8 @@ CREATE INDEX idx_tailnet_tunnels_dst_id ON tailnet_tunnels USING hash (dst_id);
CREATE INDEX idx_tailnet_tunnels_src_id ON tailnet_tunnels USING hash (src_id);
CREATE INDEX idx_telemetry_locks_period_ending_at ON telemetry_locks USING btree (period_ending_at);
CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true);
CREATE INDEX idx_template_versions_has_ai_task ON template_versions USING btree (has_ai_task);
@@ -0,0 +1 @@
ALTER TABLE aibridge_interceptions DROP COLUMN ended_at;
@@ -0,0 +1 @@
ALTER TABLE aibridge_interceptions ADD COLUMN ended_at TIMESTAMP WITH TIME ZONE DEFAULT NULL;
@@ -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.
@@ -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
);
@@ -0,0 +1,4 @@
ALTER TABLE oauth2_provider_apps
ALTER COLUMN registration_access_token
SET DATA TYPE text
USING encode(registration_access_token, 'escape');
@@ -0,0 +1,4 @@
ALTER TABLE oauth2_provider_apps
ALTER COLUMN registration_access_token
SET DATA TYPE bytea
USING decode(registration_access_token, 'escape');
@@ -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;
@@ -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);

Some files were not shown because too many files have changed in this diff Show More