Compare commits

..

40 Commits

Author SHA1 Message Date
Danny Kopping 046c0108b6 chore: remove "auto" feature
Signed-off-by: Danny Kopping <danny@coder.com>
2026-01-19 18:31:42 +02:00
Danny Kopping fb6cac93b7 chore: make lint/markdown
Signed-off-by: Danny Kopping <danny@coder.com>
2026-01-19 18:18:22 +02:00
Danny Kopping 77ef84758d chore: increase default pg settings in line with observed usage
Signed-off-by: Danny Kopping <danny@coder.com>
2026-01-19 17:38:31 +02:00
dependabot[bot] e79f1d0406 chore: bump github.com/elazarl/goproxy from 1.7.2 to 1.8.0 (#21565)
Bumps [github.com/elazarl/goproxy](https://github.com/elazarl/goproxy)
from 1.7.2 to 1.8.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/elazarl/goproxy/releases">github.com/elazarl/goproxy's
releases</a>.</em></p>
<blockquote>
<h2>v1.8.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix typo in example code snippet by <a
href="https://github.com/PrinceShaji"><code>@​PrinceShaji</code></a> in
<a
href="https://redirect.github.com/elazarl/goproxy/pull/653">elazarl/goproxy#653</a></li>
<li>Bump golang.org/x/net from 0.35.0 to 0.36.0 in /ext by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/elazarl/goproxy/pull/656">elazarl/goproxy#656</a></li>
<li>Only chunk MITM response when body was modified by <a
href="https://github.com/Skn0tt"><code>@​Skn0tt</code></a> in <a
href="https://redirect.github.com/elazarl/goproxy/pull/720">elazarl/goproxy#720</a></li>
<li>Bump actions/checkout from 4 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/elazarl/goproxy/pull/728">elazarl/goproxy#728</a></li>
<li>Bump golangci/golangci-lint-action from 6 to 9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/elazarl/goproxy/pull/725">elazarl/goproxy#725</a></li>
<li>Fix keep alive logic and replace legacy response write logic by <a
href="https://github.com/ErikPelli"><code>@​ErikPelli</code></a> in <a
href="https://redirect.github.com/elazarl/goproxy/pull/734">elazarl/goproxy#734</a></li>
<li>Bump github.com/stretchr/testify from 1.10.0 to 1.11.1 in /ext by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/elazarl/goproxy/pull/708">elazarl/goproxy#708</a></li>
<li>Bump github.com/coder/websocket from 1.8.12 to 1.8.14 in /examples
by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/elazarl/goproxy/pull/711">elazarl/goproxy#711</a></li>
<li>Bump actions/setup-go from 5 to 6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/elazarl/goproxy/pull/709">elazarl/goproxy#709</a></li>
<li>fix auth remote proxy in cascadeproxy by <a
href="https://github.com/mcarbonneaux"><code>@​mcarbonneaux</code></a>
in <a
href="https://redirect.github.com/elazarl/goproxy/pull/664">elazarl/goproxy#664</a></li>
<li>Fix linter configuration &amp; issues by <a
href="https://github.com/ErikPelli"><code>@​ErikPelli</code></a> in <a
href="https://redirect.github.com/elazarl/goproxy/pull/735">elazarl/goproxy#735</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/PrinceShaji"><code>@​PrinceShaji</code></a>
made their first contribution in <a
href="https://redirect.github.com/elazarl/goproxy/pull/653">elazarl/goproxy#653</a></li>
<li><a href="https://github.com/Skn0tt"><code>@​Skn0tt</code></a> made
their first contribution in <a
href="https://redirect.github.com/elazarl/goproxy/pull/720">elazarl/goproxy#720</a></li>
<li><a
href="https://github.com/mcarbonneaux"><code>@​mcarbonneaux</code></a>
made their first contribution in <a
href="https://redirect.github.com/elazarl/goproxy/pull/664">elazarl/goproxy#664</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/elazarl/goproxy/compare/v1.7.2...v1.8.0">https://github.com/elazarl/goproxy/compare/v1.7.2...v1.8.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/elazarl/goproxy/commit/26d3e758aa11576025fc7bd07458a41a257337fa"><code>26d3e75</code></a>
Fix linter configuration &amp; issues (<a
href="https://redirect.github.com/elazarl/goproxy/issues/735">#735</a>)</li>
<li><a
href="https://github.com/elazarl/goproxy/commit/5f529678d4cfd2745820ce17626a6094060bc82e"><code>5f52967</code></a>
fix auth remote proxy in cascadeproxy (<a
href="https://redirect.github.com/elazarl/goproxy/issues/664">#664</a>)</li>
<li><a
href="https://github.com/elazarl/goproxy/commit/b81733c4621a3df4917602dd23424526cd1fdf38"><code>b81733c</code></a>
Bump actions/setup-go from 5 to 6 (<a
href="https://redirect.github.com/elazarl/goproxy/issues/709">#709</a>)</li>
<li><a
href="https://github.com/elazarl/goproxy/commit/2df6d8b266c9637a996df5995ce44e68d98e66ea"><code>2df6d8b</code></a>
Bump github.com/coder/websocket from 1.8.12 to 1.8.14 in /examples (<a
href="https://redirect.github.com/elazarl/goproxy/issues/711">#711</a>)</li>
<li><a
href="https://github.com/elazarl/goproxy/commit/18547706ca8c493ee7f84f0688374b442defe6a7"><code>1854770</code></a>
Bump github.com/stretchr/testify from 1.10.0 to 1.11.1 in /ext (<a
href="https://redirect.github.com/elazarl/goproxy/issues/708">#708</a>)</li>
<li><a
href="https://github.com/elazarl/goproxy/commit/78c76be575d4666ae42cabab15b7a114146981cb"><code>78c76be</code></a>
Fix keep alive logic and replace legacy response write logic (<a
href="https://redirect.github.com/elazarl/goproxy/issues/734">#734</a>)</li>
<li><a
href="https://github.com/elazarl/goproxy/commit/8766328c5e76fbd70f70f0fdec7ea4c598484b17"><code>8766328</code></a>
Bump golangci/golangci-lint-action from 6 to 9 (<a
href="https://redirect.github.com/elazarl/goproxy/issues/725">#725</a>)</li>
<li><a
href="https://github.com/elazarl/goproxy/commit/fad3713f171574c048a7eb12f74f89c7e2226988"><code>fad3713</code></a>
Merge pull request <a
href="https://redirect.github.com/elazarl/goproxy/issues/728">#728</a>
from elazarl/dependabot/github_actions/actions/checko...</li>
<li><a
href="https://github.com/elazarl/goproxy/commit/3cfbd83639757d626f45fede9166520bbfbbd2b9"><code>3cfbd83</code></a>
Bump actions/checkout from 4 to 6</li>
<li><a
href="https://github.com/elazarl/goproxy/commit/29d155006e8fa45f062387256160650d23a3333d"><code>29d1550</code></a>
Merge pull request <a
href="https://redirect.github.com/elazarl/goproxy/issues/720">#720</a>
from Skn0tt/reproduce-mitm-content-length-bug</li>
<li>Additional commits viewable in <a
href="https://github.com/elazarl/goproxy/compare/v1.7.2...v1.8.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/elazarl/goproxy&package-manager=go_modules&previous-version=1.7.2&new-version=1.8.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>
2026-01-19 13:48:38 +00:00
dependabot[bot] 2bfd54dfdb chore: bump google.golang.org/api from 0.259.0 to 0.260.0 (#21566)
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

Bumps
[google.golang.org/api](https://github.com/googleapis/google-api-go-client)
from 0.259.0 to 0.260.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.260.0</h2>
<h2><a
href="https://github.com/googleapis/google-api-go-client/compare/v0.259.0...v0.260.0">0.260.0</a>
(2026-01-14)</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/3428">#3428</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/0afb986761463235d97270ab501a134b4b8f30ab">0afb986</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3430">#3430</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/6fe40c61fa1b8990057b5e668e54ba8657a57ea1">6fe40c6</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3431">#3431</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/02e27cf37dfd4ac6b5177aea1e7e1e6c9489e19e">02e27cf</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3432">#3432</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/b147c8bae5b8087c272b85f423f5655d8eadba6c">b147c8b</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3433">#3433</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/d2187ce982d4fef390ad018c8939299bcc8a9b2e">d2187ce</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3435">#3435</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/b93c288ec0e6dc55b121228c8236338de24d7256">b93c288</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3437">#3437</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/28ff500331f494c94fc461dfa66a442a7c0dede8">28ff500</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3438">#3438</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/0172d5662d927cd0e7411516e52b3181f8ce3c00">0172d56</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.259.0...v0.260.0">0.260.0</a>
(2026-01-14)</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/3428">#3428</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/0afb986761463235d97270ab501a134b4b8f30ab">0afb986</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3430">#3430</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/6fe40c61fa1b8990057b5e668e54ba8657a57ea1">6fe40c6</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3431">#3431</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/02e27cf37dfd4ac6b5177aea1e7e1e6c9489e19e">02e27cf</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3432">#3432</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/b147c8bae5b8087c272b85f423f5655d8eadba6c">b147c8b</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3433">#3433</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/d2187ce982d4fef390ad018c8939299bcc8a9b2e">d2187ce</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3435">#3435</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/b93c288ec0e6dc55b121228c8236338de24d7256">b93c288</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3437">#3437</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/28ff500331f494c94fc461dfa66a442a7c0dede8">28ff500</a>)</li>
<li><strong>all:</strong> Auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3438">#3438</a>)
(<a
href="https://github.com/googleapis/google-api-go-client/commit/0172d5662d927cd0e7411516e52b3181f8ce3c00">0172d56</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/b916f2cc942c0693d35f70fbe578b4a115be6253"><code>b916f2c</code></a>
chore(main): release 0.260.0 (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3429">#3429</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/0172d5662d927cd0e7411516e52b3181f8ce3c00"><code>0172d56</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3438">#3438</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/ccb5b87ebc6adb8e1eb46e4276ee47185a1629ca"><code>ccb5b87</code></a>
chore: switch test driver to use gotestsum (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3436">#3436</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/28ff500331f494c94fc461dfa66a442a7c0dede8"><code>28ff500</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3437">#3437</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/33b7ab5e940b4a85c5ea3315a6372805fcf62c31"><code>33b7ab5</code></a>
chore(all): update module
github.com/googleapis/enterprise-certificate-proxy ...</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/b93c288ec0e6dc55b121228c8236338de24d7256"><code>b93c288</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3435">#3435</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/d2187ce982d4fef390ad018c8939299bcc8a9b2e"><code>d2187ce</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3433">#3433</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/b147c8bae5b8087c272b85f423f5655d8eadba6c"><code>b147c8b</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3432">#3432</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/02e27cf37dfd4ac6b5177aea1e7e1e6c9489e19e"><code>02e27cf</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3431">#3431</a>)</li>
<li><a
href="https://github.com/googleapis/google-api-go-client/commit/6fe40c61fa1b8990057b5e668e54ba8657a57ea1"><code>6fe40c6</code></a>
feat(all): auto-regenerate discovery clients (<a
href="https://redirect.github.com/googleapis/google-api-go-client/issues/3430">#3430</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/googleapis/google-api-go-client/compare/v0.259.0...v0.260.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.259.0&new-version=0.260.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>
2026-01-19 13:47:32 +00:00
dependabot[bot] 8db1e0481a chore: bump the x group with 3 updates (#21564)
Bumps the x group with 3 updates:
[golang.org/x/crypto](https://github.com/golang/crypto),
[golang.org/x/net](https://github.com/golang/net) and
[golang.org/x/tools](https://github.com/golang/tools).

Updates `golang.org/x/crypto` from 0.46.0 to 0.47.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/crypto/commit/506e022208b864bc3c9c4a416fe56be75d10ad24"><code>506e022</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/crypto/commit/7dacc380ba001e8fe7c3c7a46bf3cbdaa5064df9"><code>7dacc38</code></a>
chacha20poly1305: error out in fips140=only mode</li>
<li>See full diff in <a
href="https://github.com/golang/crypto/compare/v0.46.0...v0.47.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/net` from 0.48.0 to 0.49.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/net/commit/d977772e17ccaa1903b2af736f6405ab3a9f05cc"><code>d977772</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/net/commit/eea413e2942fbb59b323a2af0b1740da4d8aa93e"><code>eea413e</code></a>
internal/http3: use go1.25 synctest.Test instead of go1.24
synctest.Run</li>
<li><a
href="https://github.com/golang/net/commit/9ace223794aa203b4c877d08a1f7bf2f595f6242"><code>9ace223</code></a>
websocket: add missing call to resp.Body.Close</li>
<li><a
href="https://github.com/golang/net/commit/7d3dbb06ceb45c3180f4f446cd635e6b59a0b9c2"><code>7d3dbb0</code></a>
http2: buffer the most recently received PRIORITY_UPDATE frame</li>
<li>See full diff in <a
href="https://github.com/golang/net/compare/v0.48.0...v0.49.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `golang.org/x/tools` from 0.40.0 to 0.41.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/tools/commit/2ad2b30edf98d0e3b67a7b3e8f6d1d6e41c963c3"><code>2ad2b30</code></a>
go.mod: update golang.org/x dependencies</li>
<li><a
href="https://github.com/golang/tools/commit/5832cce571d5c6583d80a58f5c0ff69664056e6c"><code>5832cce</code></a>
internal/diff/lcs: introduce line diffs</li>
<li><a
href="https://github.com/golang/tools/commit/67c42573e2e2b0a6b9c421a2bd2ef4c95adb93d5"><code>67c4257</code></a>
gopls/internal/golang: Definition: fix Windows bug wrt //go:embed</li>
<li><a
href="https://github.com/golang/tools/commit/12c1f0453e55dae26e5fa2206e34a059380e6191"><code>12c1f04</code></a>
gopls/completion: check Selection invariant</li>
<li><a
href="https://github.com/golang/tools/commit/6d871857886c38ce4fbc25c25c4da1619271051e"><code>6d87185</code></a>
internal/server: add vulncheck scanning after vulncheck prompt</li>
<li><a
href="https://github.com/golang/tools/commit/0c3a1fec5617ed70197ee010406883919ede02d7"><code>0c3a1fe</code></a>
go/ast/inspector: FindByPos returns the first innermost node</li>
<li><a
href="https://github.com/golang/tools/commit/ca281cf9505443eb482db8a3e806721c29dfa7f2"><code>ca281cf</code></a>
go/analysis/passes/ctrlflow: add noreturn funcs from popular pkgs</li>
<li><a
href="https://github.com/golang/tools/commit/09c21a934282b0bcf790d54982ff24b869f832c9"><code>09c21a9</code></a>
gopls/internal/analysis/unusedfunc: remove warnings for unused enum
consts</li>
<li><a
href="https://github.com/golang/tools/commit/03cb4551c662c0e078502fe5f317ca4114b89cd8"><code>03cb455</code></a>
internal/modindex: suppress missing modcacheindex message</li>
<li><a
href="https://github.com/golang/tools/commit/15d13e8a95dd0247dec2960fb57e85252984509d"><code>15d13e8</code></a>
gopls/internal/util/typesutil: refine EnclosingSignature bug.Report</li>
<li>Additional commits viewable in <a
href="https://github.com/golang/tools/compare/v0.40.0...v0.41.0">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>
2026-01-19 13:46:32 +00:00
dependabot[bot] 31654deb87 chore: bump rust from 6cff8a3 to bf3368a in /dogfood/coder (#21569)
Bumps rust from `6cff8a3` to `bf3368a`.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rust&package-manager=docker&previous-version=slim&new-version=slim)](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>
2026-01-19 13:46:11 +00:00
dependabot[bot] 25ac3dbab8 chore: bump ubuntu from 104ae83 to c7eb020 in /dogfood/coder (#21570)
Bumps ubuntu from `104ae83` to `c7eb020`.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ubuntu&package-manager=docker&previous-version=jammy&new-version=jammy)](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>
2026-01-19 13:46:07 +00:00
Susana Ferreira a002fbbae6 refactor: avoid terminology collision with aibridge by renaming passthrough to tunneled (#21562)
## Description

Renames "passthrough" to "tunneled" in aiproxy to avoid terminology
collision with aibridge, which has its own passthrough concept.

Follow-up from:
https://github.com/coder/coder/pull/21512#discussion_r2698231778

---------

Co-authored-by: Danny Kopping <danny@coder.com>
2026-01-19 13:23:42 +00:00
Cian Johnston 08343a7a9f perf: reduce number of queries made by /api/v2/workspaceagents/{id} (#21522)
Relates to https://github.com/coder/internal/issues/1214

The `ExtractWorkspaceAgentParam` middleware ends up making 4 database
queries to follow the chain of `WorkspaceAgent` -> `WorkspaceResource`
-> `ProvisionerJob` -> `WorkspaceBuild` -- but then dropping all that
hard work on the floor. The `api.workspaceAgent` handler that references
this middleware then has to do all of that work again, plus one more
query to get the related `User` so we can get the username. This pattern
is also mirrored in `getDatabaseTerminal` but without the middleware.

This PR:
* Adds a new query `GetWorkspaceAgentAndWorkspaceByID` to fetch all
this information at once to avoid the multiple round-trips,
* Updates the existing usage of `GetWorkspaceAgentByID` to this new
query instead,
* Updates `ExtractWorkspaceAgentParam` to also store the workspace in
the request context

Dalibo: [0.63ms](https://explain.dalibo.com/plan/40bb597f3539gc6c)
2026-01-19 12:36:33 +00:00
Cian Johnston d176714f90 chore: increase du interval to 1h in dogfood/coder template (#21555)
Increases the interval of running `du` on `/home/coder` and
`/var/lib/docker` to 1h.
Also decreases the timout to 1m; having `du` run for longer is likely
not great.
2026-01-19 09:57:29 +00:00
Cian Johnston 34c7fe2eaf chore: update agent metadata in WCOC template (#21549)
This PR:
- Removes the host-related agent metadata. It's not particularly useful
and the hosts have dedicated monitoring via Netdata.
- Adds two metdata blocks to expose the sizes of `/home/coder` and
`/var/lib/docker`
2026-01-19 09:07:44 +00:00
Susana Ferreira a406ed7cc5 feat: add upstream proxy support to aiproxy for passthrough requests (#21512)
## Description

Adds upstream proxy support for AI Bridge Proxy passthrough requests.
This allows aiproxy to forward non-allowlisted requests through an
upstream proxy. Currently, the only supported configuration is when
aiproxy is the first proxy in the chain (client → aiproxy → upstream
proxy).

## Changes

* Add `--aibridge-proxy-upstream` option to configure an upstream
HTTP/HTTPS proxy URL for passthrough requests
* Add `--aibridge-proxy-upstream-ca` option to trust custom CA
certificates for HTTPS upstream proxies
* Passthrough requests (non-allowlisted domains) are forwarded through
the upstream proxy
* MITM'd requests (allowlisted domains) continue to go directly to
aibridge, not through the upstream proxy
* Add tests for upstream proxy configuration and request routing

Closes: https://github.com/coder/internal/issues/1204
2026-01-19 08:50:57 +00:00
Dean Sheather 1813605012 chore: update dogfood templates to new server (#21543) 2026-01-18 01:23:31 +11:00
Atif Ali a4e14448c2 chore: add Go module domains to boundary allowlist (#21548)
Add 21 domains to the boundary allowlist to support Go module downloads
in the dogfood environment.

When running `go mod download` with `GOPROXY=direct`, Go fetches modules
directly from their source domains. Several dependencies in `go.mod` use
non-standard import paths that were being blocked by boundary with `403
Forbidden` errors.

**Added domains:**

| Domain | Purpose |
|--------|---------|
| `go.dev`, `dl.google.com` | Go toolchain downloads |
| `cdr.dev` | cdr.dev/slog (Coder logging) |
| `cel.dev` | cel.dev/expr |
| `dario.cat` | dario.cat/mergo |
| `git.sr.ht` | git.sr.ht/~jackmordaunt/go-toast |
| `go.mozilla.org` | go.mozilla.org/pkcs7 |
| `go.nhat.io` | go.nhat.io/otelsql |
| `go.opentelemetry.io` | OpenTelemetry packages |
| `go.uber.org` | go.uber.org/atomic, etc. |
| `go.yaml.in` | go.yaml.in/yaml |
| `go4.org` | go4.org/netipx |
| `golang.zx2c4.com` | WireGuard Go packages |
| `gonum.org` | gonum.org/v1/gonum |
| `gopkg.in` | gopkg.in/yaml.v3, etc. |
| `gvisor.dev` | gvisor.dev/gvisor |
| `howett.net` | howett.net/plist |
| `kernel.org` | libcap packages |
| `mvdan.cc` | mvdan.cc/gofumpt |
| `sigs.k8s.io` | sigs.k8s.io/yaml |
| `storj.io` | storj.io/drpc |

**Tested:** All domains verified working through boundary in a Linux
container.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:20:43 -05:00
Asher 4d414a0df7 feat: add --use-parameter-defaults flag (#21119)
This is like `--yes`, but for parameter prompts.
2026-01-16 17:04:57 -09:00
Asher ff9ed91811 chore: move agent's file API into separate package (#21531)
This makes it so we can test it directly without having to go through
Tailnet, which appears to be causing flakes in CI where the requests
time out and never make it to the agent.

Takes inspiration from the container-related API endpoints.

Would probably make sense to refactor the ls tests to also go through
the API (rather than be internal tests like they are currently) but I
left those alone for now to keep the diff minimal.
2026-01-16 17:03:17 -09:00
Zach ea465d4ea3 docs: add documentation for boundary audit logs (#21529) 2026-01-16 13:04:06 -07:00
Yevhenii Shcherbina fe68ec9095 chore: bump claude-code module version (#21527)
- update boundary docs
- bump claude-code module version
- modify boundary policy for dogfood
2026-01-16 12:31:25 -05:00
Cian Johnston ab126e0f0a feat: improve usability of coder show (#21539)
This PR improves the usability of `coder show`:

- Adds a header with workspace owner/name, latest build status and time
since, and template name / version name.
- Updates `namedWorkspace` to allow looking up by UUID
- Also improves associated `TestShow` to respect context deadlines.
2026-01-16 15:45:33 +00:00
Cian Johnston ad23ea3561 chore: remove unused ExtractWorkspaceAndAgentParam (#21537)
While investigating https://github.com/coder/internal/issues/1214 I
noticed that `ExtractWorkspaceAndAgentParam` appeared to be unused
outside of tests.
2026-01-16 15:11:10 +00:00
blinkagent[bot] 3b07f7b9c4 fix: remove unreachable exit after error call in check_pg_schema.sh (#21530)
Fixes shellcheck warning reported in
https://github.com/coder/coder/pull/21496#discussion_r2696470065

## Problem

The `error()` function in `lib.sh` already calls `exit 1`, so the `exit
1` on line 17 of `check_pg_schema.sh` was unreachable:

```
In ./scripts/check_pg_schema.sh line 17:
	exit 1
        ^----^ SC2317 (info): Command appears to be unreachable.
```

## Solution

Remove the redundant `exit 1` since `error()` already handles exiting.

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2026-01-16 10:48:20 +02:00
uzair-coder07 11b35a5f94 feat(helm): add com.coder/component pod annotation to identify component type (#21378) 2026-01-16 09:17:11 +11:00
Asher 170fbcdb14 chore: refactor template insight page conditionals (#21331) 2026-01-15 12:31:05 -09:00
Jaayden Halko 3db5558603 fix: fix navigation when clicking on share workspace from the task overview page (#21523) 2026-01-15 15:17:20 -05:00
Yevhenii Shcherbina 61961db41d docs: update boundary docs (#21524)
- update boundary docs
- bump boundary version in dogfood
2026-01-15 15:07:40 -05:00
ケイラ d2d7c0ee40 chore: migrate a bunch of tests to vitest (#21514) 2026-01-15 12:38:29 -07:00
Jaayden Halko d25d95231f feat: add workspace sharing toggle on organization settings page (#21456)
resolves coder/internal#1211

<img width="1448" height="757" alt="Screenshot 2026-01-08 at 11 16 34"
src="https://github.com/user-attachments/assets/8d1e1b8b-e808-42a4-825a-f7f0f6fd8689"
/>

<img width="600" height="384" alt="Screenshot 2026-01-08 at 11 03 49"
src="https://github.com/user-attachments/assets/7fbe9b77-4617-4621-a566-972712210cbb"
/>

---------

Co-authored-by: George Katsitadze <george.katsitadze@gmail.com>
2026-01-15 17:18:23 +00:00
Cian Johnston 3a62a8e70e chore: improve healthcheck timeout message (#21520)
Relates to https://github.com/coder/internal/issues/272

This flake has been persisting for a while, and unfortunately there's no
detail on which healthcheck in particular is holding things up.

This PR adds a concurrency-safe `healthcheck.Progress` and wires it
through `healthcheck.Run`. If the healthcheck times out, it will provide
information on which healthchecks are completed / running, and how long
they took / are still taking.

🤖 Claude Opus 4.5 completed the first round of this implementation,
which I then refactored.
2026-01-15 16:37:05 +00:00
blinkagent[bot] 7fc84ecf0b feat: move stop button from shortcuts to kebab menu in workspace list (#21518)
## Summary

Moves the stop action from the icon-button shortcuts to the kebab menu
(WorkspaceMoreActions) in the workspaces list view.

## Problem

The stop icon was difficult to recognize without context in the
workspace list view. Users couldn't easily identify what the stop button
did based on the icon alone.

## Solution

- The stop action is not a primary action and doesn't need to be
highlighted in the icon-button view
- Moved the stop action into the kebab (⋮) menu
- The start button remains as a primary action when the workspace is
offline, since starting a workspace is a more common and expected action

## Changes

- `WorkspaceMoreActions`: Added optional `onStop` and `isStopPending`
props to conditionally render a "Stop" menu item
- `WorkspacesTable`: Removed the stop `PrimaryAction` button and instead
passes the stop callback to `WorkspaceMoreActions` when the workspace
can be stopped

## Testing

- TypeScript compiles without errors
- All existing tests pass
- Manually verified that the stop action appears in the kebab menu when
the workspace is running

Fixes #21516

---

Created on behalf of @jacobhqh1

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: Jake Howell <jake@hwll.me>
2026-01-15 16:19:20 +00:00
Sas Swart 0ebe8e57ad chore: add scaletesting tools for aibridge (#21279)
This pull request adds scaletesting tools for aibridge.

See
https://www.notion.so/Scale-tests-2c5d579be5928088b565d15dd8bdea41?source=copy_link
for information and instructions.

closes: https://github.com/coder/internal/issues/1156
closes: https://github.com/coder/internal/issues/1155
closes: https://github.com/coder/internal/issues/1158
2026-01-15 17:05:46 +02:00
Jakub Domeracki 3894edbcc3 chore: update Go to 1.24.11 (#21519)
Resolves:
https://github.com/coder/coder/issues/21470
2026-01-15 15:12:31 +01:00
blinkagent[bot] d5296a4855 chore: add lint/migrations to detect hardcoded public schema (#21496)
## Problem

Migration 000401 introduced a hardcoded `public.` schema qualifier which
broke deployments using non-public schemas (see #21493). We need to
prevent this from happening again.

## Solution

Adds a new `lint/migrations` Make target that validates database
migrations do not hardcode the `public` schema qualifier. Migrations
should rely on `search_path` instead to support deployments using
non-public schemas.

## Changes

- Added `scripts/check_migrations_schema.sh` - a linter script that
checks for `public.` references in migration files (excluding test
fixtures)
- Added `lint/migrations` target to the Makefile
- Added `lint/migrations` to the main `lint` target so it runs in CI

## Testing

- Verified the linter **fails** on current `main` (which has the
hardcoded `public.` in migration 000401)
- Verified the linter **passes** after applying the fix from #21493

```bash
# On main (fails)
$ make lint/migrations
ERROR: Migrations must not hardcode the 'public' schema. Use unqualified table names instead.

# After fix (passes)
$ make lint/migrations
Migration schema references OK
```

## Depends on

- #21493 must be merged first (or this PR will fail CI until it is)

---------

Signed-off-by: Danny Kopping <danny@coder.com>
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: Danny Kopping <danny@coder.com>
2026-01-15 14:17:16 +02:00
Cian Johnston 5073493850 feat(coderd/database/dbmetrics): add query_counts_total metric (#21506)
Adds a new Prometheus metric `coderd_db_query_counts_total` that tracks
the total number of queries by route, method, and query name. This is
aimed at helping us track down potential optimization candidates for
HTTP handlers that may trigger a number of queries. It is expected to be
used alongside `coderd_api_requests_processed_total` for correlation.

Depends upon new middleware introduced in
https://github.com/coder/coder/pull/21498

Relates to https://github.com/coder/internal/issues/1214
2026-01-15 10:58:56 +00:00
Cian Johnston 32354261d3 chore(coderd/httpmw): extract HTTPRoute middleware (#21498)
Extracts part of the prometheus middleware that stores the route
information in the request context into its own middleware. Also adds
request method information to context.

Relates to https://github.com/coder/internal/issues/1214
2026-01-15 10:26:50 +00:00
Ehab Younes 6683d807ac refactor: add RFC-compliant enum types and use SDK as source of truth (#21468)
Add comprehensive OAuth2 enum types to codersdk following RFC specifications:
- OAuth2ProviderGrantType (RFC 6749)
- OAuth2ProviderResponseType (RFC 6749)
- OAuth2TokenEndpointAuthMethod (RFC 7591)
- OAuth2PKCECodeChallengeMethod (RFC 7636)
- OAuth2TokenType (RFC 6749, RFC 9449)
- OAuth2RevocationTokenTypeHint (RFC 7009)
- OAuth2ErrorCode (RFC 6749, RFC 7009, RFC 8707)

Add OAuth2TokenRequest, OAuth2TokenResponse, OAuth2TokenRevocationRequest,
and OAuth2Error structs to the SDK. Update OAuth2ClientRegistrationRequest,
OAuth2ClientRegistrationResponse, OAuth2ClientConfiguration, and
OAuth2AuthorizationServerMetadata to use typed enums instead of raw strings.

This makes codersdk the single source of truth for OAuth2 types, eliminating
duplication between SDK and server-side structs.

Closes #21476
2026-01-15 12:41:28 +03:00
Atif Ali 7c2479ce92 chore(dogfood): remove JetBrains Fleet module (#21510) 2026-01-15 14:32:13 +05:00
Jaayden Halko e1156b050f feat: add workspace sharing buttons to tasks (#21491)
resolves coder/internal#1130

This adds a workspace sharing button to tasks in 3 places

Figma:
https://www.figma.com/design/KriBGfS73GAwkplnVhCBoU/Tasks?node-id=278-2455&t=vhU6Q8G1b7fDWiAP-1

<img width="320" height="374" alt="Screenshot 2026-01-13 at 15 16 06"
src="https://github.com/user-attachments/assets/cf232a12-b0c8-4f5c-91fa-d84eac8cb106"
/>
<img width="582" height="372" alt="Screenshot 2026-01-13 at 15 16 36"
src="https://github.com/user-attachments/assets/90654afc-720a-4bfe-9c67-fcbcebb4aa2b"
/>
<img width="768" height="317" alt="Screenshot 2026-01-13 at 15 18 03"
src="https://github.com/user-attachments/assets/0281cb84-c941-4075-9a20-00ad3958864b"
/>
2026-01-14 23:21:44 +00:00
George K 0712faef4f feat(enterprise): implement organization "disable workspace sharing" option (#21376)
Adds a per-organization setting to disable workspace sharing. When enabled,
all existing workspace ACLs in the organization are cleared and the workspace
ACL mutation API endpoints return `403 Forbidden`.

This complements the existing site-wide `--disable-workspace-sharing` flag by
providing more granular control at the organization level.

Closes https://github.com/coder/internal/issues/1073 (part 2)

---------

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
2026-01-14 09:47:50 -08:00
Danny Kopping 7d5cd06f83 feat: add aibridge structured logging (#21492)
Closes https://github.com/coder/internal/issues/1151

Sample:

```
[API] 2026-01-13 15:50:20.795 [info]  coderd.aibridgedserver: interception started  trace=8bb5a1d8eb10526cc46ad90f191bb468  span=a3e5b5da9546032a  record_type=interception_start  interception_id=97461880-4a6c-47c1-8292-3588dd715312  initiator_id=360c6167-a93a-4442-9c3e-f87a6d1cfb66  api_key_id=vg1sbUv97d  provider=anthropic  model=claude-opus-4-5-20251101  started_at="2026-01-13T15:50:20.790690781Z"  metadata={}
[API] 2026-01-13 15:50:23.741 [info]  coderd.aibridgedserver: token usage recorded  trace=8bb5a1d8eb10526cc46ad90f191bb468  span=a114f0cc3047296e  record_type=token_usage  interception_id=97461880-4a6c-47c1-8292-3588dd715312  msg_id=msg_01VJH1rYKspfun8BW29CrYEu  input_tokens=10  output_tokens=8  created_at="2026-01-13T15:50:23.731587038Z"  metadata={"cache_creation_input":53194,"cache_ephemeral_1h_input":0,"cache_ephemeral_5m_input":53194,"cache_read_input":0,"web_search_requests":0}
[API] 2026-01-13 15:50:26.265 [info]  coderd.aibridgedserver: token usage recorded  trace=8bb5a1d8eb10526cc46ad90f191bb468  span=dbdafb563bff2c9c  record_type=token_usage  interception_id=97461880-4a6c-47c1-8292-3588dd715312  msg_id=msg_01VJH1rYKspfun8BW29CrYEu  input_tokens=0  output_tokens=130  created_at="2026-01-13T15:50:26.254467904Z"  metadata={}
[API] 2026-01-13 15:50:26.268 [info]  coderd.aibridgedserver: prompt usage recorded  trace=8bb5a1d8eb10526cc46ad90f191bb468  span=da51887a757226fc  record_type=prompt_usage  interception_id=97461880-4a6c-47c1-8292-3588dd715312  msg_id=msg_01VJH1rYKspfun8BW29CrYEu  prompt="list the jmia share price"  created_at="2026-01-13T15:50:26.255299811Z"  metadata={}
[API] 2026-01-13 15:50:26.268 [info]  coderd.aibridgedserver: interception ended  trace=8bb5a1d8eb10526cc46ad90f191bb468  span=3fa25397705ee7c9  record_type=interception_end  interception_id=97461880-4a6c-47c1-8292-3588dd715312  ended_at="2026-01-13T15:50:26.25555547Z"
[API] 2026-01-13 15:50:26.269 [info]  coderd.aibridgedserver: tool usage recorded  trace=8bb5a1d8eb10526cc46ad90f191bb468  span=b54af90afc604d29  record_type=tool_usage  interception_id=97461880-4a6c-47c1-8292-3588dd715312  msg_id=msg_01VJH1rYKspfun8BW29CrYEu  tool=mcp__stonks__getStockPriceSnapshot  input="{\"ticker\":\"JMIA\"}"  server_url=""  injected=false  invocation_error=""  created_at="2026-01-13T15:50:26.255164652Z"  metadata={}
```

Structured logging is only enabled when
`CODER_AIBRIDGE_STRUCTURED_LOGGING=true`.

---------

Signed-off-by: Danny Kopping <danny@coder.com>
2026-01-14 17:26:08 +02:00
371 changed files with 9191 additions and 10738 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ description: |
inputs:
version:
description: "The Go version to use."
default: "1.24.10"
default: "1.24.11"
use-preinstalled-go:
description: "Whether to use preinstalled Go."
default: "false"
-8
View File
@@ -211,14 +211,6 @@ issues:
- path: scripts/rules.go
linters:
- ALL
# Boundary code is imported from github.com/coder/boundary and has different
# lint standards. Suppress lint issues in this imported code.
- path: enterprise/cli/boundary/
linters:
- revive
- gocritic
- gosec
- errorlint
fix: true
max-issues-per-linter: 0
+10 -1
View File
@@ -69,6 +69,9 @@ MOST_GO_SRC_FILES := $(shell \
# All the shell files in the repo, excluding ignored files.
SHELL_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
MIGRATION_FILES := $(shell find ./coderd/database/migrations/ -maxdepth 1 $(FIND_EXCLUSIONS) -type f -name '*.sql')
FIXTURE_FILES := $(shell find ./coderd/database/migrations/testdata/fixtures/ $(FIND_EXCLUSIONS) -type f -name '*.sql')
# Ensure we don't use the user's git configs which might cause side-effects
GIT_FLAGS = GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
@@ -561,7 +564,7 @@ endif
# Note: we don't run zizmor in the lint target because it takes a while. CI
# runs it explicitly.
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint lint/check-scopes
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/actions/actionlint lint/check-scopes lint/migrations
.PHONY: lint
lint/site-icons:
@@ -619,6 +622,12 @@ lint/check-scopes: coderd/database/dump.sql
go run ./scripts/check-scopes
.PHONY: lint/check-scopes
# Verify migrations do not hardcode the public schema.
lint/migrations:
./scripts/check_pg_schema.sh "Migrations" $(MIGRATION_FILES)
./scripts/check_pg_schema.sh "Fixtures" $(FIXTURE_FILES)
.PHONY: lint/migrations
# All files generated by the database should be added here, and this can be used
# as a target for jobs that need to run after the database is generated.
DB_GEN_FILES := \
+5
View File
@@ -40,6 +40,7 @@ import (
"github.com/coder/clistat"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentfiles"
"github.com/coder/coder/v2/agent/agentscripts"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/agentssh"
@@ -295,6 +296,8 @@ type agent struct {
containerAPIOptions []agentcontainers.Option
containerAPI *agentcontainers.API
filesAPI *agentfiles.API
socketServerEnabled bool
socketPath string
socketServer *agentsocket.Server
@@ -365,6 +368,8 @@ func (a *agent) init() {
a.containerAPI = agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...)
a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem)
a.reconnectingPTYServer = reconnectingpty.NewServer(
a.logger.Named("reconnecting-pty"),
a.sshServer,
+36
View File
@@ -0,0 +1,36 @@
package agentfiles
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/spf13/afero"
"cdr.dev/slog/v3"
)
// API exposes file-related operations performed through the agent.
type API struct {
logger slog.Logger
filesystem afero.Fs
}
func NewAPI(logger slog.Logger, filesystem afero.Fs) *API {
api := &API{
logger: logger,
filesystem: filesystem,
}
return api
}
// Routes returns the HTTP handler for file-related routes.
func (api *API) Routes() http.Handler {
r := chi.NewRouter()
r.Post("/list-directory", api.HandleLS)
r.Get("/read-file", api.HandleReadFile)
r.Post("/write-file", api.HandleWriteFile)
r.Post("/edit-files", api.HandleEditFiles)
return r
}
+20 -20
View File
@@ -1,4 +1,4 @@
package agent
package agentfiles
import (
"context"
@@ -25,7 +25,7 @@ import (
type HTTPResponseCode = int
func (a *agent) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
func (api *API) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
query := r.URL.Query()
@@ -42,7 +42,7 @@ func (a *agent) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
return
}
status, err := a.streamFile(ctx, rw, path, offset, limit)
status, err := api.streamFile(ctx, rw, path, offset, limit)
if err != nil {
httpapi.Write(ctx, rw, status, codersdk.Response{
Message: err.Error(),
@@ -51,12 +51,12 @@ func (a *agent) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
}
}
func (a *agent) streamFile(ctx context.Context, rw http.ResponseWriter, path string, offset, limit int64) (HTTPResponseCode, error) {
func (api *API) streamFile(ctx context.Context, rw http.ResponseWriter, path string, offset, limit int64) (HTTPResponseCode, error) {
if !filepath.IsAbs(path) {
return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path)
}
f, err := a.filesystem.Open(path)
f, err := api.filesystem.Open(path)
if err != nil {
status := http.StatusInternalServerError
switch {
@@ -97,13 +97,13 @@ func (a *agent) streamFile(ctx context.Context, rw http.ResponseWriter, path str
reader := io.NewSectionReader(f, offset, bytesToRead)
_, err = io.Copy(rw, reader)
if err != nil && !errors.Is(err, io.EOF) && ctx.Err() == nil {
a.logger.Error(ctx, "workspace agent read file", slog.Error(err))
api.logger.Error(ctx, "workspace agent read file", slog.Error(err))
}
return 0, nil
}
func (a *agent) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
func (api *API) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
query := r.URL.Query()
@@ -118,7 +118,7 @@ func (a *agent) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
return
}
status, err := a.writeFile(ctx, r, path)
status, err := api.writeFile(ctx, r, path)
if err != nil {
httpapi.Write(ctx, rw, status, codersdk.Response{
Message: err.Error(),
@@ -131,13 +131,13 @@ func (a *agent) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
})
}
func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HTTPResponseCode, error) {
func (api *API) writeFile(ctx context.Context, r *http.Request, path string) (HTTPResponseCode, error) {
if !filepath.IsAbs(path) {
return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path)
}
dir := filepath.Dir(path)
err := a.filesystem.MkdirAll(dir, 0o755)
err := api.filesystem.MkdirAll(dir, 0o755)
if err != nil {
status := http.StatusInternalServerError
switch {
@@ -149,7 +149,7 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT
return status, err
}
f, err := a.filesystem.Create(path)
f, err := api.filesystem.Create(path)
if err != nil {
status := http.StatusInternalServerError
switch {
@@ -164,13 +164,13 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT
_, err = io.Copy(f, r.Body)
if err != nil && !errors.Is(err, io.EOF) && ctx.Err() == nil {
a.logger.Error(ctx, "workspace agent write file", slog.Error(err))
api.logger.Error(ctx, "workspace agent write file", slog.Error(err))
}
return 0, nil
}
func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
func (api *API) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req workspacesdk.FileEditRequest
@@ -188,7 +188,7 @@ func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
var combinedErr error
status := http.StatusOK
for _, edit := range req.Files {
s, err := a.editFile(r.Context(), edit.Path, edit.Edits)
s, err := api.editFile(r.Context(), edit.Path, edit.Edits)
// Keep the highest response status, so 500 will be preferred over 400, etc.
if s > status {
status = s
@@ -210,7 +210,7 @@ func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
})
}
func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.FileEdit) (int, error) {
func (api *API) editFile(ctx context.Context, path string, edits []workspacesdk.FileEdit) (int, error) {
if path == "" {
return http.StatusBadRequest, xerrors.New("\"path\" is required")
}
@@ -223,7 +223,7 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
return http.StatusBadRequest, xerrors.New("must specify at least one edit")
}
f, err := a.filesystem.Open(path)
f, err := api.filesystem.Open(path)
if err != nil {
status := http.StatusInternalServerError
switch {
@@ -252,7 +252,7 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
// Create an adjacent file to ensure it will be on the same device and can be
// moved atomically.
tmpfile, err := afero.TempFile(a.filesystem, filepath.Dir(path), filepath.Base(path))
tmpfile, err := afero.TempFile(api.filesystem, filepath.Dir(path), filepath.Base(path))
if err != nil {
return http.StatusInternalServerError, err
}
@@ -260,13 +260,13 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
_, err = io.Copy(tmpfile, replace.Chain(f, transforms...))
if err != nil {
if rerr := a.filesystem.Remove(tmpfile.Name()); rerr != nil {
a.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
if rerr := api.filesystem.Remove(tmpfile.Name()); rerr != nil {
api.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
}
return http.StatusInternalServerError, xerrors.Errorf("edit %s: %w", path, err)
}
err = a.filesystem.Rename(tmpfile.Name(), path)
err = api.filesystem.Rename(tmpfile.Name(), path)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -1,11 +1,13 @@
package agent_test
package agentfiles_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
@@ -16,10 +18,10 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk/agentsdk"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentfiles"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/testutil"
)
@@ -106,15 +108,15 @@ func TestReadFile(t *testing.T) {
tmpdir := os.TempDir()
noPermsFilePath := filepath.Join(tmpdir, "no-perms")
//nolint:dogsled
conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) {
opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error {
if file == noPermsFilePath {
return os.ErrPermission
}
return nil
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
if file == noPermsFilePath {
return os.ErrPermission
}
return nil
})
api := agentfiles.NewAPI(logger, fs)
dirPath := filepath.Join(tmpdir, "a-directory")
err := fs.MkdirAll(dirPath, 0o755)
@@ -260,19 +262,22 @@ func TestReadFile(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
reader, mimeType, err := conn.ReadFile(ctx, tt.path, tt.offset, tt.limit)
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("/read-file?path=%s&offset=%d&limit=%d", tt.path, tt.offset, tt.limit), nil)
api.Routes().ServeHTTP(w, r)
if tt.errCode != 0 {
require.Error(t, err)
cerr := coderdtest.SDKError(t, err)
require.Contains(t, cerr.Error(), tt.error)
require.Equal(t, tt.errCode, cerr.StatusCode())
} else {
got := &codersdk.Error{}
err := json.NewDecoder(w.Body).Decode(got)
require.NoError(t, err)
defer reader.Close()
bytes, err := io.ReadAll(reader)
require.ErrorContains(t, got, tt.error)
require.Equal(t, tt.errCode, w.Code)
} else {
bytes, err := io.ReadAll(w.Body)
require.NoError(t, err)
require.Equal(t, tt.bytes, bytes)
require.Equal(t, tt.mimeType, mimeType)
require.Equal(t, tt.mimeType, w.Header().Get("Content-Type"))
require.Equal(t, http.StatusOK, w.Code)
}
})
}
@@ -284,15 +289,14 @@ func TestWriteFile(t *testing.T) {
tmpdir := os.TempDir()
noPermsFilePath := filepath.Join(tmpdir, "no-perms-file")
noPermsDirPath := filepath.Join(tmpdir, "no-perms-dir")
//nolint:dogsled
conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) {
opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error {
if file == noPermsFilePath || file == noPermsDirPath {
return os.ErrPermission
}
return nil
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
if file == noPermsFilePath || file == noPermsDirPath {
return os.ErrPermission
}
return nil
})
api := agentfiles.NewAPI(logger, fs)
dirPath := filepath.Join(tmpdir, "directory")
err := fs.MkdirAll(dirPath, 0o755)
@@ -371,17 +375,21 @@ func TestWriteFile(t *testing.T) {
defer cancel()
reader := bytes.NewReader(tt.bytes)
err := conn.WriteFile(ctx, tt.path, reader)
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("/write-file?path=%s", tt.path), reader)
api.Routes().ServeHTTP(w, r)
if tt.errCode != 0 {
require.Error(t, err)
cerr := coderdtest.SDKError(t, err)
require.Contains(t, cerr.Error(), tt.error)
require.Equal(t, tt.errCode, cerr.StatusCode())
got := &codersdk.Error{}
err := json.NewDecoder(w.Body).Decode(got)
require.NoError(t, err)
require.ErrorContains(t, got, tt.error)
require.Equal(t, tt.errCode, w.Code)
} else {
bytes, err := afero.ReadFile(fs, tt.path)
require.NoError(t, err)
b, err := afero.ReadFile(fs, tt.path)
require.NoError(t, err)
require.Equal(t, tt.bytes, b)
require.Equal(t, tt.bytes, bytes)
require.Equal(t, http.StatusOK, w.Code)
}
})
}
@@ -393,21 +401,20 @@ func TestEditFiles(t *testing.T) {
tmpdir := os.TempDir()
noPermsFilePath := filepath.Join(tmpdir, "no-perms-file")
failRenameFilePath := filepath.Join(tmpdir, "fail-rename")
//nolint:dogsled
conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) {
opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error {
if file == noPermsFilePath {
return &os.PathError{
Op: call,
Path: file,
Err: os.ErrPermission,
}
} else if file == failRenameFilePath && call == "rename" {
return xerrors.New("rename failed")
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
if file == noPermsFilePath {
return &os.PathError{
Op: call,
Path: file,
Err: os.ErrPermission,
}
return nil
})
} else if file == failRenameFilePath && call == "rename" {
return xerrors.New("rename failed")
}
return nil
})
api := agentfiles.NewAPI(logger, fs)
dirPath := filepath.Join(tmpdir, "directory")
err := fs.MkdirAll(dirPath, 0o755)
@@ -701,16 +708,26 @@ func TestEditFiles(t *testing.T) {
require.NoError(t, err)
}
err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: tt.edits})
buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
err := enc.Encode(workspacesdk.FileEditRequest{Files: tt.edits})
require.NoError(t, err)
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
api.Routes().ServeHTTP(w, r)
if tt.errCode != 0 {
require.Error(t, err)
cerr := coderdtest.SDKError(t, err)
for _, error := range tt.errors {
require.Contains(t, cerr.Error(), error)
}
require.Equal(t, tt.errCode, cerr.StatusCode())
} else {
got := &codersdk.Error{}
err := json.NewDecoder(w.Body).Decode(got)
require.NoError(t, err)
for _, error := range tt.errors {
require.ErrorContains(t, got, error)
}
require.Equal(t, tt.errCode, w.Code)
} else {
require.Equal(t, http.StatusOK, w.Code)
}
for path, expect := range tt.expected {
b, err := afero.ReadFile(fs, path)
+3 -3
View File
@@ -1,4 +1,4 @@
package agent
package agentfiles
import (
"errors"
@@ -21,7 +21,7 @@ import (
var WindowsDriveRegex = regexp.MustCompile(`^[a-zA-Z]:\\$`)
func (a *agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
func (api *API) HandleLS(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// An absolute path may be optionally provided, otherwise a path split into an
@@ -43,7 +43,7 @@ func (a *agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
return
}
resp, err := listFiles(a.filesystem, path, req)
resp, err := listFiles(api.filesystem, path, req)
if err != nil {
status := http.StatusInternalServerError
switch {
@@ -1,4 +1,4 @@
package agent
package agentfiles
import (
"os"
+2 -4
View File
@@ -27,6 +27,8 @@ func (a *agent) apiHandler() http.Handler {
})
})
r.Mount("/api/v0", a.filesAPI.Routes())
if a.devcontainers {
r.Mount("/api/v0/containers", a.containerAPI.Routes())
} else if manifest := a.manifest.Load(); manifest != nil && manifest.ParentID != uuid.Nil {
@@ -49,10 +51,6 @@ func (a *agent) apiHandler() http.Handler {
r.Get("/api/v0/listening-ports", a.listeningPortsHandler.handler)
r.Get("/api/v0/netcheck", a.HandleNetcheck)
r.Post("/api/v0/list-directory", a.HandleLS)
r.Get("/api/v0/read-file", a.HandleReadFile)
r.Post("/api/v0/write-file", a.HandleWriteFile)
r.Post("/api/v0/edit-files", a.HandleEditFiles)
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)
+2 -11
View File
@@ -10,12 +10,8 @@ import (
"github.com/coder/serpent"
)
func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.TemplateVersionParameter, defaultOverrides map[string]string) (string, error) {
label := templateVersionParameter.Name
if templateVersionParameter.DisplayName != "" {
label = templateVersionParameter.DisplayName
}
func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.TemplateVersionParameter, name, defaultValue string) (string, error) {
label := name
if templateVersionParameter.Ephemeral {
label += pretty.Sprint(DefaultStyles.Warn, " (build option)")
}
@@ -26,11 +22,6 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
_, _ = fmt.Fprintln(inv.Stdout, " "+strings.TrimSpace(strings.Join(strings.Split(templateVersionParameter.DescriptionPlaintext, "\n"), "\n "))+"\n")
}
defaultValue := templateVersionParameter.DefaultValue
if v, ok := defaultOverrides[templateVersionParameter.Name]; ok {
defaultValue = v
}
var err error
var value string
switch {
+2 -2
View File
@@ -32,12 +32,12 @@ type PromptOptions struct {
const skipPromptFlag = "yes"
// SkipPromptOption adds a "--yes/-y" flag to the cmd that can be used to skip
// prompts.
// confirmation prompts.
func SkipPromptOption() serpent.Option {
return serpent.Option{
Flag: skipPromptFlag,
FlagShorthand: "y",
Description: "Bypass prompts.",
Description: "Bypass confirmation prompts.",
// Discard
Value: serpent.BoolOf(new(bool)),
}
+17 -5
View File
@@ -42,9 +42,10 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
stopAfter time.Duration
workspaceName string
parameterFlags workspaceParameterFlags
autoUpdates string
copyParametersFrom string
parameterFlags workspaceParameterFlags
autoUpdates string
copyParametersFrom string
useParameterDefaults bool
// Organization context is only required if more than 1 template
// shares the same name across multiple organizations.
orgContext = NewOrganizationContext()
@@ -308,7 +309,7 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
displayAppliedPreset(inv, preset, presetParameters)
} else {
// Inform the user that no preset was applied
_, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied."))
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", cliui.Bold("No preset applied."))
}
if opts.BeforeCreate != nil {
@@ -329,6 +330,8 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
RichParameterDefaults: cliBuildParameterDefaults,
SourceWorkspaceParameters: sourceWorkspaceParameters,
UseParameterDefaults: useParameterDefaults,
})
if err != nil {
return xerrors.Errorf("prepare build: %w", err)
@@ -435,6 +438,12 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
Description: "Specify the source workspace name to copy parameters from.",
Value: serpent.StringOf(&copyParametersFrom),
},
serpent.Option{
Flag: "use-parameter-defaults",
Env: "CODER_WORKSPACE_USE_PARAMETER_DEFAULTS",
Description: "Automatically accept parameter defaults when no value is provided.",
Value: serpent.BoolOf(&useParameterDefaults),
},
cliui.SkipPromptOption(),
)
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
@@ -459,6 +468,8 @@ type prepWorkspaceBuildArgs struct {
RichParameters []codersdk.WorkspaceBuildParameter
RichParameterFile string
RichParameterDefaults []codersdk.WorkspaceBuildParameter
UseParameterDefaults bool
}
// resolvePreset returns the preset matching the given presetName (if specified),
@@ -561,7 +572,8 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
WithPromptRichParameters(args.PromptRichParameters).
WithRichParameters(args.RichParameters).
WithRichParametersFile(parameterFile).
WithRichParametersDefaults(args.RichParameterDefaults)
WithRichParametersDefaults(args.RichParameterDefaults).
WithUseParameterDefaults(args.UseParameterDefaults)
buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters)
if err != nil {
return nil, err
+411 -327
View File
@@ -318,353 +318,437 @@ func prepareEchoResponses(parameters []*proto.RichParameter, presets ...*proto.P
}
}
type param struct {
name string
ptype string
value string
mutable bool
}
func TestCreateWithRichParameters(t *testing.T) {
t.Parallel()
const (
firstParameterName = "first_parameter"
firstParameterDescription = "This is first parameter"
firstParameterValue = "1"
secondParameterName = "second_parameter"
secondParameterDisplayName = "Second Parameter"
secondParameterDescription = "This is second parameter"
secondParameterValue = "2"
immutableParameterName = "third_parameter"
immutableParameterDescription = "This is not mutable parameter"
immutableParameterValue = "4"
)
echoResponses := func() *echo.Responses {
return prepareEchoResponses([]*proto.RichParameter{
{Name: firstParameterName, Description: firstParameterDescription, Mutable: true},
{Name: secondParameterName, DisplayName: secondParameterDisplayName, Description: secondParameterDescription, Mutable: true},
{Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false},
})
// Default parameters and their expected values.
params := []param{
{
name: "number_param",
ptype: "number",
value: "777",
mutable: true,
},
{
name: "string_param",
ptype: "string",
value: "qux",
mutable: true,
},
{
name: "bool_param",
// TODO: Setting the type breaks booleans. It claims the default is false
// but when you then accept this default it errors saying that the value
// must be true or false. For now, use a string.
ptype: "string",
value: "false",
mutable: true,
},
{
name: "immutable_string_param",
ptype: "string",
value: "i am eternal",
mutable: false,
},
}
t.Run("InputParameters", func(t *testing.T) {
t.Parallel()
type testContext struct {
client *codersdk.Client
member *codersdk.Client
owner codersdk.CreateFirstUserResponse
template codersdk.Template
workspaceName string
}
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
tests := []struct {
name string
// setup runs before the command is started and return arguments that will
// be appended to the create command.
setup func() []string
// handlePty optionally runs after the command is started. It should handle
// all expected prompts from the pty.
handlePty func(pty *ptytest.PTY)
// postRun runs after the command has finished but before the workspace is
// verified. It must return the workspace name to check (used for the copy
// workspace tests).
postRun func(t *testing.T, args testContext) string
// errors contains expected errors. The workspace will not be verified if
// errors are expected.
errors []string
// inputParameters overrides the default parameters.
inputParameters []param
// expectedParameters defaults to inputParameters.
expectedParameters []param
// withDefaults sets DefaultValue to each parameter's value.
withDefaults bool
}{
{
name: "ValuesFromPrompt",
handlePty: func(pty *ptytest.PTY) {
// Enter the value for each parameter as prompted.
for _, param := range params {
pty.ExpectMatch(param.name)
pty.WriteLine(param.value)
}
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
},
},
{
name: "ValuesFromDefaultFlags",
setup: func() []string {
// Provide the defaults on the command line.
args := []string{}
for _, param := range params {
args = append(args, "--parameter-default", fmt.Sprintf("%s=%s", param.name, param.value))
}
return args
},
handlePty: func(pty *ptytest.PTY) {
// Simply accept the defaults.
for _, param := range params {
pty.ExpectMatch(param.name)
pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
pty.WriteLine("")
}
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
},
},
{
name: "ValuesFromFile",
setup: func() []string {
// Create a file with the values.
tempDir := t.TempDir()
removeTmpDirUntilSuccessAfterTest(t, tempDir)
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
for _, param := range params {
_, err := parameterFile.WriteString(fmt.Sprintf("%s: %s\n", param.name, param.value))
require.NoError(t, err)
}
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
return []string{"--rich-parameter-file", parameterFile.Name()}
},
handlePty: func(pty *ptytest.PTY) {
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
},
},
{
name: "ValuesFromFlags",
setup: func() []string {
// Provide the values on the command line.
var args []string
for _, param := range params {
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
}
return args
},
handlePty: func(pty *ptytest.PTY) {
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
},
},
{
name: "MisspelledParameter",
setup: func() []string {
// Provide the values on the command line.
args := []string{}
for i, param := range params {
if i == 0 {
// Slightly misspell the first parameter with an extra character.
args = append(args, "--parameter", fmt.Sprintf("n%s=%s", param.name, param.value))
} else {
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
}
}
return args
},
errors: []string{
"parameter \"n" + params[0].name + "\" is not present in the template",
"Did you mean: " + params[0].name,
},
},
{
name: "ValuesFromWorkspace",
setup: func() []string {
// Provide the values on the command line.
args := []string{"-y"}
for _, param := range params {
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
}
return args
},
postRun: func(t *testing.T, tctx testContext) string {
inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y")
clitest.SetupConfig(t, tctx.member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err := inv.Run()
require.NoError(t, err, "failed to create a workspace based on the source workspace")
return "other-workspace"
},
},
{
name: "ValuesFromOutdatedWorkspace",
setup: func() []string {
// Provide the values on the command line.
args := []string{"-y"}
for _, param := range params {
args = append(args, "--parameter", fmt.Sprintf("%s=%s", param.name, param.value))
}
return args
},
postRun: func(t *testing.T, tctx testContext) string {
// Update the template to a new version.
version2 := coderdtest.CreateTemplateVersion(t, tctx.client, tctx.owner.OrganizationID, prepareEchoResponses([]*proto.RichParameter{
{Name: "another_parameter", Type: "string", DefaultValue: "not-relevant"},
}), func(ctvr *codersdk.CreateTemplateVersionRequest) {
ctvr.TemplateID = tctx.template.ID
})
coderdtest.AwaitTemplateVersionJobCompleted(t, tctx.client, version2.ID)
coderdtest.UpdateActiveTemplateVersion(t, tctx.client, tctx.template.ID, version2.ID)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
// Then create the copy. It should use the old template version.
inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y")
clitest.SetupConfig(t, tctx.member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err := inv.Run()
require.NoError(t, err, "failed to create a workspace based on the source workspace")
return "other-workspace"
},
},
{
name: "ValuesFromTemplateDefaults",
handlePty: func(pty *ptytest.PTY) {
// Simply accept the defaults.
for _, param := range params {
pty.ExpectMatch(param.name)
pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
pty.WriteLine("")
}
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
},
withDefaults: true,
},
{
name: "ValuesFromTemplateDefaultsNoPrompt",
setup: func() []string {
return []string{"--use-parameter-defaults"}
},
handlePty: func(pty *ptytest.PTY) {
// Default values should get printed.
for _, param := range params {
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
}
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
},
withDefaults: true,
},
{
name: "ValuesFromDefaultFlagsNoPrompt",
setup: func() []string {
// Provide the defaults on the command line.
args := []string{"--use-parameter-defaults"}
for _, param := range params {
args = append(args, "--parameter-default", fmt.Sprintf("%s=%s", param.name, param.value))
}
return args
},
handlePty: func(pty *ptytest.PTY) {
// Default values should get printed.
for _, param := range params {
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
}
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
},
},
{
// File and flags should override template defaults. Additionally, if a
// value has no default value we should still get a prompt for it.
name: "ValuesFromMultipleSources",
setup: func() []string {
tempDir := t.TempDir()
removeTmpDirUntilSuccessAfterTest(t, tempDir)
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
_, err := parameterFile.WriteString(`
file_param: from file
cli_param: from file`)
require.NoError(t, err)
return []string{
"--use-parameter-defaults",
"--rich-parameter-file", parameterFile.Name(),
"--parameter-default", "file_param=from cli default",
"--parameter-default", "cli_param=from cli default",
"--parameter", "cli_param=from cli",
}
},
handlePty: func(pty *ptytest.PTY) {
// Should get prompted for the input param since it has no default.
pty.ExpectMatch("input_param")
pty.WriteLine("from input")
matches := []string{
firstParameterDescription, firstParameterValue,
secondParameterDisplayName, "",
secondParameterDescription, secondParameterValue,
immutableParameterDescription, immutableParameterValue,
"Confirm create?", "yes",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
// Confirm the creation.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
},
withDefaults: true,
inputParameters: []param{
{
name: "template_param",
value: "from template default",
},
{
name: "file_param",
value: "from template default",
},
{
name: "cli_param",
value: "from template default",
},
{
name: "input_param",
},
},
expectedParameters: []param{
{
name: "template_param",
value: "from template default",
},
{
name: "file_param",
value: "from file",
},
{
name: "cli_param",
value: "from cli",
},
{
name: "input_param",
value: "from input",
},
},
},
}
if value != "" {
pty.WriteLine(value)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
parameters := params
if len(tt.inputParameters) > 0 {
parameters = tt.inputParameters
}
}
<-doneChan
})
t.Run("ParametersDefaults", func(t *testing.T) {
t.Parallel()
// Convert parameters for the echo provisioner response.
var rparams []*proto.RichParameter
for i, param := range parameters {
defaultValue := ""
if tt.withDefaults {
defaultValue = param.value
}
rparams = append(rparams, &proto.RichParameter{
Name: param.name,
Type: param.ptype,
Mutable: param.mutable,
DefaultValue: defaultValue,
Order: int32(i), //nolint:gosec
})
}
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
// Set up the template.
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(rparams))
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name,
"--parameter-default", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
"--parameter-default", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
"--parameter-default", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
// Run the command, possibly setting up values.
workspaceName := "my-workspace"
args := []string{"create", workspaceName, "--template", template.Name}
if tt.setup != nil {
args = append(args, tt.setup()...)
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan error)
pty := ptytest.New(t).Attach(inv)
go func() {
doneChan <- inv.Run()
}()
matches := []string{
firstParameterDescription, firstParameterValue,
secondParameterDescription, secondParameterValue,
immutableParameterDescription, immutableParameterValue,
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
defaultValue := matches[i+1]
// The test may do something with the pty.
if tt.handlePty != nil {
tt.handlePty(pty)
}
pty.ExpectMatch(match)
pty.ExpectMatch(`Enter a value (default: "` + defaultValue + `")`)
pty.WriteLine("")
}
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
<-doneChan
// Wait for the command to exit.
err := <-doneChan
// Verify that the expected default values were used.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// The test may want to run additional setup like copying the workspace.
if tt.postRun != nil {
workspaceName = tt.postRun(t, testContext{
client: client,
member: member,
owner: owner,
template: template,
workspaceName: workspaceName,
})
}
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Name: "my-workspace",
if len(tt.errors) > 0 {
require.Error(t, err)
for _, errstr := range tt.errors {
assert.ErrorContains(t, err, errstr)
}
} else {
require.NoError(t, err)
// Verify the workspace was created and has the right template and values.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{Name: workspaceName})
require.NoError(t, err, "expected to find created workspace")
require.Len(t, workspaces.Workspaces, 1)
workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
buildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceLatestBuild.ID)
require.NoError(t, err)
if len(tt.expectedParameters) > 0 {
parameters = tt.expectedParameters
}
require.Len(t, buildParameters, len(parameters))
for _, param := range parameters {
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: param.name, Value: param.value})
}
}
})
require.NoError(t, err, "can't list available workspaces")
require.Len(t, workspaces.Workspaces, 1)
workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
buildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceLatestBuild.ID)
require.NoError(t, err)
require.Len(t, buildParameters, 3)
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue})
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue})
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue})
})
t.Run("RichParametersFile", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
tempDir := t.TempDir()
removeTmpDirUntilSuccessAfterTest(t, tempDir)
parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
_, _ = parameterFile.WriteString(
firstParameterName + ": " + firstParameterValue + "\n" +
secondParameterName + ": " + secondParameterValue + "\n" +
immutableParameterName + ": " + immutableParameterValue)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name())
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
matches := []string{
"Confirm create?", "yes",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
}
<-doneChan
})
t.Run("ParameterFlags", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name,
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
matches := []string{
"Confirm create?", "yes",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
}
<-doneChan
})
t.Run("WrongParameterName/DidYouMean", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
wrongFirstParameterName := "frst-prameter"
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name,
"--parameter", fmt.Sprintf("%s=%s", wrongFirstParameterName, firstParameterValue),
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err := inv.Run()
assert.ErrorContains(t, err, "parameter \""+wrongFirstParameterName+"\" is not present in the template")
assert.ErrorContains(t, err, "Did you mean: "+firstParameterName)
})
t.Run("CopyParameters", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
// Firstly, create a regular workspace using template with parameters.
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "-y",
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err := inv.Run()
require.NoError(t, err, "can't create first workspace")
// Secondly, create a new workspace using parameters from the previous workspace.
const otherWorkspace = "other-workspace"
inv, root = clitest.New(t, "create", "--copy-parameters-from", "my-workspace", otherWorkspace, "-y")
clitest.SetupConfig(t, member, root)
pty = ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err = inv.Run()
require.NoError(t, err, "can't create a workspace based on the source workspace")
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Name: otherWorkspace,
})
require.NoError(t, err, "can't list available workspaces")
require.Len(t, workspaces.Workspaces, 1)
otherWorkspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
buildParameters, err := client.WorkspaceBuildParameters(ctx, otherWorkspaceLatestBuild.ID)
require.NoError(t, err)
require.Len(t, buildParameters, 3)
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue})
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue})
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue})
})
t.Run("CopyParametersFromNotUpdatedWorkspace", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
// Firstly, create a regular workspace using template with parameters.
inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "-y",
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
"--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue),
"--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue))
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err := inv.Run()
require.NoError(t, err, "can't create first workspace")
// Secondly, update the template to the newer version.
version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses([]*proto.RichParameter{
{Name: "third_parameter", Type: "string", DefaultValue: "not-relevant"},
}), func(ctvr *codersdk.CreateTemplateVersionRequest) {
ctvr.TemplateID = template.ID
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID)
coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, version2.ID)
// Thirdly, create a new workspace using parameters from the previous workspace.
const otherWorkspace = "other-workspace"
inv, root = clitest.New(t, "create", "--copy-parameters-from", "my-workspace", otherWorkspace, "-y")
clitest.SetupConfig(t, member, root)
pty = ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err = inv.Run()
require.NoError(t, err, "can't create a workspace based on the source workspace")
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Name: otherWorkspace,
})
require.NoError(t, err, "can't list available workspaces")
require.Len(t, workspaces.Workspaces, 1)
otherWorkspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
require.Equal(t, version.ID, otherWorkspaceLatestBuild.TemplateVersionID)
buildParameters, err := client.WorkspaceBuildParameters(ctx, otherWorkspaceLatestBuild.ID)
require.NoError(t, err)
require.Len(t, buildParameters, 3)
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue})
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue})
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue})
})
}
}
func TestCreateWithPreset(t *testing.T) {
+4 -10
View File
@@ -1,18 +1,12 @@
package cli
import (
"golang.org/x/xerrors"
boundarycli "github.com/coder/boundary/cli"
"github.com/coder/serpent"
)
func (*RootCmd) boundary() *serpent.Command {
return &serpent.Command{
Use: "boundary",
Short: "Network isolation tool for monitoring and restricting HTTP/HTTPS requests (enterprise)",
Long: `boundary creates an isolated network environment for target processes. This is an enterprise feature.`,
Handler: func(_ *serpent.Invocation) error {
return xerrors.New("boundary is an enterprise feature; upgrade to use this command")
},
}
cmd := boundarycli.BaseCommand() // Package coder/boundary/cli exports a "base command" designed to be integrated as a subcommand.
cmd.Use += " [args...]" // The base command looks like `boundary -- command`. Serpent adds the flags piece, but we need to add the args.
return cmd
}
+7 -3
View File
@@ -5,13 +5,15 @@ import (
"github.com/stretchr/testify/assert"
boundarycli "github.com/coder/boundary/cli"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
// Here we want to test that integrating boundary as a subcommand doesn't break anything.
// The full boundary functionality is tested in enterprise/cli.
// Actually testing the functionality of coder/boundary takes place in the
// coder/boundary repo, since it's a dependency of coder.
// Here we want to test basically that integrating it as a subcommand doesn't break anything.
func TestBoundarySubcommand(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
@@ -25,5 +27,7 @@ func TestBoundarySubcommand(t *testing.T) {
}()
// Expect the --help output to include the short description.
pty.ExpectMatch("Network isolation tool")
// We're simply confirming that `coder boundary --help` ran without a runtime error as
// a good chunk of serpents self validation logic happens at runtime.
pty.ExpectMatch(boundarycli.BaseCommand().Short)
}
+2
View File
@@ -68,6 +68,8 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
r.scaletestTaskStatus(),
r.scaletestSMTP(),
r.scaletestPrebuilds(),
r.scaletestBridge(),
r.scaletestLLMMock(),
},
}
+278
View File
@@ -0,0 +1,278 @@
//go:build !slim
package cli
import (
"fmt"
"net/http"
"os/signal"
"strconv"
"text/tabwriter"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/scaletest/bridge"
"github.com/coder/coder/v2/scaletest/createusers"
"github.com/coder/coder/v2/scaletest/harness"
"github.com/coder/serpent"
)
func (r *RootCmd) scaletestBridge() *serpent.Command {
var (
concurrentUsers int64
noCleanup bool
mode string
upstreamURL string
provider string
requestsPerUser int64
useStreamingAPI bool
requestPayloadSize int64
numMessages int64
httpTimeout time.Duration
timeoutStrategy = &timeoutFlags{}
cleanupStrategy = newScaletestCleanupStrategy()
output = &scaletestOutputFlags{}
prometheusFlags = &scaletestPrometheusFlags{}
)
cmd := &serpent.Command{
Use: "bridge",
Short: "Generate load on the AI Bridge service.",
Long: `Generate load for AI Bridge testing. Supports two modes: 'bridge' mode routes requests through the Coder AI Bridge, 'direct' mode makes requests directly to an upstream URL (useful for baseline comparisons).
Examples:
# Test OpenAI API through bridge
coder scaletest bridge --mode bridge --provider openai --concurrent-users 10 --request-count 5 --num-messages 10
# Test Anthropic API through bridge
coder scaletest bridge --mode bridge --provider anthropic --concurrent-users 10 --request-count 5 --num-messages 10
# Test directly against mock server
coder scaletest bridge --mode direct --provider openai --upstream-url http://localhost:8080/v1/chat/completions
`,
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
client, err := r.InitClient(inv)
if err != nil {
return err
}
client.HTTPClient = &http.Client{
Transport: &codersdk.HeaderTransport{
Transport: http.DefaultTransport,
Header: map[string][]string{
codersdk.BypassRatelimitHeader: {"true"},
},
},
}
reg := prometheus.NewRegistry()
metrics := bridge.NewMetrics(reg)
logger := inv.Logger
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
defer prometheusSrvClose()
defer func() {
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
<-time.After(prometheusFlags.Wait)
}()
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
defer stop()
ctx = notifyCtx
var userConfig createusers.Config
if bridge.RequestMode(mode) == bridge.RequestModeBridge {
me, err := requireAdmin(ctx, client)
if err != nil {
return err
}
if len(me.OrganizationIDs) == 0 {
return xerrors.Errorf("admin user must have at least one organization")
}
userConfig = createusers.Config{
OrganizationID: me.OrganizationIDs[0],
}
_, _ = fmt.Fprintln(inv.Stderr, "Bridge mode: creating users and making requests through AI Bridge...")
} else {
_, _ = fmt.Fprintf(inv.Stderr, "Direct mode: making requests directly to %s\n", upstreamURL)
}
outputs, err := output.parse()
if err != nil {
return xerrors.Errorf("parse output flags: %w", err)
}
config := bridge.Config{
Mode: bridge.RequestMode(mode),
Metrics: metrics,
Provider: provider,
RequestCount: int(requestsPerUser),
Stream: useStreamingAPI,
RequestPayloadSize: int(requestPayloadSize),
NumMessages: int(numMessages),
HTTPTimeout: httpTimeout,
UpstreamURL: upstreamURL,
User: userConfig,
}
if err := config.Validate(); err != nil {
return xerrors.Errorf("validate config: %w", err)
}
if err := config.PrepareRequestBody(); err != nil {
return xerrors.Errorf("prepare request body: %w", err)
}
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
for i := range concurrentUsers {
id := strconv.Itoa(int(i))
name := fmt.Sprintf("bridge-%s", id)
var runner harness.Runnable = bridge.NewRunner(client, config)
th.AddRun(name, id, runner)
}
_, _ = fmt.Fprintln(inv.Stderr, "Bridge scaletest configuration:")
tw := tabwriter.NewWriter(inv.Stderr, 0, 0, 2, ' ', 0)
for _, opt := range inv.Command.Options {
if opt.Hidden || opt.ValueSource == serpent.ValueSourceNone {
continue
}
_, _ = fmt.Fprintf(tw, " %s:\t%s", opt.Name, opt.Value.String())
if opt.ValueSource != serpent.ValueSourceDefault {
_, _ = fmt.Fprintf(tw, "\t(from %s)", opt.ValueSource)
}
_, _ = fmt.Fprintln(tw)
}
_ = tw.Flush()
_, _ = fmt.Fprintln(inv.Stderr, "\nRunning bridge scaletest...")
testCtx, testCancel := timeoutStrategy.toContext(ctx)
defer testCancel()
err = th.Run(testCtx)
if err != nil {
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
}
// If the command was interrupted, skip stats.
if notifyCtx.Err() != nil {
return notifyCtx.Err()
}
res := th.Results()
for _, o := range outputs {
err = o.write(res, inv.Stdout)
if err != nil {
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
}
}
if !noCleanup {
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
defer cleanupCancel()
err = th.Cleanup(cleanupCtx)
if err != nil {
return xerrors.Errorf("cleanup tests: %w", err)
}
}
if res.TotalFail > 0 {
return xerrors.New("load test failed, see above for more details")
}
return nil
},
}
cmd.Options = serpent.OptionSet{
{
Flag: "concurrent-users",
FlagShorthand: "c",
Env: "CODER_SCALETEST_BRIDGE_CONCURRENT_USERS",
Description: "Required: Number of concurrent users.",
Value: serpent.Validate(serpent.Int64Of(&concurrentUsers), func(value *serpent.Int64) error {
if value == nil || value.Value() <= 0 {
return xerrors.Errorf("--concurrent-users must be greater than 0")
}
return nil
}),
Required: true,
},
{
Flag: "mode",
Env: "CODER_SCALETEST_BRIDGE_MODE",
Default: "direct",
Description: "Request mode: 'bridge' (create users and use AI Bridge) or 'direct' (make requests directly to upstream-url).",
Value: serpent.EnumOf(&mode, string(bridge.RequestModeBridge), string(bridge.RequestModeDirect)),
},
{
Flag: "upstream-url",
Env: "CODER_SCALETEST_BRIDGE_UPSTREAM_URL",
Description: "URL to make requests to directly (required in direct mode, e.g., http://localhost:8080/v1/chat/completions).",
Value: serpent.StringOf(&upstreamURL),
},
{
Flag: "provider",
Env: "CODER_SCALETEST_BRIDGE_PROVIDER",
Default: "openai",
Description: "API provider to use.",
Value: serpent.EnumOf(&provider, "openai", "anthropic"),
},
{
Flag: "request-count",
Env: "CODER_SCALETEST_BRIDGE_REQUEST_COUNT",
Default: "1",
Description: "Number of sequential requests to make per runner.",
Value: serpent.Validate(serpent.Int64Of(&requestsPerUser), func(value *serpent.Int64) error {
if value == nil || value.Value() <= 0 {
return xerrors.Errorf("--request-count must be greater than 0")
}
return nil
}),
},
{
Flag: "stream",
Env: "CODER_SCALETEST_BRIDGE_STREAM",
Description: "Enable streaming requests.",
Value: serpent.BoolOf(&useStreamingAPI),
},
{
Flag: "request-payload-size",
Env: "CODER_SCALETEST_BRIDGE_REQUEST_PAYLOAD_SIZE",
Default: "1024",
Description: "Size in bytes of the request payload (user message content). If 0, uses default message content.",
Value: serpent.Int64Of(&requestPayloadSize),
},
{
Flag: "num-messages",
Env: "CODER_SCALETEST_BRIDGE_NUM_MESSAGES",
Default: "1",
Description: "Number of messages to include in the conversation.",
Value: serpent.Int64Of(&numMessages),
},
{
Flag: "no-cleanup",
Env: "CODER_SCALETEST_NO_CLEANUP",
Description: "Do not clean up resources after the test completes.",
Value: serpent.BoolOf(&noCleanup),
},
{
Flag: "http-timeout",
Env: "CODER_SCALETEST_BRIDGE_HTTP_TIMEOUT",
Default: "30s",
Description: "Timeout for individual HTTP requests to the upstream provider.",
Value: serpent.DurationOf(&httpTimeout),
},
}
timeoutStrategy.attach(&cmd.Options)
cleanupStrategy.attach(&cmd.Options)
output.attach(&cmd.Options)
prometheusFlags.attach(&cmd.Options)
return cmd
}
+118
View File
@@ -0,0 +1,118 @@
//go:build !slim
package cli
import (
"fmt"
"os/signal"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"github.com/coder/coder/v2/scaletest/llmmock"
"github.com/coder/serpent"
)
func (*RootCmd) scaletestLLMMock() *serpent.Command {
var (
address string
artificialLatency time.Duration
responsePayloadSize int64
pprofEnable bool
pprofAddress string
traceEnable bool
)
cmd := &serpent.Command{
Use: "llm-mock",
Short: "Start a mock LLM API server for testing",
Long: `Start a mock LLM API server that simulates OpenAI and Anthropic APIs`,
Handler: func(inv *serpent.Invocation) error {
ctx, stop := signal.NotifyContext(inv.Context(), StopSignals...)
defer stop()
logger := slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelInfo)
if pprofEnable {
closePprof := ServeHandler(ctx, logger, nil, pprofAddress, "pprof")
defer closePprof()
logger.Info(ctx, "pprof server started", slog.F("address", pprofAddress))
}
config := llmmock.Config{
Address: address,
Logger: logger,
ArtificialLatency: artificialLatency,
ResponsePayloadSize: int(responsePayloadSize),
PprofEnable: pprofEnable,
PprofAddress: pprofAddress,
TraceEnable: traceEnable,
}
srv := new(llmmock.Server)
if err := srv.Start(ctx, config); err != nil {
return xerrors.Errorf("start mock LLM server: %w", err)
}
defer func() {
_ = srv.Stop()
}()
_, _ = fmt.Fprintf(inv.Stdout, "Mock LLM API server started on %s\n", srv.APIAddress())
_, _ = fmt.Fprintf(inv.Stdout, " OpenAI endpoint: %s/v1/chat/completions\n", srv.APIAddress())
_, _ = fmt.Fprintf(inv.Stdout, " Anthropic endpoint: %s/v1/messages\n", srv.APIAddress())
<-ctx.Done()
return nil
},
}
cmd.Options = []serpent.Option{
{
Flag: "address",
Env: "CODER_SCALETEST_LLM_MOCK_ADDRESS",
Default: "localhost",
Description: "Address to bind the mock LLM API server. Can include a port (e.g., 'localhost:8080' or ':8080'). Uses a random port if no port is specified.",
Value: serpent.StringOf(&address),
},
{
Flag: "artificial-latency",
Env: "CODER_SCALETEST_LLM_MOCK_ARTIFICIAL_LATENCY",
Default: "0s",
Description: "Artificial latency to add to each response (e.g., 100ms, 1s). Simulates slow upstream processing.",
Value: serpent.DurationOf(&artificialLatency),
},
{
Flag: "response-payload-size",
Env: "CODER_SCALETEST_LLM_MOCK_RESPONSE_PAYLOAD_SIZE",
Default: "0",
Description: "Size in bytes of the response payload. If 0, uses default context-aware responses.",
Value: serpent.Int64Of(&responsePayloadSize),
},
{
Flag: "pprof-enable",
Env: "CODER_SCALETEST_LLM_MOCK_PPROF_ENABLE",
Default: "false",
Description: "Serve pprof metrics on the address defined by pprof-address.",
Value: serpent.BoolOf(&pprofEnable),
},
{
Flag: "pprof-address",
Env: "CODER_SCALETEST_LLM_MOCK_PPROF_ADDRESS",
Default: "127.0.0.1:6060",
Description: "The bind address to serve pprof.",
Value: serpent.StringOf(&pprofAddress),
},
{
Flag: "trace-enable",
Env: "CODER_SCALETEST_LLM_MOCK_TRACE_ENABLE",
Default: "false",
Description: "Whether application tracing data is collected. It exports to a backend configured by environment variables. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md.",
Value: serpent.BoolOf(&traceEnable),
},
}
return cmd
}
+16
View File
@@ -65,6 +65,22 @@ func (r *RootCmd) organizationSettings(orgContext *OrganizationContext) *serpent
return cli.OrganizationIDPSyncSettings(ctx)
},
},
{
Name: "workspace-sharing",
Aliases: []string{"workspacesharing"},
Short: "Workspace sharing settings for the organization.",
Patch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID, input json.RawMessage) (any, error) {
var req codersdk.WorkspaceSharingSettings
err := json.Unmarshal(input, &req)
if err != nil {
return nil, xerrors.Errorf("unmarshalling workspace sharing settings: %w", err)
}
return cli.PatchWorkspaceSharingSettings(ctx, org.String(), req)
},
Fetch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID) (any, error) {
return cli.WorkspaceSharingSettings(ctx, org.String())
},
},
}
cmd := &serpent.Command{
Use: "settings",
+35 -5
View File
@@ -34,6 +34,7 @@ type ParameterResolver struct {
promptRichParameters bool
promptEphemeralParameters bool
useParameterDefaults bool
}
func (pr *ParameterResolver) WithLastBuildParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
@@ -86,8 +87,21 @@ func (pr *ParameterResolver) WithPromptEphemeralParameters(promptEphemeralParame
return pr
}
// Resolve gathers workspace build parameters in a layered fashion, applying values from various sources
// in order of precedence: parameter file < CLI/ENV < source build < last build < preset < user input.
func (pr *ParameterResolver) WithUseParameterDefaults(useParameterDefaults bool) *ParameterResolver {
pr.useParameterDefaults = useParameterDefaults
return pr
}
// Resolve gathers workspace build parameters in a layered fashion, applying
// values from various sources in order of precedence:
// 1. template defaults (if auto-accepting defaults)
// 2. cli parameter defaults (if auto-accepting defaults)
// 3. parameter file
// 4. CLI/ENV
// 5. source build
// 6. last build
// 7. preset
// 8. user input (unless auto-accepting defaults)
func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) {
var staged []codersdk.WorkspaceBuildParameter
var err error
@@ -262,9 +276,25 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild
(action == WorkspaceUpdate && tvp.Mutable && tvp.Required) ||
(action == WorkspaceUpdate && !tvp.Mutable && firstTimeUse) ||
(tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) {
parameterValue, err := cliui.RichParameter(inv, tvp, pr.richParametersDefaults)
if err != nil {
return nil, err
name := tvp.Name
if tvp.DisplayName != "" {
name = tvp.DisplayName
}
parameterValue := tvp.DefaultValue
if v, ok := pr.richParametersDefaults[tvp.Name]; ok {
parameterValue = v
}
// Auto-accept the default if there is one.
if pr.useParameterDefaults && parameterValue != "" {
_, _ = fmt.Fprintf(inv.Stdout, "Using default value for %s: '%s'\n", name, parameterValue)
} else {
var err error
parameterValue, err = cliui.RichParameter(inv, tvp, name, parameterValue)
if err != nil {
return nil, err
}
}
resolved = append(resolved, codersdk.WorkspaceBuildParameter{
+4
View File
@@ -24,6 +24,7 @@ import (
"text/tabwriter"
"time"
"github.com/google/uuid"
"github.com/mattn/go-isatty"
"github.com/mitchellh/go-wordwrap"
"golang.org/x/mod/semver"
@@ -923,6 +924,9 @@ func splitNamedWorkspace(identifier string) (owner string, workspaceName string,
// a bare name (for a workspace owned by the current user) or a "user/workspace" combination,
// where user is either a username or UUID.
func namedWorkspace(ctx context.Context, client *codersdk.Client, identifier string) (codersdk.Workspace, error) {
if uid, err := uuid.Parse(identifier); err == nil {
return client.Workspace(ctx, uid)
}
owner, name, err := splitNamedWorkspace(identifier)
if err != nil {
return codersdk.Workspace{}, err
+1 -4
View File
@@ -748,10 +748,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
var pubsubWatchdogTimeout <-chan struct{}
maxOpenConns := int(vals.PostgresConnMaxOpen.Value())
maxIdleConns, err := codersdk.ComputeMaxIdleConns(maxOpenConns, vals.PostgresConnMaxIdle.Value())
if err != nil {
return xerrors.Errorf("compute max idle connections: %w", err)
}
maxIdleConns := int(vals.PostgresConnMaxIdle.Value())
logger.Debug(ctx, "creating database connection pool", slog.F("max_open_conns", maxOpenConns), slog.F("max_idle_conns", maxIdleConns))
sqlDB, dbURL, err := getAndMigratePostgresDB(ctx, logger, vals.PostgresURL.String(), codersdk.PostgresAuth(vals.PostgresAuth), sqlDriver,
WithMaxOpenConns(maxOpenConns),
+3 -2
View File
@@ -1,8 +1,10 @@
package cli
import (
"fmt"
"sort"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
@@ -43,11 +45,11 @@ func (r *RootCmd) show() *serpent.Command {
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
}
options := cliui.WorkspaceResourcesOptions{
WorkspaceName: workspace.Name,
ServerVersion: buildInfo.Version,
ShowDetails: details,
Title: fmt.Sprintf("%s/%s (%s since %s) %s:%s", workspace.OwnerName, workspace.Name, workspace.LatestBuild.Status, time.Since(workspace.LatestBuild.CreatedAt).Round(time.Second).String(), workspace.TemplateName, workspace.LatestBuild.TemplateVersionName),
}
if workspace.LatestBuild.Status == codersdk.WorkspaceStatusRunning {
// Get listening ports for each agent.
@@ -55,7 +57,6 @@ func (r *RootCmd) show() *serpent.Command {
options.ListeningPorts = ports
options.Devcontainers = devcontainers
}
return cliui.WorkspaceResources(inv.Stdout, workspace.LatestBuild.Resources, options)
},
}
+10 -4
View File
@@ -2,6 +2,7 @@ package cli_test
import (
"bytes"
"fmt"
"testing"
"time"
@@ -15,6 +16,7 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func TestShow(t *testing.T) {
@@ -28,7 +30,7 @@ func TestShow(t *testing.T) {
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
args := []string{
"show",
@@ -38,26 +40,30 @@ func TestShow(t *testing.T) {
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
ctx := testutil.Context(t, testutil.WaitShort)
go func() {
defer close(doneChan)
err := inv.Run()
err := inv.WithContext(ctx).Run()
assert.NoError(t, err)
}()
matches := []struct {
match string
write string
}{
{match: fmt.Sprintf("%s/%s", workspace.OwnerName, workspace.Name)},
{match: fmt.Sprintf("(%s since ", build.Status)},
{match: fmt.Sprintf("%s:%s", workspace.TemplateName, workspace.LatestBuild.TemplateVersionName)},
{match: "compute.main"},
{match: "smith (linux, i386)"},
{match: "coder ssh " + workspace.Name},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
pty.ExpectMatchContext(ctx, m.match)
if len(m.write) > 0 {
pty.WriteLine(m.write)
}
}
<-doneChan
_ = testutil.TryReceive(ctx, t, doneChan)
})
}
+1 -1
View File
@@ -7,7 +7,7 @@ USAGE:
OPTIONS:
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -55,7 +55,7 @@ OPTIONS:
configured in the workspace template is used.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+4 -1
View File
@@ -49,8 +49,11 @@ OPTIONS:
--template-version string, $CODER_TEMPLATE_VERSION
Specify a template version name.
--use-parameter-defaults bool, $CODER_WORKSPACE_USE_PARAMETER_DEFAULTS
Automatically accept parameter defaults when no value is provided.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -18,7 +18,7 @@ OPTIONS:
resources.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -24,7 +24,7 @@ OPTIONS:
empty, will use $HOME.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -7,7 +7,7 @@ USAGE:
OPTIONS:
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -7,7 +7,7 @@ USAGE:
OPTIONS:
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
@@ -18,7 +18,7 @@ OPTIONS:
Reads stdin for the json role definition to upload.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
@@ -23,7 +23,7 @@ OPTIONS:
Reads stdin for the json role definition to upload.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
@@ -15,6 +15,7 @@ SUBCOMMANDS:
memberships from an IdP.
role-sync Role sync settings to sync organization roles from an
IdP.
workspace-sharing Workspace sharing settings for the organization.
———
Run `coder --help` for a list of global options.
@@ -15,6 +15,7 @@ SUBCOMMANDS:
memberships from an IdP.
role-sync Role sync settings to sync organization roles from an
IdP.
workspace-sharing Workspace sharing settings for the organization.
———
Run `coder --help` for a list of global options.
@@ -15,6 +15,7 @@ SUBCOMMANDS:
memberships from an IdP.
role-sync Role sync settings to sync organization roles from an
IdP.
workspace-sharing Workspace sharing settings for the organization.
———
Run `coder --help` for a list of global options.
@@ -15,6 +15,7 @@ SUBCOMMANDS:
memberships from an IdP.
role-sync Role sync settings to sync organization roles from an
IdP.
workspace-sharing Workspace sharing settings for the organization.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -13,7 +13,7 @@ OPTIONS:
services it's registered with.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -7,7 +7,7 @@ USAGE:
OPTIONS:
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -39,7 +39,7 @@ OPTIONS:
pairs for the parameters.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+19 -5
View File
@@ -65,12 +65,11 @@ OPTIONS:
Type of auth to use when connecting to postgres. For AWS RDS, using
IAM authentication (awsiamrds) is recommended.
--postgres-conn-max-idle string, $CODER_PG_CONN_MAX_IDLE (default: auto)
Maximum number of idle connections to the database. Set to "auto" (the
default) to use max open / 3. Value must be greater or equal to 0; 0
means explicitly no idle connections.
--postgres-conn-max-idle int, $CODER_PG_CONN_MAX_IDLE (default: 15)
Maximum number of idle connections to the database. Value must be
greater or equal to 0; 0 means explicitly no idle connections.
--postgres-conn-max-open int, $CODER_PG_CONN_MAX_OPEN (default: 10)
--postgres-conn-max-open int, $CODER_PG_CONN_MAX_OPEN (default: 30)
Maximum number of open connections to the database. Defaults to 10.
--postgres-url string, $CODER_PG_CONNECTION_URL
@@ -147,6 +146,10 @@ AI BRIDGE OPTIONS:
Maximum number of AI Bridge requests per second per replica. Set to 0
to disable (unlimited).
--aibridge-structured-logging bool, $CODER_AIBRIDGE_STRUCTURED_LOGGING (default: false)
Emit structured logs for AI Bridge interception records. Use this for
exporting these records to external SIEM or observability systems.
AI BRIDGE PROXY OPTIONS:
--aibridge-proxy-cert-file string, $CODER_AIBRIDGE_PROXY_CERT_FILE
Path to the CA certificate file for AI Bridge Proxy.
@@ -161,6 +164,17 @@ AI BRIDGE PROXY OPTIONS:
--aibridge-proxy-listen-addr string, $CODER_AIBRIDGE_PROXY_LISTEN_ADDR (default: :8888)
The address the AI Bridge Proxy will listen on.
--aibridge-proxy-upstream string, $CODER_AIBRIDGE_PROXY_UPSTREAM
URL of an upstream HTTP proxy to chain tunneled (non-allowlisted)
requests through. Format: http://[user:pass@]host:port or
https://[user:pass@]host:port.
--aibridge-proxy-upstream-ca string, $CODER_AIBRIDGE_PROXY_UPSTREAM_CA
Path to a PEM-encoded CA certificate to trust for the upstream proxy's
TLS connection. Only needed for HTTPS upstream proxies with
certificates not trusted by the system. If not provided, the system
certificate pool is used.
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.
+1 -1
View File
@@ -42,7 +42,7 @@ OPTIONS:
pairs for the parameters.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -7,7 +7,7 @@ USAGE:
OPTIONS:
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -19,7 +19,7 @@ OPTIONS:
example, if you need to troubleshoot a specific Coder replica.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -21,7 +21,7 @@ USAGE:
OPTIONS:
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -14,7 +14,7 @@ OPTIONS:
versions are archived.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -68,7 +68,7 @@ OPTIONS:
Specify a file path with values for Terraform-managed variables.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -12,7 +12,7 @@ OPTIONS:
Select which organization (uuid or name) to use.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -91,7 +91,7 @@ OPTIONS:
for more details.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -18,7 +18,7 @@ OPTIONS:
the template version to pull.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
--zip bool
Output the template as a zip archive to stdout.
+1 -1
View File
@@ -48,7 +48,7 @@ OPTIONS:
Specify a file path with values for Terraform-managed variables.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
@@ -11,7 +11,7 @@ OPTIONS:
Select which organization (uuid or name) to use.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
@@ -11,7 +11,7 @@ OPTIONS:
Select which organization (uuid or name) to use.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -11,7 +11,7 @@ OPTIONS:
the user may have.
-y, --yes bool
Bypass prompts.
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+19 -7
View File
@@ -484,13 +484,12 @@ ephemeralDeployment: false
# (default: password, type: enum[password\|awsiamrds])
pgAuth: password
# Maximum number of open connections to the database. Defaults to 10.
# (default: 10, type: int)
pgConnMaxOpen: 10
# Maximum number of idle connections to the database. Set to "auto" (the default)
# to use max open / 3. Value must be greater or equal to 0; 0 means explicitly no
# idle connections.
# (default: auto, type: string)
pgConnMaxIdle: auto
# (default: 30, type: int)
pgConnMaxOpen: 30
# Maximum number of idle connections to the database. Value must be greater or
# equal to 0; 0 means explicitly no idle connections.
# (default: 15, type: int)
pgConnMaxIdle: 15
# A URL to an external Terms of Service that must be accepted by users when
# logging in.
# (default: <unset>, type: string)
@@ -773,6 +772,10 @@ aibridge:
# (unlimited).
# (default: 0, type: int)
rateLimit: 0
# Emit structured logs for AI Bridge interception records. Use this for exporting
# these records to external SIEM or observability systems.
# (default: false, type: bool)
structuredLogging: false
aibridgeproxy:
# Enable the AI Bridge MITM Proxy for intercepting and decrypting AI provider
# requests.
@@ -794,6 +797,15 @@ aibridgeproxy:
domain_allowlist:
- api.anthropic.com
- api.openai.com
# URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests
# through. Format: http://[user:pass@]host:port or https://[user:pass@]host:port.
# (default: <unset>, type: string)
upstream_proxy: ""
# Path to a PEM-encoded CA certificate to trust for the upstream proxy's TLS
# connection. Only needed for HTTPS upstream proxies with certificates not trusted
# by the system. If not provided, the system certificate pool is used.
# (default: <unset>, type: string)
upstream_proxy_ca: ""
# Configure data retention policies for various database tables. Retention
# policies automatically purge old data to reduce database size and improve
# performance. Setting a retention duration to 0 disables automatic purging for
+172 -21
View File
@@ -2628,7 +2628,8 @@ const docTemplate = `{
},
{
"enum": [
"code"
"code",
"token"
],
"type": "string",
"description": "Response type",
@@ -2683,7 +2684,8 @@ const docTemplate = `{
},
{
"enum": [
"code"
"code",
"token"
],
"type": "string",
"description": "Response type",
@@ -2914,7 +2916,10 @@ const docTemplate = `{
{
"enum": [
"authorization_code",
"refresh_token"
"refresh_token",
"password",
"client_credentials",
"implicit"
],
"type": "string",
"description": "Grant type",
@@ -4566,6 +4571,86 @@ const docTemplate = `{
}
}
},
"/organizations/{organization}/settings/workspace-sharing": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get workspace sharing settings for organization",
"operationId": "get-workspace-sharing-settings-for-organization",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
}
}
}
},
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Update workspace sharing settings for organization",
"operationId": "update-workspace-sharing-settings-for-organization",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"description": "Workspace sharing settings",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
}
}
}
}
},
"/organizations/{organization}/templates": {
"get": {
"security": [
@@ -11970,6 +12055,9 @@ const docTemplate = `{
},
"retention": {
"type": "integer"
},
"structured_logging": {
"type": "boolean"
}
}
},
@@ -12069,6 +12157,12 @@ const docTemplate = `{
},
"listen_addr": {
"type": "string"
},
"upstream_proxy": {
"type": "string"
},
"upstream_proxy_ca": {
"type": "string"
}
}
},
@@ -14387,7 +14481,7 @@ const docTemplate = `{
"type": "string"
},
"pg_conn_max_idle": {
"type": "string"
"type": "integer"
},
"pg_conn_max_open": {
"type": "integer"
@@ -15761,13 +15855,13 @@ const docTemplate = `{
"code_challenge_methods_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2PKCECodeChallengeMethod"
}
},
"grant_types_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"issuer": {
@@ -15779,7 +15873,7 @@ const docTemplate = `{
"response_types_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"revocation_endpoint": {
@@ -15797,7 +15891,7 @@ const docTemplate = `{
"token_endpoint_auth_methods_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
}
}
}
@@ -15829,7 +15923,7 @@ const docTemplate = `{
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -15851,10 +15945,7 @@ const docTemplate = `{
}
},
"registration_access_token": {
"type": "array",
"items": {
"type": "integer"
}
"type": "string"
},
"registration_client_uri": {
"type": "string"
@@ -15862,7 +15953,7 @@ const docTemplate = `{
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -15875,7 +15966,7 @@ const docTemplate = `{
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -15900,7 +15991,7 @@ const docTemplate = `{
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -15924,7 +16015,7 @@ const docTemplate = `{
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -15940,7 +16031,7 @@ const docTemplate = `{
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -15977,7 +16068,7 @@ const docTemplate = `{
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -16007,7 +16098,7 @@ const docTemplate = `{
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -16020,7 +16111,7 @@ const docTemplate = `{
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -16073,6 +16164,17 @@ const docTemplate = `{
}
}
},
"codersdk.OAuth2PKCECodeChallengeMethod": {
"type": "string",
"enum": [
"S256",
"plain"
],
"x-enum-varnames": [
"OAuth2PKCECodeChallengeMethodS256",
"OAuth2PKCECodeChallengeMethodPlain"
]
},
"codersdk.OAuth2ProtectedResourceMetadata": {
"type": "object",
"properties": {
@@ -16152,6 +16254,47 @@ const docTemplate = `{
}
}
},
"codersdk.OAuth2ProviderGrantType": {
"type": "string",
"enum": [
"authorization_code",
"refresh_token",
"password",
"client_credentials",
"implicit"
],
"x-enum-varnames": [
"OAuth2ProviderGrantTypeAuthorizationCode",
"OAuth2ProviderGrantTypeRefreshToken",
"OAuth2ProviderGrantTypePassword",
"OAuth2ProviderGrantTypeClientCredentials",
"OAuth2ProviderGrantTypeImplicit"
]
},
"codersdk.OAuth2ProviderResponseType": {
"type": "string",
"enum": [
"code",
"token"
],
"x-enum-varnames": [
"OAuth2ProviderResponseTypeCode",
"OAuth2ProviderResponseTypeToken"
]
},
"codersdk.OAuth2TokenEndpointAuthMethod": {
"type": "string",
"enum": [
"client_secret_basic",
"client_secret_post",
"none"
],
"x-enum-varnames": [
"OAuth2TokenEndpointAuthMethodClientSecretBasic",
"OAuth2TokenEndpointAuthMethodClientSecretPost",
"OAuth2TokenEndpointAuthMethodNone"
]
},
"codersdk.OAuthConversionResponse": {
"type": "object",
"properties": {
@@ -21451,6 +21594,14 @@ const docTemplate = `{
"WorkspaceRoleDeleted"
]
},
"codersdk.WorkspaceSharingSettings": {
"type": "object",
"properties": {
"sharing_disabled": {
"type": "boolean"
}
}
},
"codersdk.WorkspaceStatus": {
"type": "string",
"enum": [
+153 -21
View File
@@ -2304,7 +2304,7 @@
"required": true
},
{
"enum": ["code"],
"enum": ["code", "token"],
"type": "string",
"description": "Response type",
"name": "response_type",
@@ -2355,7 +2355,7 @@
"required": true
},
{
"enum": ["code"],
"enum": ["code", "token"],
"type": "string",
"description": "Response type",
"name": "response_type",
@@ -2555,7 +2555,13 @@
"in": "formData"
},
{
"enum": ["authorization_code", "refresh_token"],
"enum": [
"authorization_code",
"refresh_token",
"password",
"client_credentials",
"implicit"
],
"type": "string",
"description": "Grant type",
"name": "grant_type",
@@ -4036,6 +4042,76 @@
}
}
},
"/organizations/{organization}/settings/workspace-sharing": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get workspace sharing settings for organization",
"operationId": "get-workspace-sharing-settings-for-organization",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
}
}
}
},
"patch": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Update workspace sharing settings for organization",
"operationId": "update-workspace-sharing-settings-for-organization",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"description": "Workspace sharing settings",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceSharingSettings"
}
}
}
}
},
"/organizations/{organization}/templates": {
"get": {
"security": [
@@ -10631,6 +10707,9 @@
},
"retention": {
"type": "integer"
},
"structured_logging": {
"type": "boolean"
}
}
},
@@ -10730,6 +10809,12 @@
},
"listen_addr": {
"type": "string"
},
"upstream_proxy": {
"type": "string"
},
"upstream_proxy_ca": {
"type": "string"
}
}
},
@@ -12966,7 +13051,7 @@
"type": "string"
},
"pg_conn_max_idle": {
"type": "string"
"type": "integer"
},
"pg_conn_max_open": {
"type": "integer"
@@ -14280,13 +14365,13 @@
"code_challenge_methods_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2PKCECodeChallengeMethod"
}
},
"grant_types_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"issuer": {
@@ -14298,7 +14383,7 @@
"response_types_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"revocation_endpoint": {
@@ -14316,7 +14401,7 @@
"token_endpoint_auth_methods_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
}
}
}
@@ -14348,7 +14433,7 @@
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -14370,10 +14455,7 @@
}
},
"registration_access_token": {
"type": "array",
"items": {
"type": "integer"
}
"type": "string"
},
"registration_client_uri": {
"type": "string"
@@ -14381,7 +14463,7 @@
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -14394,7 +14476,7 @@
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -14419,7 +14501,7 @@
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -14443,7 +14525,7 @@
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -14459,7 +14541,7 @@
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -14496,7 +14578,7 @@
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -14526,7 +14608,7 @@
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -14539,7 +14621,7 @@
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -14592,6 +14674,14 @@
}
}
},
"codersdk.OAuth2PKCECodeChallengeMethod": {
"type": "string",
"enum": ["S256", "plain"],
"x-enum-varnames": [
"OAuth2PKCECodeChallengeMethodS256",
"OAuth2PKCECodeChallengeMethodPlain"
]
},
"codersdk.OAuth2ProtectedResourceMetadata": {
"type": "object",
"properties": {
@@ -14671,6 +14761,40 @@
}
}
},
"codersdk.OAuth2ProviderGrantType": {
"type": "string",
"enum": [
"authorization_code",
"refresh_token",
"password",
"client_credentials",
"implicit"
],
"x-enum-varnames": [
"OAuth2ProviderGrantTypeAuthorizationCode",
"OAuth2ProviderGrantTypeRefreshToken",
"OAuth2ProviderGrantTypePassword",
"OAuth2ProviderGrantTypeClientCredentials",
"OAuth2ProviderGrantTypeImplicit"
]
},
"codersdk.OAuth2ProviderResponseType": {
"type": "string",
"enum": ["code", "token"],
"x-enum-varnames": [
"OAuth2ProviderResponseTypeCode",
"OAuth2ProviderResponseTypeToken"
]
},
"codersdk.OAuth2TokenEndpointAuthMethod": {
"type": "string",
"enum": ["client_secret_basic", "client_secret_post", "none"],
"x-enum-varnames": [
"OAuth2TokenEndpointAuthMethodClientSecretBasic",
"OAuth2TokenEndpointAuthMethodClientSecretPost",
"OAuth2TokenEndpointAuthMethodNone"
]
},
"codersdk.OAuthConversionResponse": {
"type": "object",
"properties": {
@@ -19730,6 +19854,14 @@
"WorkspaceRoleDeleted"
]
},
"codersdk.WorkspaceSharingSettings": {
"type": "object",
"properties": {
"sharing_disabled": {
"type": "boolean"
}
}
},
"codersdk.WorkspaceStatus": {
"type": "string",
"enum": [
+8 -7
View File
@@ -205,7 +205,7 @@ type Options struct {
// tokens issued by and passed to the coordinator DRPC API.
CoordinatorResumeTokenProvider tailnet.ResumeTokenProvider
HealthcheckFunc func(ctx context.Context, apiKey string) *healthsdk.HealthcheckReport
HealthcheckFunc func(ctx context.Context, apiKey string, progress *healthcheck.Progress) *healthsdk.HealthcheckReport
HealthcheckTimeout time.Duration
HealthcheckRefresh time.Duration
WorkspaceProxiesFetchUpdater *atomic.Pointer[healthcheck.WorkspaceProxiesFetchUpdater]
@@ -681,7 +681,7 @@ func New(options *Options) *API {
}
if options.HealthcheckFunc == nil {
options.HealthcheckFunc = func(ctx context.Context, apiKey string) *healthsdk.HealthcheckReport {
options.HealthcheckFunc = func(ctx context.Context, apiKey string, progress *healthcheck.Progress) *healthsdk.HealthcheckReport {
// NOTE: dismissed healthchecks are marked in formatHealthcheck.
// Not here, as this result gets cached.
return healthcheck.Run(ctx, &healthcheck.ReportOptions{
@@ -709,6 +709,7 @@ func New(options *Options) *API {
StaleInterval: provisionerdserver.StaleInterval,
// TimeNow set to default, see healthcheck/provisioner.go
},
Progress: progress,
})
}
}
@@ -881,6 +882,7 @@ func New(options *Options) *API {
loggermw.Logger(api.Logger),
singleSlashMW,
rolestore.CustomRoleMW,
httpmw.HTTPRoute, // NB: prometheusMW depends on this middleware.
prometheusMW,
// Build-Version is helpful for debugging.
func(next http.Handler) http.Handler {
@@ -1434,9 +1436,7 @@ func New(options *Options) *API {
Optional: true,
}),
httpmw.RequireAPIKeyOrWorkspaceProxyAuth(),
httpmw.ExtractWorkspaceAgentParam(options.Database),
httpmw.ExtractWorkspaceParam(options.Database),
httpmw.ExtractWorkspaceAgentAndWorkspaceParam(options.Database),
)
r.Get("/", api.workspaceAgent)
r.Get("/watch-metadata", api.watchWorkspaceAgentMetadataSSE)
@@ -1859,8 +1859,9 @@ type API struct {
// This is used to gate features that are not yet ready for production.
Experiments codersdk.Experiments
healthCheckGroup *singleflight.Group[string, *healthsdk.HealthcheckReport]
healthCheckCache atomic.Pointer[healthsdk.HealthcheckReport]
healthCheckGroup *singleflight.Group[string, *healthsdk.HealthcheckReport]
healthCheckCache atomic.Pointer[healthsdk.HealthcheckReport]
healthCheckProgress healthcheck.Progress
statsReporter *workspacestats.Reporter
+2 -1
View File
@@ -69,6 +69,7 @@ import (
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/files"
"github.com/coder/coder/v2/coderd/gitsshkey"
"github.com/coder/coder/v2/coderd/healthcheck"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/jobreaper"
"github.com/coder/coder/v2/coderd/notifications"
@@ -131,7 +132,7 @@ type Options struct {
CoordinatorResumeTokenProvider tailnet.ResumeTokenProvider
ConnectionLogger connectionlog.ConnectionLogger
HealthcheckFunc func(ctx context.Context, apiKey string) *healthsdk.HealthcheckReport
HealthcheckFunc func(ctx context.Context, apiKey string, progress *healthcheck.Progress) *healthsdk.HealthcheckReport
HealthcheckTimeout time.Duration
HealthcheckRefresh time.Duration
+1 -1
View File
@@ -569,7 +569,7 @@ func AppSubdomain(dbApp database.WorkspaceApp, agentName, workspaceName, ownerNa
}.String()
}
func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace) []codersdk.WorkspaceApp {
func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus, agent database.WorkspaceAgent, ownerName string, workspace database.WorkspaceTable) []codersdk.WorkspaceApp {
sort.Slice(dbApps, func(i, j int) bool {
if dbApps[i].DisplayOrder != dbApps[j].DisplayOrder {
return dbApps[i].DisplayOrder < dbApps[j].DisplayOrder
+20 -1
View File
@@ -1965,6 +1965,14 @@ func (q *querier) DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) erro
return fetchAndExec(q.log, q.auth, policy.ActionShare, fetch, q.db.DeleteWorkspaceACLByID)(ctx, id)
}
func (q *querier) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error {
// This is a system-only function.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return err
}
return q.db.DeleteWorkspaceACLsByOrganization(ctx, organizationID)
}
func (q *querier) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error {
w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID)
if err != nil {
@@ -3592,7 +3600,7 @@ func (q *querier) GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (databa
if err != nil {
return database.GetWorkspaceACLByIDRow{}, err
}
if err := q.authorizeContext(ctx, policy.ActionShare, workspace); err != nil {
if err := q.authorizeContext(ctx, policy.ActionRead, workspace); err != nil {
return database.GetWorkspaceACLByIDRow{}, err
}
return q.db.GetWorkspaceACLByID(ctx, id)
@@ -3606,6 +3614,10 @@ func (q *querier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context
return q.db.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, authToken)
}
func (q *querier) GetWorkspaceAgentAndWorkspaceByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceAgentAndWorkspaceByIDRow, error) {
return fetch(q.log, q.auth, q.db.GetWorkspaceAgentAndWorkspaceByID)(ctx, id)
}
func (q *querier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (database.WorkspaceAgent, error) {
// Fast path: Check if we have a workspace RBAC object in context.
// In the agent API this is set at agent connection time to avoid the expensive
@@ -5099,6 +5111,13 @@ 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) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) {
fetch := func(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) {
return q.db.GetOrganizationByID(ctx, arg.ID)
}
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateOrganizationWorkspaceSharingSettings)(ctx, arg)
}
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 {
+21 -1
View File
@@ -880,6 +880,16 @@ func (s *MethodTestSuite) TestOrganization() {
dbm.EXPECT().InsertOrganization(gomock.Any(), arg).Return(database.Organization{ID: arg.ID, Name: arg.Name}, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceOrganization, policy.ActionCreate)
}))
s.Run("UpdateOrganizationWorkspaceSharingSettings", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
org := testutil.Fake(s.T(), faker, database.Organization{})
arg := database.UpdateOrganizationWorkspaceSharingSettingsParams{
ID: org.ID,
WorkspaceSharingDisabled: true,
}
dbm.EXPECT().GetOrganizationByID(gomock.Any(), org.ID).Return(org, nil).AnyTimes()
dbm.EXPECT().UpdateOrganizationWorkspaceSharingSettings(gomock.Any(), arg).Return(org, nil).AnyTimes()
check.Args(arg).Asserts(org, policy.ActionUpdate).Returns(org)
}))
s.Run("InsertOrganizationMember", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
o := testutil.Fake(s.T(), faker, database.Organization{})
u := testutil.Fake(s.T(), faker, database.User{})
@@ -1784,7 +1794,7 @@ func (s *MethodTestSuite) TestWorkspace() {
ws := testutil.Fake(s.T(), faker, database.Workspace{})
dbM.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes()
dbM.EXPECT().GetWorkspaceACLByID(gomock.Any(), ws.ID).Return(database.GetWorkspaceACLByIDRow{}, nil).AnyTimes()
check.Args(ws.ID).Asserts(ws, policy.ActionShare)
check.Args(ws.ID).Asserts(ws, policy.ActionRead)
}))
s.Run("UpdateWorkspaceACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
@@ -1799,6 +1809,11 @@ func (s *MethodTestSuite) TestWorkspace() {
dbm.EXPECT().DeleteWorkspaceACLByID(gomock.Any(), w.ID).Return(nil).AnyTimes()
check.Args(w.ID).Asserts(w, policy.ActionShare)
}))
s.Run("DeleteWorkspaceACLsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
orgID := uuid.New()
dbm.EXPECT().DeleteWorkspaceACLsByOrganization(gomock.Any(), orgID).Return(nil).AnyTimes()
check.Args(orgID).Asserts(rbac.ResourceSystem, policy.ActionUpdate)
}))
s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: w.ID})
@@ -1813,6 +1828,11 @@ func (s *MethodTestSuite) TestWorkspace() {
dbm.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agt.ID).Return(agt, nil).AnyTimes()
check.Args(agt.ID).Asserts(w, policy.ActionRead).Returns(agt)
}))
s.Run("GetWorkspaceAgentAndWorkspaceByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
aww := testutil.Fake(s.T(), faker, database.GetWorkspaceAgentAndWorkspaceByIDRow{})
dbm.EXPECT().GetWorkspaceAgentAndWorkspaceByID(gomock.Any(), aww.WorkspaceAgent.ID).Return(aww, nil).AnyTimes()
check.Args(aww.WorkspaceAgent.ID).Asserts(aww.WorkspaceTable, policy.ActionRead).Returns(aww)
}))
s.Run("GetWorkspaceAgentsByWorkspaceAndBuildNumber", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
File diff suppressed because it is too large Load Diff
+44
View File
@@ -1055,6 +1055,20 @@ func (mr *MockStoreMockRecorder) DeleteWorkspaceACLByID(ctx, id any) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceACLByID", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceACLByID), ctx, id)
}
// DeleteWorkspaceACLsByOrganization mocks base method.
func (m *MockStore) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteWorkspaceACLsByOrganization", ctx, organizationID)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteWorkspaceACLsByOrganization indicates an expected call of DeleteWorkspaceACLsByOrganization.
func (mr *MockStoreMockRecorder) DeleteWorkspaceACLsByOrganization(ctx, organizationID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceACLsByOrganization", reflect.TypeOf((*MockStore)(nil).DeleteWorkspaceACLsByOrganization), ctx, organizationID)
}
// DeleteWorkspaceAgentPortShare mocks base method.
func (m *MockStore) DeleteWorkspaceAgentPortShare(ctx context.Context, arg database.DeleteWorkspaceAgentPortShareParams) error {
m.ctrl.T.Helper()
@@ -4123,6 +4137,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx,
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAgentAndLatestBuildByAuthToken", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAgentAndLatestBuildByAuthToken), ctx, authToken)
}
// GetWorkspaceAgentAndWorkspaceByID mocks base method.
func (m *MockStore) GetWorkspaceAgentAndWorkspaceByID(ctx context.Context, id uuid.UUID) (database.GetWorkspaceAgentAndWorkspaceByIDRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetWorkspaceAgentAndWorkspaceByID", ctx, id)
ret0, _ := ret[0].(database.GetWorkspaceAgentAndWorkspaceByIDRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetWorkspaceAgentAndWorkspaceByID indicates an expected call of GetWorkspaceAgentAndWorkspaceByID.
func (mr *MockStoreMockRecorder) GetWorkspaceAgentAndWorkspaceByID(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAgentAndWorkspaceByID", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAgentAndWorkspaceByID), ctx, id)
}
// GetWorkspaceAgentByID mocks base method.
func (m *MockStore) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (database.WorkspaceAgent, error) {
m.ctrl.T.Helper()
@@ -6645,6 +6674,21 @@ func (mr *MockStoreMockRecorder) UpdateOrganizationDeletedByID(ctx, arg any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganizationDeletedByID", reflect.TypeOf((*MockStore)(nil).UpdateOrganizationDeletedByID), ctx, arg)
}
// UpdateOrganizationWorkspaceSharingSettings mocks base method.
func (m *MockStore) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg database.UpdateOrganizationWorkspaceSharingSettingsParams) (database.Organization, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateOrganizationWorkspaceSharingSettings", ctx, arg)
ret0, _ := ret[0].(database.Organization)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateOrganizationWorkspaceSharingSettings indicates an expected call of UpdateOrganizationWorkspaceSharingSettings.
func (mr *MockStoreMockRecorder) UpdateOrganizationWorkspaceSharingSettings(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganizationWorkspaceSharingSettings", reflect.TypeOf((*MockStore)(nil).UpdateOrganizationWorkspaceSharingSettings), ctx, arg)
}
// UpdatePrebuildProvisionerJobWithCancel mocks base method.
func (m *MockStore) UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg database.UpdatePrebuildProvisionerJobWithCancelParams) ([]database.UpdatePrebuildProvisionerJobWithCancelRow, error) {
m.ctrl.T.Helper()
File diff suppressed because one or more lines are too long
@@ -1,34 +1,34 @@
-- This is a deleted user that shares the same username and linked_id as the existing user below.
-- Any future migrations need to handle this case.
INSERT INTO public.users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
INSERT INTO users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
VALUES ('a0061a8e-7db7-4585-838c-3116a003dd21', 'githubuser@coder.com', 'githubuser', '\x', '2022-11-02 13:05:21.445455+02', '2022-11-02 13:05:21.445455+02', 'active', '{}', true) ON CONFLICT DO NOTHING;
INSERT INTO public.organization_members VALUES ('a0061a8e-7db7-4585-838c-3116a003dd21', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
INSERT INTO public.user_links(user_id, login_type, linked_id, oauth_access_token)
INSERT INTO organization_members VALUES ('a0061a8e-7db7-4585-838c-3116a003dd21', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
INSERT INTO user_links(user_id, login_type, linked_id, oauth_access_token)
VALUES('a0061a8e-7db7-4585-838c-3116a003dd21', 'github', '100', '');
INSERT INTO public.users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
INSERT INTO users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
VALUES ('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'githubuser@coder.com', 'githubuser', '\x', '2022-11-02 13:05:21.445455+02', '2022-11-02 13:05:21.445455+02', 'active', '{}', false) ON CONFLICT DO NOTHING;
INSERT INTO public.organization_members VALUES ('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
INSERT INTO public.user_links(user_id, login_type, linked_id, oauth_access_token)
INSERT INTO organization_members VALUES ('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
INSERT INTO user_links(user_id, login_type, linked_id, oauth_access_token)
VALUES('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'github', '100', '');
-- Additionally, there is no unique constraint on user_id. So also add another user_link for the same user.
-- This has happened on a production database.
INSERT INTO public.user_links(user_id, login_type, linked_id, oauth_access_token)
INSERT INTO user_links(user_id, login_type, linked_id, oauth_access_token)
VALUES('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'oidc', 'foo', '');
-- Lastly, make 2 other users who have the same user link.
INSERT INTO public.users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
INSERT INTO users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
VALUES ('580ed397-727d-4aaf-950a-51f89f556c24', 'dup_link_a@coder.com', 'dupe_a', '\x', '2022-11-02 13:05:21.445455+02', '2022-11-02 13:05:21.445455+02', 'active', '{}', false) ON CONFLICT DO NOTHING;
INSERT INTO public.organization_members VALUES ('580ed397-727d-4aaf-950a-51f89f556c24', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
INSERT INTO public.user_links(user_id, login_type, linked_id, oauth_access_token)
INSERT INTO organization_members VALUES ('580ed397-727d-4aaf-950a-51f89f556c24', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
INSERT INTO user_links(user_id, login_type, linked_id, oauth_access_token)
VALUES('580ed397-727d-4aaf-950a-51f89f556c24', 'github', '500', '');
INSERT INTO public.users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
INSERT INTO users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted)
VALUES ('c813366b-2fde-45ae-920c-101c3ad6a1e1', 'dup_link_b@coder.com', 'dupe_b', '\x', '2022-11-02 13:05:21.445455+02', '2022-11-02 13:05:21.445455+02', 'active', '{}', false) ON CONFLICT DO NOTHING;
INSERT INTO public.organization_members VALUES ('c813366b-2fde-45ae-920c-101c3ad6a1e1', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
INSERT INTO public.user_links(user_id, login_type, linked_id, oauth_access_token)
INSERT INTO organization_members VALUES ('c813366b-2fde-45ae-920c-101c3ad6a1e1', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', '2022-11-02 13:05:21.447595+02', '2022-11-02 13:05:21.447595+02', '{}') ON CONFLICT DO NOTHING;
INSERT INTO user_links(user_id, login_type, linked_id, oauth_access_token)
VALUES('c813366b-2fde-45ae-920c-101c3ad6a1e1', 'github', '500', '');
@@ -1,4 +1,4 @@
INSERT INTO public.workspace_app_stats (
INSERT INTO workspace_app_stats (
id,
user_id,
workspace_id,
@@ -1,5 +1,5 @@
INSERT INTO
public.workspace_modules (
workspace_modules (
id,
job_id,
transition,
@@ -1,15 +1,15 @@
INSERT INTO public.organizations (id, name, description, created_at, updated_at, is_default, display_name, icon) VALUES ('20362772-802a-4a72-8e4f-3648b4bfd168', 'strange_hopper58', 'wizardly_stonebraker60', '2025-02-07 07:46:19.507551 +00:00', '2025-02-07 07:46:19.507552 +00:00', false, 'competent_rhodes59', '');
INSERT INTO organizations (id, name, description, created_at, updated_at, is_default, display_name, icon) VALUES ('20362772-802a-4a72-8e4f-3648b4bfd168', 'strange_hopper58', 'wizardly_stonebraker60', '2025-02-07 07:46:19.507551 +00:00', '2025-02-07 07:46:19.507552 +00:00', false, 'competent_rhodes59', '');
INSERT INTO public.users (id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at) VALUES ('6c353aac-20de-467b-bdfb-3c30a37adcd2', 'vigorous_murdock61', 'affectionate_hawking62', 'lqTu9C5363AwD7NVNH6noaGjp91XIuZJ', '2025-02-07 07:46:19.510861 +00:00', '2025-02-07 07:46:19.512949 +00:00', 'active', '{}', 'password', '', false, '0001-01-01 00:00:00.000000', '', '', 'vigilant_hugle63', null, null, null);
INSERT INTO users (id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at) VALUES ('6c353aac-20de-467b-bdfb-3c30a37adcd2', 'vigorous_murdock61', 'affectionate_hawking62', 'lqTu9C5363AwD7NVNH6noaGjp91XIuZJ', '2025-02-07 07:46:19.510861 +00:00', '2025-02-07 07:46:19.512949 +00:00', 'active', '{}', 'password', '', false, '0001-01-01 00:00:00.000000', '', '', 'vigilant_hugle63', null, null, null);
INSERT INTO public.templates (id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level) VALUES ('6b298946-7a4f-47ac-9158-b03b08740a41', '2025-02-07 07:46:19.513317 +00:00', '2025-02-07 07:46:19.513317 +00:00', '20362772-802a-4a72-8e4f-3648b4bfd168', false, 'modest_leakey64', 'echo', 'e6cfa2a4-e4cf-4182-9e19-08b975682a28', 'upbeat_wright65', 604800000000000, '6c353aac-20de-467b-bdfb-3c30a37adcd2', 'nervous_keller66', '{}', '{"20362772-802a-4a72-8e4f-3648b4bfd168": ["read", "use"]}', 'determined_aryabhata67', false, true, true, 0, 0, 0, 0, 0, 0, false, '', 3600000000000, 'owner');
INSERT INTO public.template_versions (id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id) VALUES ('af58bd62-428c-4c33-849b-d43a3be07d93', '6b298946-7a4f-47ac-9158-b03b08740a41', '20362772-802a-4a72-8e4f-3648b4bfd168', '2025-02-07 07:46:19.514782 +00:00', '2025-02-07 07:46:19.514782 +00:00', 'distracted_shockley68', 'sleepy_turing69', 'f2e2ea1c-5aa3-4a1d-8778-2e5071efae59', '6c353aac-20de-467b-bdfb-3c30a37adcd2', '[]', '', false, null);
INSERT INTO templates (id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, activity_bump, max_port_sharing_level) VALUES ('6b298946-7a4f-47ac-9158-b03b08740a41', '2025-02-07 07:46:19.513317 +00:00', '2025-02-07 07:46:19.513317 +00:00', '20362772-802a-4a72-8e4f-3648b4bfd168', false, 'modest_leakey64', 'echo', 'e6cfa2a4-e4cf-4182-9e19-08b975682a28', 'upbeat_wright65', 604800000000000, '6c353aac-20de-467b-bdfb-3c30a37adcd2', 'nervous_keller66', '{}', '{"20362772-802a-4a72-8e4f-3648b4bfd168": ["read", "use"]}', 'determined_aryabhata67', false, true, true, 0, 0, 0, 0, 0, 0, false, '', 3600000000000, 'owner');
INSERT INTO template_versions (id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id) VALUES ('af58bd62-428c-4c33-849b-d43a3be07d93', '6b298946-7a4f-47ac-9158-b03b08740a41', '20362772-802a-4a72-8e4f-3648b4bfd168', '2025-02-07 07:46:19.514782 +00:00', '2025-02-07 07:46:19.514782 +00:00', 'distracted_shockley68', 'sleepy_turing69', 'f2e2ea1c-5aa3-4a1d-8778-2e5071efae59', '6c353aac-20de-467b-bdfb-3c30a37adcd2', '[]', '', false, null);
INSERT INTO public.template_version_presets (id, template_version_id, name, created_at) VALUES ('28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe', 'af58bd62-428c-4c33-849b-d43a3be07d93', 'test', '0001-01-01 00:00:00.000000 +00:00');
INSERT INTO template_version_presets (id, template_version_id, name, created_at) VALUES ('28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe', 'af58bd62-428c-4c33-849b-d43a3be07d93', 'test', '0001-01-01 00:00:00.000000 +00:00');
-- Add presets with the same template version ID and name
-- to ensure they're correctly handled by the 00031*_preset_prebuilds migration.
INSERT INTO public.template_version_presets (
INSERT INTO template_version_presets (
id, template_version_id, name, created_at
)
VALUES (
@@ -19,7 +19,7 @@ VALUES (
'0001-01-01 00:00:00.000000 +00:00'
);
INSERT INTO public.template_version_presets (
INSERT INTO template_version_presets (
id, template_version_id, name, created_at
)
VALUES (
@@ -29,4 +29,4 @@ VALUES (
'0001-01-01 00:00:00.000000 +00:00'
);
INSERT INTO public.template_version_preset_parameters (id, template_version_preset_id, name, value) VALUES ('ea90ccd2-5024-459e-87e4-879afd24de0f', '28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe', 'test', 'test');
INSERT INTO template_version_preset_parameters (id, template_version_preset_id, name, value) VALUES ('ea90ccd2-5024-459e-87e4-879afd24de0f', '28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe', 'test', 'test');
@@ -1,4 +1,4 @@
INSERT INTO public.tasks VALUES (
INSERT INTO tasks VALUES (
'f5a1c3e4-8b2d-4f6a-9d7e-2a8b5c9e1f3d', -- id
'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', -- organization_id
'30095c71-380b-457a-8995-97b8ee6e5307', -- owner_id
@@ -11,7 +11,7 @@ INSERT INTO public.tasks VALUES (
NULL -- deleted_at
) ON CONFLICT DO NOTHING;
INSERT INTO public.task_workspace_apps VALUES (
INSERT INTO task_workspace_apps VALUES (
'f5a1c3e4-8b2d-4f6a-9d7e-2a8b5c9e1f3d', -- task_id
'a8c0b8c5-c9a8-4f33-93a4-8142e6858244', -- workspace_build_id
'8fa17bbd-c48c-44c7-91ae-d4acbc755fad', -- workspace_agent_id
@@ -1,4 +1,4 @@
INSERT INTO public.task_workspace_apps VALUES (
INSERT INTO task_workspace_apps VALUES (
'f5a1c3e4-8b2d-4f6a-9d7e-2a8b5c9e1f3d', -- task_id
NULL, -- workspace_agent_id
NULL, -- workspace_app_id
+5
View File
@@ -884,3 +884,8 @@ func WorkspaceIdentityFromWorkspace(w Workspace) WorkspaceIdentity {
AutostartSchedule: w.AutostartSchedule,
}
}
// A workspace agent belongs to the owner of the associated workspace.
func (r GetWorkspaceAgentAndWorkspaceByIDRow) RBACObject() rbac.Object {
return r.WorkspaceTable.RBACObject()
}
+3
View File
@@ -139,6 +139,7 @@ type sqlcQuerier interface {
DeleteWebpushSubscriptionByUserIDAndEndpoint(ctx context.Context, arg DeleteWebpushSubscriptionByUserIDAndEndpointParams) error
DeleteWebpushSubscriptions(ctx context.Context, ids []uuid.UUID) error
DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) error
DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error
DeleteWorkspaceAgentPortShare(ctx context.Context, arg DeleteWorkspaceAgentPortShareParams) error
DeleteWorkspaceAgentPortSharesByTemplate(ctx context.Context, templateID uuid.UUID) error
DeleteWorkspaceSubAgentByID(ctx context.Context, id uuid.UUID) error
@@ -466,6 +467,7 @@ type sqlcQuerier interface {
GetWebpushVAPIDKeys(ctx context.Context) (GetWebpushVAPIDKeysRow, error)
GetWorkspaceACLByID(ctx context.Context, id uuid.UUID) (GetWorkspaceACLByIDRow, error)
GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Context, authToken uuid.UUID) (GetWorkspaceAgentAndLatestBuildByAuthTokenRow, error)
GetWorkspaceAgentAndWorkspaceByID(ctx context.Context, id uuid.UUID) (GetWorkspaceAgentAndWorkspaceByIDRow, error)
GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error)
GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error)
GetWorkspaceAgentDevcontainersByAgentID(ctx context.Context, workspaceAgentID uuid.UUID) ([]WorkspaceAgentDevcontainer, error)
@@ -677,6 +679,7 @@ type sqlcQuerier interface {
UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error)
UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error)
UpdateOrganizationDeletedByID(ctx context.Context, arg UpdateOrganizationDeletedByIDParams) error
UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg UpdateOrganizationWorkspaceSharingSettingsParams) (Organization, error)
// Cancels all pending provisioner jobs for prebuilt workspaces on a specific preset from an
// inactive template version.
// This is an optimization to clean up stale pending jobs.
+88
View File
@@ -2304,6 +2304,94 @@ func TestDeleteCustomRoleDoesNotDeleteSystemRole(t *testing.T) {
require.True(t, roles[0].IsSystem)
}
func TestUpdateOrganizationWorkspaceSharingSettings(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
ctx := testutil.Context(t, testutil.WaitShort)
updated, err := db.UpdateOrganizationWorkspaceSharingSettings(ctx, database.UpdateOrganizationWorkspaceSharingSettingsParams{
ID: org.ID,
WorkspaceSharingDisabled: true,
UpdatedAt: dbtime.Now(),
})
require.NoError(t, err)
require.True(t, updated.WorkspaceSharingDisabled)
got, err := db.GetOrganizationByID(ctx, org.ID)
require.NoError(t, err)
require.True(t, got.WorkspaceSharingDisabled)
}
func TestDeleteWorkspaceACLsByOrganization(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org1 := dbgen.Organization(t, db, database.Organization{})
org2 := dbgen.Organization(t, db, database.Organization{})
owner1 := dbgen.User(t, db, database.User{})
owner2 := dbgen.User(t, db, database.User{})
sharedUser := dbgen.User(t, db, database.User{})
sharedGroup := dbgen.Group(t, db, database.Group{
OrganizationID: org1.ID,
})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: org1.ID,
UserID: owner1.ID,
})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: org2.ID,
UserID: owner2.ID,
})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: org1.ID,
UserID: sharedUser.ID,
})
ws1 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OwnerID: owner1.ID,
OrganizationID: org1.ID,
UserACL: database.WorkspaceACL{
sharedUser.ID.String(): {
Permissions: []policy.Action{policy.ActionRead},
},
},
GroupACL: database.WorkspaceACL{
sharedGroup.ID.String(): {
Permissions: []policy.Action{policy.ActionRead},
},
},
}).Do().Workspace
ws2 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OwnerID: owner2.ID,
OrganizationID: org2.ID,
UserACL: database.WorkspaceACL{
uuid.NewString(): {
Permissions: []policy.Action{policy.ActionRead},
},
},
}).Do().Workspace
ctx := testutil.Context(t, testutil.WaitShort)
err := db.DeleteWorkspaceACLsByOrganization(ctx, org1.ID)
require.NoError(t, err)
got1, err := db.GetWorkspaceByID(ctx, ws1.ID)
require.NoError(t, err)
require.Empty(t, got1.UserACL)
require.Empty(t, got1.GroupACL)
got2, err := db.GetWorkspaceByID(ctx, ws2.ID)
require.NoError(t, err)
require.NotEmpty(t, got2.UserACL)
}
func TestAuthorizedAuditLogs(t *testing.T) {
t.Parallel()
+143
View File
@@ -8197,6 +8197,41 @@ func (q *sqlQuerier) UpdateOrganizationDeletedByID(ctx context.Context, arg Upda
return err
}
const updateOrganizationWorkspaceSharingSettings = `-- name: UpdateOrganizationWorkspaceSharingSettings :one
UPDATE
organizations
SET
workspace_sharing_disabled = $1,
updated_at = $2
WHERE
id = $3
RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled
`
type UpdateOrganizationWorkspaceSharingSettingsParams struct {
WorkspaceSharingDisabled bool `db:"workspace_sharing_disabled" json:"workspace_sharing_disabled"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateOrganizationWorkspaceSharingSettings(ctx context.Context, arg UpdateOrganizationWorkspaceSharingSettingsParams) (Organization, error) {
row := q.db.QueryRowContext(ctx, updateOrganizationWorkspaceSharingSettings, arg.WorkspaceSharingDisabled, arg.UpdatedAt, arg.ID)
var i Organization
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
&i.IsDefault,
&i.DisplayName,
&i.Icon,
&i.Deleted,
&i.WorkspaceSharingDisabled,
)
return i, err
}
const getParameterSchemasByJobID = `-- name: GetParameterSchemasByJobID :many
SELECT
id, created_at, job_id, name, description, default_source_scheme, default_source_value, allow_override_source, default_destination_scheme, allow_override_destination, default_refresh, redisplay_value, validation_error, validation_condition, validation_type_system, validation_value_type, index
@@ -18120,6 +18155,99 @@ func (q *sqlQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Cont
return i, err
}
const getWorkspaceAgentAndWorkspaceByID = `-- name: GetWorkspaceAgentAndWorkspaceByID :one
SELECT
workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope, workspace_agents.deleted,
workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl,
users.username as owner_username
FROM
workspace_agents
JOIN
workspace_resources ON workspace_agents.resource_id = workspace_resources.id
JOIN
provisioner_jobs ON workspace_resources.job_id = provisioner_jobs.id
JOIN
workspace_builds ON provisioner_jobs.id = workspace_builds.job_id
JOIN
workspaces ON workspace_builds.workspace_id = workspaces.id
JOIN
users ON workspaces.owner_id = users.id
WHERE
workspace_agents.id = $1
AND workspace_agents.deleted = FALSE
AND provisioner_jobs.type = 'workspace_build'::provisioner_job_type
AND workspaces.deleted = FALSE
AND users.deleted = FALSE
LIMIT 1
`
type GetWorkspaceAgentAndWorkspaceByIDRow struct {
WorkspaceAgent WorkspaceAgent `db:"workspace_agent" json:"workspace_agent"`
WorkspaceTable WorkspaceTable `db:"workspace_table" json:"workspace_table"`
OwnerUsername string `db:"owner_username" json:"owner_username"`
}
func (q *sqlQuerier) GetWorkspaceAgentAndWorkspaceByID(ctx context.Context, id uuid.UUID) (GetWorkspaceAgentAndWorkspaceByIDRow, error) {
row := q.db.QueryRowContext(ctx, getWorkspaceAgentAndWorkspaceByID, id)
var i GetWorkspaceAgentAndWorkspaceByIDRow
err := row.Scan(
&i.WorkspaceAgent.ID,
&i.WorkspaceAgent.CreatedAt,
&i.WorkspaceAgent.UpdatedAt,
&i.WorkspaceAgent.Name,
&i.WorkspaceAgent.FirstConnectedAt,
&i.WorkspaceAgent.LastConnectedAt,
&i.WorkspaceAgent.DisconnectedAt,
&i.WorkspaceAgent.ResourceID,
&i.WorkspaceAgent.AuthToken,
&i.WorkspaceAgent.AuthInstanceID,
&i.WorkspaceAgent.Architecture,
&i.WorkspaceAgent.EnvironmentVariables,
&i.WorkspaceAgent.OperatingSystem,
&i.WorkspaceAgent.InstanceMetadata,
&i.WorkspaceAgent.ResourceMetadata,
&i.WorkspaceAgent.Directory,
&i.WorkspaceAgent.Version,
&i.WorkspaceAgent.LastConnectedReplicaID,
&i.WorkspaceAgent.ConnectionTimeoutSeconds,
&i.WorkspaceAgent.TroubleshootingURL,
&i.WorkspaceAgent.MOTDFile,
&i.WorkspaceAgent.LifecycleState,
&i.WorkspaceAgent.ExpandedDirectory,
&i.WorkspaceAgent.LogsLength,
&i.WorkspaceAgent.LogsOverflowed,
&i.WorkspaceAgent.StartedAt,
&i.WorkspaceAgent.ReadyAt,
pq.Array(&i.WorkspaceAgent.Subsystems),
pq.Array(&i.WorkspaceAgent.DisplayApps),
&i.WorkspaceAgent.APIVersion,
&i.WorkspaceAgent.DisplayOrder,
&i.WorkspaceAgent.ParentID,
&i.WorkspaceAgent.APIKeyScope,
&i.WorkspaceAgent.Deleted,
&i.WorkspaceTable.ID,
&i.WorkspaceTable.CreatedAt,
&i.WorkspaceTable.UpdatedAt,
&i.WorkspaceTable.OwnerID,
&i.WorkspaceTable.OrganizationID,
&i.WorkspaceTable.TemplateID,
&i.WorkspaceTable.Deleted,
&i.WorkspaceTable.Name,
&i.WorkspaceTable.AutostartSchedule,
&i.WorkspaceTable.Ttl,
&i.WorkspaceTable.LastUsedAt,
&i.WorkspaceTable.DormantAt,
&i.WorkspaceTable.DeletingAt,
&i.WorkspaceTable.AutomaticUpdates,
&i.WorkspaceTable.Favorite,
&i.WorkspaceTable.NextStartAt,
&i.WorkspaceTable.GroupACL,
&i.WorkspaceTable.UserACL,
&i.OwnerUsername,
)
return i, err
}
const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one
SELECT
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order, parent_id, api_key_scope, deleted
@@ -22151,6 +22279,21 @@ func (q *sqlQuerier) DeleteWorkspaceACLByID(ctx context.Context, id uuid.UUID) e
return err
}
const deleteWorkspaceACLsByOrganization = `-- name: DeleteWorkspaceACLsByOrganization :exec
UPDATE
workspaces
SET
group_acl = '{}'::jsonb,
user_acl = '{}'::jsonb
WHERE
organization_id = $1
`
func (q *sqlQuerier) DeleteWorkspaceACLsByOrganization(ctx context.Context, organizationID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteWorkspaceACLsByOrganization, organizationID)
return err
}
const favoriteWorkspace = `-- name: FavoriteWorkspace :exec
UPDATE workspaces SET favorite = true WHERE id = $1
`
+10
View File
@@ -143,3 +143,13 @@ WHERE
id = @id AND
is_default = false;
-- name: UpdateOrganizationWorkspaceSharingSettings :one
UPDATE
organizations
SET
workspace_sharing_disabled = @workspace_sharing_disabled,
updated_at = @updated_at
WHERE
id = @id
RETURNING *;
@@ -393,3 +393,28 @@ AND wb.build_number = (
WHERE wb2.workspace_id = w.id
)
AND workspace_agents.deleted = FALSE;
-- name: GetWorkspaceAgentAndWorkspaceByID :one
SELECT
sqlc.embed(workspace_agents),
sqlc.embed(workspaces),
users.username as owner_username
FROM
workspace_agents
JOIN
workspace_resources ON workspace_agents.resource_id = workspace_resources.id
JOIN
provisioner_jobs ON workspace_resources.job_id = provisioner_jobs.id
JOIN
workspace_builds ON provisioner_jobs.id = workspace_builds.job_id
JOIN
workspaces ON workspace_builds.workspace_id = workspaces.id
JOIN
users ON workspaces.owner_id = users.id
WHERE
workspace_agents.id = @id
AND workspace_agents.deleted = FALSE
AND provisioner_jobs.type = 'workspace_build'::provisioner_job_type
AND workspaces.deleted = FALSE
AND users.deleted = FALSE
LIMIT 1;
+9
View File
@@ -947,6 +947,15 @@ SET
WHERE
id = @id;
-- name: DeleteWorkspaceACLsByOrganization :exec
UPDATE
workspaces
SET
group_acl = '{}'::jsonb,
user_acl = '{}'::jsonb
WHERE
organization_id = @organization_id;
-- name: GetRegularWorkspaceCreateMetrics :many
-- Count regular workspaces: only those whose first successful 'start' build
-- was not initiated by the prebuild system user.
+6 -2
View File
@@ -83,17 +83,21 @@ func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(context.Background(), api.Options.HealthcheckTimeout)
defer cancel()
report := api.HealthcheckFunc(ctx, apiKey)
// Create and store progress tracker for timeout diagnostics.
report := api.HealthcheckFunc(ctx, apiKey, &api.healthCheckProgress)
if report != nil { // Only store non-nil reports.
api.healthCheckCache.Store(report)
}
api.healthCheckProgress.Reset()
return report, nil
})
select {
case <-ctx.Done():
summary := api.healthCheckProgress.Summary()
httpapi.Write(ctx, rw, http.StatusServiceUnavailable, codersdk.Response{
Message: "Healthcheck is in progress and did not complete in time. Try again in a few seconds.",
Message: "Healthcheck timed out.",
Detail: summary,
})
return
case res := <-resChan:
+20 -17
View File
@@ -14,6 +14,8 @@ import (
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/healthcheck"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/healthsdk"
"github.com/coder/coder/v2/testutil"
)
@@ -28,7 +30,7 @@ func TestDebugHealth(t *testing.T) {
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
sessionToken string
client = coderdtest.New(t, &coderdtest.Options{
HealthcheckFunc: func(_ context.Context, apiKey string) *healthsdk.HealthcheckReport {
HealthcheckFunc: func(_ context.Context, apiKey string, _ *healthcheck.Progress) *healthsdk.HealthcheckReport {
calls.Add(1)
assert.Equal(t, sessionToken, apiKey)
return &healthsdk.HealthcheckReport{
@@ -61,7 +63,7 @@ func TestDebugHealth(t *testing.T) {
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
sessionToken string
client = coderdtest.New(t, &coderdtest.Options{
HealthcheckFunc: func(_ context.Context, apiKey string) *healthsdk.HealthcheckReport {
HealthcheckFunc: func(_ context.Context, apiKey string, _ *healthcheck.Progress) *healthsdk.HealthcheckReport {
calls.Add(1)
assert.Equal(t, sessionToken, apiKey)
return &healthsdk.HealthcheckReport{
@@ -93,19 +95,14 @@ func TestDebugHealth(t *testing.T) {
// Need to ignore errors due to ctx timeout
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
done = make(chan struct{})
client = coderdtest.New(t, &coderdtest.Options{
Logger: &logger,
HealthcheckTimeout: time.Microsecond,
HealthcheckFunc: func(context.Context, string) *healthsdk.HealthcheckReport {
t := time.NewTimer(time.Second)
defer t.Stop()
select {
case <-ctx.Done():
return &healthsdk.HealthcheckReport{}
case <-t.C:
return &healthsdk.HealthcheckReport{}
}
HealthcheckTimeout: time.Second,
HealthcheckFunc: func(_ context.Context, _ string, progress *healthcheck.Progress) *healthsdk.HealthcheckReport {
progress.Start("test")
<-done
return &healthsdk.HealthcheckReport{}
},
})
_ = coderdtest.CreateFirstUser(t, client)
@@ -115,8 +112,14 @@ func TestDebugHealth(t *testing.T) {
res, err := client.Request(ctx, "GET", "/api/v2/debug/health", nil)
require.NoError(t, err)
defer res.Body.Close()
_, _ = io.ReadAll(res.Body)
close(done)
bs, err := io.ReadAll(res.Body)
require.NoError(t, err, "reading body")
require.Equal(t, http.StatusServiceUnavailable, res.StatusCode)
var sdkResp codersdk.Response
require.NoError(t, json.Unmarshal(bs, &sdkResp), "unmarshaling sdk response")
require.Equal(t, "Healthcheck timed out.", sdkResp.Message)
require.Contains(t, sdkResp.Detail, "Still running: test (elapsed:")
})
t.Run("Refresh", func(t *testing.T) {
@@ -128,7 +131,7 @@ func TestDebugHealth(t *testing.T) {
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
client = coderdtest.New(t, &coderdtest.Options{
HealthcheckRefresh: time.Microsecond,
HealthcheckFunc: func(context.Context, string) *healthsdk.HealthcheckReport {
HealthcheckFunc: func(context.Context, string, *healthcheck.Progress) *healthsdk.HealthcheckReport {
calls <- struct{}{}
return &healthsdk.HealthcheckReport{}
},
@@ -173,7 +176,7 @@ func TestDebugHealth(t *testing.T) {
client = coderdtest.New(t, &coderdtest.Options{
HealthcheckRefresh: time.Hour,
HealthcheckTimeout: time.Hour,
HealthcheckFunc: func(context.Context, string) *healthsdk.HealthcheckReport {
HealthcheckFunc: func(context.Context, string, *healthcheck.Progress) *healthsdk.HealthcheckReport {
calls++
return &healthsdk.HealthcheckReport{
Time: time.Now(),
@@ -207,7 +210,7 @@ func TestDebugHealth(t *testing.T) {
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort)
sessionToken string
client = coderdtest.New(t, &coderdtest.Options{
HealthcheckFunc: func(_ context.Context, apiKey string) *healthsdk.HealthcheckReport {
HealthcheckFunc: func(_ context.Context, apiKey string, _ *healthcheck.Progress) *healthsdk.HealthcheckReport {
assert.Equal(t, sessionToken, apiKey)
return &healthsdk.HealthcheckReport{
Time: time.Now(),
+114
View File
@@ -2,6 +2,9 @@ package healthcheck
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
@@ -10,8 +13,91 @@ import (
"github.com/coder/coder/v2/coderd/healthcheck/health"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk/healthsdk"
"github.com/coder/quartz"
)
// Progress tracks the progress of healthcheck components for timeout
// diagnostics. It records which checks have started and completed, along with
// their durations, to provide useful information when a healthcheck times out.
// The zero value is usable.
type Progress struct {
Clock quartz.Clock
mu sync.Mutex
checks map[string]*checkStatus
}
type checkStatus struct {
startedAt time.Time
completedAt time.Time
}
// Start records that a check has started.
func (p *Progress) Start(name string) {
p.mu.Lock()
defer p.mu.Unlock()
if p.Clock == nil {
p.Clock = quartz.NewReal()
}
if p.checks == nil {
p.checks = make(map[string]*checkStatus)
}
p.checks[name] = &checkStatus{startedAt: p.Clock.Now()}
}
// Complete records that a check has finished.
func (p *Progress) Complete(name string) {
p.mu.Lock()
defer p.mu.Unlock()
if p.Clock == nil {
p.Clock = quartz.NewReal()
}
if p.checks == nil {
p.checks = make(map[string]*checkStatus)
}
if p.checks[name] == nil {
p.checks[name] = &checkStatus{startedAt: p.Clock.Now()}
}
p.checks[name].completedAt = p.Clock.Now()
}
// Reset clears all recorded check statuses.
func (p *Progress) Reset() {
p.mu.Lock()
defer p.mu.Unlock()
p.checks = make(map[string]*checkStatus)
}
// Summary returns a human-readable summary of check progress.
// Example: "Completed: AccessURL (95ms), Database (120ms). Still running: DERP, Websocket"
func (p *Progress) Summary() string {
p.mu.Lock()
defer p.mu.Unlock()
var completed, running []string
for name, status := range p.checks {
if status.completedAt.IsZero() {
elapsed := p.Clock.Now().Sub(status.startedAt).Round(time.Millisecond)
running = append(running, fmt.Sprintf("%s (elapsed: %dms)", name, elapsed.Milliseconds()))
continue
}
duration := status.completedAt.Sub(status.startedAt).Round(time.Millisecond)
completed = append(completed, fmt.Sprintf("%s (%dms)", name, duration.Milliseconds()))
}
// Sort for consistent output.
slices.Sort(completed)
slices.Sort(running)
var parts []string
if len(completed) > 0 {
parts = append(parts, "Completed: "+strings.Join(completed, ", "))
}
if len(running) > 0 {
parts = append(parts, "Still running: "+strings.Join(running, ", "))
}
return strings.Join(parts, ". ")
}
type Checker interface {
DERP(ctx context.Context, opts *derphealth.ReportOptions) healthsdk.DERPHealthReport
AccessURL(ctx context.Context, opts *AccessURLReportOptions) healthsdk.AccessURLReport
@@ -30,6 +116,10 @@ type ReportOptions struct {
ProvisionerDaemons ProvisionerDaemonsReportDeps
Checker Checker
// Progress tracks healthcheck progress for timeout diagnostics.
// If set, each check will record its start and completion time.
Progress *Progress
}
type defaultChecker struct{}
@@ -89,6 +179,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
}
}()
if opts.Progress != nil {
opts.Progress.Start("DERP")
defer opts.Progress.Complete("DERP")
}
report.DERP = opts.Checker.DERP(ctx, &opts.DerpHealth)
}()
@@ -101,6 +195,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
}
}()
if opts.Progress != nil {
opts.Progress.Start("AccessURL")
defer opts.Progress.Complete("AccessURL")
}
report.AccessURL = opts.Checker.AccessURL(ctx, &opts.AccessURL)
}()
@@ -113,6 +211,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
}
}()
if opts.Progress != nil {
opts.Progress.Start("Websocket")
defer opts.Progress.Complete("Websocket")
}
report.Websocket = opts.Checker.Websocket(ctx, &opts.Websocket)
}()
@@ -125,6 +227,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
}
}()
if opts.Progress != nil {
opts.Progress.Start("Database")
defer opts.Progress.Complete("Database")
}
report.Database = opts.Checker.Database(ctx, &opts.Database)
}()
@@ -137,6 +243,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
}
}()
if opts.Progress != nil {
opts.Progress.Start("WorkspaceProxy")
defer opts.Progress.Complete("WorkspaceProxy")
}
report.WorkspaceProxy = opts.Checker.WorkspaceProxy(ctx, &opts.WorkspaceProxy)
}()
@@ -149,6 +259,10 @@ func Run(ctx context.Context, opts *ReportOptions) *healthsdk.HealthcheckReport
}
}()
if opts.Progress != nil {
opts.Progress.Start("ProvisionerDaemons")
defer opts.Progress.Complete("ProvisionerDaemons")
}
report.ProvisionerDaemons = opts.Checker.ProvisionerDaemons(ctx, &opts.ProvisionerDaemons)
}()
+68
View File
@@ -3,6 +3,7 @@ package healthcheck_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
@@ -10,6 +11,7 @@ import (
"github.com/coder/coder/v2/coderd/healthcheck/derphealth"
"github.com/coder/coder/v2/coderd/healthcheck/health"
"github.com/coder/coder/v2/codersdk/healthsdk"
"github.com/coder/quartz"
)
type testChecker struct {
@@ -533,3 +535,69 @@ func TestHealthcheck(t *testing.T) {
})
}
}
func TestCheckProgress(t *testing.T) {
t.Parallel()
t.Run("Summary", func(t *testing.T) {
t.Parallel()
mClock := quartz.NewMock(t)
progress := healthcheck.Progress{Clock: mClock}
// Start some checks
progress.Start("Database")
progress.Start("DERP")
progress.Start("AccessURL")
// Advance time to simulate check duration
mClock.Advance(100 * time.Millisecond)
// Complete some checks
progress.Complete("Database")
progress.Complete("AccessURL")
summary := progress.Summary()
// Verify completed and running checks are listed with duration / elapsed
assert.Equal(t, summary, "Completed: AccessURL (100ms), Database (100ms). Still running: DERP (elapsed: 100ms)")
})
t.Run("EmptyProgress", func(t *testing.T) {
t.Parallel()
mClock := quartz.NewMock(t)
progress := healthcheck.Progress{Clock: mClock}
summary := progress.Summary()
// Should be empty string when nothing tracked
assert.Empty(t, summary)
})
t.Run("AllCompleted", func(t *testing.T) {
t.Parallel()
mClock := quartz.NewMock(t)
progress := healthcheck.Progress{Clock: mClock}
progress.Start("Database")
progress.Start("DERP")
mClock.Advance(50 * time.Millisecond)
progress.Complete("Database")
progress.Complete("DERP")
summary := progress.Summary()
assert.Equal(t, summary, "Completed: DERP (50ms), Database (50ms)")
})
t.Run("AllRunning", func(t *testing.T) {
t.Parallel()
mClock := quartz.NewMock(t)
progress := healthcheck.Progress{Clock: mClock}
progress.Start("Database")
progress.Start("DERP")
summary := progress.Summary()
assert.Equal(t, summary, "Still running: DERP (elapsed: 0ms), Database (elapsed: 0ms)")
})
}
+2 -8
View File
@@ -493,16 +493,10 @@ func OneWayWebSocketEventSender(rw http.ResponseWriter, r *http.Request) (
return sendEvent, closed, nil
}
// OAuth2Error represents an OAuth2-compliant error response per RFC 6749.
type OAuth2Error struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// WriteOAuth2Error writes an OAuth2-compliant error response per RFC 6749.
// This should be used for all OAuth2 endpoints (/oauth2/*) to ensure compliance.
func WriteOAuth2Error(ctx context.Context, rw http.ResponseWriter, status int, errorCode, description string) {
Write(ctx, rw, status, OAuth2Error{
func WriteOAuth2Error(ctx context.Context, rw http.ResponseWriter, status int, errorCode codersdk.OAuth2ErrorCode, description string) {
Write(ctx, rw, status, codersdk.OAuth2Error{
Error: errorCode,
ErrorDescription: description,
})
+71
View File
@@ -0,0 +1,71 @@
package httpmw
import (
"context"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
type (
httpRouteInfoKey struct{}
)
type httpRouteInfo struct {
Route string
Method string
}
// ExtractHTTPRoute retrieves just the HTTP route pattern from context.
// Returns empty string if not set.
func ExtractHTTPRoute(ctx context.Context) string {
ri, _ := ctx.Value(httpRouteInfoKey{}).(httpRouteInfo)
return ri.Route
}
// ExtractHTTPMethod retrieves just the HTTP method from context.
// Returns empty string if not set.
func ExtractHTTPMethod(ctx context.Context) string {
ri, _ := ctx.Value(httpRouteInfoKey{}).(httpRouteInfo)
return ri.Method
}
// HTTPRoute is middleware that stores the HTTP route pattern and method in
// context for use by downstream handlers and services (e.g. prometheus).
func HTTPRoute(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
route := getRoutePattern(r)
ctx := context.WithValue(r.Context(), httpRouteInfoKey{}, httpRouteInfo{
Route: route,
Method: r.Method,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func getRoutePattern(r *http.Request) string {
rctx := chi.RouteContext(r.Context())
if rctx == nil {
return ""
}
routePath := r.URL.Path
if r.URL.RawPath != "" {
routePath = r.URL.RawPath
}
tctx := chi.NewRouteContext()
routes := rctx.Routes
if routes != nil && !routes.Match(tctx, r.Method, routePath) {
// No matching pattern. /api/* requests will be matched as "UNKNOWN"
// All other ones will be matched as "STATIC".
if strings.HasPrefix(routePath, "/api/") {
return "UNKNOWN"
}
return "STATIC"
}
// tctx has the updated pattern, since Match mutates it
return tctx.RoutePattern()
}
+104
View File
@@ -0,0 +1,104 @@
package httpmw_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/testutil"
)
func TestHTTPRoute(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
reqFn func() *http.Request
registerRoutes map[string]string
mws []func(http.Handler) http.Handler
expectedRoute string
expectedMethod string
}{
{
name: "without middleware",
reqFn: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/", nil)
},
registerRoutes: map[string]string{http.MethodGet: "/"},
mws: []func(http.Handler) http.Handler{},
expectedRoute: "",
expectedMethod: "",
},
{
name: "root",
reqFn: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/", nil)
},
registerRoutes: map[string]string{http.MethodGet: "/"},
mws: []func(http.Handler) http.Handler{httpmw.HTTPRoute},
expectedRoute: "/",
expectedMethod: http.MethodGet,
},
{
name: "parameterized route",
reqFn: func() *http.Request {
return httptest.NewRequest(http.MethodPut, "/users/123", nil)
},
registerRoutes: map[string]string{http.MethodPut: "/users/{id}"},
mws: []func(http.Handler) http.Handler{httpmw.HTTPRoute},
expectedRoute: "/users/{id}",
expectedMethod: http.MethodPut,
},
{
name: "unknown",
reqFn: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/api/a", nil)
},
registerRoutes: map[string]string{http.MethodGet: "/api/b"},
mws: []func(http.Handler) http.Handler{httpmw.HTTPRoute},
expectedRoute: "UNKNOWN",
expectedMethod: http.MethodGet,
},
{
name: "static",
reqFn: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/some/static/file.png", nil)
},
registerRoutes: map[string]string{http.MethodGet: "/"},
mws: []func(http.Handler) http.Handler{httpmw.HTTPRoute},
expectedRoute: "STATIC",
expectedMethod: http.MethodGet,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
r := chi.NewRouter()
done := make(chan string)
for _, mw := range tc.mws {
r.Use(mw)
}
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(done)
method := httpmw.ExtractHTTPMethod(r.Context())
route := httpmw.ExtractHTTPRoute(r.Context())
assert.Equal(t, tc.expectedMethod, method, "expected method mismatch")
assert.Equal(t, tc.expectedRoute, route, "expected route mismatch")
next.ServeHTTP(w, r)
})
})
for method, route := range tc.registerRoutes {
r.MethodFunc(method, route, func(w http.ResponseWriter, r *http.Request) {})
}
req := tc.reqFn()
r.ServeHTTP(httptest.NewRecorder(), req)
_ = testutil.TryReceive(ctx, t, done)
})
}
}
+3 -3
View File
@@ -290,15 +290,15 @@ func (*codersdkErrorWriter) writeClientNotFound(ctx context.Context, rw http.Res
type oauth2ErrorWriter struct{}
func (*oauth2ErrorWriter) writeMissingClientID(ctx context.Context, rw http.ResponseWriter) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Missing client_id parameter")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "Missing client_id parameter")
}
func (*oauth2ErrorWriter) writeInvalidClientID(ctx context.Context, rw http.ResponseWriter, _ error) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, "invalid_client", "The client credentials are invalid")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
}
func (*oauth2ErrorWriter) writeClientNotFound(ctx context.Context, rw http.ResponseWriter) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, "invalid_client", "The client credentials are invalid")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
}
// extractOAuth2ProviderAppBase is the internal implementation that uses the strategy pattern
+1 -29
View File
@@ -3,10 +3,8 @@ package httpmw
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@@ -71,7 +69,7 @@ func Prometheus(register prometheus.Registerer) func(http.Handler) http.Handler
var (
dist *prometheus.HistogramVec
distOpts []string
path = getRoutePattern(r)
path = ExtractHTTPRoute(r.Context())
)
// We want to count WebSockets separately.
@@ -98,29 +96,3 @@ func Prometheus(register prometheus.Registerer) func(http.Handler) http.Handler
})
}
}
func getRoutePattern(r *http.Request) string {
rctx := chi.RouteContext(r.Context())
if rctx == nil {
return ""
}
routePath := r.URL.Path
if r.URL.RawPath != "" {
routePath = r.URL.RawPath
}
tctx := chi.NewRouteContext()
routes := rctx.Routes
if routes != nil && !routes.Match(tctx, r.Method, routePath) {
// No matching pattern. /api/* requests will be matched as "UNKNOWN"
// All other ones will be matched as "STATIC".
if strings.HasPrefix(routePath, "/api/") {
return "UNKNOWN"
}
return "STATIC"
}
// tctx has the updated pattern, since Match mutates it
return tctx.RoutePattern()
}
+7 -4
View File
@@ -29,9 +29,9 @@ func TestPrometheus(t *testing.T) {
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, chi.NewRouteContext()))
res := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
reg := prometheus.NewRegistry()
httpmw.Prometheus(reg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpmw.HTTPRoute(httpmw.Prometheus(reg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(res, req)
}))).ServeHTTP(res, req)
metrics, err := reg.Gather()
require.NoError(t, err)
require.Greater(t, len(metrics), 0)
@@ -57,7 +57,7 @@ func TestPrometheus(t *testing.T) {
wrappedHandler := promMW(testHandler)
r := chi.NewRouter()
r.Use(tracing.StatusWriterMiddleware, promMW)
r.Use(tracing.StatusWriterMiddleware, httpmw.HTTPRoute, promMW)
r.Get("/api/v2/build/{build}/logs", func(rw http.ResponseWriter, r *http.Request) {
wrappedHandler.ServeHTTP(rw, r)
})
@@ -85,7 +85,7 @@ func TestPrometheus(t *testing.T) {
promMW := httpmw.Prometheus(reg)
r := chi.NewRouter()
r.With(promMW).Get("/api/v2/users/{user}", func(w http.ResponseWriter, r *http.Request) {})
r.With(httpmw.HTTPRoute).With(promMW).Get("/api/v2/users/{user}", func(w http.ResponseWriter, r *http.Request) {})
req := httptest.NewRequest("GET", "/api/v2/users/john", nil)
@@ -115,6 +115,7 @@ func TestPrometheus(t *testing.T) {
promMW := httpmw.Prometheus(reg)
r := chi.NewRouter()
r.Use(httpmw.HTTPRoute)
r.Use(promMW)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
@@ -145,6 +146,7 @@ func TestPrometheus(t *testing.T) {
promMW := httpmw.Prometheus(reg)
r := chi.NewRouter()
r.Use(httpmw.HTTPRoute)
r.Use(promMW)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
@@ -173,6 +175,7 @@ func TestPrometheus(t *testing.T) {
promMW := httpmw.Prometheus(reg)
r := chi.NewRouter()
r.Use(httpmw.HTTPRoute)
r.Use(promMW)
r.Get("/api/v2/workspaceagents/{workspaceagent}/pty", func(w http.ResponseWriter, r *http.Request) {})
+12 -51
View File
@@ -13,19 +13,19 @@ import (
"github.com/coder/coder/v2/codersdk"
)
type workspaceAgentParamContextKey struct{}
type workspaceAgentAndWorkspaceParamContextKey struct{}
// WorkspaceAgentParam returns the workspace agent from the ExtractWorkspaceAgentParam handler.
func WorkspaceAgentParam(r *http.Request) database.WorkspaceAgent {
user, ok := r.Context().Value(workspaceAgentParamContextKey{}).(database.WorkspaceAgent)
// WorkspaceAgentAndWorkspaceParam returns the workspace agent and its associated workspace from the ExtractWorkspaceAgentParam handler.
func WorkspaceAgentAndWorkspaceParam(r *http.Request) database.GetWorkspaceAgentAndWorkspaceByIDRow {
aw, ok := r.Context().Value(workspaceAgentAndWorkspaceParamContextKey{}).(database.GetWorkspaceAgentAndWorkspaceByIDRow)
if !ok {
panic("developer error: agent middleware not provided")
}
return user
return aw
}
// ExtractWorkspaceAgentParam grabs a workspace agent from the "workspaceagent" URL parameter.
func ExtractWorkspaceAgentParam(db database.Store) func(http.Handler) http.Handler {
// ExtractWorkspaceAgentAndWorkspaceParam grabs a workspace agent and its associated workspace from the "workspaceagent" URL parameter.
func ExtractWorkspaceAgentAndWorkspaceParam(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -34,60 +34,21 @@ func ExtractWorkspaceAgentParam(db database.Store) func(http.Handler) http.Handl
return
}
agent, err := db.GetWorkspaceAgentByID(ctx, agentUUID)
agentWithWorkspace, err := db.GetWorkspaceAgentAndWorkspaceByID(ctx, agentUUID)
if httpapi.Is404Error(err) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Agent doesn't exist with that id, or you do not have access to it.",
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agent.",
Detail: err.Error(),
})
return
}
resource, err := db.GetWorkspaceResourceByID(ctx, agent.ResourceID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace resource.",
Detail: err.Error(),
})
return
}
job, err := db.GetProvisionerJobByID(ctx, resource.JobID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Detail: err.Error(),
})
return
}
if job.Type != database.ProvisionerJobTypeWorkspaceBuild {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Workspace agents can only be fetched for builds.",
})
return
}
build, err := db.GetWorkspaceBuildByJobID(ctx, job.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace build.",
Detail: err.Error(),
})
return
}
ctx = context.WithValue(ctx, workspaceAgentParamContextKey{}, agent)
chi.RouteContext(ctx).URLParams.Add("workspace", build.WorkspaceID.String())
ctx = context.WithValue(ctx, workspaceAgentAndWorkspaceParamContextKey{}, agentWithWorkspace)
chi.RouteContext(ctx).URLParams.Add("workspace", agentWithWorkspace.WorkspaceTable.ID.String())
if rlogger := loggermw.RequestLoggerFromContext(ctx); rlogger != nil {
rlogger.WithFields(
slog.F("workspace_name", resource.Name),
slog.F("agent_name", agent.Name),
slog.F("workspace_name", agentWithWorkspace.WorkspaceTable.Name),
slog.F("agent_name", agentWithWorkspace.WorkspaceAgent.Name),
)
}
+5 -5
View File
@@ -86,7 +86,7 @@ func TestWorkspaceAgentParam(t *testing.T) {
db, _ := dbtestutil.NewDB(t)
dbtestutil.DisableForeignKeysAndTriggers(t, db)
rtr := chi.NewRouter()
rtr.Use(httpmw.ExtractWorkspaceAgentParam(db))
rtr.Use(httpmw.ExtractWorkspaceAgentAndWorkspaceParam(db))
rtr.Get("/", nil)
r, _ := setupAuthentication(db)
@@ -113,10 +113,10 @@ func TestWorkspaceAgentParam(t *testing.T) {
RedirectToLogin: false,
}),
// Only fail authz in this middleware
httpmw.ExtractWorkspaceAgentParam(dbFail),
httpmw.ExtractWorkspaceAgentAndWorkspaceParam(dbFail),
)
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
_ = httpmw.WorkspaceAgentParam(r)
_ = httpmw.WorkspaceAgentAndWorkspaceParam(r)
rw.WriteHeader(http.StatusOK)
})
@@ -140,10 +140,10 @@ func TestWorkspaceAgentParam(t *testing.T) {
DB: db,
RedirectToLogin: false,
}),
httpmw.ExtractWorkspaceAgentParam(db),
httpmw.ExtractWorkspaceAgentAndWorkspaceParam(db),
)
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
_ = httpmw.WorkspaceAgentParam(r)
_ = httpmw.WorkspaceAgentAndWorkspaceParam(r)
rw.WriteHeader(http.StatusOK)
})
-118
View File
@@ -2,12 +2,7 @@ package httpmw
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
@@ -59,116 +54,3 @@ func ExtractWorkspaceParam(db database.Store) func(http.Handler) http.Handler {
})
}
}
// ExtractWorkspaceAndAgentParam grabs a workspace and an agent from the
// "workspace_and_agent" URL parameter. `ExtractUserParam` must be called
// before this.
// This can be in the form of:
// - "<workspace-name>.[workspace-agent]" : If multiple agents exist
// - "<workspace-name>" : If one agent exists
func ExtractWorkspaceAndAgentParam(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := UserParam(r)
workspaceWithAgent := chi.URLParam(r, "workspace_and_agent")
workspaceParts := strings.Split(workspaceWithAgent, ".")
workspace, err := db.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
OwnerID: user.ID,
Name: workspaceParts[0],
})
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(),
})
return
}
build, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace build.",
Detail: err.Error(),
})
return
}
resources, err := db.GetWorkspaceResourcesByJobID(ctx, build.JobID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace resources.",
Detail: err.Error(),
})
return
}
resourceIDs := make([]uuid.UUID, 0)
for _, resource := range resources {
resourceIDs = append(resourceIDs, resource.ID)
}
agents, err := db.GetWorkspaceAgentsByResourceIDs(ctx, resourceIDs)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agents.",
Detail: err.Error(),
})
return
}
if len(agents) == 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "No agents exist for this workspace",
})
return
}
// If we have more than 1 workspace agent, we need to specify which one to use.
if len(agents) > 1 && len(workspaceParts) <= 1 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "More than one agent exists, but no agent specified.",
})
return
}
var agent database.WorkspaceAgent
var found bool
// If we have more than 1 workspace agent, we need to specify which one to use.
// If the user specified an agent, we need to make sure that agent
// actually exists.
if len(workspaceParts) > 1 || len(agents) > 1 {
for _, otherAgent := range agents {
if otherAgent.Name == workspaceParts[1] {
agent = otherAgent
found = true
break
}
}
if !found {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("No agent exists with the name %q", workspaceParts[1]),
})
return
}
} else {
agent = agents[0]
}
ctx = context.WithValue(ctx, workspaceParamContextKey{}, workspace)
ctx = context.WithValue(ctx, workspaceAgentParamContextKey{}, agent)
if rlogger := loggermw.RequestLoggerFromContext(ctx); rlogger != nil {
rlogger.WithFields(
slog.F("workspace_name", workspace.Name),
slog.F("agent_name", agent.Name),
)
}
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}
-278
View File
@@ -2,7 +2,6 @@ package httpmw_test
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
@@ -13,7 +12,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
@@ -153,279 +151,3 @@ func TestWorkspaceParam(t *testing.T) {
require.Equal(t, http.StatusOK, res.StatusCode)
})
}
func TestWorkspaceAgentByNameParam(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
// Agents are mapped to a resource
Agents map[string][]string
URLParam string
WorkspaceName string
ExpectedAgent string
ExpectedStatusCode int
ExpectedError string
}{
{
Name: "NoAgents",
WorkspaceName: "dev",
Agents: map[string][]string{},
URLParam: "dev",
ExpectedError: "No agents exist",
ExpectedStatusCode: http.StatusBadRequest,
},
{
Name: "NoAgentsSpecify",
WorkspaceName: "dev",
Agents: map[string][]string{},
URLParam: "dev.agent",
ExpectedError: "No agents exist",
ExpectedStatusCode: http.StatusBadRequest,
},
{
Name: "MultipleAgents",
WorkspaceName: "dev",
Agents: map[string][]string{
"resource-a": {
"agent-one",
"agent-two",
},
},
URLParam: "dev",
ExpectedStatusCode: http.StatusBadRequest,
ExpectedError: "More than one agent exists, but no agent specified",
},
{
Name: "MultipleResources",
WorkspaceName: "dev",
Agents: map[string][]string{
"resource-a": {
"agent-one",
},
"resource-b": {
"agent-two",
},
},
URLParam: "dev",
ExpectedStatusCode: http.StatusBadRequest,
ExpectedError: "More than one agent exists, but no agent specified",
},
{
Name: "NotExistsOneAgent",
WorkspaceName: "dev",
Agents: map[string][]string{
"resource-a": {
"agent-one",
},
},
URLParam: "dev.not-exists",
ExpectedStatusCode: http.StatusBadRequest,
ExpectedError: "No agent exists with the name",
},
{
Name: "NotExistsMultipleAgents",
WorkspaceName: "dev",
Agents: map[string][]string{
"resource-a": {
"agent-one",
},
"resource-b": {
"agent-two",
},
"resource-c": {
"agent-three",
},
},
URLParam: "dev.not-exists",
ExpectedStatusCode: http.StatusBadRequest,
ExpectedError: "No agent exists with the name",
},
// OKs
{
Name: "MultipleResourcesOneAgent",
WorkspaceName: "dev",
Agents: map[string][]string{
"resource-a": {},
"resource-b": {
"agent-one",
},
},
URLParam: "dev",
ExpectedAgent: "agent-one",
ExpectedStatusCode: http.StatusOK,
},
{
Name: "OneAgent",
WorkspaceName: "dev",
Agents: map[string][]string{
"resource-a": {
"agent-one",
},
},
URLParam: "dev",
ExpectedAgent: "agent-one",
ExpectedStatusCode: http.StatusOK,
},
{
Name: "OneAgentSelected",
WorkspaceName: "dev",
Agents: map[string][]string{
"resource-a": {
"agent-one",
},
},
URLParam: "dev.agent-one",
ExpectedAgent: "agent-one",
ExpectedStatusCode: http.StatusOK,
},
{
Name: "MultipleAgentSelectOne",
WorkspaceName: "dev",
Agents: map[string][]string{
"resource-a": {
"agent-one",
"agent-two",
"agent-selected",
},
},
URLParam: "dev.agent-selected",
ExpectedAgent: "agent-selected",
ExpectedStatusCode: http.StatusOK,
},
{
Name: "MultipleResourcesSelectOne",
WorkspaceName: "dev",
Agents: map[string][]string{
"resource-a": {
"agent-one",
},
"resource-b": {
"agent-two",
},
"resource-c": {
"agent-selected",
"agent-three",
},
},
URLParam: "dev.agent-selected",
ExpectedAgent: "agent-selected",
ExpectedStatusCode: http.StatusOK,
},
}
for _, c := range testCases {
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
db, r := setupWorkspaceWithAgents(t, setupConfig{
WorkspaceName: c.WorkspaceName,
Agents: c.Agents,
})
chi.RouteContext(r.Context()).URLParams.Add("workspace_and_agent", c.URLParam)
rtr := chi.NewRouter()
rtr.Use(
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: true,
}),
httpmw.ExtractUserParam(db),
httpmw.ExtractWorkspaceAndAgentParam(db),
)
rtr.Get("/", func(w http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
agent := httpmw.WorkspaceAgentParam(r)
assert.Equal(t, c.ExpectedAgent, agent.Name, "expected agent name")
assert.Equal(t, c.WorkspaceName, workspace.Name, "expected workspace name")
})
rw := httptest.NewRecorder()
rtr.ServeHTTP(rw, r)
res := rw.Result()
var coderResp codersdk.Response
_ = json.NewDecoder(res.Body).Decode(&coderResp)
res.Body.Close()
require.Equal(t, c.ExpectedStatusCode, res.StatusCode)
if c.ExpectedError != "" {
require.Contains(t, coderResp.Message, c.ExpectedError)
}
})
}
}
type setupConfig struct {
WorkspaceName string
// Agents are mapped to a resource
Agents map[string][]string
}
func setupWorkspaceWithAgents(t testing.TB, cfg setupConfig) (database.Store, *http.Request) {
t.Helper()
db, _ := dbtestutil.NewDB(t)
var (
user = dbgen.User(t, db, database.User{})
_, token = dbgen.APIKey(t, db, database.APIKey{
UserID: user.ID,
})
org = dbgen.Organization(t, db, database.Organization{})
tpl = dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
})
workspace = dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: tpl.ID,
Name: cfg.WorkspaceName,
})
job = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
Type: database.ProvisionerJobTypeWorkspaceBuild,
Provisioner: database.ProvisionerTypeEcho,
StorageMethod: database.ProvisionerStorageMethodFile,
})
tv = dbgen.TemplateVersion(t, db, database.TemplateVersion{
TemplateID: uuid.NullUUID{
UUID: tpl.ID,
Valid: true,
},
JobID: job.ID,
OrganizationID: org.ID,
CreatedBy: user.ID,
})
_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
JobID: job.ID,
WorkspaceID: workspace.ID,
Transition: database.WorkspaceTransitionStart,
Reason: database.BuildReasonInitiator,
TemplateVersionID: tv.ID,
})
)
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, token)
for resourceName, agentNames := range cfg.Agents {
resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
JobID: job.ID,
Name: resourceName,
Transition: database.WorkspaceTransitionStart,
})
for _, name := range agentNames {
_ = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ResourceID: resource.ID,
Name: name,
})
}
}
ctx := chi.NewRouteContext()
ctx.URLParams.Add("user", codersdk.Me)
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx))
return db, r
}
+2 -2
View File
@@ -99,7 +99,7 @@ func TestOAuth2RegistrationErrorCodes(t *testing.T) {
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
GrantTypes: []string{"unsupported_grant_type"},
GrantTypes: []codersdk.OAuth2ProviderGrantType{"unsupported_grant_type"},
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
@@ -109,7 +109,7 @@ func TestOAuth2RegistrationErrorCodes(t *testing.T) {
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
ResponseTypes: []string{"unsupported_response_type"},
ResponseTypes: []codersdk.OAuth2ProviderResponseType{"unsupported_response_type"},
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
+4 -4
View File
@@ -44,10 +44,10 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
require.NotEmpty(t, metadata.Issuer)
require.NotEmpty(t, metadata.AuthorizationEndpoint)
require.NotEmpty(t, metadata.TokenEndpoint)
require.Contains(t, metadata.ResponseTypesSupported, "code")
require.Contains(t, metadata.GrantTypesSupported, "authorization_code")
require.Contains(t, metadata.GrantTypesSupported, "refresh_token")
require.Contains(t, metadata.CodeChallengeMethodsSupported, "S256")
require.Contains(t, metadata.ResponseTypesSupported, codersdk.OAuth2ProviderResponseTypeCode)
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeRefreshToken)
require.Contains(t, metadata.CodeChallengeMethodsSupported, codersdk.OAuth2PKCECodeChallengeMethodS256)
// Supported scopes are published from the curated catalog
require.Equal(t, rbac.ExternalScopeNames(), metadata.ScopesSupported)
}
+20 -20
View File
@@ -277,47 +277,47 @@ func TestOAuth2ClientMetadataValidation(t *testing.T) {
tests := []struct {
name string
grantTypes []string
grantTypes []codersdk.OAuth2ProviderGrantType
expectError bool
}{
{
name: "DefaultEmpty",
grantTypes: []string{},
grantTypes: []codersdk.OAuth2ProviderGrantType{},
expectError: false,
},
{
name: "ValidAuthorizationCode",
grantTypes: []string{"authorization_code"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"authorization_code"},
expectError: false,
},
{
name: "InvalidRefreshTokenAlone",
grantTypes: []string{"refresh_token"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"refresh_token"},
expectError: true, // refresh_token requires authorization_code to be present
},
{
name: "ValidMultiple",
grantTypes: []string{"authorization_code", "refresh_token"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"authorization_code", "refresh_token"},
expectError: false,
},
{
name: "InvalidUnsupported",
grantTypes: []string{"client_credentials"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"client_credentials"},
expectError: true,
},
{
name: "InvalidPassword",
grantTypes: []string{"password"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"password"},
expectError: true,
},
{
name: "InvalidImplicit",
grantTypes: []string{"implicit"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"implicit"},
expectError: true,
},
{
name: "MixedValidInvalid",
grantTypes: []string{"authorization_code", "client_credentials"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"authorization_code", "client_credentials"},
expectError: true,
},
}
@@ -352,32 +352,32 @@ func TestOAuth2ClientMetadataValidation(t *testing.T) {
tests := []struct {
name string
responseTypes []string
responseTypes []codersdk.OAuth2ProviderResponseType
expectError bool
}{
{
name: "DefaultEmpty",
responseTypes: []string{},
responseTypes: []codersdk.OAuth2ProviderResponseType{},
expectError: false,
},
{
name: "ValidCode",
responseTypes: []string{"code"},
responseTypes: []codersdk.OAuth2ProviderResponseType{"code"},
expectError: false,
},
{
name: "InvalidToken",
responseTypes: []string{"token"},
responseTypes: []codersdk.OAuth2ProviderResponseType{"token"},
expectError: true,
},
{
name: "InvalidImplicit",
responseTypes: []string{"id_token"},
responseTypes: []codersdk.OAuth2ProviderResponseType{"id_token"},
expectError: true,
},
{
name: "InvalidMultiple",
responseTypes: []string{"code", "token"},
responseTypes: []codersdk.OAuth2ProviderResponseType{"code", "token"},
expectError: true,
},
}
@@ -412,7 +412,7 @@ func TestOAuth2ClientMetadataValidation(t *testing.T) {
tests := []struct {
name string
authMethod string
authMethod codersdk.OAuth2TokenEndpointAuthMethod
expectError bool
}{
{
@@ -659,14 +659,14 @@ func TestOAuth2ClientMetadataDefaults(t *testing.T) {
require.NoError(t, err)
// Should default to authorization_code
require.Contains(t, config.GrantTypes, "authorization_code")
require.Contains(t, config.GrantTypes, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
// Should default to code
require.Contains(t, config.ResponseTypes, "code")
require.Contains(t, config.ResponseTypes, codersdk.OAuth2ProviderResponseTypeCode)
// Should default to client_secret_basic or client_secret_post
require.True(t, config.TokenEndpointAuthMethod == "client_secret_basic" ||
config.TokenEndpointAuthMethod == "client_secret_post" ||
require.True(t, config.TokenEndpointAuthMethod == codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic ||
config.TokenEndpointAuthMethod == codersdk.OAuth2TokenEndpointAuthMethodClientSecretPost ||
config.TokenEndpointAuthMethod == "")
// Client secret should be generated
+7 -7
View File
@@ -1329,10 +1329,10 @@ func TestOAuth2DynamicClientRegistration(t *testing.T) {
require.Equal(t, int64(0), resp.ClientSecretExpiresAt) // Non-expiring
// Verify default values
require.Contains(t, resp.GrantTypes, "authorization_code")
require.Contains(t, resp.GrantTypes, "refresh_token")
require.Contains(t, resp.ResponseTypes, "code")
require.Equal(t, "client_secret_basic", resp.TokenEndpointAuthMethod)
require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeRefreshToken)
require.Contains(t, resp.ResponseTypes, codersdk.OAuth2ProviderResponseTypeCode)
require.Equal(t, codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic, resp.TokenEndpointAuthMethod)
// Verify request values are preserved
require.Equal(t, req.RedirectURIs, resp.RedirectURIs)
@@ -1363,9 +1363,9 @@ func TestOAuth2DynamicClientRegistration(t *testing.T) {
require.NotEmpty(t, resp.RegistrationClientURI)
// Should have defaults applied
require.Contains(t, resp.GrantTypes, "authorization_code")
require.Contains(t, resp.ResponseTypes, "code")
require.Equal(t, "client_secret_basic", resp.TokenEndpointAuthMethod)
require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
require.Contains(t, resp.ResponseTypes, codersdk.OAuth2ProviderResponseTypeCode)
require.Equal(t, codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic, resp.TokenEndpointAuthMethod)
})
t.Run("InvalidRedirectURI", func(t *testing.T) {
+7 -7
View File
@@ -137,13 +137,13 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc {
callbackURL, err := url.Parse(app.CallbackURL)
if err != nil {
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, "server_error", "Failed to validate query parameters")
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Failed to validate query parameters")
return
}
params, _, err := extractAuthorizeParams(r, callbackURL)
if err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", err.Error())
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, err.Error())
return
}
@@ -151,10 +151,10 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc {
if params.codeChallenge != "" {
// If code_challenge is provided but method is not, default to S256
if params.codeChallengeMethod == "" {
params.codeChallengeMethod = "S256"
params.codeChallengeMethod = string(codersdk.OAuth2PKCECodeChallengeMethodS256)
}
if params.codeChallengeMethod != "S256" {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid code_challenge_method: only S256 is supported")
if err := codersdk.ValidatePKCECodeChallengeMethod(params.codeChallengeMethod); err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, err.Error())
return
}
}
@@ -162,7 +162,7 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc {
// TODO: Ignoring scope for now, but should look into implementing.
code, err := GenerateSecret()
if err != nil {
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, "server_error", "Failed to generate OAuth2 app authorization code")
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Failed to generate OAuth2 app authorization code")
return
}
err = db.InTx(func(tx database.Store) error {
@@ -202,7 +202,7 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc {
return nil
}, nil)
if err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Failed to generate OAuth2 authorization code")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Failed to generate OAuth2 authorization code")
return
}
+4 -4
View File
@@ -19,11 +19,11 @@ func GetAuthorizationServerMetadata(accessURL *url.URL) http.HandlerFunc {
TokenEndpoint: accessURL.JoinPath("/oauth2/tokens").String(),
RegistrationEndpoint: accessURL.JoinPath("/oauth2/register").String(), // RFC 7591
RevocationEndpoint: accessURL.JoinPath("/oauth2/revoke").String(), // RFC 7009
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
CodeChallengeMethodsSupported: []string{"S256"},
ResponseTypesSupported: []codersdk.OAuth2ProviderResponseType{codersdk.OAuth2ProviderResponseTypeCode},
GrantTypesSupported: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode, codersdk.OAuth2ProviderGrantTypeRefreshToken},
CodeChallengeMethodsSupported: []codersdk.OAuth2PKCECodeChallengeMethod{codersdk.OAuth2PKCECodeChallengeMethodS256},
ScopesSupported: rbac.ExternalScopeNames(),
TokenEndpointAuthMethodsSupported: []string{"client_secret_post"},
TokenEndpointAuthMethodsSupported: []codersdk.OAuth2TokenEndpointAuthMethod{codersdk.OAuth2TokenEndpointAuthMethodClientSecretPost},
}
httpapi.Write(ctx, rw, http.StatusOK, metadata)
}
+4 -4
View File
@@ -32,10 +32,10 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
require.NotEmpty(t, metadata.Issuer)
require.NotEmpty(t, metadata.AuthorizationEndpoint)
require.NotEmpty(t, metadata.TokenEndpoint)
require.Contains(t, metadata.ResponseTypesSupported, "code")
require.Contains(t, metadata.GrantTypesSupported, "authorization_code")
require.Contains(t, metadata.GrantTypesSupported, "refresh_token")
require.Contains(t, metadata.CodeChallengeMethodsSupported, "S256")
require.Contains(t, metadata.ResponseTypesSupported, codersdk.OAuth2ProviderResponseTypeCode)
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeRefreshToken)
require.Contains(t, metadata.CodeChallengeMethodsSupported, codersdk.OAuth2PKCECodeChallengeMethodS256)
// Supported scopes are published from the curated catalog
require.Equal(t, rbac.ExternalScopeNames(), metadata.ScopesSupported)
}

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