mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
abd7b7aeba
Bumps the github-actions group with 10 updates in the / directory: | Package | From | To | | --- | --- | --- | | [crate-ci/typos](https://github.com/crate-ci/typos) | `1.40.0` | `1.44.0` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `6.0.0` | `7.0.0` | | [docker/login-action](https://github.com/docker/login-action) | `3.7.0` | `4.0.0` | | [actions/attest](https://github.com/actions/attest) | `3.2.0` | `4.1.0` | | [tj-actions/changed-files](https://github.com/tj-actions/changed-files) | `47.0.1` | `47.0.5` | | [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3.12.0` | `4.0.0` | | [linear/linear-release-action](https://github.com/linear/linear-release-action) | `0.4.0` | `0.5.0` | | [benc-uk/workflow-dispatch](https://github.com/benc-uk/workflow-dispatch) | `1.2.4` | `1.3.1` | | [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) | `c1824fd6edce30d7ab345a9989de00bbd46ef284` | `57a97c7e7821a5776cebc9bb87c984fa69cba8f1` | | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | Updates `crate-ci/typos` from 1.40.0 to 1.44.0 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/crate-ci/typos/releases">crate-ci/typos's releases</a>.</em></p> <blockquote> <h2>v1.44.0</h2> <h2>[1.44.0] - 2026-02-27</h2> <h3>Features</h3> <ul> <li>Updated the dictionary with the <a href="https://redirect.github.com/crate-ci/typos/issues/1488">February 2026</a> changes</li> </ul> <h2>v1.43.5</h2> <h2>[1.43.5] - 2026-02-16</h2> <h3>Fixes</h3> <ul> <li><em>(pypi)</em> Hopefully fix the sdist build</li> </ul> <h2>v1.43.4</h2> <h2>[1.43.4] - 2026-02-09</h2> <h3>Fixes</h3> <ul> <li>Don't correct <code>pincher</code></li> </ul> <h2>v1.43.3</h2> <h2>[1.43.3] - 2026-02-06</h2> <h3>Fixes</h3> <ul> <li><em>(action)</em> Adjust how typos are reported to github</li> </ul> <h2>v1.43.2</h2> <h2>[1.43.2] - 2026-02-05</h2> <h3>Fixes</h3> <ul> <li>Don't correct <code>certifi</code> in Python</li> </ul> <h2>v1.43.1</h2> <h2>[1.43.1] - 2026-02-03</h2> <h3>Fixes</h3> <ul> <li>Don't correct <code>consts</code></li> </ul> <h2>v1.43.0</h2> <h2>[1.43.0] - 2026-02-02</h2> <h3>Features</h3> <ul> <li>Updated the dictionary with the <a href="https://redirect.github.com/crate-ci/typos/issues/1453">January 2026</a> changes</li> </ul> <h2>v1.42.3</h2> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Changelog</summary> <p><em>Sourced from <a href="https://github.com/crate-ci/typos/blob/master/CHANGELOG.md">crate-ci/typos's changelog</a>.</em></p> <blockquote> <h1>Change Log</h1> <p>All notable changes to this project will be documented in this file.</p> <p>The format is based on <a href="https://keepachangelog.com/">Keep a Changelog</a> and this project adheres to <a href="https://semver.org/">Semantic Versioning</a>.</p> <!-- raw HTML omitted --> <h2>[Unreleased] - ReleaseDate</h2> <h2>[1.44.0] - 2026-02-27</h2> <h3>Features</h3> <ul> <li>Updated the dictionary with the <a href="https://redirect.github.com/crate-ci/typos/issues/1488">February 2026</a> changes</li> </ul> <h2>[1.43.5] - 2026-02-16</h2> <h3>Fixes</h3> <ul> <li><em>(pypi)</em> Hopefully fix the sdist build</li> </ul> <h2>[1.43.4] - 2026-02-09</h2> <h3>Fixes</h3> <ul> <li>Don't correct <code>pincher</code></li> </ul> <h2>[1.43.3] - 2026-02-06</h2> <h3>Fixes</h3> <ul> <li><em>(action)</em> Adjust how typos are reported to github</li> </ul> <h2>[1.43.2] - 2026-02-05</h2> <h3>Fixes</h3> <ul> <li>Don't correct <code>certifi</code> in Python</li> </ul> <h2>[1.43.1] - 2026-02-03</h2> <h3>Fixes</h3> <ul> <li>Don't correct <code>consts</code></li> </ul> <h2>[1.43.0] - 2026-02-02</h2> <h3>Compatibility</h3> <ul> <li>Bumped MSRV to 1.91</li> </ul> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/crate-ci/typos/commit/631208b7aac2daa8b707f55e7331f9112b0e062d"><code>631208b</code></a> chore: Release</li> <li><a href="https://github.com/crate-ci/typos/commit/3d3c6e376823e66c4f3e2583fc47b8be83b66d71"><code>3d3c6e3</code></a> chore: Release</li> <li><a href="https://github.com/crate-ci/typos/commit/ba1f545443d223c6bc2c821dad76c210fa78b46f"><code>ba1f545</code></a> docs: Update changelog</li> <li><a href="https://github.com/crate-ci/typos/commit/102f66c093f0eb1a69937d3d1c589d5f16c5569b"><code>102f66c</code></a> Merge pull request <a href="https://redirect.github.com/crate-ci/typos/issues/1510">#1510</a> from epage/feb</li> <li><a href="https://github.com/crate-ci/typos/commit/d303c9398affd88fc562292a2ec9433a37817b28"><code>d303c93</code></a> feat(dict): February updates</li> <li><a href="https://github.com/crate-ci/typos/commit/30eea72e385d435c00a24eeba0d96f87048f42ec"><code>30eea72</code></a> chore(ci): Update pre-build binary workflow</li> <li><a href="https://github.com/crate-ci/typos/commit/57b11c6b7e54c402ccd9cda953f1072ec4f78e33"><code>57b11c6</code></a> chore: Release</li> <li><a href="https://github.com/crate-ci/typos/commit/105ced22a5a7fedc36cbef6e5dec31b708e9ec5b"><code>105ced2</code></a> docs: Update changelog</li> <li><a href="https://github.com/crate-ci/typos/commit/4f89be7e4a7933f8d9693a9da7a9e9258a8671ba"><code>4f89be7</code></a> Merge pull request <a href="https://redirect.github.com/crate-ci/typos/issues/1504">#1504</a> from schnellerhase/bump-maturin</li> <li><a href="https://github.com/crate-ci/typos/commit/d8547ad9c141d0e2c568b2344f0804a446ff25ab"><code>d8547ad</code></a> Merge pull request <a href="https://redirect.github.com/crate-ci/typos/issues/1503">#1503</a> from 1195343015/patch-1</li> <li>Additional commits viewable in <a href="https://github.com/crate-ci/typos/compare/2d0ce569feab1f8752f1dde43cc2f2aa53236e06...631208b7aac2daa8b707f55e7331f9112b0e062d">compare view</a></li> </ul> </details> <br /> Updates `actions/upload-artifact` from 6.0.0 to 7.0.0 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/actions/upload-artifact/releases">actions/upload-artifact's releases</a>.</em></p> <blockquote> <h2>v7.0.0</h2> <h2>v7 What's new</h2> <h3>Direct Uploads</h3> <p>Adds support for uploading single files directly (unzipped). Callers can set the new <code>archive</code> parameter to <code>false</code> to skip zipping the file during upload. Right now, we only support single files. The action will fail if the glob passed resolves to multiple files. The <code>name</code> parameter is also ignored with this setting. Instead, the name of the artifact will be the name of the uploaded file.</p> <h3>ESM</h3> <p>To support new versions of the <code>@actions/*</code> packages, we've upgraded the package to ESM.</p> <h2>What's Changed</h2> <ul> <li>Add proxy integration test by <a href="https://github.com/Link"><code>@Link</code></a>- in <a href="https://redirect.github.com/actions/upload-artifact/pull/754">actions/upload-artifact#754</a></li> <li>Upgrade the module to ESM and bump dependencies by <a href="https://github.com/danwkennedy"><code>@danwkennedy</code></a> in <a href="https://redirect.github.com/actions/upload-artifact/pull/762">actions/upload-artifact#762</a></li> <li>Support direct file uploads by <a href="https://github.com/danwkennedy"><code>@danwkennedy</code></a> in <a href="https://redirect.github.com/actions/upload-artifact/pull/764">actions/upload-artifact#764</a></li> </ul> <h2>New Contributors</h2> <ul> <li><a href="https://github.com/Link"><code>@Link</code></a>- made their first contribution in <a href="https://redirect.github.com/actions/upload-artifact/pull/754">actions/upload-artifact#754</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/actions/upload-artifact/compare/v6...v7.0.0">https://github.com/actions/upload-artifact/compare/v6...v7.0.0</a></p> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/actions/upload-artifact/commit/bbbca2ddaa5d8feaa63e36b76fdaad77386f024f"><code>bbbca2d</code></a> Support direct file uploads (<a href="https://redirect.github.com/actions/upload-artifact/issues/764">#764</a>)</li> <li><a href="https://github.com/actions/upload-artifact/commit/589182c5a4cec8920b8c1bce3e2fab1c97a02296"><code>589182c</code></a> Upgrade the module to ESM and bump dependencies (<a href="https://redirect.github.com/actions/upload-artifact/issues/762">#762</a>)</li> <li><a href="https://github.com/actions/upload-artifact/commit/47309c993abb98030a35d55ef7ff34b7fa1074b5"><code>47309c9</code></a> Merge pull request <a href="https://redirect.github.com/actions/upload-artifact/issues/754">#754</a> from actions/Link-/add-proxy-integration-tests</li> <li><a href="https://github.com/actions/upload-artifact/commit/02a8460834e70dab0ce194c64360c59dc1475ef0"><code>02a8460</code></a> Add proxy integration test</li> <li>See full diff in <a href="https://github.com/actions/upload-artifact/compare/b7c566a772e6b6bfb58ed0dc250532a479d7789f...bbbca2ddaa5d8feaa63e36b76fdaad77386f024f">compare view</a></li> </ul> </details> <br /> Updates `docker/login-action` from 3.7.0 to 4.0.0 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/docker/login-action/releases">docker/login-action's releases</a>.</em></p> <blockquote> <h2>v4.0.0</h2> <ul> <li>Node 24 as default runtime (requires <a href="https://github.com/actions/runner/releases/tag/v2.327.1">Actions Runner v2.327.1</a> or later) by <a href="https://github.com/crazy-max"><code>@crazy-max</code></a> in <a href="https://redirect.github.com/docker/login-action/pull/929">docker/login-action#929</a></li> <li>Switch to ESM and update config/test wiring by <a href="https://github.com/crazy-max"><code>@crazy-max</code></a> in <a href="https://redirect.github.com/docker/login-action/pull/927">docker/login-action#927</a></li> <li>Bump <code>@actions/core</code> from 1.11.1 to 3.0.0 in <a href="https://redirect.github.com/docker/login-action/pull/919">docker/login-action#919</a></li> <li>Bump <code>@aws-sdk/client-ecr</code> from 3.890.0 to 3.1000.0 in <a href="https://redirect.github.com/docker/login-action/pull/909">docker/login-action#909</a> <a href="https://redirect.github.com/docker/login-action/pull/920">docker/login-action#920</a></li> <li>Bump <code>@aws-sdk/client-ecr-public</code> from 3.890.0 to 3.1000.0 in <a href="https://redirect.github.com/docker/login-action/pull/909">docker/login-action#909</a> <a href="https://redirect.github.com/docker/login-action/pull/920">docker/login-action#920</a></li> <li>Bump <code>@docker/actions-toolkit</code> from 0.63.0 to 0.77.0 in <a href="https://redirect.github.com/docker/login-action/pull/910">docker/login-action#910</a> <a href="https://redirect.github.com/docker/login-action/pull/928">docker/login-action#928</a></li> <li>Bump <code>@isaacs/brace-expansion</code> from 5.0.0 to 5.0.1 in <a href="https://redirect.github.com/docker/login-action/pull/921">docker/login-action#921</a></li> <li>Bump js-yaml from 4.1.0 to 4.1.1 in <a href="https://redirect.github.com/docker/login-action/pull/901">docker/login-action#901</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/docker/login-action/compare/v3.7.0...v4.0.0">https://github.com/docker/login-action/compare/v3.7.0...v4.0.0</a></p> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/docker/login-action/commit/b45d80f862d83dbcd57f89517bcf500b2ab88fb2"><code>b45d80f</code></a> Merge pull request <a href="https://redirect.github.com/docker/login-action/issues/929">#929</a> from crazy-max/node24</li> <li><a href="https://github.com/docker/login-action/commit/176cb9c12abea98dfe844071c0999ff6ee9688a7"><code>176cb9c</code></a> node 24 as default runtime</li> <li><a href="https://github.com/docker/login-action/commit/cad89843109a11cb6f69f52fe695c42cf69d57d3"><code>cad8984</code></a> Merge pull request <a href="https://redirect.github.com/docker/login-action/issues/920">#920</a> from docker/dependabot/npm_and_yarn/aws-sdk-dependenc...</li> <li><a href="https://github.com/docker/login-action/commit/92cbcb231ed341e7dc71693351b21f5ba65f8349"><code>92cbcb2</code></a> chore: update generated content</li> <li><a href="https://github.com/docker/login-action/commit/5a2d6a71bd3e0cb4abb6faae33f3dde61ece8e5b"><code>5a2d6a7</code></a> build(deps): bump the aws-sdk-dependencies group with 2 updates</li> <li><a href="https://github.com/docker/login-action/commit/44512b6b2e08b878e82b107b394fcd1af5748e63"><code>44512b6</code></a> Merge pull request <a href="https://redirect.github.com/docker/login-action/issues/928">#928</a> from docker/dependabot/npm_and_yarn/docker/actions-to...</li> <li><a href="https://github.com/docker/login-action/commit/28737a5e46bc0c62910ef429b2e55f9cabbbd5df"><code>28737a5</code></a> chore: update generated content</li> <li><a href="https://github.com/docker/login-action/commit/dac079354afbd8db4c3b58b8cc6946573479b2a6"><code>dac0793</code></a> build(deps): bump <code>@docker/actions-toolkit</code> from 0.76.0 to 0.77.0</li> <li><a href="https://github.com/docker/login-action/commit/62029f315d6d05c8646343320e4a1552e5f1c77a"><code>62029f3</code></a> Merge pull request <a href="https://redirect.github.com/docker/login-action/issues/919">#919</a> from docker/dependabot/npm_and_yarn/actions/core-3.0.0</li> <li><a href="https://github.com/docker/login-action/commit/08c8f064bf22a1c55918ee608a81d87b13cc4461"><code>08c8f06</code></a> chore: update generated content</li> <li>Additional commits viewable in <a href="https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...b45d80f862d83dbcd57f89517bcf500b2ab88fb2">compare view</a></li> </ul> </details> <br /> Updates `actions/attest` from 3.2.0 to 4.1.0 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/actions/attest/releases">actions/attest's releases</a>.</em></p> <blockquote> <h2>v4.1.0</h2> <h2>What's Changed</h2> <ul> <li>Bump <code>@actions/attest</code> from 3.0.0 to 3.1.0 by <a href="https://github.com/bdehamer"><code>@bdehamer</code></a> in <a href="https://redirect.github.com/actions/attest/pull/362">actions/attest#362</a></li> <li>Bump <code>@actions/attest</code> from 3.1.0 to 3.2.0 by <a href="https://github.com/bdehamer"><code>@bdehamer</code></a> in <a href="https://redirect.github.com/actions/attest/pull/365">actions/attest#365</a></li> <li>Add new <code>subject-version</code> input for inclusion in storage record by <a href="https://github.com/bdehamer"><code>@bdehamer</code></a> in <a href="https://redirect.github.com/actions/attest/pull/364">actions/attest#364</a></li> <li>Add storage record content to README by <a href="https://github.com/bdehamer"><code>@bdehamer</code></a> in <a href="https://redirect.github.com/actions/attest/pull/366">actions/attest#366</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/actions/attest/compare/v4.0.0...v4.1.0">https://github.com/actions/attest/compare/v4.0.0...v4.1.0</a></p> <h2>v4.0.0</h2> <p>All of the capabilities of <a href="https://github.com/actions/attest-build-provenance"><code>actions/attest-build-provenance</code></a>, and <a href="https://github.com/actions/attest-sbom"><code>actions/attest-sbom</code></a> have now been folded into <code>actions/attest</code>.</p> <h2>What's Changed</h2> <ul> <li>Bump <code>@actions/core</code> from 2.0.1 to 2.0.2 in the npm-production group by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/actions/attest/pull/323">actions/attest#323</a></li> <li>Bump tar from 7.4.3 to 7.5.6 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/actions/attest/pull/333">actions/attest#333</a></li> <li>Bump <code>@actions/github</code> from 6.0.1 to 7.0.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/actions/attest/pull/324">actions/attest#324</a></li> <li>Bump <code>@actions/attest</code> from 2.1.0 to 2.2.1 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/actions/attest/pull/325">actions/attest#325</a></li> <li>Bump tar from 7.4.3 to 7.5.7 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/actions/attest/pull/337">actions/attest#337</a></li> <li>Bump <code>@isaacs/brace-expansion</code> from 5.0.0 to 5.0.1 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/actions/attest/pull/342">actions/attest#342</a></li> <li>Consolidate attestation actions by <a href="https://github.com/bdehamer"><code>@bdehamer</code></a> in <a href="https://redirect.github.com/actions/attest/pull/346">actions/attest#346</a></li> <li>ESM Conversion by <a href="https://github.com/bdehamer"><code>@bdehamer</code></a> in <a href="https://redirect.github.com/actions/attest/pull/347">actions/attest#347</a></li> <li>Test suite refactor by <a href="https://github.com/bdehamer"><code>@bdehamer</code></a> in <a href="https://redirect.github.com/actions/attest/pull/356">actions/attest#356</a></li> <li>Bump tar from 7.5.7 to 7.5.9 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/actions/attest/pull/354">actions/attest#354</a></li> <li>Bump version in package.json to v4.0.0 by <a href="https://github.com/bdehamer"><code>@bdehamer</code></a> in <a href="https://redirect.github.com/actions/attest/pull/360">actions/attest#360</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/actions/attest/compare/v3.2.0...v4.0.0">https://github.com/actions/attest/compare/v3.2.0...v4.0.0</a></p> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/actions/attest/commit/59d89421af93a897026c735860bf21b6eb4f7b26"><code>59d8942</code></a> add storage record content to README (<a href="https://redirect.github.com/actions/attest/issues/366">#366</a>)</li> <li><a href="https://github.com/actions/attest/commit/ec072a1cb2a95a9fb38f16ee92f72e0270cbf263"><code>ec072a1</code></a> add new subject-version input (<a href="https://redirect.github.com/actions/attest/issues/364">#364</a>)</li> <li><a href="https://github.com/actions/attest/commit/8b290b8d865f4d5d2caca84a45d0de9620d2187a"><code>8b290b8</code></a> bump <code>@actions/attest</code> from 3.1.0 to 3.2.0 (<a href="https://redirect.github.com/actions/attest/issues/365">#365</a>)</li> <li><a href="https://github.com/actions/attest/commit/35cfe2422ed5658cfc87b5cca7e50507f7d478da"><code>35cfe24</code></a> bump <code>@actions/attest</code> from 3.0.0 to 3.1.0 (<a href="https://redirect.github.com/actions/attest/issues/362">#362</a>)</li> <li><a href="https://github.com/actions/attest/commit/c32b4b8b198b65d0bd9d63490e847ff7b53989d4"><code>c32b4b8</code></a> bump version in package.json to v4.0.0 (<a href="https://redirect.github.com/actions/attest/issues/360">#360</a>)</li> <li><a href="https://github.com/actions/attest/commit/1e73be196c8840af1fa1fbff376890066093a323"><code>1e73be1</code></a> Bump typescript-eslint in the npm-development group (<a href="https://redirect.github.com/actions/attest/issues/358">#358</a>)</li> <li><a href="https://github.com/actions/attest/commit/e1345cbec46c2ad797722d96bfa19e14e3548b70"><code>e1345cb</code></a> Bump the npm-development group across 1 directory with 3 updates (<a href="https://redirect.github.com/actions/attest/issues/357">#357</a>)</li> <li><a href="https://github.com/actions/attest/commit/09cd5f66cb420c0389c6f725c641e08df274410e"><code>09cd5f6</code></a> Bump tar from 7.5.7 to 7.5.9 (<a href="https://redirect.github.com/actions/attest/issues/354">#354</a>)</li> <li><a href="https://github.com/actions/attest/commit/19ad753d23453c7b9e9caf8a907f1d9e08816359"><code>19ad753</code></a> test suite re-write (<a href="https://redirect.github.com/actions/attest/issues/356">#356</a>)</li> <li><a href="https://github.com/actions/attest/commit/7d7ff4475a8e98e172944ad0b6687ab116043a85"><code>7d7ff44</code></a> ESM Conversion (<a href="https://redirect.github.com/actions/attest/issues/347">#347</a>)</li> <li>Additional commits viewable in <a href="https://github.com/actions/attest/compare/e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d...59d89421af93a897026c735860bf21b6eb4f7b26">compare view</a></li> </ul> </details> <br /> Updates `tj-actions/changed-files` from 47.0.1 to 47.0.5 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/tj-actions/changed-files/releases">tj-actions/changed-files's releases</a>.</em></p> <blockquote> <h2>v47.0.5</h2> <h2>What's Changed</h2> <ul> <li>Upgraded to v47.0.4 by <a href="https://github.com/github-actions"><code>@github-actions</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2802">tj-actions/changed-files#2802</a></li> <li>Updated README.md by <a href="https://github.com/github-actions"><code>@github-actions</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2803">tj-actions/changed-files#2803</a></li> <li>Updated README.md by <a href="https://github.com/github-actions"><code>@github-actions</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2805">tj-actions/changed-files#2805</a></li> <li>chore(deps-dev): bump <code>@types/node</code> from 25.2.2 to 25.3.2 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2811">tj-actions/changed-files#2811</a></li> <li>chore(deps): bump actions/download-artifact from 7.0.0 to 8.0.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2810">tj-actions/changed-files#2810</a></li> <li>chore(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2809">tj-actions/changed-files#2809</a></li> <li>chore(deps-dev): bump eslint-plugin-jest from 29.12.1 to 29.15.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2799">tj-actions/changed-files#2799</a></li> <li>chore(deps): bump github/codeql-action from 4.32.2 to 4.32.4 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2806">tj-actions/changed-files#2806</a></li> <li>chore(deps-dev): bump prettier from 3.7.4 to 3.8.1 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2775">tj-actions/changed-files#2775</a></li> <li>chore(deps): bump peter-evans/create-pull-request from 8.0.0 to 8.1.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2774">tj-actions/changed-files#2774</a></li> <li>chore(deps): bump lodash and <code>@types/lodash</code> by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2807">tj-actions/changed-files#2807</a></li> <li>chore(deps-dev): bump eslint-plugin-prettier from 5.5.4 to 5.5.5 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2764">tj-actions/changed-files#2764</a></li> <li>chore(deps): bump github/codeql-action from 4.32.4 to 4.32.5 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2815">tj-actions/changed-files#2815</a></li> <li>chore(deps-dev): bump <code>@types/node</code> from 25.3.2 to 25.3.3 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2814">tj-actions/changed-files#2814</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/tj-actions/changed-files/compare/v47.0.4...v47.0.5">https://github.com/tj-actions/changed-files/compare/v47.0.4...v47.0.5</a></p> <h2>v47.0.4</h2> <h2>What's Changed</h2> <ul> <li>update: release-tagger action to version 6.0.6 by <a href="https://github.com/jackton1"><code>@jackton1</code></a> in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2801">tj-actions/changed-files#2801</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/tj-actions/changed-files/compare/v47.0.3...v47.0.4">https://github.com/tj-actions/changed-files/compare/v47.0.3...v47.0.4</a></p> <h2>v47.0.3</h2> <h2>What's Changed</h2> <ul> <li>chore(deps): bump github/codeql-action from 4.31.10 to 4.32.2 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2790">tj-actions/changed-files#2790</a></li> <li>update: release-tagger action to version 6.0.0 by <a href="https://github.com/jackton1"><code>@jackton1</code></a> in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2800">tj-actions/changed-files#2800</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/tj-actions/changed-files/compare/v47.0.2...v47.0.3">https://github.com/tj-actions/changed-files/compare/v47.0.2...v47.0.3</a></p> <h2>v47.0.2</h2> <h2>What's Changed</h2> <ul> <li>chore(deps-dev): bump eslint-plugin-jest from 29.2.1 to 29.11.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2751">tj-actions/changed-files#2751</a></li> <li>chore(deps): bump actions/upload-artifact from 5.0.0 to 6.0.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2741">tj-actions/changed-files#2741</a></li> <li>chore(deps): bump actions/download-artifact from 6.0.0 to 7.0.0 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2743">tj-actions/changed-files#2743</a></li> <li>chore(deps): bump <code>@actions/core</code> from 2.0.0 to 2.0.2 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2757">tj-actions/changed-files#2757</a></li> <li>Updated README.md by <a href="https://github.com/github-actions"><code>@github-actions</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2768">tj-actions/changed-files#2768</a></li> <li>chore: update dist by <a href="https://github.com/jackton1"><code>@jackton1</code></a> in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2769">tj-actions/changed-files#2769</a></li> <li>chore: update matrix-example.yml by <a href="https://github.com/jackton1"><code>@jackton1</code></a> in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2752">tj-actions/changed-files#2752</a></li> <li>feat: add support for excluding symlinks and fix bug with commit not found by <a href="https://github.com/jackton1"><code>@jackton1</code></a> in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2770">tj-actions/changed-files#2770</a></li> <li>chore(deps): bump github/codeql-action from 4.31.7 to 4.31.10 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2761">tj-actions/changed-files#2761</a></li> <li>Updated README.md by <a href="https://github.com/github-actions"><code>@github-actions</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2771">tj-actions/changed-files#2771</a></li> <li>chore(deps-dev): bump eslint-plugin-jest from 29.11.0 to 29.12.1 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2756">tj-actions/changed-files#2756</a></li> <li>chore(deps-dev): bump <code>@types/lodash</code> from 4.17.21 to 4.17.23 by <a href="https://github.com/dependabot"><code>@dependabot</code></a>[bot] in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2759">tj-actions/changed-files#2759</a></li> <li>fix: Update test.yml by <a href="https://github.com/jackton1"><code>@jackton1</code></a> in <a href="https://redirect.github.com/tj-actions/changed-files/pull/2781">tj-actions/changed-files#2781</a></li> </ul> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Changelog</summary> <p><em>Sourced from <a href="https://github.com/tj-actions/changed-files/blob/main/HISTORY.md">tj-actions/changed-files's changelog</a>.</em></p> <blockquote> <h1>Changelog</h1> <h1><a href="https://github.com/tj-actions/changed-files/compare/v47.0.4...v47.0.5">47.0.5</a> - (2026-03-03)</h1> <h2><!-- raw HTML omitted -->🔄 Update</h2> <ul> <li>Updated README.md (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2805">#2805</a>)</li> </ul> <p>Co-authored-by: github-actions[bot] <41898282+github-actions[bot]<a href="https://github.com/users"><code>@users</code></a>.noreply.github.com> (<a href="https://github.com/tj-actions/changed-files/commit/35dace0375d89e25e78db5f0a44127b61f4e5c20">35dace0</a>) - (github-actions[bot])</p> <ul> <li>Updated README.md (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2803">#2803</a>)</li> </ul> <p>Co-authored-by: github-actions[bot] <41898282+github-actions[bot]<a href="https://github.com/users"><code>@users</code></a>.noreply.github.com> Co-authored-by: Tonye Jack <a href="mailto:jtonye@ymail.com">jtonye@ymail.com</a> (<a href="https://github.com/tj-actions/changed-files/commit/9ee99eb5bda5d6a67fedcd50ecd24fb10add2f41">9ee99eb</a>) - (github-actions[bot])</p> <h2><!-- raw HTML omitted -->⚙️ Miscellaneous Tasks</h2> <ul> <li><strong>deps-dev:</strong> Bump <code>@types/node</code> from 25.3.2 to 25.3.3 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2814">#2814</a>) (<a href="https://github.com/tj-actions/changed-files/commit/22103cc46bda19c2b464ffe86db46df6922fd323">22103cc</a>) - (dependabot[bot])</li> <li><strong>deps:</strong> Bump github/codeql-action from 4.32.4 to 4.32.5 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2815">#2815</a>) (<a href="https://github.com/tj-actions/changed-files/commit/6c02e900a24488df269842eb1cf6ffe3391ce182">6c02e90</a>) - (dependabot[bot])</li> <li><strong>deps-dev:</strong> Bump eslint-plugin-prettier from 5.5.4 to 5.5.5 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2764">#2764</a>) (<a href="https://github.com/tj-actions/changed-files/commit/05f9457d921137103bb9687b6b571075f75a65f2">05f9457</a>) - (dependabot[bot])</li> <li><strong>deps:</strong> Bump lodash and <code>@types/lodash</code> (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2807">#2807</a>) (<a href="https://github.com/tj-actions/changed-files/commit/52ed872dd71bea01a73ce5c7c595e78cb9566401">52ed872</a>) - (dependabot[bot])</li> <li><strong>deps:</strong> Bump peter-evans/create-pull-request from 8.0.0 to 8.1.0 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2774">#2774</a>) (<a href="https://github.com/tj-actions/changed-files/commit/1cc574637935a98713e34cbd4e8cf01a985f942c">1cc5746</a>) - (dependabot[bot])</li> <li><strong>deps-dev:</strong> Bump prettier from 3.7.4 to 3.8.1 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2775">#2775</a>) (<a href="https://github.com/tj-actions/changed-files/commit/de2962f9f408abd241f7c1a8b6cac3ab44358d1a">de2962f</a>) - (dependabot[bot])</li> <li><strong>deps:</strong> Bump github/codeql-action from 4.32.2 to 4.32.4 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2806">#2806</a>) (<a href="https://github.com/tj-actions/changed-files/commit/37e96ccbfefb9100f34f87d75c890c50c6e78d15">37e96cc</a>) - (dependabot[bot])</li> <li><strong>deps-dev:</strong> Bump eslint-plugin-jest from 29.12.1 to 29.15.0 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2799">#2799</a>) (<a href="https://github.com/tj-actions/changed-files/commit/2180b0f05d03655e0bedd1657d13f6abc6313014">2180b0f</a>) - (dependabot[bot])</li> <li><strong>deps:</strong> Bump actions/upload-artifact from 6.0.0 to 7.0.0 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2809">#2809</a>) (<a href="https://github.com/tj-actions/changed-files/commit/cf021c158c722f81dea97fe5edc8bd2de1cc2bc1">cf021c1</a>) - (dependabot[bot])</li> <li><strong>deps:</strong> Bump actions/download-artifact from 7.0.0 to 8.0.0 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2810">#2810</a>) (<a href="https://github.com/tj-actions/changed-files/commit/b54ac6f17f95fdc4ec5ee3bf355ea7c354dc9c53">b54ac6f</a>) - (dependabot[bot])</li> <li><strong>deps-dev:</strong> Bump <code>@types/node</code> from 25.2.2 to 25.3.2 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2811">#2811</a>) (<a href="https://github.com/tj-actions/changed-files/commit/0f2a510bd7ac84bc12cdc52c2094298bc26b1692">0f2a510</a>) - (dependabot[bot])</li> </ul> <h2><!-- raw HTML omitted -->⬆️ Upgrades</h2> <ul> <li>Upgraded to v47.0.4 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2802">#2802</a>)</li> </ul> <p>Co-authored-by: github-actions[bot] <41898282+github-actions[bot]<a href="https://github.com/users"><code>@users</code></a>.noreply.github.com> Co-authored-by: Tonye Jack <a href="mailto:jtonye@ymail.com">jtonye@ymail.com</a> (<a href="https://github.com/tj-actions/changed-files/commit/b7ac303c8684d5e668c6c810e61a6fe32a53fe25">b7ac303</a>) - (github-actions[bot])</p> <h1><a href="https://github.com/tj-actions/changed-files/compare/v47.0.3...v47.0.4">47.0.4</a> - (2026-02-17)</h1> <h2><!-- raw HTML omitted -->🔄 Update</h2> <ul> <li>Release-tagger action to version 6.0.6 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2801">#2801</a>) (<a href="https://github.com/tj-actions/changed-files/commit/7dee1b0c1557f278e5c7dc244927139d78c0e22a">7dee1b0</a>) - (Tonye Jack)</li> </ul> <h1><a href="https://github.com/tj-actions/changed-files/compare/v47.0.2...v47.0.3">47.0.3</a> - (2026-02-17)</h1> <h2><!-- raw HTML omitted -->🔄 Update</h2> <ul> <li>Release-tagger action to version 6.0.0 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2800">#2800</a>) (<a href="https://github.com/tj-actions/changed-files/commit/28b28f6e4e9e3d997beb9dce86cfd8cf0ce7c7f6">28b28f6</a>) - (Tonye Jack)</li> </ul> <h2><!-- raw HTML omitted -->⚙️ Miscellaneous Tasks</h2> <ul> <li><strong>deps:</strong> Bump github/codeql-action from 4.31.10 to 4.32.2 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2790">#2790</a>) (<a href="https://github.com/tj-actions/changed-files/commit/875e6e5df8b8b00995fe6f0afd7ff1531ac1c47d">875e6e5</a>) - (dependabot[bot])</li> </ul> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/tj-actions/changed-files/commit/22103cc46bda19c2b464ffe86db46df6922fd323"><code>22103cc</code></a> chore(deps-dev): bump <code>@types/node</code> from 25.3.2 to 25.3.3 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2814">#2814</a>)</li> <li><a href="https://github.com/tj-actions/changed-files/commit/6c02e900a24488df269842eb1cf6ffe3391ce182"><code>6c02e90</code></a> chore(deps): bump github/codeql-action from 4.32.4 to 4.32.5 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2815">#2815</a>)</li> <li><a href="https://github.com/tj-actions/changed-files/commit/05f9457d921137103bb9687b6b571075f75a65f2"><code>05f9457</code></a> chore(deps-dev): bump eslint-plugin-prettier from 5.5.4 to 5.5.5 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2764">#2764</a>)</li> <li><a href="https://github.com/tj-actions/changed-files/commit/52ed872dd71bea01a73ce5c7c595e78cb9566401"><code>52ed872</code></a> chore(deps): bump lodash and <code>@types/lodash</code> (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2807">#2807</a>)</li> <li><a href="https://github.com/tj-actions/changed-files/commit/1cc574637935a98713e34cbd4e8cf01a985f942c"><code>1cc5746</code></a> chore(deps): bump peter-evans/create-pull-request from 8.0.0 to 8.1.0 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2774">#2774</a>)</li> <li><a href="https://github.com/tj-actions/changed-files/commit/de2962f9f408abd241f7c1a8b6cac3ab44358d1a"><code>de2962f</code></a> chore(deps-dev): bump prettier from 3.7.4 to 3.8.1 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2775">#2775</a>)</li> <li><a href="https://github.com/tj-actions/changed-files/commit/37e96ccbfefb9100f34f87d75c890c50c6e78d15"><code>37e96cc</code></a> chore(deps): bump github/codeql-action from 4.32.2 to 4.32.4 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2806">#2806</a>)</li> <li><a href="https://github.com/tj-actions/changed-files/commit/2180b0f05d03655e0bedd1657d13f6abc6313014"><code>2180b0f</code></a> chore(deps-dev): bump eslint-plugin-jest from 29.12.1 to 29.15.0 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2799">#2799</a>)</li> <li><a href="https://github.com/tj-actions/changed-files/commit/cf021c158c722f81dea97fe5edc8bd2de1cc2bc1"><code>cf021c1</code></a> chore(deps): bump actions/upload-artifact from 6.0.0 to 7.0.0 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2809">#2809</a>)</li> <li><a href="https://github.com/tj-actions/changed-files/commit/b54ac6f17f95fdc4ec5ee3bf355ea7c354dc9c53"><code>b54ac6f</code></a> chore(deps): bump actions/download-artifact from 7.0.0 to 8.0.0 (<a href="https://redirect.github.com/tj-actions/changed-files/issues/2810">#2810</a>)</li> <li>Additional commits viewable in <a href="https://github.com/tj-actions/changed-files/compare/e0021407031f5be11a464abee9a0776171c79891...22103cc46bda19c2b464ffe86db46df6922fd323">compare view</a></li> </ul> </details> <br /> Updates `docker/setup-buildx-action` from 3.12.0 to 4.0.0 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/docker/setup-buildx-action/releases">docker/setup-buildx-action's releases</a>.</em></p> <blockquote> <h2>v4.0.0</h2> <ul> <li>Node 24 as default runtime (requires <a href="https://github.com/actions/runner/releases/tag/v2.327.1">Actions Runner v2.327.1</a> or later) by <a href="https://github.com/crazy-max"><code>@crazy-max</code></a> in <a href="https://redirect.github.com/docker/setup-buildx-action/pull/483">docker/setup-buildx-action#483</a></li> <li>Remove deprecated inputs/outputs by <a href="https://github.com/crazy-max"><code>@crazy-max</code></a> in <a href="https://redirect.github.com/docker/setup-buildx-action/pull/464">docker/setup-buildx-action#464</a></li> <li>Switch to ESM and update config/test wiring by <a href="https://github.com/crazy-max"><code>@crazy-max</code></a> in <a href="https://redirect.github.com/docker/setup-buildx-action/pull/481">docker/setup-buildx-action#481</a></li> <li>Bump <code>@actions/core</code> from 1.11.1 to 3.0.0 in <a href="https://redirect.github.com/docker/setup-buildx-action/pull/475">docker/setup-buildx-action#475</a></li> <li>Bump <code>@docker/actions-toolkit</code> from 0.63.0 to 0.79.0 in <a href="https://redirect.github.com/docker/setup-buildx-action/pull/482">docker/setup-buildx-action#482</a> <a href="https://redirect.github.com/docker/setup-buildx-action/pull/485">docker/setup-buildx-action#485</a></li> <li>Bump js-yaml from 4.1.0 to 4.1.1 in <a href="https://redirect.github.com/docker/setup-buildx-action/pull/452">docker/setup-buildx-action#452</a></li> <li>Bump lodash from 4.17.21 to 4.17.23 in <a href="https://redirect.github.com/docker/setup-buildx-action/pull/472">docker/setup-buildx-action#472</a></li> <li>Bump minimatch from 3.1.2 to 3.1.5 in <a href="https://redirect.github.com/docker/setup-buildx-action/pull/480">docker/setup-buildx-action#480</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/docker/setup-buildx-action/compare/v3.12.0...v4.0.0">https://github.com/docker/setup-buildx-action/compare/v3.12.0...v4.0.0</a></p> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/docker/setup-buildx-action/commit/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd"><code>4d04d5d</code></a> Merge pull request <a href="https://redirect.github.com/docker/setup-buildx-action/issues/485">#485</a> from docker/dependabot/npm_and_yarn/docker/actions-to...</li> <li><a href="https://github.com/docker/setup-buildx-action/commit/cd74e05d9bae4eeec789f90ba15dc6fb4b60ae5d"><code>cd74e05</code></a> chore: update generated content</li> <li><a href="https://github.com/docker/setup-buildx-action/commit/eee38ec7b3ed034ee896d3e212e5d11c04562b84"><code>eee38ec</code></a> build(deps): bump <code>@docker/actions-toolkit</code> from 0.77.0 to 0.79.0</li> <li><a href="https://github.com/docker/setup-buildx-action/commit/7a83f65b5a215b3c81b210dafdc20362bd2b4e24"><code>7a83f65</code></a> Merge pull request <a href="https://redirect.github.com/docker/setup-buildx-action/issues/484">#484</a> from docker/dependabot/github_actions/docker/setup-qe...</li> <li><a href="https://github.com/docker/setup-buildx-action/commit/a5aa96747d67f62520b42af91aeb306e7374b327"><code>a5aa967</code></a> Merge pull request <a href="https://redirect.github.com/docker/setup-buildx-action/issues/464">#464</a> from crazy-max/rm-deprecated</li> <li><a href="https://github.com/docker/setup-buildx-action/commit/e73d53fa4ed86ff46faaf2b13a228d6e93c51af3"><code>e73d53f</code></a> build(deps): bump docker/setup-qemu-action from 3 to 4</li> <li><a href="https://github.com/docker/setup-buildx-action/commit/28a438e9ed9ef7ae2ebd0bf839039005c9501312"><code>28a438e</code></a> Merge pull request <a href="https://redirect.github.com/docker/setup-buildx-action/issues/483">#483</a> from crazy-max/node24</li> <li><a href="https://github.com/docker/setup-buildx-action/commit/034e9d37dd436b56b0167bea5a11ab731413e8cf"><code>034e9d3</code></a> chore: update generated content</li> <li><a href="https://github.com/docker/setup-buildx-action/commit/b4664d8fd0ba15ff14560ab001737c666076d5be"><code>b4664d8</code></a> remove deprecated inputs/outputs</li> <li><a href="https://github.com/docker/setup-buildx-action/commit/a8257dec35f244ad06b4ff6c90fdd2ba97f262ba"><code>a8257de</code></a> node 24 as default runtime</li> <li>Additional commits viewable in <a href="https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd">compare view</a></li> </ul> </details> <br /> Updates `linear/linear-release-action` from 0.4.0 to 0.5.0 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/linear/linear-release-action/releases">linear/linear-release-action's releases</a>.</em></p> <blockquote> <h2>v0.5.0</h2> <h2>What's Changed</h2> <ul> <li>Documentation improvements by <a href="https://github.com/RomainCscn"><code>@RomainCscn</code></a> in <a href="https://redirect.github.com/linear/linear-release-action/pull/8">linear/linear-release-action#8</a></li> <li>Add support for release_version, same as the CLI by <a href="https://github.com/RomainCscn"><code>@RomainCscn</code></a> in <a href="https://redirect.github.com/linear/linear-release-action/pull/9">linear/linear-release-action#9</a></li> <li>Set CLI version default to latest</li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/linear/linear-release-action/compare/v0.4.0...v0.5.0">https://github.com/linear/linear-release-action/compare/v0.4.0...v0.5.0</a></p> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/linear/linear-release-action/commit/5cbaabc187ceb63eee9d446e62e68e5c29a03ae8"><code>5cbaabc</code></a> Make latest the default cli version</li> <li><a href="https://github.com/linear/linear-release-action/commit/7fb27ceb7e17ef4353a87f85f4fc1e3d3416c057"><code>7fb27ce</code></a> Add support for release_version, same as the CLI (<a href="https://redirect.github.com/linear/linear-release-action/issues/9">#9</a>)</li> <li><a href="https://github.com/linear/linear-release-action/commit/fbf0176c7348aa6444e5e3d14db454cb4f4baab8"><code>fbf0176</code></a> Ensure name is properly used when creating scheduled release (<a href="https://redirect.github.com/linear/linear-release-action/issues/8">#8</a>)</li> <li>See full diff in <a href="https://github.com/linear/linear-release-action/compare/v0.4.0...5cbaabc187ceb63eee9d446e62e68e5c29a03ae8">compare view</a></li> </ul> </details> <br /> Updates `benc-uk/workflow-dispatch` from 1.2.4 to 1.3.1 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/benc-uk/workflow-dispatch/releases">benc-uk/workflow-dispatch's releases</a>.</em></p> <blockquote> <h2>v1.3.1</h2> <h2>Features</h2> <ul> <li><strong>New <code>sync-status</code> input</strong> — when used with <code>wait-for-completion</code>, mirrors the triggered workflow's conclusion (failure/cancelled) back to this action's status (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/84">#84</a>)</li> <li><strong>Alternate <code>ref</code> default for PRs</strong> — automatically uses <code>github.head_ref</code> when running in a pull request context, avoiding <code>refs/pull/.../merge</code> errors (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/79">#79</a>)</li> </ul> <h2>Bug Fixes</h2> <ul> <li><strong>Safer JSON input parsing</strong> — invalid <code>inputs</code> JSON now logs an error instead of throwing an unhandled exception (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/84">#84</a>)</li> <li><strong>Improved timeout handling</strong> — timeout now sets a distinct <code>timed_out</code> status and emits a warning instead of silently breaking (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/84">#84</a>)</li> <li><strong>Improved warning message formatting</strong> for workflow run timeout</li> </ul> <h2>Internal Changes & Chores</h2> <ul> <li>Replaced <code>console.log</code> calls with <code>core.info</code> for proper Actions log integration (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/84">#84</a>)</li> <li>Removed stale <code>ref</code>/<code>inputs</code> parameters from the workflow list API call (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/84">#84</a>)</li> <li>Expanded CI test matrix from 3 sequential steps to 9 parallel test jobs covering workflow lookup, output assertions, wait-for-completion, sync-status, and error handling (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/84">#84</a>)</li> <li>Added CI path filters to skip docs-only changes (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/84">#84</a>)</li> <li>Changed echo-3 test fixture from <code>workflow_call</code> to <code>workflow_dispatch</code> with deterministic failure (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/84">#84</a>)</li> <li>Removed unused <code>.vscode/settings.json</code> (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/84">#84</a>)</li> <li>Added <code>.github/copilot-instructions.md</code> (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/84">#84</a>)</li> <li>General project chores</li> </ul> <h2>Documentation Updates</h2> <ul> <li>No documentation updates in this release</li> </ul> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/benc-uk/workflow-dispatch/commit/7a027648b88c2413826b6ddd6c76114894dc5ec4"><code>7a02764</code></a> Improvements: sync-status, error handling, CI test coverage & path filters (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/84">#84</a>)</li> <li><a href="https://github.com/benc-uk/workflow-dispatch/commit/3162154e5e0697f47fb76f12ed5508c5f3c066d7"><code>3162154</code></a> Use alternate <code>ref</code> default for PRs (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/79">#79</a>)</li> <li><a href="https://github.com/benc-uk/workflow-dispatch/commit/4085c9787530f7d3f497838f77fce7b96a554397"><code>4085c97</code></a> project chores</li> <li><a href="https://github.com/benc-uk/workflow-dispatch/commit/6fd6de2826a993af5b50dfb55da903d4f1ca05ee"><code>6fd6de2</code></a> Improve warning message formatting for workflow run timeout</li> <li><a href="https://github.com/benc-uk/workflow-dispatch/commit/a54f9d194fed472732282ed1597dc4909e4b4080"><code>a54f9d1</code></a> 2026 refresh (<a href="https://redirect.github.com/benc-uk/workflow-dispatch/issues/83">#83</a>)</li> <li>See full diff in <a href="https://github.com/benc-uk/workflow-dispatch/compare/e2e5e9a103e331dad343f381a29e654aea3cf8fc...7a027648b88c2413826b6ddd6c76114894dc5ec4">compare view</a></li> </ul> </details> <br /> Updates `aquasecurity/trivy-action` from c1824fd6edce30d7ab345a9989de00bbd46ef284 to 57a97c7e7821a5776cebc9bb87c984fa69cba8f1 | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/aquasecurity/trivy-action/commit/57a97c7e7821a5776cebc9bb87c984fa69cba8f1"><code>57a97c7</code></a> chore(deps): Update trivy to v0.69.3 (<a href="https://redirect.github.com/aquasecurity/trivy-action/issues/519">#519</a>)</li> | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | <li><a href="https://github.com/aquasecurity/trivy-action/commit/97e0b3872f55f89b95b2f65b3dbab56962816478"><code>97e0b38</code></a> chore: bump Trivy version to v0.69.2 in test workflow and README (<a href="https://redirect.github.com/aquasecurity/trivy-action/issues/515">#515</a>)</li> | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | <li><a href="https://github.com/aquasecurity/trivy-action/commit/4c61e6329bab9be735ca35291551614bc663dff3"><code>4c61e63</code></a> chore: bump default Trivy version to v0.69.2 (<a href="https://redirect.github.com/aquasecurity/trivy-action/issues/513">#513</a>)</li> | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | <li><a href="https://github.com/aquasecurity/trivy-action/commit/1bd062560b422f5944df1de50abd05162bea079e"><code>1bd0625</code></a> Merge pull request <a href="https://redirect.github.com/aquasecurity/trivy-action/issues/508">#508</a> from nikpivkin/feat/pass-yaml-ignore-file</li> | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | <li><a href="https://github.com/aquasecurity/trivy-action/commit/bce3086c4aa186dadd6671d45ad6dd5d1b8440ac"><code>bce3086</code></a> remove unused init-cache target</li> | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | <li><a href="https://github.com/aquasecurity/trivy-action/commit/5a9fbb1236dc1b5ee9e73b5a515009a1dc684548"><code>5a9fbb1</code></a> supress progress bar when download db</li> | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | <li><a href="https://github.com/aquasecurity/trivy-action/commit/16154502cae788884830e8df2671639b8cbaa03f"><code>1615450</code></a> update trivyignores input description</li> | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | <li><a href="https://github.com/aquasecurity/trivy-action/commit/df85774a457f1f0a32a8e5744c2bced057257d65"><code>df85774</code></a> add comment about fd3</li> | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | <li><a href="https://github.com/aquasecurity/trivy-action/commit/56c8daebb96c35cabeeda8187a6dd3ec711d0a72"><code>56c8dae</code></a> remove unused variable</li> | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | <li><a href="https://github.com/aquasecurity/trivy-action/commit/e368e328979b113139d6f9068e03accaed98a518"><code>e368e32</code></a> ci(test): add zizmor security linter for GitHub Actions (<a href="https://redirect.github.com/aquasecurity/trivy-action/issues/502">#502</a>)</li> | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | <li>Additional commits viewable in <a href="https://github.com/aquasecurity/trivy-action/compare/c1824fd6edce30d7ab345a9989de00bbd46ef284...57a97c7e7821a5776cebc9bb87c984fa69cba8f1">compare view</a></li> | [step-security/harden-runner](https://github.com/step-security/harden-runner) | `2.14.2` | `2.16.0` | </ul> </details> <br /> <details> <summary>Most Recent Ignore Conditions Applied to This Pull Request</summary> | Dependency Name | Ignore Conditions | | --- | --- | | crate-ci/typos | [>= 1.30.a, < 1.31] | </details> 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 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> Co-authored-by: Atif Ali <atif@coder.com>
3059 lines
97 KiB
Go
3059 lines
97 KiB
Go
package coderd
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"slices"
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/dustin/go-humanize"
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/google/uuid"
|
||
"golang.org/x/sync/errgroup"
|
||
"golang.org/x/xerrors"
|
||
|
||
"cdr.dev/slog/v3"
|
||
"github.com/coder/coder/v2/agent/proto"
|
||
"github.com/coder/coder/v2/coderd/audit"
|
||
"github.com/coder/coder/v2/coderd/database"
|
||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
|
||
"github.com/coder/coder/v2/coderd/httpapi"
|
||
"github.com/coder/coder/v2/coderd/httpapi/httperror"
|
||
"github.com/coder/coder/v2/coderd/httpmw"
|
||
"github.com/coder/coder/v2/coderd/notifications"
|
||
"github.com/coder/coder/v2/coderd/prebuilds"
|
||
"github.com/coder/coder/v2/coderd/rbac"
|
||
"github.com/coder/coder/v2/coderd/rbac/acl"
|
||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||
"github.com/coder/coder/v2/coderd/schedule"
|
||
"github.com/coder/coder/v2/coderd/schedule/cron"
|
||
"github.com/coder/coder/v2/coderd/searchquery"
|
||
"github.com/coder/coder/v2/coderd/telemetry"
|
||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||
"github.com/coder/coder/v2/coderd/util/slice"
|
||
"github.com/coder/coder/v2/coderd/wsbuilder"
|
||
"github.com/coder/coder/v2/coderd/wspubsub"
|
||
"github.com/coder/coder/v2/codersdk"
|
||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||
"github.com/coder/websocket"
|
||
)
|
||
|
||
var (
|
||
ttlMinimum = time.Minute
|
||
ttlMaximum = 30 * 24 * time.Hour
|
||
|
||
errTTLMin = xerrors.New("time until shutdown must be at least one minute")
|
||
errTTLMax = xerrors.New("time until shutdown must be less than 30 days")
|
||
errDeadlineTooSoon = xerrors.New("new deadline must be at least 30 minutes in the future")
|
||
errDeadlineBeforeStart = xerrors.New("new deadline must be before workspace start time")
|
||
)
|
||
|
||
// @Summary Get workspace metadata by ID
|
||
// @ID get-workspace-metadata-by-id
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param include_deleted query bool false "Return data instead of HTTP 404 if the workspace is deleted"
|
||
// @Success 200 {object} codersdk.Workspace
|
||
// @Router /workspaces/{workspace} [get]
|
||
func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
workspace := httpmw.WorkspaceParam(r)
|
||
apiKey := httpmw.APIKey(r)
|
||
|
||
var (
|
||
deletedStr = r.URL.Query().Get("include_deleted")
|
||
showDeleted = false
|
||
)
|
||
if deletedStr != "" {
|
||
var err error
|
||
showDeleted, err = strconv.ParseBool(deletedStr)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: fmt.Sprintf("Invalid boolean value %q for \"include_deleted\" query param.", deletedStr),
|
||
Validations: []codersdk.ValidationError{
|
||
{Field: "deleted", Detail: "Must be a valid boolean"},
|
||
},
|
||
})
|
||
return
|
||
}
|
||
}
|
||
if workspace.Deleted && !showDeleted {
|
||
httpapi.Write(ctx, rw, http.StatusGone, codersdk.Response{
|
||
Message: fmt.Sprintf("Workspace %q was deleted, you can view this workspace by specifying '?deleted=true' and trying again.", workspace.ID.String()),
|
||
})
|
||
return
|
||
}
|
||
|
||
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace resources.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
if len(data.templates) == 0 {
|
||
httpapi.Forbidden(rw)
|
||
return
|
||
}
|
||
|
||
appStatus := codersdk.WorkspaceAppStatus{}
|
||
if len(data.appStatuses) > 0 {
|
||
appStatus = data.appStatuses[0]
|
||
}
|
||
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
api.Logger,
|
||
apiKey.UserID,
|
||
workspace,
|
||
data.builds[0],
|
||
data.templates[0],
|
||
api.Options.AllowWorkspaceRenames,
|
||
appStatus,
|
||
)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
httpapi.Write(ctx, rw, http.StatusOK, w)
|
||
}
|
||
|
||
// workspaces returns all workspaces a user can read.
|
||
// Optional filters with query params
|
||
//
|
||
// @Summary List workspaces
|
||
// @ID list-workspaces
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy."
|
||
// @Param limit query int false "Page limit"
|
||
// @Param offset query int false "Page offset"
|
||
// @Success 200 {object} codersdk.WorkspacesResponse
|
||
// @Router /workspaces [get]
|
||
func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
apiKey := httpmw.APIKey(r)
|
||
|
||
page, ok := ParsePagination(rw, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
queryStr := r.URL.Query().Get("q")
|
||
filter, errs := searchquery.Workspaces(ctx, api.Database, queryStr, page, api.AgentInactiveDisconnectTimeout)
|
||
if len(errs) > 0 {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid workspace search query.",
|
||
Validations: errs,
|
||
})
|
||
return
|
||
}
|
||
|
||
if filter.OwnerUsername == "me" {
|
||
filter.OwnerID = apiKey.UserID
|
||
filter.OwnerUsername = ""
|
||
}
|
||
|
||
prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceWorkspace.Type)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error preparing sql filter.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
// To show the requester's favorite workspaces first, we pass their userID and compare it to
|
||
// the workspace owner_id when ordering the rows.
|
||
filter.RequesterID = apiKey.UserID
|
||
|
||
// We need the technical row to present the correct count on every page.
|
||
filter.WithSummary = true
|
||
|
||
workspaceRows, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, prepared)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspaces.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
if len(workspaceRows) == 0 {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspaces.",
|
||
Detail: "Workspace summary row is missing.",
|
||
})
|
||
return
|
||
}
|
||
if len(workspaceRows) == 1 {
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
|
||
Workspaces: []codersdk.Workspace{},
|
||
Count: int(workspaceRows[0].Count),
|
||
})
|
||
return
|
||
}
|
||
// Skip technical summary row
|
||
workspaceRows = workspaceRows[:len(workspaceRows)-1]
|
||
|
||
if len(workspaceRows) == 0 {
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
|
||
Workspaces: []codersdk.Workspace{},
|
||
Count: 0,
|
||
})
|
||
return
|
||
}
|
||
|
||
workspaces, err := database.ConvertWorkspaceRows(workspaceRows)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace rows.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
data, err := api.workspaceData(ctx, workspaces)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace resources.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
wss, err := convertWorkspaces(
|
||
ctx,
|
||
api.Logger,
|
||
apiKey.UserID,
|
||
workspaces,
|
||
data,
|
||
)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspaces.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
|
||
Workspaces: wss,
|
||
Count: int(workspaceRows[0].Count),
|
||
})
|
||
}
|
||
|
||
// @Summary Get workspace metadata by user and workspace name
|
||
// @ID get-workspace-metadata-by-user-and-workspace-name
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param user path string true "User ID, name, or me"
|
||
// @Param workspacename path string true "Workspace name"
|
||
// @Param include_deleted query bool false "Return data instead of HTTP 404 if the workspace is deleted"
|
||
// @Success 200 {object} codersdk.Workspace
|
||
// @Router /users/{user}/workspace/{workspacename} [get]
|
||
func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
|
||
mems := httpmw.OrganizationMembersParam(r)
|
||
workspaceName := chi.URLParam(r, "workspacename")
|
||
apiKey := httpmw.APIKey(r)
|
||
|
||
includeDeleted := false
|
||
if s := r.URL.Query().Get("include_deleted"); s != "" {
|
||
var err error
|
||
includeDeleted, err = strconv.ParseBool(s)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: fmt.Sprintf("Invalid boolean value %q for \"include_deleted\" query param.", s),
|
||
Validations: []codersdk.ValidationError{
|
||
{Field: "include_deleted", Detail: "Must be a valid boolean"},
|
||
},
|
||
})
|
||
return
|
||
}
|
||
}
|
||
|
||
workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
|
||
OwnerID: mems.UserID(),
|
||
Name: workspaceName,
|
||
})
|
||
if includeDeleted && errors.Is(err, sql.ErrNoRows) {
|
||
workspace, err = api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
|
||
OwnerID: mems.UserID(),
|
||
Name: workspaceName,
|
||
Deleted: includeDeleted,
|
||
})
|
||
}
|
||
if httpapi.Is404Error(err) {
|
||
httpapi.ResourceNotFound(rw)
|
||
return
|
||
}
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace by name.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace resources.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
if len(data.builds) == 0 || len(data.templates) == 0 {
|
||
httpapi.ResourceNotFound(rw)
|
||
return
|
||
}
|
||
|
||
appStatus := codersdk.WorkspaceAppStatus{}
|
||
if len(data.appStatuses) > 0 {
|
||
appStatus = data.appStatuses[0]
|
||
}
|
||
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
api.Logger,
|
||
apiKey.UserID,
|
||
workspace,
|
||
data.builds[0],
|
||
data.templates[0],
|
||
api.Options.AllowWorkspaceRenames,
|
||
appStatus,
|
||
)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
httpapi.Write(ctx, rw, http.StatusOK, w)
|
||
}
|
||
|
||
// Create a new workspace for the currently authenticated user.
|
||
//
|
||
// @Summary Create user workspace by organization
|
||
// @Description Create a new workspace using a template. The request must
|
||
// @Description specify either the Template ID or the Template Version ID,
|
||
// @Description not both. If the Template ID is specified, the active version
|
||
// @Description of the template will be used.
|
||
// @Deprecated Use /users/{user}/workspaces instead.
|
||
// @ID create-user-workspace-by-organization
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param organization path string true "Organization ID" format(uuid)
|
||
// @Param user path string true "Username, UUID, or me"
|
||
// @Param request body codersdk.CreateWorkspaceRequest true "Create workspace request"
|
||
// @Success 200 {object} codersdk.Workspace
|
||
// @Router /organizations/{organization}/members/{user}/workspaces [post]
|
||
func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
apiKey = httpmw.APIKey(r)
|
||
auditor = api.Auditor.Load()
|
||
organization = httpmw.OrganizationParam(r)
|
||
member = httpmw.OrganizationMemberParam(r)
|
||
workspaceResourceInfo = audit.AdditionalFields{
|
||
WorkspaceOwner: member.Username,
|
||
}
|
||
)
|
||
|
||
aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionCreate,
|
||
AdditionalFields: workspaceResourceInfo,
|
||
OrganizationID: organization.ID,
|
||
})
|
||
|
||
defer commitAudit()
|
||
|
||
var req codersdk.CreateWorkspaceRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
owner := workspaceOwner{
|
||
ID: member.UserID,
|
||
Username: member.Username,
|
||
AvatarURL: member.AvatarURL,
|
||
}
|
||
|
||
w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, &createWorkspaceOptions{
|
||
remoteAddr: r.RemoteAddr,
|
||
})
|
||
if err != nil {
|
||
httperror.WriteResponseError(ctx, rw, err)
|
||
return
|
||
}
|
||
|
||
httpapi.Write(ctx, rw, http.StatusCreated, w)
|
||
}
|
||
|
||
// Create a new workspace for the currently authenticated user.
|
||
//
|
||
// @Summary Create user workspace
|
||
// @Description Create a new workspace using a template. The request must
|
||
// @Description specify either the Template ID or the Template Version ID,
|
||
// @Description not both. If the Template ID is specified, the active version
|
||
// @Description of the template will be used.
|
||
// @ID create-user-workspace
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param user path string true "Username, UUID, or me"
|
||
// @Param request body codersdk.CreateWorkspaceRequest true "Create workspace request"
|
||
// @Success 200 {object} codersdk.Workspace
|
||
// @Router /users/{user}/workspaces [post]
|
||
func (api *API) postUserWorkspaces(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
apiKey = httpmw.APIKey(r)
|
||
auditor = api.Auditor.Load()
|
||
mems = httpmw.OrganizationMembersParam(r)
|
||
)
|
||
|
||
var req codersdk.CreateWorkspaceRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
var owner workspaceOwner
|
||
if mems.User != nil {
|
||
// This user fetch is an optimization path for the most common case of creating a
|
||
// workspace for 'Me'.
|
||
//
|
||
// This is also required to allow `owners` to create workspaces for users
|
||
// that are not in an organization.
|
||
owner = workspaceOwner{
|
||
ID: mems.User.ID,
|
||
Username: mems.User.Username,
|
||
AvatarURL: mems.User.AvatarURL,
|
||
}
|
||
} else {
|
||
// A workspace can still be created if the caller can read the organization
|
||
// member. The organization is required, which can be sourced from the
|
||
// template.
|
||
//
|
||
// TODO: This code gets called twice for each workspace build request.
|
||
// This is inefficient and costs at most 2 extra RTTs to the DB.
|
||
// This can be optimized. It exists as it is now for code simplicity.
|
||
// The most common case is to create a workspace for 'Me'. Which does
|
||
// not enter this code branch.
|
||
template, err := requestTemplate(ctx, req, api.Database)
|
||
if err != nil {
|
||
httperror.WriteResponseError(ctx, rw, err)
|
||
return
|
||
}
|
||
|
||
// If the caller can find the organization membership in the same org
|
||
// as the template, then they can continue.
|
||
orgIndex := slices.IndexFunc(mems.Memberships, func(mem httpmw.OrganizationMember) bool {
|
||
return mem.OrganizationID == template.OrganizationID
|
||
})
|
||
if orgIndex == -1 {
|
||
httpapi.ResourceNotFound(rw)
|
||
return
|
||
}
|
||
|
||
member := mems.Memberships[orgIndex]
|
||
owner = workspaceOwner{
|
||
ID: member.UserID,
|
||
Username: member.Username,
|
||
AvatarURL: member.AvatarURL,
|
||
}
|
||
}
|
||
|
||
aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionCreate,
|
||
AdditionalFields: audit.AdditionalFields{
|
||
WorkspaceOwner: owner.Username,
|
||
},
|
||
})
|
||
|
||
defer commitAudit()
|
||
|
||
w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, &createWorkspaceOptions{
|
||
remoteAddr: r.RemoteAddr,
|
||
})
|
||
if err != nil {
|
||
httperror.WriteResponseError(ctx, rw, err)
|
||
return
|
||
}
|
||
|
||
httpapi.Write(ctx, rw, http.StatusCreated, w)
|
||
}
|
||
|
||
type workspaceOwner struct {
|
||
ID uuid.UUID
|
||
Username string
|
||
AvatarURL string
|
||
}
|
||
|
||
type createWorkspaceOptions struct {
|
||
// preCreateInTX is a function that is called within the transaction, before
|
||
// the workspace is created.
|
||
preCreateInTX func(ctx context.Context, tx database.Store) error
|
||
// postCreateInTX is a function that is called within the transaction, after
|
||
// the workspace is created but before the workspace build is created.
|
||
postCreateInTX func(ctx context.Context, tx database.Store, workspace database.Workspace) error
|
||
// remoteAddr is the IP address of the request initiator, used for
|
||
// audit logging. HTTP handlers should pass r.RemoteAddr;
|
||
// programmatic callers may leave it empty.
|
||
remoteAddr string
|
||
}
|
||
|
||
func createWorkspace(
|
||
ctx context.Context,
|
||
auditReq *audit.Request[database.WorkspaceTable],
|
||
initiatorID uuid.UUID,
|
||
api *API,
|
||
owner workspaceOwner,
|
||
req codersdk.CreateWorkspaceRequest,
|
||
opts *createWorkspaceOptions,
|
||
) (codersdk.Workspace, error) {
|
||
if opts == nil {
|
||
opts = &createWorkspaceOptions{}
|
||
}
|
||
|
||
template, err := requestTemplate(ctx, req, api.Database)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, err
|
||
}
|
||
|
||
// This is a premature auth check to avoid doing unnecessary work if the user
|
||
// doesn't have permission to create a workspace.
|
||
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionCreate,
|
||
rbac.ResourceWorkspace.InOrg(template.OrganizationID).WithOwner(owner.ID.String())) {
|
||
// If this check fails, return a proper unauthorized error to the user to indicate
|
||
// what is going on.
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
|
||
Message: "Unauthorized to create workspace.",
|
||
Detail: "You are unable to create a workspace in this organization. " +
|
||
"It is possible to have access to the template, but not be able to create a workspace. " +
|
||
"Please contact an administrator about your permissions if you feel this is an error.",
|
||
})
|
||
}
|
||
|
||
// Update audit log's organization
|
||
auditReq.UpdateOrganizationID(template.OrganizationID)
|
||
|
||
// Do this upfront to save work. If this fails, the rest of the work
|
||
// would be wasted.
|
||
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionCreate,
|
||
rbac.ResourceWorkspace.InOrg(template.OrganizationID).WithOwner(owner.ID.String())) {
|
||
return codersdk.Workspace{}, httperror.ErrResourceNotFound
|
||
}
|
||
// The user also needs permission to use the template. At this point they have
|
||
// read perms, but not necessarily "use". This is also checked in `db.InsertWorkspace`.
|
||
// Doing this up front can save some work below if the user doesn't have permission.
|
||
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionUse, template) {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
|
||
Message: fmt.Sprintf("Unauthorized access to use the template %q.", template.Name),
|
||
Detail: "Although you are able to view the template, you are unable to create a workspace using it. " +
|
||
"Please contact an administrator about your permissions if you feel this is an error.",
|
||
})
|
||
}
|
||
|
||
templateAccessControl := (*(api.AccessControlStore.Load())).GetTemplateAccessControl(template)
|
||
if templateAccessControl.IsDeprecated() {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: fmt.Sprintf("Template %q has been deprecated, and cannot be used to create a new workspace.", template.Name),
|
||
// Pass the deprecated message to the user.
|
||
Detail: templateAccessControl.Deprecated,
|
||
})
|
||
}
|
||
|
||
dbAutostartSchedule, err := validWorkspaceSchedule(req.AutostartSchedule)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid Autostart Schedule.",
|
||
Validations: []codersdk.ValidationError{{Field: "schedule", Detail: err.Error()}},
|
||
})
|
||
}
|
||
|
||
templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, template.ID)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching template schedule.",
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
|
||
nextStartAt := sql.NullTime{}
|
||
if dbAutostartSchedule.Valid {
|
||
next, err := schedule.NextAllowedAutostart(dbtime.Now(), dbAutostartSchedule.String, templateSchedule)
|
||
if err == nil {
|
||
nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())}
|
||
}
|
||
}
|
||
|
||
dbTTL, err := validWorkspaceTTLMillis(req.TTLMillis, templateSchedule.DefaultTTL)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid Workspace Time to Shutdown.",
|
||
Validations: []codersdk.ValidationError{{Field: "ttl_ms", Detail: err.Error()}},
|
||
})
|
||
}
|
||
|
||
// back-compatibility: default to "never" if not included.
|
||
dbAU := database.AutomaticUpdatesNever
|
||
if req.AutomaticUpdates != "" {
|
||
dbAU, err = validWorkspaceAutomaticUpdates(req.AutomaticUpdates)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid Workspace Automatic Updates setting.",
|
||
Validations: []codersdk.ValidationError{{Field: "automatic_updates", Detail: err.Error()}},
|
||
})
|
||
}
|
||
}
|
||
|
||
// TODO: This should be a system call as the actor might not be able to
|
||
// read other workspaces. Ideally we check the error on create and look for
|
||
// a postgres conflict error.
|
||
workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
|
||
OwnerID: owner.ID,
|
||
Name: req.Name,
|
||
})
|
||
if err == nil {
|
||
// If the workspace already exists, don't allow creation.
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusConflict, codersdk.Response{
|
||
Message: fmt.Sprintf("Workspace %q already exists.", req.Name),
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "name",
|
||
Detail: "This value is already in use and should be unique.",
|
||
}},
|
||
})
|
||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: fmt.Sprintf("Internal error fetching workspace by name %q.", req.Name),
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
|
||
var (
|
||
provisionerJob *database.ProvisionerJob
|
||
workspaceBuild *database.WorkspaceBuild
|
||
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
|
||
)
|
||
|
||
err = api.Database.InTx(func(db database.Store) error {
|
||
var (
|
||
prebuildsClaimer = *api.PrebuildsClaimer.Load()
|
||
workspaceID uuid.UUID
|
||
claimedWorkspace *database.Workspace
|
||
)
|
||
|
||
// If a preCreate hook is provided, execute it before creating or
|
||
// claiming the workspace. This can be used to perform additional
|
||
// setup or validation before the workspace is created (e.g. task
|
||
// creation).
|
||
if opts.preCreateInTX != nil {
|
||
if err := opts.preCreateInTX(ctx, db); err != nil {
|
||
return xerrors.Errorf("workspace preCreate failed: %w", err)
|
||
}
|
||
}
|
||
|
||
// Use injected Clock to allow time mocking in tests
|
||
now := dbtime.Time(api.Clock.Now())
|
||
|
||
templateVersionPresetID := req.TemplateVersionPresetID
|
||
|
||
// If no preset was chosen, look for a matching preset by parameter values.
|
||
if templateVersionPresetID == uuid.Nil {
|
||
parameterNames := make([]string, len(req.RichParameterValues))
|
||
parameterValues := make([]string, len(req.RichParameterValues))
|
||
for i, parameter := range req.RichParameterValues {
|
||
parameterNames[i] = parameter.Name
|
||
parameterValues[i] = parameter.Value
|
||
}
|
||
var err error
|
||
templateVersionID := req.TemplateVersionID
|
||
if templateVersionID == uuid.Nil {
|
||
templateVersionID = template.ActiveVersionID
|
||
}
|
||
templateVersionPresetID, err = prebuilds.FindMatchingPresetID(ctx, db, templateVersionID, parameterNames, parameterValues)
|
||
if err != nil {
|
||
return xerrors.Errorf("find matching preset: %w", err)
|
||
}
|
||
}
|
||
|
||
// Try to claim a prebuilt workspace.
|
||
if templateVersionPresetID != uuid.Nil {
|
||
// Try and claim an eligible prebuild, if available.
|
||
// On successful claim, initialize all lifecycle fields from template and workspace-level config
|
||
// so the newly claimed workspace is properly managed by the lifecycle executor.
|
||
claimedWorkspace, err = claimPrebuild(
|
||
ctx, prebuildsClaimer, db, api.Logger, now, req.Name, owner,
|
||
templateVersionPresetID, dbAutostartSchedule, nextStartAt, dbTTL)
|
||
// If claiming fails with an expected error (no claimable prebuilds or AGPL does not support prebuilds),
|
||
// we fall back to creating a new workspace. Otherwise, propagate the unexpected error.
|
||
if err != nil {
|
||
isExpectedError := errors.Is(err, prebuilds.ErrNoClaimablePrebuiltWorkspaces) ||
|
||
errors.Is(err, prebuilds.ErrAGPLDoesNotSupportPrebuiltWorkspaces)
|
||
fields := []slog.Field{
|
||
slog.Error(err),
|
||
slog.F("workspace_name", req.Name),
|
||
slog.F("template_version_preset_id", templateVersionPresetID),
|
||
}
|
||
|
||
if !isExpectedError {
|
||
// if it's an unexpected error - use error log level
|
||
api.Logger.Error(ctx, "failed to claim prebuilt workspace", fields...)
|
||
|
||
return xerrors.Errorf("failed to claim prebuilt workspace: %w", err)
|
||
}
|
||
|
||
// if it's an expected error - use warn log level
|
||
api.Logger.Warn(ctx, "failed to claim prebuilt workspace", fields...)
|
||
|
||
// fall back to creating a new workspace
|
||
}
|
||
}
|
||
|
||
// No prebuild found; regular flow.
|
||
if claimedWorkspace == nil {
|
||
// Workspaces are created without any versions.
|
||
minimumWorkspace, err := db.InsertWorkspace(ctx, database.InsertWorkspaceParams{
|
||
ID: uuid.New(),
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
OwnerID: owner.ID,
|
||
OrganizationID: template.OrganizationID,
|
||
TemplateID: template.ID,
|
||
Name: req.Name,
|
||
AutostartSchedule: dbAutostartSchedule,
|
||
NextStartAt: nextStartAt,
|
||
Ttl: dbTTL,
|
||
// The workspaces page will sort by last used at, and it's useful to
|
||
// have the newly created workspace at the top of the list!
|
||
LastUsedAt: now,
|
||
AutomaticUpdates: dbAU,
|
||
})
|
||
if err != nil {
|
||
return xerrors.Errorf("insert workspace: %w", err)
|
||
}
|
||
workspaceID = minimumWorkspace.ID
|
||
} else {
|
||
// Prebuild found!
|
||
workspaceID = claimedWorkspace.ID
|
||
}
|
||
|
||
// We have to refetch the workspace for the joined in fields.
|
||
// TODO: We can use WorkspaceTable for the builder to not require
|
||
// this extra fetch.
|
||
workspace, err = db.GetWorkspaceByID(ctx, workspaceID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get workspace by ID: %w", err)
|
||
}
|
||
|
||
// If the postCreate hook is provided, execute it. This can be used to
|
||
// perform additional actions after the workspace has been created, like
|
||
// linking the workspace to a task.
|
||
if opts.postCreateInTX != nil {
|
||
if err := opts.postCreateInTX(ctx, db, workspace); err != nil {
|
||
return xerrors.Errorf("workspace postCreate failed: %w", err)
|
||
}
|
||
}
|
||
|
||
builder := wsbuilder.New(workspace, database.WorkspaceTransitionStart, *api.BuildUsageChecker.Load()).
|
||
Reason(database.BuildReasonInitiator).
|
||
Initiator(initiatorID).
|
||
ActiveVersion().
|
||
Experiments(api.Experiments).
|
||
DeploymentValues(api.DeploymentValues).
|
||
RichParameterValues(req.RichParameterValues).
|
||
BuildMetrics(api.WorkspaceBuilderMetrics)
|
||
if req.TemplateVersionID != uuid.Nil {
|
||
builder = builder.VersionID(req.TemplateVersionID)
|
||
}
|
||
if templateVersionPresetID != uuid.Nil {
|
||
builder = builder.TemplateVersionPresetID(templateVersionPresetID)
|
||
}
|
||
if claimedWorkspace != nil {
|
||
builder = builder.MarkPrebuiltWorkspaceClaim()
|
||
}
|
||
|
||
workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build(
|
||
ctx,
|
||
db,
|
||
api.FileCache,
|
||
func(action policy.Action, object rbac.Objecter) bool {
|
||
return api.HTTPAuth.AuthorizeContext(ctx, action, object)
|
||
},
|
||
audit.WorkspaceBuildBaggage{IP: opts.remoteAddr},
|
||
)
|
||
return err
|
||
}, nil)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, err
|
||
}
|
||
|
||
err = provisionerjobs.PostJob(api.Pubsub, *provisionerJob)
|
||
if err != nil {
|
||
// Client probably doesn't care about this error, so just log it.
|
||
api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err))
|
||
}
|
||
|
||
// nolint:gocritic // Need system context to fetch admins
|
||
admins, err := findTemplateAdmins(dbauthz.AsSystemRestricted(ctx), api.Database)
|
||
if err != nil {
|
||
api.Logger.Error(ctx, "find template admins", slog.Error(err))
|
||
} else {
|
||
for _, admin := range admins {
|
||
// Don't send notifications to user which initiated the event.
|
||
if admin.ID == initiatorID {
|
||
continue
|
||
}
|
||
|
||
api.notifyWorkspaceCreated(ctx, admin.ID, workspace, req.RichParameterValues)
|
||
}
|
||
}
|
||
|
||
auditReq.New = workspace.WorkspaceTable()
|
||
|
||
api.Telemetry.Report(&telemetry.Snapshot{
|
||
Workspaces: []telemetry.Workspace{telemetry.ConvertWorkspace(workspace)},
|
||
WorkspaceBuilds: []telemetry.WorkspaceBuild{telemetry.ConvertWorkspaceBuild(*workspaceBuild)},
|
||
})
|
||
|
||
apiBuild, err := api.convertWorkspaceBuild(
|
||
*workspaceBuild,
|
||
workspace,
|
||
database.GetProvisionerJobsByIDsWithQueuePositionRow{
|
||
ProvisionerJob: *provisionerJob,
|
||
QueuePosition: 0,
|
||
},
|
||
[]database.WorkspaceResource{},
|
||
[]database.WorkspaceResourceMetadatum{},
|
||
[]database.WorkspaceAgent{},
|
||
[]database.WorkspaceApp{},
|
||
[]database.WorkspaceAppStatus{},
|
||
[]database.WorkspaceAgentScript{},
|
||
[]database.WorkspaceAgentLogSource{},
|
||
database.TemplateVersion{},
|
||
provisionerDaemons,
|
||
)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace build.",
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
api.Logger,
|
||
initiatorID,
|
||
workspace,
|
||
apiBuild,
|
||
template,
|
||
api.Options.AllowWorkspaceRenames,
|
||
codersdk.WorkspaceAppStatus{},
|
||
)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
|
||
return w, nil
|
||
}
|
||
|
||
func requestTemplate(ctx context.Context, req codersdk.CreateWorkspaceRequest, db database.Store) (database.Template, error) {
|
||
// If we were given a `TemplateVersionID`, we need to determine the `TemplateID` from it.
|
||
templateID := req.TemplateID
|
||
|
||
if templateID == uuid.Nil {
|
||
templateVersion, err := db.GetTemplateVersionByID(ctx, req.TemplateVersionID)
|
||
if httpapi.Is404Error(err) {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: fmt.Sprintf("Template version %q doesn't exist.", req.TemplateVersionID),
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "template_version_id",
|
||
Detail: "template not found",
|
||
}},
|
||
})
|
||
}
|
||
if err != nil {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching template version.",
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
if templateVersion.Archived {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Archived template versions cannot be used to make a workspace.",
|
||
Validations: []codersdk.ValidationError{
|
||
{
|
||
Field: "template_version_id",
|
||
Detail: "template version archived",
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
templateID = templateVersion.TemplateID.UUID
|
||
}
|
||
|
||
template, err := db.GetTemplateByID(ctx, templateID)
|
||
if httpapi.Is404Error(err) {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: fmt.Sprintf("Template %q doesn't exist.", templateID),
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "template_id",
|
||
Detail: "template not found",
|
||
}},
|
||
})
|
||
}
|
||
if err != nil {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching template.",
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
if template.Deleted {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusNotFound, codersdk.Response{
|
||
Message: fmt.Sprintf("Template %q has been deleted!", template.Name),
|
||
})
|
||
}
|
||
return template, nil
|
||
}
|
||
|
||
func claimPrebuild(
|
||
ctx context.Context,
|
||
claimer prebuilds.Claimer,
|
||
db database.Store,
|
||
logger slog.Logger,
|
||
now time.Time,
|
||
name string,
|
||
owner workspaceOwner,
|
||
templateVersionPresetID uuid.UUID,
|
||
autostartSchedule sql.NullString,
|
||
nextStartAt sql.NullTime,
|
||
ttl sql.NullInt64,
|
||
) (*database.Workspace, error) {
|
||
claimedID, err := claimer.Claim(ctx, db, now, owner.ID, name, templateVersionPresetID, autostartSchedule, nextStartAt, ttl)
|
||
if err != nil {
|
||
// TODO: enhance this by clarifying whether this *specific* prebuild failed or whether there are none to claim.
|
||
return nil, xerrors.Errorf("claim prebuild: %w", err)
|
||
}
|
||
|
||
lookup, err := db.GetWorkspaceByID(ctx, *claimedID)
|
||
if err != nil {
|
||
logger.Error(ctx, "unable to find claimed workspace by ID", slog.Error(err), slog.F("claimed_prebuild_id", claimedID.String()))
|
||
return nil, xerrors.Errorf("find claimed workspace by ID %q: %w", claimedID.String(), err)
|
||
}
|
||
return &lookup, nil
|
||
}
|
||
|
||
func (api *API) notifyWorkspaceCreated(
|
||
ctx context.Context,
|
||
receiverID uuid.UUID,
|
||
workspace database.Workspace,
|
||
parameters []codersdk.WorkspaceBuildParameter,
|
||
) {
|
||
log := api.Logger.With(slog.F("workspace_id", workspace.ID))
|
||
|
||
template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
||
if err != nil {
|
||
log.Warn(ctx, "failed to fetch template for workspace creation notification", slog.F("template_id", workspace.TemplateID), slog.Error(err))
|
||
return
|
||
}
|
||
|
||
owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID)
|
||
if err != nil {
|
||
log.Warn(ctx, "failed to fetch user for workspace creation notification", slog.F("owner_id", workspace.OwnerID), slog.Error(err))
|
||
return
|
||
}
|
||
|
||
version, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID)
|
||
if err != nil {
|
||
log.Warn(ctx, "failed to fetch template version for workspace creation notification", slog.F("template_version_id", template.ActiveVersionID), slog.Error(err))
|
||
return
|
||
}
|
||
|
||
buildParameters := make([]map[string]any, len(parameters))
|
||
for idx, parameter := range parameters {
|
||
buildParameters[idx] = map[string]any{
|
||
"name": parameter.Name,
|
||
"value": parameter.Value,
|
||
}
|
||
}
|
||
|
||
if _, err := api.NotificationsEnqueuer.EnqueueWithData(
|
||
// nolint:gocritic // Need notifier actor to enqueue notifications
|
||
dbauthz.AsNotifier(ctx),
|
||
receiverID,
|
||
notifications.TemplateWorkspaceCreated,
|
||
map[string]string{
|
||
"workspace": workspace.Name,
|
||
"template": template.Name,
|
||
"version": version.Name,
|
||
"workspace_owner_username": owner.Username,
|
||
},
|
||
map[string]any{
|
||
"workspace": map[string]any{"id": workspace.ID, "name": workspace.Name},
|
||
"template": map[string]any{"id": template.ID, "name": template.Name},
|
||
"template_version": map[string]any{"id": version.ID, "name": version.Name},
|
||
"owner": map[string]any{"id": owner.ID, "name": owner.Name, "email": owner.Email},
|
||
"parameters": buildParameters,
|
||
},
|
||
"api-workspaces-create",
|
||
// Associate this notification with all the related entities
|
||
workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID,
|
||
); err != nil {
|
||
log.Warn(ctx, "failed to notify of workspace creation", slog.Error(err))
|
||
}
|
||
}
|
||
|
||
// @Summary Update workspace metadata by ID
|
||
// @ID update-workspace-metadata-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceRequest true "Metadata update request"
|
||
// @Success 204
|
||
// @Router /workspaces/{workspace} [patch]
|
||
func (api *API) patchWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
var req codersdk.UpdateWorkspaceRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
if req.Name == "" || req.Name == workspace.Name {
|
||
aReq.New = workspace.WorkspaceTable()
|
||
// Nothing changed, optionally this could be an error.
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
|
||
// The reason we double check here is in case more fields can be
|
||
// patched in the future, it's enough if one changes.
|
||
name := workspace.Name
|
||
if req.Name != "" || req.Name != workspace.Name {
|
||
if !api.Options.AllowWorkspaceRenames {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Workspace renames are not allowed.",
|
||
})
|
||
return
|
||
}
|
||
name = req.Name
|
||
}
|
||
|
||
newWorkspace, err := api.Database.UpdateWorkspace(ctx, database.UpdateWorkspaceParams{
|
||
ID: workspace.ID,
|
||
Name: name,
|
||
})
|
||
if err != nil {
|
||
// The query protects against updating deleted workspaces and
|
||
// the existence of the workspace is checked in the request,
|
||
// if we get ErrNoRows it means the workspace was deleted.
|
||
//
|
||
// We could do this check earlier but we'd need to start a
|
||
// transaction.
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
httpapi.Write(ctx, rw, http.StatusMethodNotAllowed, codersdk.Response{
|
||
Message: fmt.Sprintf("Workspace %q is deleted and cannot be updated.", workspace.Name),
|
||
})
|
||
return
|
||
}
|
||
// Check if the name was already in use.
|
||
if database.IsUniqueViolation(err) {
|
||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||
Message: fmt.Sprintf("Workspace %q already exists.", req.Name),
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "name",
|
||
Detail: "This value is already in use and should be unique.",
|
||
}},
|
||
})
|
||
return
|
||
}
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error updating workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
|
||
Kind: wspubsub.WorkspaceEventKindMetadataUpdate,
|
||
WorkspaceID: workspace.ID,
|
||
})
|
||
|
||
aReq.New = newWorkspace
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Update workspace autostart schedule by ID
|
||
// @ID update-workspace-autostart-schedule-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceAutostartRequest true "Schedule update request"
|
||
// @Success 204
|
||
// @Router /workspaces/{workspace}/autostart [put]
|
||
func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
var req codersdk.UpdateWorkspaceAutostartRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
// Autostart configuration is not supported for prebuilt workspaces.
|
||
// Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior
|
||
// defined per preset at the template level, not per workspace.
|
||
if workspace.IsPrebuild() {
|
||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||
Message: "Autostart is not supported for prebuilt workspaces",
|
||
Detail: "Prebuilt workspace scheduling is configured per preset at the template level. Workspace-level overrides are not supported.",
|
||
})
|
||
return
|
||
}
|
||
|
||
dbSched, err := validWorkspaceSchedule(req.Schedule)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid autostart schedule.",
|
||
Validations: []codersdk.ValidationError{{Field: "schedule", Detail: err.Error()}},
|
||
})
|
||
return
|
||
}
|
||
|
||
// Check if the template allows users to configure autostart.
|
||
templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, workspace.TemplateID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error getting template schedule options.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
if !templateSchedule.UserAutostartEnabled {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Autostart is not allowed for workspaces using this template.",
|
||
Validations: []codersdk.ValidationError{{Field: "schedule", Detail: "Autostart is not allowed for workspaces using this template."}},
|
||
})
|
||
return
|
||
}
|
||
|
||
// Use injected Clock to allow time mocking in tests
|
||
now := api.Clock.Now()
|
||
|
||
nextStartAt := sql.NullTime{}
|
||
if dbSched.Valid {
|
||
next, err := schedule.NextAllowedAutostart(now, dbSched.String, templateSchedule)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error calculating workspace autostart schedule.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())}
|
||
}
|
||
|
||
err = api.Database.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{
|
||
ID: workspace.ID,
|
||
AutostartSchedule: dbSched,
|
||
NextStartAt: nextStartAt,
|
||
})
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error updating workspace autostart schedule.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
newWorkspace := workspace
|
||
newWorkspace.AutostartSchedule = dbSched
|
||
aReq.New = newWorkspace.WorkspaceTable()
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Update workspace TTL by ID
|
||
// @ID update-workspace-ttl-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceTTLRequest true "Workspace TTL update request"
|
||
// @Success 204
|
||
// @Router /workspaces/{workspace}/ttl [put]
|
||
func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
var req codersdk.UpdateWorkspaceTTLRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
// TTL updates are not supported for prebuilt workspaces.
|
||
// Prebuild lifecycle is managed by the reconciliation loop, with TTL behavior
|
||
// defined per preset at the template level, not per workspace.
|
||
if workspace.IsPrebuild() {
|
||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||
Message: "TTL updates are not supported for prebuilt workspaces",
|
||
Detail: "Prebuilt workspace TTL is configured per preset at the template level. Workspace-level overrides are not supported.",
|
||
})
|
||
return
|
||
}
|
||
|
||
var dbTTL sql.NullInt64
|
||
|
||
err := api.Database.InTx(func(s database.Store) error {
|
||
templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, s, workspace.TemplateID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get template schedule: %w", err)
|
||
}
|
||
if !templateSchedule.UserAutostopEnabled {
|
||
return codersdk.ValidationError{Field: "ttl_ms", Detail: "Custom autostop TTL is not allowed for workspaces using this template."}
|
||
}
|
||
|
||
// don't override 0 ttl with template default here because it indicates
|
||
// disabled autostop
|
||
var validityErr error
|
||
dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0)
|
||
if validityErr != nil {
|
||
return codersdk.ValidationError{Field: "ttl_ms", Detail: validityErr.Error()}
|
||
}
|
||
if err := s.UpdateWorkspaceTTL(ctx, database.UpdateWorkspaceTTLParams{
|
||
ID: workspace.ID,
|
||
Ttl: dbTTL,
|
||
}); err != nil {
|
||
return xerrors.Errorf("update workspace time until shutdown: %w", err)
|
||
}
|
||
|
||
// Use injected Clock to allow time mocking in tests
|
||
now := api.Clock.Now()
|
||
|
||
// If autostop has been disabled, we want to remove the deadline from the
|
||
// existing workspace build (if there is one).
|
||
if !dbTTL.Valid {
|
||
build, err := s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get latest workspace build: %w", err)
|
||
}
|
||
|
||
if build.Transition == database.WorkspaceTransitionStart {
|
||
if err = s.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
|
||
ID: build.ID,
|
||
// Use the max_deadline as the new build deadline. It will
|
||
// either be zero (our target), or a non-zero value that we
|
||
// need to abide by anyway due to template policy.
|
||
//
|
||
// Previously, we would always set the deadline to zero,
|
||
// which was incorrect behavior. When max_deadline is
|
||
// non-zero, deadline must be set to a non-zero value that
|
||
// is less than max_deadline.
|
||
//
|
||
// Disabling TTL autostop (at a workspace or template level)
|
||
// does not trump the template's autostop requirement.
|
||
//
|
||
// Refer to the comments on schedule.CalculateAutostop for
|
||
// more information.
|
||
Deadline: build.MaxDeadline,
|
||
MaxDeadline: build.MaxDeadline,
|
||
UpdatedAt: dbtime.Time(now),
|
||
}); err != nil {
|
||
return xerrors.Errorf("update workspace build deadline: %w", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}, nil)
|
||
if err != nil {
|
||
resp := codersdk.Response{
|
||
Message: "Error updating workspace time until shutdown.",
|
||
}
|
||
var validErr codersdk.ValidationError
|
||
if errors.As(err, &validErr) {
|
||
resp.Validations = []codersdk.ValidationError{validErr}
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
|
||
return
|
||
}
|
||
|
||
resp.Detail = err.Error()
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, resp)
|
||
return
|
||
}
|
||
|
||
newWorkspace := workspace
|
||
newWorkspace.Ttl = dbTTL
|
||
aReq.New = newWorkspace.WorkspaceTable()
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Update workspace dormancy status by id.
|
||
// @ID update-workspace-dormancy-status-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceDormancy true "Make a workspace dormant or active"
|
||
// @Success 200 {object} codersdk.Workspace
|
||
// @Router /workspaces/{workspace}/dormant [put]
|
||
func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
oldWorkspace = httpmw.WorkspaceParam(r)
|
||
apiKey = httpmw.APIKey(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: oldWorkspace.OrganizationID,
|
||
})
|
||
)
|
||
aReq.Old = oldWorkspace.WorkspaceTable()
|
||
defer commitAudit()
|
||
|
||
var req codersdk.UpdateWorkspaceDormancy
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
// Dormancy configuration is not supported for prebuilt workspaces.
|
||
// Prebuilds are managed by the reconciliation loop and are not subject to dormancy.
|
||
if oldWorkspace.IsPrebuild() {
|
||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||
Message: "Dormancy updates are not supported for prebuilt workspaces",
|
||
Detail: "Prebuilt workspaces are not subject to dormancy. Dormancy behavior is only applicable to regular workspaces",
|
||
})
|
||
return
|
||
}
|
||
|
||
// If the workspace is already in the desired state do nothing!
|
||
if oldWorkspace.DormantAt.Valid == req.Dormant {
|
||
rw.WriteHeader(http.StatusNotModified)
|
||
return
|
||
}
|
||
|
||
// Use injected Clock to allow time mocking in tests
|
||
now := api.Clock.Now()
|
||
|
||
dormantAt := sql.NullTime{
|
||
Valid: req.Dormant,
|
||
}
|
||
if req.Dormant {
|
||
dormantAt.Time = dbtime.Time(now)
|
||
}
|
||
|
||
newWorkspace, err := api.Database.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
|
||
ID: oldWorkspace.ID,
|
||
DormantAt: dormantAt,
|
||
})
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error updating workspace locked status.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
// We don't need to notify the owner if they are the one making the request.
|
||
if req.Dormant && apiKey.UserID != newWorkspace.OwnerID {
|
||
initiator, initiatorErr := api.Database.GetUserByID(ctx, apiKey.UserID)
|
||
if initiatorErr != nil {
|
||
api.Logger.Warn(
|
||
ctx,
|
||
"failed to fetch the user that marked the workspace as dormant",
|
||
slog.Error(err),
|
||
slog.F("workspace_id", newWorkspace.ID),
|
||
slog.F("user_id", apiKey.UserID),
|
||
)
|
||
}
|
||
|
||
tmpl, tmplErr := api.Database.GetTemplateByID(ctx, newWorkspace.TemplateID)
|
||
if tmplErr != nil {
|
||
api.Logger.Warn(
|
||
ctx,
|
||
"failed to fetch the template of the workspace marked as dormant",
|
||
slog.Error(err),
|
||
slog.F("workspace_id", newWorkspace.ID),
|
||
slog.F("template_id", newWorkspace.TemplateID),
|
||
)
|
||
}
|
||
|
||
if initiatorErr == nil && tmplErr == nil {
|
||
dormantTime := dbtime.Time(now).Add(time.Duration(tmpl.TimeTilDormant))
|
||
_, err = api.NotificationsEnqueuer.Enqueue(
|
||
// nolint:gocritic // Need notifier actor to enqueue notifications
|
||
dbauthz.AsNotifier(ctx),
|
||
newWorkspace.OwnerID,
|
||
notifications.TemplateWorkspaceDormant,
|
||
map[string]string{
|
||
"name": newWorkspace.Name,
|
||
"reason": "a " + initiator.Username + " request",
|
||
"timeTilDormant": humanize.Time(dormantTime),
|
||
},
|
||
"api",
|
||
newWorkspace.ID,
|
||
newWorkspace.OwnerID,
|
||
newWorkspace.TemplateID,
|
||
newWorkspace.OrganizationID,
|
||
)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "failed to notify of workspace marked as dormant", slog.Error(err))
|
||
}
|
||
}
|
||
}
|
||
|
||
// We have to refetch the workspace to get the joined in fields.
|
||
workspace, err := api.Database.GetWorkspaceByID(ctx, newWorkspace.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace resources.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
// TODO: This is a strange error since it occurs after the mutation.
|
||
// An example of why we should join in fields to prevent this forbidden error
|
||
// from being sent, when the action did succeed.
|
||
if len(data.templates) == 0 {
|
||
httpapi.Forbidden(rw)
|
||
return
|
||
}
|
||
|
||
aReq.New = newWorkspace
|
||
|
||
appStatus := codersdk.WorkspaceAppStatus{}
|
||
if len(data.appStatuses) > 0 {
|
||
appStatus = data.appStatuses[0]
|
||
}
|
||
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
api.Logger,
|
||
apiKey.UserID,
|
||
workspace,
|
||
data.builds[0],
|
||
data.templates[0],
|
||
api.Options.AllowWorkspaceRenames,
|
||
appStatus,
|
||
)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
httpapi.Write(ctx, rw, http.StatusOK, w)
|
||
}
|
||
|
||
// @Summary Extend workspace deadline by ID
|
||
// @ID extend-workspace-deadline-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.PutExtendWorkspaceRequest true "Extend deadline update request"
|
||
// @Success 200 {object} codersdk.Response
|
||
// @Router /workspaces/{workspace}/extend [put]
|
||
func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
workspace := httpmw.WorkspaceParam(r)
|
||
|
||
var req codersdk.PutExtendWorkspaceRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
// Deadline extensions are not supported for prebuilt workspaces.
|
||
// Prebuilds are managed by the reconciliation loop and must always have
|
||
// Deadline and MaxDeadline unset.
|
||
if workspace.IsPrebuild() {
|
||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||
Message: "Deadline extension is not supported for prebuilt workspaces",
|
||
Detail: "Prebuilt workspaces do not support user deadline modifications. Deadline extension is only applicable to regular workspaces",
|
||
})
|
||
return
|
||
}
|
||
|
||
code := http.StatusOK
|
||
resp := codersdk.Response{}
|
||
|
||
err := api.Database.InTx(func(s database.Store) error {
|
||
build, err := s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
||
if err != nil {
|
||
code = http.StatusInternalServerError
|
||
resp.Message = "Error fetching workspace build."
|
||
return xerrors.Errorf("get latest workspace build: %w", err)
|
||
}
|
||
|
||
job, err := s.GetProvisionerJobByID(ctx, build.JobID)
|
||
if err != nil {
|
||
code = http.StatusInternalServerError
|
||
resp.Message = "Error fetching workspace provisioner job."
|
||
return xerrors.Errorf("get provisioner job: %w", err)
|
||
}
|
||
|
||
if build.Transition != database.WorkspaceTransitionStart {
|
||
code = http.StatusConflict
|
||
resp.Message = "Workspace must be started, current status: " + string(build.Transition)
|
||
return xerrors.Errorf("workspace must be started, current status: %s", build.Transition)
|
||
}
|
||
|
||
if !job.CompletedAt.Valid {
|
||
code = http.StatusConflict
|
||
resp.Message = "Workspace is still building!"
|
||
return xerrors.Errorf("workspace is still building")
|
||
}
|
||
|
||
if build.Deadline.IsZero() {
|
||
code = http.StatusConflict
|
||
resp.Message = "Workspace shutdown is manual."
|
||
return xerrors.Errorf("workspace shutdown is manual")
|
||
}
|
||
|
||
// Use injected Clock to allow time mocking in tests
|
||
now := api.Clock.Now()
|
||
|
||
newDeadline := req.Deadline.UTC()
|
||
if err := validWorkspaceDeadline(now, job.CompletedAt.Time, newDeadline); err != nil {
|
||
// NOTE(Cian): Putting the error in the Message field on request from the FE folks.
|
||
// Normally, we would put the validation error in Validations, but this endpoint is
|
||
// not tied to a form or specific named user input on the FE.
|
||
code = http.StatusBadRequest
|
||
resp.Message = "Cannot extend workspace: " + err.Error()
|
||
return err
|
||
}
|
||
if !build.MaxDeadline.IsZero() && newDeadline.After(build.MaxDeadline) {
|
||
code = http.StatusBadRequest
|
||
resp.Message = "Cannot extend workspace beyond max deadline."
|
||
return xerrors.New("Cannot extend workspace: deadline is beyond max deadline imposed by template")
|
||
}
|
||
|
||
if err := s.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
|
||
ID: build.ID,
|
||
UpdatedAt: dbtime.Time(now),
|
||
Deadline: newDeadline,
|
||
MaxDeadline: build.MaxDeadline,
|
||
}); err != nil {
|
||
code = http.StatusInternalServerError
|
||
resp.Message = "Failed to extend workspace deadline."
|
||
return xerrors.Errorf("update workspace build: %w", err)
|
||
}
|
||
resp.Message = "Deadline updated to " + newDeadline.Format(time.RFC3339) + "."
|
||
|
||
return nil
|
||
}, nil)
|
||
if err != nil {
|
||
api.Logger.Info(ctx, "extending workspace", slog.Error(err))
|
||
}
|
||
|
||
api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
|
||
Kind: wspubsub.WorkspaceEventKindMetadataUpdate,
|
||
WorkspaceID: workspace.ID,
|
||
})
|
||
httpapi.Write(ctx, rw, code, resp)
|
||
}
|
||
|
||
// @Summary Post Workspace Usage by ID
|
||
// @ID post-workspace-usage-by-id
|
||
// @Security CoderSessionToken
|
||
// @Tags Workspaces
|
||
// @Accept json
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.PostWorkspaceUsageRequest false "Post workspace usage request"
|
||
// @Success 204
|
||
// @Router /workspaces/{workspace}/usage [post]
|
||
func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) {
|
||
workspace := httpmw.WorkspaceParam(r)
|
||
if !api.Authorize(r, policy.ActionUpdate, workspace) {
|
||
httpapi.Forbidden(rw)
|
||
return
|
||
}
|
||
|
||
api.statsReporter.TrackUsage(workspace.ID)
|
||
|
||
if !api.Experiments.Enabled(codersdk.ExperimentWorkspaceUsage) {
|
||
// Continue previous behavior if the experiment is not enabled.
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
|
||
if r.Body == http.NoBody {
|
||
// Continue previous behavior if no body is present.
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
|
||
ctx := r.Context()
|
||
var req codersdk.PostWorkspaceUsageRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
if req.AgentID == uuid.Nil && req.AppName == "" {
|
||
// Continue previous behavior if body is empty.
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
if req.AgentID == uuid.Nil {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid request",
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "agent_id",
|
||
Detail: "must be set when app_name is set",
|
||
}},
|
||
})
|
||
return
|
||
}
|
||
if req.AppName == "" {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid request",
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "app_name",
|
||
Detail: "must be set when agent_id is set",
|
||
}},
|
||
})
|
||
return
|
||
}
|
||
if !slices.Contains(codersdk.AllowedAppNames, req.AppName) {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid request",
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "app_name",
|
||
Detail: fmt.Sprintf("must be one of %v", codersdk.AllowedAppNames),
|
||
}},
|
||
})
|
||
return
|
||
}
|
||
|
||
stat := &proto.Stats{
|
||
ConnectionCount: 1,
|
||
}
|
||
switch req.AppName {
|
||
case codersdk.UsageAppNameVscode:
|
||
stat.SessionCountVscode = 1
|
||
case codersdk.UsageAppNameJetbrains:
|
||
stat.SessionCountJetbrains = 1
|
||
case codersdk.UsageAppNameReconnectingPty:
|
||
stat.SessionCountReconnectingPty = 1
|
||
case codersdk.UsageAppNameSSH:
|
||
stat.SessionCountSsh = 1
|
||
default:
|
||
// This means the app_name is in the codersdk.AllowedAppNames but not being
|
||
// handled by this switch statement.
|
||
httpapi.InternalServerError(rw, xerrors.Errorf("unknown app_name %q", req.AppName))
|
||
return
|
||
}
|
||
|
||
agent, err := api.Database.GetWorkspaceAgentByID(ctx, req.AgentID)
|
||
if err != nil {
|
||
if httpapi.Is404Error(err) {
|
||
httpapi.ResourceNotFound(rw)
|
||
return
|
||
}
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
// template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
||
// if err != nil {
|
||
// httpapi.InternalServerError(rw, err)
|
||
// return
|
||
// }
|
||
|
||
err = api.statsReporter.ReportAgentStats(ctx, dbtime.Now(), database.WorkspaceIdentityFromWorkspace(workspace), agent, stat, true)
|
||
if err != nil {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Favorite workspace by ID.
|
||
// @ID favorite-workspace-by-id
|
||
// @Security CoderSessionToken
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 204
|
||
// @Router /workspaces/{workspace}/favorite [put]
|
||
func (api *API) putFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
apiKey = httpmw.APIKey(r)
|
||
auditor = api.Auditor.Load()
|
||
)
|
||
|
||
if apiKey.UserID != workspace.OwnerID {
|
||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||
Message: "You can only favorite workspaces that you own.",
|
||
})
|
||
return
|
||
}
|
||
|
||
aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
err := api.Database.FavoriteWorkspace(ctx, workspace.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error setting workspace as favorite",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
aReq.New = workspace.WorkspaceTable()
|
||
aReq.New.Favorite = true
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Unfavorite workspace by ID.
|
||
// @ID unfavorite-workspace-by-id
|
||
// @Security CoderSessionToken
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 204
|
||
// @Router /workspaces/{workspace}/favorite [delete]
|
||
func (api *API) deleteFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
apiKey = httpmw.APIKey(r)
|
||
auditor = api.Auditor.Load()
|
||
)
|
||
|
||
if apiKey.UserID != workspace.OwnerID {
|
||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||
Message: "You can only un-favorite workspaces that you own.",
|
||
})
|
||
return
|
||
}
|
||
|
||
aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
err := api.Database.UnfavoriteWorkspace(ctx, workspace.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error unsetting workspace as favorite",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
aReq.New = workspace.WorkspaceTable()
|
||
aReq.New.Favorite = false
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Update workspace automatic updates by ID
|
||
// @ID update-workspace-automatic-updates-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceAutomaticUpdatesRequest true "Automatic updates request"
|
||
// @Success 204
|
||
// @Router /workspaces/{workspace}/autoupdates [put]
|
||
func (api *API) putWorkspaceAutoupdates(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
var req codersdk.UpdateWorkspaceAutomaticUpdatesRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
if !database.AutomaticUpdates(req.AutomaticUpdates).Valid() {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid request",
|
||
Validations: []codersdk.ValidationError{{Field: "automatic_updates", Detail: "must be always or never"}},
|
||
})
|
||
return
|
||
}
|
||
|
||
err := api.Database.UpdateWorkspaceAutomaticUpdates(ctx, database.UpdateWorkspaceAutomaticUpdatesParams{
|
||
ID: workspace.ID,
|
||
AutomaticUpdates: database.AutomaticUpdates(req.AutomaticUpdates),
|
||
})
|
||
if httpapi.Is404Error(err) {
|
||
httpapi.ResourceNotFound(rw)
|
||
return
|
||
}
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error updating workspace automatic updates setting",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
newWorkspace := workspace
|
||
newWorkspace.AutomaticUpdates = database.AutomaticUpdates(req.AutomaticUpdates)
|
||
aReq.New = newWorkspace.WorkspaceTable()
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Resolve workspace autostart by id.
|
||
// @ID resolve-workspace-autostart-by-id
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 200 {object} codersdk.ResolveAutostartResponse
|
||
// @Router /workspaces/{workspace}/resolve-autostart [get]
|
||
func (api *API) resolveAutostart(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
)
|
||
|
||
template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
||
if err != nil {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
templateAccessControl := (*(api.AccessControlStore.Load())).GetTemplateAccessControl(template)
|
||
useActiveVersion := templateAccessControl.RequireActiveVersion || workspace.AutomaticUpdates == database.AutomaticUpdatesAlways
|
||
if !useActiveVersion {
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ResolveAutostartResponse{})
|
||
return
|
||
}
|
||
|
||
build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching latest workspace build.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
if build.TemplateVersionID == template.ActiveVersionID {
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ResolveAutostartResponse{})
|
||
return
|
||
}
|
||
|
||
version, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching template version.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
dbVersionParams, err := api.Database.GetTemplateVersionParameters(ctx, version.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching template version parameters.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
dbBuildParams, err := api.Database.GetWorkspaceBuildParameters(ctx, build.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching latest workspace build parameters.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
versionParams, err := db2sdk.TemplateVersionParameters(dbVersionParams)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting template version parameters.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
resolver := codersdk.ParameterResolver{
|
||
Rich: db2sdk.WorkspaceBuildParameters(dbBuildParams),
|
||
}
|
||
|
||
var response codersdk.ResolveAutostartResponse
|
||
for _, param := range versionParams {
|
||
_, err := resolver.ValidateResolve(param, nil)
|
||
// There's a parameter mismatch if we get an error back from the
|
||
// resolver.
|
||
response.ParameterMismatch = err != nil
|
||
if response.ParameterMismatch {
|
||
break
|
||
}
|
||
}
|
||
httpapi.Write(ctx, rw, http.StatusOK, response)
|
||
}
|
||
|
||
// @Summary Watch workspace by ID
|
||
// @ID watch-workspace-by-id
|
||
// @Security CoderSessionToken
|
||
// @Produce text/event-stream
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 200 {object} codersdk.Response
|
||
// @Router /workspaces/{workspace}/watch [get]
|
||
// @Deprecated Use /workspaces/{workspace}/watch-ws instead
|
||
func (api *API) watchWorkspaceSSE(rw http.ResponseWriter, r *http.Request) {
|
||
api.watchWorkspace(rw, r, httpapi.ServerSentEventSender)
|
||
}
|
||
|
||
// @Summary Watch workspace by ID via WebSockets
|
||
// @ID watch-workspace-by-id-via-websockets
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 200 {object} codersdk.ServerSentEvent
|
||
// @Router /workspaces/{workspace}/watch-ws [get]
|
||
func (api *API) watchWorkspaceWS(rw http.ResponseWriter, r *http.Request) {
|
||
api.watchWorkspace(rw, r, httpapi.OneWayWebSocketEventSender(api.Logger))
|
||
}
|
||
|
||
func (api *API) watchWorkspace(
|
||
rw http.ResponseWriter,
|
||
r *http.Request,
|
||
connect httpapi.EventSender,
|
||
) {
|
||
ctx := r.Context()
|
||
workspace := httpmw.WorkspaceParam(r)
|
||
apiKey := httpmw.APIKey(r)
|
||
|
||
sendEvent, senderClosed, err := connect(rw, r)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error setting up server-sent events.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
// Prevent handler from returning until the sender is closed.
|
||
defer func() {
|
||
<-senderClosed
|
||
}()
|
||
|
||
sendUpdate := func(_ context.Context, _ []byte) {
|
||
workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Internal error fetching workspace.",
|
||
Detail: err.Error(),
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
|
||
if err != nil {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Internal error fetching workspace data.",
|
||
Detail: err.Error(),
|
||
},
|
||
})
|
||
return
|
||
}
|
||
if len(data.templates) == 0 {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Forbidden reading template of selected workspace.",
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
appStatus := codersdk.WorkspaceAppStatus{}
|
||
if len(data.appStatuses) > 0 {
|
||
appStatus = data.appStatuses[0]
|
||
}
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
api.Logger,
|
||
apiKey.UserID,
|
||
workspace,
|
||
data.builds[0],
|
||
data.templates[0],
|
||
api.Options.AllowWorkspaceRenames,
|
||
appStatus,
|
||
)
|
||
if err != nil {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Internal error converting workspace.",
|
||
Detail: err.Error(),
|
||
},
|
||
})
|
||
}
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeData,
|
||
Data: w,
|
||
})
|
||
}
|
||
|
||
cancelWorkspaceSubscribe, err := api.Pubsub.SubscribeWithErr(wspubsub.WorkspaceEventChannel(workspace.OwnerID),
|
||
wspubsub.HandleWorkspaceEvent(
|
||
func(ctx context.Context, payload wspubsub.WorkspaceEvent, err error) {
|
||
if err != nil {
|
||
return
|
||
}
|
||
if payload.WorkspaceID != workspace.ID {
|
||
return
|
||
}
|
||
sendUpdate(ctx, nil)
|
||
}))
|
||
if err != nil {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Internal error subscribing to workspace events.",
|
||
Detail: err.Error(),
|
||
},
|
||
})
|
||
return
|
||
}
|
||
defer cancelWorkspaceSubscribe()
|
||
|
||
// This is required to show whether the workspace is up-to-date.
|
||
cancelTemplateSubscribe, err := api.Pubsub.Subscribe(watchTemplateChannel(workspace.TemplateID), sendUpdate)
|
||
if err != nil {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Internal error subscribing to template events.",
|
||
Detail: err.Error(),
|
||
},
|
||
})
|
||
return
|
||
}
|
||
defer cancelTemplateSubscribe()
|
||
|
||
// An initial ping signals to the request that the server is now ready
|
||
// and the client can begin servicing a channel with data.
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypePing,
|
||
})
|
||
// Send updated workspace info after connection is established. This avoids
|
||
// missing updates if the client connects after an update.
|
||
sendUpdate(ctx, nil)
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-senderClosed:
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// @Summary Watch all workspace builds
|
||
// @ID watch-all-workspace-builds
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Success 101
|
||
// @Router /experimental/watch-all-workspacebuilds [get]
|
||
// @x-apidocgen {"skip": true}
|
||
func (api *API) watchAllWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
|
||
// Buffer enough updates to avoid blocking the pubsub callback while we're
|
||
// accepting the WebSocket connection. Accepting the connection signals to
|
||
// the client that the server is subscribed and ready to forward events.
|
||
updates := make(chan codersdk.WorkspaceBuildUpdate, 256)
|
||
|
||
cancelSubscribe, err := api.Pubsub.SubscribeWithErr(wspubsub.AllWorkspaceEventChannel,
|
||
wspubsub.HandleWorkspaceBuildUpdate(
|
||
func(_ context.Context, update codersdk.WorkspaceBuildUpdate, err error) {
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "workspace build update subscription error", slog.Error(err))
|
||
return
|
||
}
|
||
select {
|
||
case updates <- update:
|
||
default:
|
||
api.Logger.Warn(ctx, "workspace build update dropped, client too slow")
|
||
}
|
||
}))
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error subscribing to workspace build events.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
defer cancelSubscribe()
|
||
|
||
conn, err := websocket.Accept(rw, r, nil)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Failed to accept WebSocket.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
defer conn.Close(websocket.StatusNormalClosure, "done")
|
||
|
||
// CloseRead starts a goroutine to read and discard messages from the client,
|
||
// including Pong messages sent in response to our Ping heartbeats.
|
||
_ = conn.CloseRead(context.Background())
|
||
|
||
ctx, cancel := context.WithCancel(ctx)
|
||
go httpapi.HeartbeatClose(ctx, api.Logger, cancel, conn)
|
||
defer cancel()
|
||
|
||
enc := wsjson.NewEncoder[codersdk.WorkspaceBuildUpdate](conn, websocket.MessageText)
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case update, ok := <-updates:
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := enc.Encode(update); err != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// @Summary Get workspace timings by ID
|
||
// @ID get-workspace-timings-by-id
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 200 {object} codersdk.WorkspaceBuildTimings
|
||
// @Router /workspaces/{workspace}/timings [get]
|
||
func (api *API) workspaceTimings(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
)
|
||
|
||
build, err := api.Database.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
|
||
}
|
||
|
||
timings, err := api.buildTimings(ctx, build)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching timings.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
httpapi.Write(ctx, rw, http.StatusOK, timings)
|
||
}
|
||
|
||
// @Summary Get workspace ACLs
|
||
// @ID get-workspace-acls
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 200 {object} codersdk.WorkspaceACL
|
||
// @Router /workspaces/{workspace}/acl [get]
|
||
func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
)
|
||
|
||
// Fetch the ACL data.
|
||
workspaceACL, err := api.Database.GetWorkspaceACLByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
// This is largely based on the template ACL implementation, and is far from
|
||
// ideal. Usually, when we use the System context it's because we need to
|
||
// run some query that won't actually be exposed to the user. That is not
|
||
// the case here. This data goes directly to an unauthorized user. We are
|
||
// just straight up breaking security promises.
|
||
//
|
||
// TODO: This needs to be fixed before GA. Currently in beta.
|
||
|
||
// Fetch all of the users and their organization memberships
|
||
userIDs := make([]uuid.UUID, 0, len(workspaceACL.Users))
|
||
for userID := range workspaceACL.Users {
|
||
id, err := uuid.Parse(userID)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
|
||
continue
|
||
}
|
||
userIDs = append(userIDs, id)
|
||
}
|
||
// For context see https://github.com/coder/coder/pull/19375
|
||
// nolint:gocritic
|
||
dbUsers, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), userIDs)
|
||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
// Convert the db types to the codersdk.WorkspaceUser type
|
||
users := make([]codersdk.WorkspaceUser, 0, len(dbUsers))
|
||
for _, it := range dbUsers {
|
||
users = append(users, codersdk.WorkspaceUser{
|
||
MinimalUser: db2sdk.MinimalUser(it),
|
||
Role: convertToWorkspaceRole(workspaceACL.Users[it.ID.String()].Permissions),
|
||
})
|
||
}
|
||
|
||
// Fetch all of the groups
|
||
groupIDs := make([]uuid.UUID, 0, len(workspaceACL.Groups))
|
||
for groupID := range workspaceACL.Groups {
|
||
id, err := uuid.Parse(groupID)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
|
||
continue
|
||
}
|
||
groupIDs = append(groupIDs, id)
|
||
}
|
||
|
||
// `GetGroups` returns all groups if `GroupIds` is empty so we check the length
|
||
// before making the DB call.
|
||
dbGroups := make([]database.GetGroupsRow, 0)
|
||
if len(groupIDs) > 0 {
|
||
// For context see https://github.com/coder/coder/pull/19375
|
||
// nolint:gocritic
|
||
dbGroups, err = api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs})
|
||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
}
|
||
|
||
groups := make([]codersdk.WorkspaceGroup, 0, len(dbGroups))
|
||
for _, it := range dbGroups {
|
||
var members []database.GroupMember
|
||
// For context see https://github.com/coder/coder/pull/19375
|
||
// nolint:gocritic
|
||
members, err = api.Database.GetGroupMembersByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersByGroupIDParams{
|
||
GroupID: it.Group.ID,
|
||
IncludeSystem: false,
|
||
})
|
||
if err != nil {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
groups = append(groups, codersdk.WorkspaceGroup{
|
||
Group: db2sdk.Group(database.GetGroupsRow{
|
||
Group: it.Group,
|
||
OrganizationName: it.OrganizationName,
|
||
OrganizationDisplayName: it.OrganizationDisplayName,
|
||
}, members, len(members)),
|
||
Role: convertToWorkspaceRole(workspaceACL.Groups[it.Group.ID.String()].Permissions),
|
||
})
|
||
}
|
||
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceACL{
|
||
Users: users,
|
||
Groups: groups,
|
||
})
|
||
}
|
||
|
||
// @Summary Update workspace ACL
|
||
// @ID update-workspace-acl
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceACL true "Update workspace ACL request"
|
||
// @Success 204
|
||
// @Router /workspaces/{workspace}/acl [patch]
|
||
func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
if !api.allowWorkspaceSharing(ctx, rw, workspace.OrganizationID) {
|
||
return
|
||
}
|
||
|
||
var req codersdk.UpdateWorkspaceACL
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
apiKey := httpmw.APIKey(r)
|
||
if _, ok := req.UserRoles[apiKey.UserID.String()]; ok {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "You cannot change your own workspace sharing role.",
|
||
})
|
||
return
|
||
}
|
||
|
||
validErrs := acl.Validate(ctx, api.Database, WorkspaceACLUpdateValidator(req))
|
||
if len(validErrs) > 0 {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid request to update workspace ACL",
|
||
Validations: validErrs,
|
||
})
|
||
return
|
||
}
|
||
|
||
err := api.Database.InTx(func(tx database.Store) error {
|
||
var err error
|
||
workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get template by ID: %w", err)
|
||
}
|
||
|
||
for id, role := range req.UserRoles {
|
||
if role == codersdk.WorkspaceRoleDeleted {
|
||
delete(workspace.UserACL, id)
|
||
continue
|
||
}
|
||
workspace.UserACL[id] = database.WorkspaceACLEntry{
|
||
Permissions: db2sdk.WorkspaceRoleActions(role),
|
||
}
|
||
}
|
||
|
||
for id, role := range req.GroupRoles {
|
||
if role == codersdk.WorkspaceRoleDeleted {
|
||
delete(workspace.GroupACL, id)
|
||
continue
|
||
}
|
||
workspace.GroupACL[id] = database.WorkspaceACLEntry{
|
||
Permissions: db2sdk.WorkspaceRoleActions(role),
|
||
}
|
||
}
|
||
|
||
err = tx.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
|
||
ID: workspace.ID,
|
||
UserACL: workspace.UserACL,
|
||
GroupACL: workspace.GroupACL,
|
||
})
|
||
if err != nil {
|
||
return xerrors.Errorf("update workspace ACL by ID: %w", err)
|
||
}
|
||
workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get updated workspace by ID: %w", err)
|
||
}
|
||
return nil
|
||
}, nil)
|
||
if err != nil {
|
||
if dbauthz.IsNotAuthorizedError(err) {
|
||
httpapi.Forbidden(rw)
|
||
} else {
|
||
httpapi.InternalServerError(rw, err)
|
||
}
|
||
return
|
||
}
|
||
|
||
aReq.New = workspace.WorkspaceTable()
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
type workspaceData struct {
|
||
templates []database.Template
|
||
builds []codersdk.WorkspaceBuild
|
||
appStatuses []codersdk.WorkspaceAppStatus
|
||
allowRenames bool
|
||
}
|
||
|
||
// @Summary Completely clears the workspace's user and group ACLs.
|
||
// @ID completely-clears-the-workspaces-user-and-group-acls
|
||
// @Security CoderSessionToken
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 204
|
||
// @Router /workspaces/{workspace}/acl [delete]
|
||
func (api *API) deleteWorkspaceACL(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAuditor = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
|
||
defer commitAuditor()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
if !api.allowWorkspaceSharing(ctx, rw, workspace.OrganizationID) {
|
||
return
|
||
}
|
||
|
||
err := api.Database.InTx(func(tx database.Store) error {
|
||
err := tx.DeleteWorkspaceACLByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
return xerrors.Errorf("delete workspace by ID: %w", err)
|
||
}
|
||
|
||
workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get updated workspace by ID: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}, nil)
|
||
if err != nil {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
aReq.New = workspace.WorkspaceTable()
|
||
|
||
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
||
}
|
||
|
||
// allowWorkspaceSharing enforces the workspace-sharing gate for an
|
||
// organization. It writes an HTTP error response and returns false if
|
||
// sharing is disabled or the org lookup fails; otherwise it returns
|
||
// true.
|
||
func (api *API) allowWorkspaceSharing(ctx context.Context, rw http.ResponseWriter, organizationID uuid.UUID) bool {
|
||
//nolint:gocritic // Use system context so this check doesn’t
|
||
// depend on the caller having organization:read.
|
||
org, err := api.Database.GetOrganizationByID(dbauthz.AsSystemRestricted(ctx), organizationID)
|
||
if err != nil {
|
||
httpapi.InternalServerError(rw, err)
|
||
return false
|
||
}
|
||
if org.ShareableWorkspaceOwners == database.ShareableWorkspaceOwnersNone {
|
||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||
Message: "Workspace sharing is disabled for this organization.",
|
||
})
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// workspacesData only returns the data the caller can access. If the caller
|
||
// does not have the correct perms to read a given template, the template will
|
||
// not be returned.
|
||
// So the caller must check the templates & users exist before using them.
|
||
func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspace) (workspaceData, error) {
|
||
workspaceIDs := make([]uuid.UUID, 0, len(workspaces))
|
||
templateIDs := make([]uuid.UUID, 0, len(workspaces))
|
||
for _, workspace := range workspaces {
|
||
workspaceIDs = append(workspaceIDs, workspace.ID)
|
||
templateIDs = append(templateIDs, workspace.TemplateID)
|
||
}
|
||
|
||
var (
|
||
templates []database.Template
|
||
builds []database.WorkspaceBuild
|
||
appStatuses []database.WorkspaceAppStatus
|
||
eg errgroup.Group
|
||
)
|
||
eg.Go(func() (err error) {
|
||
templates, err = api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{
|
||
IDs: templateIDs,
|
||
})
|
||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||
return xerrors.Errorf("get templates: %w", err)
|
||
}
|
||
return nil
|
||
})
|
||
eg.Go(func() (err error) {
|
||
// This query must be run as system restricted to be efficient.
|
||
// nolint:gocritic
|
||
builds, err = api.Database.GetLatestWorkspaceBuildsByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs)
|
||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||
return xerrors.Errorf("get workspace builds: %w", err)
|
||
}
|
||
return nil
|
||
})
|
||
eg.Go(func() (err error) {
|
||
// This query must be run as system restricted to be efficient.
|
||
// nolint:gocritic
|
||
appStatuses, err = api.Database.GetLatestWorkspaceAppStatusesByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs)
|
||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||
return xerrors.Errorf("get workspace app statuses: %w", err)
|
||
}
|
||
return nil
|
||
})
|
||
err := eg.Wait()
|
||
if err != nil {
|
||
return workspaceData{}, err
|
||
}
|
||
|
||
data, err := api.workspaceBuildsData(ctx, builds)
|
||
if err != nil {
|
||
return workspaceData{}, xerrors.Errorf("get workspace builds data: %w", err)
|
||
}
|
||
|
||
apiBuilds, err := api.convertWorkspaceBuilds(
|
||
builds,
|
||
workspaces,
|
||
data.jobs,
|
||
data.resources,
|
||
data.metadata,
|
||
data.agents,
|
||
data.apps,
|
||
data.appStatuses,
|
||
data.scripts,
|
||
data.logSources,
|
||
data.templateVersions,
|
||
data.provisionerDaemons,
|
||
)
|
||
if err != nil {
|
||
return workspaceData{}, xerrors.Errorf("convert workspace builds: %w", err)
|
||
}
|
||
|
||
return workspaceData{
|
||
templates: templates,
|
||
appStatuses: db2sdk.WorkspaceAppStatuses(appStatuses),
|
||
builds: apiBuilds,
|
||
allowRenames: api.Options.AllowWorkspaceRenames,
|
||
}, nil
|
||
}
|
||
|
||
func convertWorkspaces(
|
||
ctx context.Context,
|
||
logger slog.Logger,
|
||
requesterID uuid.UUID,
|
||
workspaces []database.Workspace,
|
||
data workspaceData,
|
||
) ([]codersdk.Workspace, error) {
|
||
buildByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceBuild{}
|
||
for _, workspaceBuild := range data.builds {
|
||
buildByWorkspaceID[workspaceBuild.WorkspaceID] = workspaceBuild
|
||
}
|
||
templateByID := map[uuid.UUID]database.Template{}
|
||
for _, template := range data.templates {
|
||
templateByID[template.ID] = template
|
||
}
|
||
appStatusesByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceAppStatus{}
|
||
for _, appStatus := range data.appStatuses {
|
||
appStatusesByWorkspaceID[appStatus.WorkspaceID] = appStatus
|
||
}
|
||
|
||
apiWorkspaces := make([]codersdk.Workspace, 0, len(workspaces))
|
||
for _, workspace := range workspaces {
|
||
// If any data is missing from the workspace, just skip returning
|
||
// this workspace. This is not ideal, but the user cannot read
|
||
// all the workspace's data, so do not show them.
|
||
// Ideally we could just return some sort of "unknown" for the missing
|
||
// fields?
|
||
build, exists := buildByWorkspaceID[workspace.ID]
|
||
if !exists {
|
||
continue
|
||
}
|
||
template, exists := templateByID[workspace.TemplateID]
|
||
if !exists {
|
||
continue
|
||
}
|
||
appStatus := appStatusesByWorkspaceID[workspace.ID]
|
||
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
logger,
|
||
requesterID,
|
||
workspace,
|
||
build,
|
||
template,
|
||
data.allowRenames,
|
||
appStatus,
|
||
)
|
||
if err != nil {
|
||
return nil, xerrors.Errorf("convert workspace: %w", err)
|
||
}
|
||
|
||
apiWorkspaces = append(apiWorkspaces, w)
|
||
}
|
||
return apiWorkspaces, nil
|
||
}
|
||
|
||
func convertWorkspace(
|
||
ctx context.Context,
|
||
logger slog.Logger,
|
||
requesterID uuid.UUID,
|
||
workspace database.Workspace,
|
||
workspaceBuild codersdk.WorkspaceBuild,
|
||
template database.Template,
|
||
allowRenames bool,
|
||
latestAppStatus codersdk.WorkspaceAppStatus,
|
||
) (codersdk.Workspace, error) {
|
||
if requesterID == uuid.Nil {
|
||
return codersdk.Workspace{}, xerrors.Errorf("developer error: requesterID cannot be uuid.Nil!")
|
||
}
|
||
var autostartSchedule *string
|
||
if workspace.AutostartSchedule.Valid {
|
||
autostartSchedule = &workspace.AutostartSchedule.String
|
||
}
|
||
|
||
var dormantAt *time.Time
|
||
if workspace.DormantAt.Valid {
|
||
dormantAt = &workspace.DormantAt.Time
|
||
}
|
||
|
||
var deletingAt *time.Time
|
||
if workspace.DeletingAt.Valid {
|
||
deletingAt = &workspace.DeletingAt.Time
|
||
}
|
||
|
||
var nextStartAt *time.Time
|
||
if workspace.NextStartAt.Valid {
|
||
nextStartAt = &workspace.NextStartAt.Time
|
||
}
|
||
|
||
failingAgents := []uuid.UUID{}
|
||
for _, resource := range workspaceBuild.Resources {
|
||
for _, agent := range resource.Agents {
|
||
// Sub-agents (e.g., devcontainer agents) are excluded from the
|
||
// workspace health calculation. Their health is managed by
|
||
// their parent agent, and temporary disconnections during
|
||
// devcontainer rebuilds should not affect workspace health.
|
||
if agent.ParentID.Valid {
|
||
continue
|
||
}
|
||
if !agent.Health.Healthy {
|
||
failingAgents = append(failingAgents, agent.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
ttlMillis := convertWorkspaceTTLMillis(workspace.Ttl)
|
||
// If the template doesn't allow a workspace-configured value, then report the
|
||
// template value instead.
|
||
if !template.AllowUserAutostop {
|
||
ttlMillis = convertWorkspaceTTLMillis(sql.NullInt64{Valid: true, Int64: template.DefaultTTL})
|
||
}
|
||
|
||
// Only show favorite status if you own the workspace.
|
||
requesterFavorite := workspace.OwnerID == requesterID && workspace.Favorite
|
||
|
||
appStatus := &latestAppStatus
|
||
if latestAppStatus.ID == uuid.Nil {
|
||
appStatus = nil
|
||
}
|
||
|
||
return codersdk.Workspace{
|
||
ID: workspace.ID,
|
||
CreatedAt: workspace.CreatedAt,
|
||
UpdatedAt: workspace.UpdatedAt,
|
||
OwnerID: workspace.OwnerID,
|
||
OwnerName: workspace.OwnerUsername,
|
||
OwnerAvatarURL: workspace.OwnerAvatarUrl,
|
||
OrganizationID: workspace.OrganizationID,
|
||
OrganizationName: workspace.OrganizationName,
|
||
TemplateID: workspace.TemplateID,
|
||
LatestBuild: workspaceBuild,
|
||
LatestAppStatus: appStatus,
|
||
TemplateName: workspace.TemplateName,
|
||
TemplateIcon: workspace.TemplateIcon,
|
||
TemplateDisplayName: workspace.TemplateDisplayName,
|
||
TemplateAllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||
TemplateActiveVersionID: template.ActiveVersionID,
|
||
TemplateRequireActiveVersion: template.RequireActiveVersion,
|
||
TemplateUseClassicParameterFlow: template.UseClassicParameterFlow,
|
||
Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(),
|
||
Name: workspace.Name,
|
||
AutostartSchedule: autostartSchedule,
|
||
TTLMillis: ttlMillis,
|
||
LastUsedAt: workspace.LastUsedAt,
|
||
DeletingAt: deletingAt,
|
||
DormantAt: dormantAt,
|
||
Health: codersdk.WorkspaceHealth{
|
||
Healthy: len(failingAgents) == 0,
|
||
FailingAgents: failingAgents,
|
||
},
|
||
AutomaticUpdates: codersdk.AutomaticUpdates(workspace.AutomaticUpdates),
|
||
AllowRenames: allowRenames,
|
||
Favorite: requesterFavorite,
|
||
NextStartAt: nextStartAt,
|
||
IsPrebuild: workspace.IsPrebuild(),
|
||
TaskID: workspace.TaskID,
|
||
SharedWith: sharedWorkspaceActors(ctx, logger, workspace),
|
||
}, nil
|
||
}
|
||
|
||
func sharedWorkspaceActors(
|
||
ctx context.Context,
|
||
logger slog.Logger,
|
||
workspace database.Workspace,
|
||
) []codersdk.SharedWorkspaceActor {
|
||
out := make([]codersdk.SharedWorkspaceActor, 0, len(workspace.UserACL)+len(workspace.GroupACL))
|
||
|
||
// Users
|
||
for id, aclEntry := range workspace.UserACL {
|
||
userID, err := uuid.Parse(id)
|
||
if err != nil {
|
||
logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
|
||
continue
|
||
}
|
||
|
||
out = append(out, codersdk.SharedWorkspaceActor{
|
||
ID: userID,
|
||
ActorType: codersdk.SharedWorkspaceActorTypeUser,
|
||
Roles: []codersdk.WorkspaceRole{convertToWorkspaceRole(aclEntry.Permissions)},
|
||
Name: workspace.UserACLDisplayInfo[id].Name,
|
||
AvatarURL: workspace.UserACLDisplayInfo[id].AvatarURL,
|
||
})
|
||
}
|
||
|
||
// Groups
|
||
for id, aclEntry := range workspace.GroupACL {
|
||
groupID, err := uuid.Parse(id)
|
||
if err != nil {
|
||
logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
|
||
continue
|
||
}
|
||
|
||
out = append(out, codersdk.SharedWorkspaceActor{
|
||
ID: groupID,
|
||
ActorType: codersdk.SharedWorkspaceActorTypeGroup,
|
||
Roles: []codersdk.WorkspaceRole{convertToWorkspaceRole(aclEntry.Permissions)},
|
||
Name: workspace.GroupACLDisplayInfo[id].Name,
|
||
AvatarURL: workspace.GroupACLDisplayInfo[id].AvatarURL,
|
||
})
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
func convertWorkspaceTTLMillis(i sql.NullInt64) *int64 {
|
||
if !i.Valid {
|
||
return nil
|
||
}
|
||
|
||
millis := time.Duration(i.Int64).Milliseconds()
|
||
return &millis
|
||
}
|
||
|
||
func validWorkspaceTTLMillis(millis *int64, templateDefault time.Duration) (sql.NullInt64, error) {
|
||
if ptr.NilOrZero(millis) {
|
||
if templateDefault == 0 {
|
||
return sql.NullInt64{}, nil
|
||
}
|
||
|
||
return sql.NullInt64{
|
||
Int64: int64(templateDefault),
|
||
Valid: true,
|
||
}, nil
|
||
}
|
||
|
||
dur := time.Duration(*millis) * time.Millisecond
|
||
truncated := dur.Truncate(time.Minute)
|
||
if truncated < ttlMinimum {
|
||
return sql.NullInt64{}, errTTLMin
|
||
}
|
||
|
||
if truncated > ttlMaximum {
|
||
return sql.NullInt64{}, errTTLMax
|
||
}
|
||
|
||
return sql.NullInt64{
|
||
Valid: true,
|
||
Int64: int64(truncated),
|
||
}, nil
|
||
}
|
||
|
||
func validWorkspaceAutomaticUpdates(updates codersdk.AutomaticUpdates) (database.AutomaticUpdates, error) {
|
||
if updates == "" {
|
||
return database.AutomaticUpdatesNever, nil
|
||
}
|
||
dbAU := database.AutomaticUpdates(updates)
|
||
if !dbAU.Valid() {
|
||
return "", xerrors.New("Automatic updates must be always or never")
|
||
}
|
||
return dbAU, nil
|
||
}
|
||
|
||
func validWorkspaceDeadline(now, startedAt, newDeadline time.Time) error {
|
||
soon := now.Add(29 * time.Minute)
|
||
if newDeadline.Before(soon) {
|
||
return errDeadlineTooSoon
|
||
}
|
||
|
||
// No idea how this could happen.
|
||
if newDeadline.Before(startedAt) {
|
||
return errDeadlineBeforeStart
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func validWorkspaceSchedule(s *string) (sql.NullString, error) {
|
||
if ptr.NilOrEmpty(s) {
|
||
return sql.NullString{}, nil
|
||
}
|
||
|
||
_, err := cron.Weekly(*s)
|
||
if err != nil {
|
||
return sql.NullString{}, err
|
||
}
|
||
|
||
return sql.NullString{
|
||
Valid: true,
|
||
String: *s,
|
||
}, nil
|
||
}
|
||
|
||
func (api *API) publishWorkspaceUpdate(ctx context.Context, ownerID uuid.UUID, event wspubsub.WorkspaceEvent) {
|
||
err := event.Validate()
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "invalid workspace update event",
|
||
slog.F("workspace_id", event.WorkspaceID),
|
||
slog.F("event_kind", event.Kind), slog.Error(err))
|
||
return
|
||
}
|
||
msg, err := json.Marshal(event)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "failed to marshal workspace update",
|
||
slog.F("workspace_id", event.WorkspaceID), slog.Error(err))
|
||
return
|
||
}
|
||
err = api.Pubsub.Publish(wspubsub.WorkspaceEventChannel(ownerID), msg)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "failed to publish workspace update",
|
||
slog.F("workspace_id", event.WorkspaceID), slog.Error(err))
|
||
}
|
||
}
|
||
|
||
func (api *API) publishWorkspaceAgentLogsUpdate(ctx context.Context, workspaceAgentID uuid.UUID, m agentsdk.LogsNotifyMessage) {
|
||
b, err := json.Marshal(m)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "failed to marshal logs notify message", slog.F("workspace_agent_id", workspaceAgentID), slog.Error(err))
|
||
}
|
||
err = api.Pubsub.Publish(agentsdk.LogsNotifyChannel(workspaceAgentID), b)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "failed to publish workspace agent logs update", slog.F("workspace_agent_id", workspaceAgentID), slog.Error(err))
|
||
}
|
||
}
|
||
|
||
type WorkspaceACLUpdateValidator codersdk.UpdateWorkspaceACL
|
||
|
||
var (
|
||
workspaceACLUpdateUsersFieldName = "user_roles"
|
||
workspaceACLUpdateGroupsFieldName = "group_roles"
|
||
)
|
||
|
||
// WorkspaceACLUpdateValidator implements acl.UpdateValidator[codersdk.WorkspaceRole]
|
||
var _ acl.UpdateValidator[codersdk.WorkspaceRole] = WorkspaceACLUpdateValidator{}
|
||
|
||
func (w WorkspaceACLUpdateValidator) Users() (map[string]codersdk.WorkspaceRole, string) {
|
||
return w.UserRoles, workspaceACLUpdateUsersFieldName
|
||
}
|
||
|
||
func (w WorkspaceACLUpdateValidator) Groups() (map[string]codersdk.WorkspaceRole, string) {
|
||
return w.GroupRoles, workspaceACLUpdateGroupsFieldName
|
||
}
|
||
|
||
func (WorkspaceACLUpdateValidator) ValidateRole(role codersdk.WorkspaceRole) error {
|
||
actions := db2sdk.WorkspaceRoleActions(role)
|
||
if len(actions) == 0 && role != codersdk.WorkspaceRoleDeleted {
|
||
return xerrors.Errorf("role %q is not a valid workspace role", role)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func convertToWorkspaceRole(actions []policy.Action) codersdk.WorkspaceRole {
|
||
switch {
|
||
case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleAdmin)):
|
||
return codersdk.WorkspaceRoleAdmin
|
||
case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleUse)):
|
||
return codersdk.WorkspaceRoleUse
|
||
}
|
||
|
||
return codersdk.WorkspaceRoleDeleted
|
||
}
|
||
|
||
// @Summary Get users available for workspace creation
|
||
// @ID get-users-available-for-workspace-creation
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param organization path string true "Organization ID" format(uuid)
|
||
// @Param user path string true "User ID, name, or me"
|
||
// @Param q query string false "Search query"
|
||
// @Param limit query int false "Limit results"
|
||
// @Param offset query int false "Offset for pagination"
|
||
// @Success 200 {array} codersdk.MinimalUser
|
||
// @Router /organizations/{organization}/members/{user}/workspaces/available-users [get]
|
||
func (api *API) workspaceAvailableUsers(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
organization := httpmw.OrganizationParam(r)
|
||
|
||
// This endpoint requires the user to be able to create workspaces for other
|
||
// users in this organization. We check if they can create a workspace with
|
||
// a wildcard owner.
|
||
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceWorkspace.InOrg(organization.ID).WithOwner(policy.WildcardSymbol)) {
|
||
httpapi.Forbidden(rw)
|
||
return
|
||
}
|
||
|
||
// Use system context to list all users. The authorization check above
|
||
// ensures only users who can create workspaces for others can access this.
|
||
//nolint:gocritic // System context needed to list users for workspace owner selection.
|
||
users, _, ok := api.GetUsers(rw, r.WithContext(dbauthz.AsSystemRestricted(ctx)))
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
minimalUsers := make([]codersdk.MinimalUser, 0, len(users))
|
||
for _, user := range users {
|
||
minimalUsers = append(minimalUsers, codersdk.MinimalUser{
|
||
ID: user.ID,
|
||
Username: user.Username,
|
||
Name: user.Name,
|
||
AvatarURL: user.AvatarURL,
|
||
})
|
||
}
|
||
|
||
httpapi.Write(ctx, rw, http.StatusOK, minimalUsers)
|
||
}
|