From a8a640f90e273901f8f3500f81979b98b90a2a00 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Tue, 20 May 2025 19:52:03 +0000 Subject: [PATCH 01/33] chore: fall back to `gh auth login` for update_experiments.sh script --- scripts/release/docs_update_experiments.sh | 54 ++++++++-------------- 1 file changed, 18 insertions(+), 36 deletions(-) diff --git a/scripts/release/docs_update_experiments.sh b/scripts/release/docs_update_experiments.sh index 1e5e6d1eb6b3e..08a2d4d2bc63b 100755 --- a/scripts/release/docs_update_experiments.sh +++ b/scripts/release/docs_update_experiments.sh @@ -12,27 +12,33 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/../lib.sh" cdroot +# Ensure GITHUB_TOKEN is available +if [[ -z "${GITHUB_TOKEN:-}" ]]; then + if GITHUB_TOKEN="$(gh auth token 2>/dev/null)"; then + export GITHUB_TOKEN + else + echo "Error: GitHub token not found. Please run 'gh auth login' to authenticate." >&2 + exit 1 + fi +fi + if isdarwin; then dependencies gsed gawk sed() { gsed "$@"; } awk() { gawk "$@"; } fi -# From install.sh echo_latest_stable_version() { - # https://gist.github.com/lukechilds/a83e1d7127b78fef38c2914c4ececc3c#gistcomment-2758860 + # Extract redirect URL to determine latest stable tag version="$(curl -fsSLI -o /dev/null -w "%{url_effective}" https://github.com/coder/coder/releases/latest)" version="${version#https://github.com/coder/coder/releases/tag/v}" echo "v${version}" } echo_latest_mainline_version() { - # Fetch the releases from the GitHub API, sort by version number, - # and take the first result. Note that we're sorting by space- - # separated numbers and without utilizing the sort -V flag for the - # best compatibility. + # Use GitHub API to get latest release version, authenticated echo "v$( - curl -fsSL https://api.github.com/repos/coder/coder/releases | + curl -fsSL -H "Authorization: token ${GITHUB_TOKEN}" https://api.github.com/repos/coder/coder/releases | awk -F'"' '/"tag_name"/ {print $4}' | tr -d v | tr . ' ' | @@ -42,7 +48,6 @@ echo_latest_mainline_version() { )" } -# For testing or including experiments from `main`. echo_latest_main_version() { echo origin/main } @@ -59,11 +64,6 @@ sparse_clone_codersdk() { } parse_all_experiments() { - # Go doc doesn't include inline array comments, so this parsing should be - # good enough. We remove all whitespaces so that we can extract a plain - # string that looks like {}, {ExpA}, or {ExpA,ExpB,}. - # - # Example: ExperimentsAll=Experiments{ExperimentNotifications,ExperimentAutoFillParameters,} go doc -all -C "${dir}" ./codersdk ExperimentsAll | tr -d $'\n\t ' | grep -E -o 'ExperimentsAll=Experiments\{[^}]*\}' | @@ -72,20 +72,6 @@ parse_all_experiments() { } parse_experiments() { - # Extracts the experiment name and description from the Go doc output. - # The output is in the format: - # - # ||Add new experiments here! - # ExperimentExample|example|This isn't used for anything. - # ExperimentAutoFillParameters|auto-fill-parameters|This should not be taken out of experiments until we have redesigned the feature. - # ExperimentMultiOrganization|multi-organization|Requires organization context for interactions, default org is assumed. - # ExperimentCustomRoles|custom-roles|Allows creating runtime custom roles. - # ExperimentNotifications|notifications|Sends notifications via SMTP and webhooks following certain events. - # ExperimentWorkspaceUsage|workspace-usage|Enables the new workspace usage tracking. - # ||ExperimentTest is an experiment with - # ||a preceding multi line comment!? - # ExperimentTest|test| - # go doc -all -C "${1}" ./codersdk Experiment | sed \ -e 's/\t\(Experiment[^ ]*\)\ \ *Experiment = "\([^"]*\)"\(.*\/\/ \(.*\)\)\?/\1|\2|\4/' \ @@ -104,6 +90,11 @@ for channel in mainline stable; do log "Fetching experiments from ${channel}" tag=$(echo_latest_"${channel}"_version) + if [[ -z "${tag}" || "${tag}" == "v" ]]; then + echo "Error: Failed to retrieve valid ${channel} version tag. Check your GitHub token or rate limit." >&2 + exit 1 + fi + dir="$(sparse_clone_codersdk "${workdir}" "${channel}" "${tag}")" declare -A all_experiments=() @@ -115,14 +106,12 @@ for channel in mainline stable; do done fi - # Track preceding/multiline comments. maybe_desc= while read -r line; do line=${line//$'\n'/} readarray -d '|' -t parts <<<"$line" - # Missing var/key, this is a comment or description. if [[ -z ${parts[0]} ]]; then maybe_desc+="${parts[2]//$'\n'/ }" continue @@ -133,24 +122,20 @@ for channel in mainline stable; do desc="${parts[2]}" desc=${desc//$'\n'/} - # If desc (trailing comment) is empty, use the preceding/multiline comment. if [[ -z "${desc}" ]]; then desc="${maybe_desc% }" fi maybe_desc= - # Skip experiments not listed in ExperimentsAll. if [[ ! -v all_experiments[$var] ]]; then log "Skipping ${var}, not listed in ExperimentsAll" continue fi - # Don't overwrite desc, prefer first come, first served (i.e. mainline > stable). if [[ ! -v experiments[$key] ]]; then experiments[$key]="$desc" fi - # Track the release channels where the experiment is available. experiment_tags[$key]+="${channel}, " done < <(parse_experiments "${dir}") done @@ -170,8 +155,6 @@ table="$( done )" -# Use awk to print everything outside the BEING/END block and insert the -# table in between. awk \ -v table="${table}" \ 'BEGIN{include=1} /BEGIN: available-experimental-features/{print; print table; include=0} /END: available-experimental-features/{include=1} include' \ @@ -179,5 +162,4 @@ awk \ >"${dest}".tmp mv "${dest}".tmp "${dest}" -# Format the file for a pretty table (target single file for speed). (cd site && pnpm exec prettier --cache --write ../"${dest}") From b4ed9d258d752befbdbe9eab360b6ad05d38ea75 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Tue, 20 May 2025 20:09:34 +0000 Subject: [PATCH 02/33] refactor: update experiment parsing script to use ExperimentsSafe instead of ExperimentsAll --- scripts/release/docs_update_experiments.sh | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/scripts/release/docs_update_experiments.sh b/scripts/release/docs_update_experiments.sh index 08a2d4d2bc63b..360e0daee4649 100755 --- a/scripts/release/docs_update_experiments.sh +++ b/scripts/release/docs_update_experiments.sh @@ -64,9 +64,24 @@ sparse_clone_codersdk() { } parse_all_experiments() { - go doc -all -C "${dir}" ./codersdk ExperimentsAll | + # Try ExperimentsSafe first, then fall back to ExperimentsAll if needed + experiments_var="ExperimentsSafe" + experiments_output=$(go doc -all -C "${dir}" ./codersdk "${experiments_var}" 2>/dev/null || true) + + if [[ -z "${experiments_output}" ]]; then + # Fall back to ExperimentsAll if ExperimentsSafe is not found + experiments_var="ExperimentsAll" + experiments_output=$(go doc -all -C "${dir}" ./codersdk "${experiments_var}" 2>/dev/null || true) + + if [[ -z "${experiments_output}" ]]; then + log "Warning: Neither ExperimentsSafe nor ExperimentsAll found in ${dir}" + return + fi + fi + + echo "${experiments_output}" | tr -d $'\n\t ' | - grep -E -o 'ExperimentsAll=Experiments\{[^}]*\}' | + grep -E -o "${experiments_var}=Experiments\{[^}]*\}" | sed -e 's/.*{\(.*\)}.*/\1/' | tr ',' '\n' } @@ -128,7 +143,7 @@ for channel in mainline stable; do maybe_desc= if [[ ! -v all_experiments[$var] ]]; then - log "Skipping ${var}, not listed in ExperimentsAll" + log "Skipping ${var}, not listed in experiments list" continue fi From 89144e9eece8d000c807cd1d08f355109948e8c6 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Tue, 20 May 2025 20:12:13 +0000 Subject: [PATCH 03/33] docs: add prebuilds experiment --- docs/install/releases/feature-stages.md | 94 ++++++++++++++++--------- 1 file changed, 59 insertions(+), 35 deletions(-) diff --git a/docs/install/releases/feature-stages.md b/docs/install/releases/feature-stages.md index 5730a5d76288e..535192efaa95c 100644 --- a/docs/install/releases/feature-stages.md +++ b/docs/install/releases/feature-stages.md @@ -10,7 +10,7 @@ If you encounter an issue with any Coder feature, please submit a ## Feature stages | Feature stage | Stable | Production-ready | Support | Description | -|----------------------------------------|--------|------------------|-----------------------|-------------------------------------------------------------------------------------------------------------------------------| +| -------------------------------------- | ------ | ---------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | [Early Access](#early-access-features) | No | No | GitHub issues | For staging only. Not feature-complete or stable. Disabled by default. | | [Beta](#beta) | No | Not fully | Docs, Discord, GitHub | Publicly available. In active development with minor bugs. Suitable for staging; optional for production. Not covered by SLA. | | [GA](#general-availability-ga) | Yes | Yes | License-based | Stable and tested. Enabled by default. Fully documented. Support based on license. | @@ -24,32 +24,37 @@ If you encounter an issue with any Coder feature, please submit a Early access features are neither feature-complete nor stable. We do not recommend using early access features in production deployments. -Coder sometimes releases early access features that are available for use, but are disabled by default. -You shouldn't use early access features in production because they might cause performance or stability issues. -Early access features can be mostly feature-complete, but require further internal testing and remain in the early access stage for at least one month. +Coder sometimes releases early access features that are available for use, but +are disabled by default. You shouldn't use early access features in production +because they might cause performance or stability issues. Early access features +can be mostly feature-complete, but require further internal testing and remain +in the early access stage for at least one month. -Coder may make significant changes or revert features to a feature flag at any time. +Coder may make significant changes or revert features to a feature flag at any +time. If you plan to activate an early access feature, we suggest that you use a staging deployment.
To enable early access features: -Use the [Coder CLI](../../install/cli.md) `--experiments` flag to enable early access features: +Use the [Coder CLI](../../install/cli.md) `--experiments` flag to enable early +access features: - Enable all early access features: - ```shell - coder server --experiments=* - ``` + ```shell + coder server --experiments=* + ``` - Enable multiple early access features: - ```shell - coder server --experiments=feature1,feature2 - ``` + ```shell + coder server --experiments=feature1,feature2 + ``` -You can also use the `CODER_EXPERIMENTS` [environment variable](../../admin/setup/index.md). +You can also use the `CODER_EXPERIMENTS` +[environment variable](../../admin/setup/index.md). You can opt-out of a feature after you've enabled it. @@ -60,7 +65,9 @@ You can opt-out of a feature after you've enabled it. -Currently no experimental features are available in the latest mainline or stable release. +| Feature | Description | Available in | +| --------------------- | -------------------------------------------- | ------------ | +| `workspace-prebuilds` | Enables the new workspace prebuilds feature. | mainline | @@ -68,24 +75,32 @@ Currently no experimental features are available in the latest mainline or stabl - **Stable**: No - **Production-ready**: Not fully -- **Support**: Documentation, [Discord](https://discord.gg/coder), and [GitHub issues](https://github.com/coder/coder/issues) +- **Support**: Documentation, [Discord](https://discord.gg/coder), and + [GitHub issues](https://github.com/coder/coder/issues) Beta features are open to the public and are tagged with a `Beta` label. -They’re in active development and subject to minor changes. -They might contain minor bugs, but are generally ready for use. +They’re in active development and subject to minor changes. They might contain +minor bugs, but are generally ready for use. -Beta features are often ready for general availability within two-three releases. -You should test beta features in staging environments. -You can use beta features in production, but should set expectations and inform users that some features may be incomplete. +Beta features are often ready for general availability within two-three +releases. You should test beta features in staging environments. You can use +beta features in production, but should set expectations and inform users that +some features may be incomplete. -We keep documentation about beta features up-to-date with the latest information, including planned features, limitations, and workarounds. -If you encounter an issue, please contact your [Coder account team](https://coder.com/contact), reach out on [Discord](https://discord.gg/coder), or create a [GitHub issues](https://github.com/coder/coder/issues) if there isn't one already. -While we will do our best to provide support with beta features, most issues will be escalated to the product team. -Beta features are not covered within service-level agreements (SLA). +We keep documentation about beta features up-to-date with the latest +information, including planned features, limitations, and workarounds. If you +encounter an issue, please contact your +[Coder account team](https://coder.com/contact), reach out on +[Discord](https://discord.gg/coder), or create a +[GitHub issues](https://github.com/coder/coder/issues) if there isn't one +already. While we will do our best to provide support with beta features, most +issues will be escalated to the product team. Beta features are not covered +within service-level agreements (SLA). -Most beta features are enabled by default. -Beta features are announced through the [Coder Changelog](https://coder.com/changelog), and more information is available in the documentation. +Most beta features are enabled by default. Beta features are announced through +the [Coder Changelog](https://coder.com/changelog), and more information is +available in the documentation. ## General Availability (GA) @@ -93,16 +108,25 @@ Beta features are announced through the [Coder Changelog](https://coder.com/chan - **Production-ready**: Yes - **Support**: Yes, [based on license](https://coder.com/pricing). -All features that are not explicitly tagged as `Early access` or `Beta` are considered generally available (GA). -They have been tested, are stable, and are enabled by default. +All features that are not explicitly tagged as `Early access` or `Beta` are +considered generally available (GA). They have been tested, are stable, and are +enabled by default. -If your Coder license includes an SLA, please consult it for an outline of specific expectations. +If your Coder license includes an SLA, please consult it for an outline of +specific expectations. -For support, consult our knowledgeable and growing community on [Discord](https://discord.gg/coder), or create a [GitHub issue](https://github.com/coder/coder/issues) if one doesn't exist already. -Customers with a valid Coder license, can submit a support request or contact your [account team](https://coder.com/contact). +For support, consult our knowledgeable and growing community on +[Discord](https://discord.gg/coder), or create a +[GitHub issue](https://github.com/coder/coder/issues) if one doesn't exist +already. Customers with a valid Coder license, can submit a support request or +contact your [account team](https://coder.com/contact). -We intend [Coder documentation](../../README.md) to be the [single source of truth](https://en.wikipedia.org/wiki/Single_source_of_truth) and all features should have some form of complete documentation that outlines how to use or implement a feature. -If you discover an error or if you have a suggestion that could improve the documentation, please [submit a GitHub issue](https://github.com/coder/internal/issues/new?title=request%28docs%29%3A+request+title+here&labels=["customer-feedback","docs"]&body=please+enter+your+request+here). +We intend [Coder documentation](../../README.md) to be the +[single source of truth](https://en.wikipedia.org/wiki/Single_source_of_truth) +and all features should have some form of complete documentation that outlines +how to use or implement a feature. If you discover an error or if you have a +suggestion that could improve the documentation, please +[submit a GitHub issue](https://github.com/coder/internal/issues/new?title=request%28docs%29%3A+request+title+here&labels=["customer-feedback","docs"]&body=please+enter+your+request+here). -Some GA features can be disabled for air-gapped deployments. -Consult the feature's documentation or submit a support ticket for assistance. +Some GA features can be disabled for air-gapped deployments. Consult the +feature's documentation or submit a support ticket for assistance. From 6bc879646d0894d6592340179bcc0fc748e998cd Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Tue, 20 May 2025 20:13:57 +0000 Subject: [PATCH 04/33] bump version --- docs/install/kubernetes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index 176fc7c452805..92e97e3cf902c 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -133,7 +133,7 @@ We support two release channels: mainline and stable - read the helm install coder coder-v2/coder \ --namespace coder \ --values values.yaml \ - --version 2.20.0 + --version 2.22.1 ``` - **Stable** Coder release: From ba5ce86814763bd9a3387ab98ffca8de233d42fb Mon Sep 17 00:00:00 2001 From: Danny Kopping <17991901+EdwardAngert@users.noreply.github.com> Date: Tue, 20 May 2025 22:16:23 +0200 Subject: [PATCH 05/33] chore: reduce `ignore_changes` suggestion scope (#17947) We probably shouldn't be suggesting `ignore_changes = all`. Only the attributes which cause drift in prebuilds should be ignored; everything else can behave as normal. --------- Signed-off-by: Danny Kopping Co-authored-by: Edward Angert --- .../extending-templates/prebuilt-workspaces.md | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/docs/admin/templates/extending-templates/prebuilt-workspaces.md b/docs/admin/templates/extending-templates/prebuilt-workspaces.md index 3fd82d62d1943..57f3dc0b3109f 100644 --- a/docs/admin/templates/extending-templates/prebuilt-workspaces.md +++ b/docs/admin/templates/extending-templates/prebuilt-workspaces.md @@ -142,7 +142,7 @@ To prevent this, add a `lifecycle` block with `ignore_changes`: ```hcl resource "docker_container" "workspace" { lifecycle { - ignore_changes = all + ignore_changes = [env, image] # include all fields which caused drift } count = data.coder_workspace.me.start_count @@ -151,19 +151,8 @@ resource "docker_container" "workspace" { } ``` -For more targeted control, specify which attributes to ignore: - -```hcl -resource "docker_container" "workspace" { - lifecycle { - ignore_changes = [name] - } - - count = data.coder_workspace.me.start_count - name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" - ... -} -``` +Limit the scope of `ignore_changes` to include only the fields specified in the notification. +If you include too many fields, Terraform might ignore changes that wouldn't otherwise cause drift. Learn more about `ignore_changes` in the [Terraform documentation](https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle#ignore_changes). From 83424ed5387357fd74e2dde280b1c8f6b3062471 Mon Sep 17 00:00:00 2001 From: Danielle Maywood <17991901+EdwardAngert@users.noreply.github.com> Date: Tue, 20 May 2025 21:20:56 +0100 Subject: [PATCH 06/33] chore(coderd/rbac): add `Action{Create,Delete}Agent` to `ResourceWorkspace` (#17932) --- coderd/apidoc/docs.go | 4 ++ coderd/apidoc/swagger.json | 4 ++ coderd/database/dbauthz/dbauthz.go | 25 ++++++++-- coderd/database/dbauthz/dbauthz_test.go | 33 ++++++++++++- coderd/database/dbmem/dbmem.go | 27 +++++++++++ coderd/database/dbmetrics/querymetrics.go | 7 +++ coderd/database/dbmock/dbmock.go | 15 ++++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 59 +++++++++++++++++++++++ coderd/database/queries/workspaces.sql | 24 +++++++++ coderd/rbac/object_gen.go | 6 +++ coderd/rbac/policy/policy.go | 6 +++ coderd/rbac/roles.go | 16 ++++-- coderd/rbac/roles_test.go | 11 ++++- codersdk/rbacresources_gen.go | 6 ++- docs/reference/api/members.md | 10 ++++ docs/reference/api/schemas.md | 2 + site/src/api/rbacresourcesGenerated.ts | 4 ++ site/src/api/typesGenerated.ts | 4 ++ 19 files changed, 253 insertions(+), 11 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 95e2cc0f48ac8..e98197d3b5bb2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14901,7 +14901,9 @@ const docTemplate = `{ "application_connect", "assign", "create", + "create_agent", "delete", + "delete_agent", "read", "read_personal", "ssh", @@ -14917,7 +14919,9 @@ const docTemplate = `{ "ActionApplicationConnect", "ActionAssign", "ActionCreate", + "ActionCreateAgent", "ActionDelete", + "ActionDeleteAgent", "ActionRead", "ActionReadPersonal", "ActionSSH", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 02212d9944415..fa103f55fbe9f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13509,7 +13509,9 @@ "application_connect", "assign", "create", + "create_agent", "delete", + "delete_agent", "read", "read_personal", "ssh", @@ -13525,7 +13527,9 @@ "ActionApplicationConnect", "ActionAssign", "ActionCreate", + "ActionCreateAgent", "ActionDelete", + "ActionDeleteAgent", "ActionRead", "ActionReadPersonal", "ActionSSH", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 20afcf66c7867..ab3781452dd2d 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -177,7 +177,7 @@ var ( // Unsure why provisionerd needs update and read personal rbac.ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal}, rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop}, - rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop}, + rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionCreateAgent}, rbac.ResourceApiKey.Type: {policy.WildcardSymbol}, // When org scoped provisioner credentials are implemented, // this can be reduced to read a specific org. @@ -339,7 +339,7 @@ var ( rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, rbac.ResourceUser.Type: rbac.ResourceUser.AvailableActions(), rbac.ResourceWorkspaceDormant.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStop}, - rbac.ResourceWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionSSH}, + rbac.ResourceWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionSSH, policy.ActionCreateAgent, policy.ActionDeleteAgent}, rbac.ResourceWorkspaceProxy.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceDeploymentConfig.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, @@ -3180,6 +3180,10 @@ func (q *querier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg database return fetch(q.log, q.auth, q.db.GetWorkspaceByOwnerIDAndName)(ctx, arg) } +func (q *querier) GetWorkspaceByResourceID(ctx context.Context, resourceID uuid.UUID) (database.Workspace, error) { + return fetch(q.log, q.auth, q.db.GetWorkspaceByResourceID)(ctx, resourceID) +} + func (q *querier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceAppID uuid.UUID) (database.Workspace, error) { return fetch(q.log, q.auth, q.db.GetWorkspaceByWorkspaceAppID)(ctx, workspaceAppID) } @@ -3713,9 +3717,24 @@ func (q *querier) InsertWorkspace(ctx context.Context, arg database.InsertWorksp } func (q *querier) InsertWorkspaceAgent(ctx context.Context, arg database.InsertWorkspaceAgentParams) (database.WorkspaceAgent, error) { - if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { + // NOTE(DanielleMaywood): + // Currently, the only way to link a Resource back to a Workspace is by following this chain: + // + // WorkspaceResource -> WorkspaceBuild -> Workspace + // + // It is possible for this function to be called without there existing + // a `WorkspaceBuild` to link back to. This means that we want to allow + // execution to continue if there isn't a workspace found to allow this + // behavior to continue. + workspace, err := q.db.GetWorkspaceByResourceID(ctx, arg.ResourceID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { return database.WorkspaceAgent{}, err } + + if err := q.authorizeContext(ctx, policy.ActionCreateAgent, workspace); err != nil { + return database.WorkspaceAgent{}, err + } + return q.db.InsertWorkspaceAgent(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 1e4b4ea879b77..e8b90afbc396d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1928,6 +1928,22 @@ func (s *MethodTestSuite) TestWorkspace() { }) check.Args(ws.ID).Asserts(ws, policy.ActionRead) })) + s.Run("GetWorkspaceByResourceID", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) + tpl := dbgen.Template(s.T(), db, database.Template{CreatedBy: u.ID, OrganizationID: o.ID}) + tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, + JobID: j.ID, + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{OwnerID: u.ID, TemplateID: tpl.ID, OrganizationID: o.ID}) + _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: j.ID, TemplateVersionID: tv.ID}) + res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: j.ID}) + check.Args(res.ID).Asserts(ws, policy.ActionRead) + })) s.Run("GetWorkspaces", s.Subtest(func(_ database.Store, check *expects) { // No asserts here because SQLFilter. check.Args(database.GetWorkspacesParams{}).Asserts() @@ -4018,12 +4034,25 @@ func (s *MethodTestSuite) TestSystemFunctions() { Returns(slice.New(a, b)) })) s.Run("InsertWorkspaceAgent", s.Subtest(func(db database.Store, check *expects) { - dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{Type: database.ProvisionerJobTypeWorkspaceBuild}) + tpl := dbgen.Template(s.T(), db, database.Template{CreatedBy: u.ID, OrganizationID: o.ID}) + tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, + JobID: j.ID, + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{OwnerID: u.ID, TemplateID: tpl.ID, OrganizationID: o.ID}) + _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: j.ID, TemplateVersionID: tv.ID}) + res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: j.ID}) check.Args(database.InsertWorkspaceAgentParams{ ID: uuid.New(), + ResourceID: res.ID, Name: "dev", APIKeyScope: database.AgentKeyScopeEnumAll, - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) + }).Asserts(ws, policy.ActionCreateAgent) })) s.Run("InsertWorkspaceApp", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 3ab2895876ac5..75c56b9c2324d 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -8053,6 +8053,33 @@ func (q *FakeQuerier) GetWorkspaceByOwnerIDAndName(_ context.Context, arg databa return database.Workspace{}, sql.ErrNoRows } +func (q *FakeQuerier) GetWorkspaceByResourceID(ctx context.Context, resourceID uuid.UUID) (database.Workspace, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, resource := range q.workspaceResources { + if resource.ID != resourceID { + continue + } + + for _, build := range q.workspaceBuilds { + if build.JobID != resource.JobID { + continue + } + + for _, workspace := range q.workspaces { + if workspace.ID != build.WorkspaceID { + continue + } + + return q.extendWorkspace(workspace), nil + } + } + } + + return database.Workspace{}, sql.ErrNoRows +} + func (q *FakeQuerier) GetWorkspaceByWorkspaceAppID(_ context.Context, workspaceAppID uuid.UUID) (database.Workspace, error) { if err := validateDatabaseType(workspaceAppID); err != nil { return database.Workspace{}, err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 9122cedbf786c..47ec185915660 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1887,6 +1887,13 @@ func (m queryMetricsStore) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg return workspace, err } +func (m queryMetricsStore) GetWorkspaceByResourceID(ctx context.Context, resourceID uuid.UUID) (database.Workspace, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceByResourceID(ctx, resourceID) + m.queryLatencies.WithLabelValues("GetWorkspaceByResourceID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceAppID uuid.UUID) (database.Workspace, error) { start := time.Now() workspace, err := m.s.GetWorkspaceByWorkspaceAppID(ctx, workspaceAppID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e7af9ecd8fee8..e3a9a14698e42 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3963,6 +3963,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceByOwnerIDAndName(ctx, arg any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceByOwnerIDAndName", reflect.TypeOf((*MockStore)(nil).GetWorkspaceByOwnerIDAndName), ctx, arg) } +// GetWorkspaceByResourceID mocks base method. +func (m *MockStore) GetWorkspaceByResourceID(ctx context.Context, resourceID uuid.UUID) (database.Workspace, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceByResourceID", ctx, resourceID) + ret0, _ := ret[0].(database.Workspace) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceByResourceID indicates an expected call of GetWorkspaceByResourceID. +func (mr *MockStoreMockRecorder) GetWorkspaceByResourceID(ctx, resourceID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceByResourceID", reflect.TypeOf((*MockStore)(nil).GetWorkspaceByResourceID), ctx, resourceID) +} + // GetWorkspaceByWorkspaceAppID mocks base method. func (m *MockStore) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceAppID uuid.UUID) (database.Workspace, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 78a88426349da..d248780397ead 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -422,6 +422,7 @@ type sqlcQuerier interface { GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUID) (Workspace, error) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) + GetWorkspaceByResourceID(ctx context.Context, resourceID uuid.UUID) (Workspace, error) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceAppID uuid.UUID) (Workspace, error) GetWorkspaceModulesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceModule, error) GetWorkspaceModulesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceModule, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b956fc1db5f91..99a8bf4603b57 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -18143,6 +18143,65 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo return i, err } +const getWorkspaceByResourceID = `-- name: GetWorkspaceByResourceID :one +SELECT + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, owner_avatar_url, owner_username, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description +FROM + workspaces_expanded as workspaces +WHERE + workspaces.id = ( + SELECT + workspace_id + FROM + workspace_builds + WHERE + workspace_builds.job_id = ( + SELECT + job_id + FROM + workspace_resources + WHERE + workspace_resources.id = $1 + ) + ) +LIMIT + 1 +` + +func (q *sqlQuerier) GetWorkspaceByResourceID(ctx context.Context, resourceID uuid.UUID) (Workspace, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceByResourceID, resourceID) + var i Workspace + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.Ttl, + &i.LastUsedAt, + &i.DormantAt, + &i.DeletingAt, + &i.AutomaticUpdates, + &i.Favorite, + &i.NextStartAt, + &i.OwnerAvatarUrl, + &i.OwnerUsername, + &i.OrganizationName, + &i.OrganizationDisplayName, + &i.OrganizationIcon, + &i.OrganizationDescription, + &i.TemplateName, + &i.TemplateDisplayName, + &i.TemplateIcon, + &i.TemplateDescription, + ) + return i, err +} + const getWorkspaceByWorkspaceAppID = `-- name: GetWorkspaceByWorkspaceAppID :one SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, owner_avatar_url, owner_username, organization_name, organization_display_name, organization_icon, organization_description, template_name, template_display_name, template_icon, template_description diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 4ec74c066fe41..44b7dcbf0387d 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -8,6 +8,30 @@ WHERE LIMIT 1; +-- name: GetWorkspaceByResourceID :one +SELECT + * +FROM + workspaces_expanded as workspaces +WHERE + workspaces.id = ( + SELECT + workspace_id + FROM + workspace_builds + WHERE + workspace_builds.job_id = ( + SELECT + job_id + FROM + workspace_resources + WHERE + workspace_resources.id = @resource_id + ) + ) +LIMIT + 1; + -- name: GetWorkspaceByWorkspaceAppID :one SELECT * diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index ad1a510fd44bd..f19d90894dd55 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -308,7 +308,9 @@ var ( // Valid Actions // - "ActionApplicationConnect" :: connect to workspace apps via browser // - "ActionCreate" :: create a new workspace + // - "ActionCreateAgent" :: create a new workspace agent // - "ActionDelete" :: delete workspace + // - "ActionDeleteAgent" :: delete an existing workspace agent // - "ActionRead" :: read workspace data to view on the UI // - "ActionSSH" :: ssh into a given workspace // - "ActionWorkspaceStart" :: allows starting a workspace @@ -338,7 +340,9 @@ var ( // Valid Actions // - "ActionApplicationConnect" :: connect to workspace apps via browser // - "ActionCreate" :: create a new workspace + // - "ActionCreateAgent" :: create a new workspace agent // - "ActionDelete" :: delete workspace + // - "ActionDeleteAgent" :: delete an existing workspace agent // - "ActionRead" :: read workspace data to view on the UI // - "ActionSSH" :: ssh into a given workspace // - "ActionWorkspaceStart" :: allows starting a workspace @@ -406,7 +410,9 @@ func AllActions() []policy.Action { policy.ActionApplicationConnect, policy.ActionAssign, policy.ActionCreate, + policy.ActionCreateAgent, policy.ActionDelete, + policy.ActionDeleteAgent, policy.ActionRead, policy.ActionReadPersonal, policy.ActionSSH, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index c37e84c48f964..160062283f857 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -24,6 +24,9 @@ const ( ActionReadPersonal Action = "read_personal" ActionUpdatePersonal Action = "update_personal" + + ActionCreateAgent Action = "create_agent" + ActionDeleteAgent Action = "delete_agent" ) type PermissionDefinition struct { @@ -67,6 +70,9 @@ var workspaceActions = map[Action]ActionDefinition{ // Running a workspace ActionSSH: actDef("ssh into a given workspace"), ActionApplicationConnect: actDef("connect to workspace apps via browser"), + + ActionCreateAgent: actDef("create a new workspace agent"), + ActionDeleteAgent: actDef("delete an existing workspace agent"), } // RBACPermissions is indexed by the type diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 0b94a74201b16..89f86b567a48d 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -272,7 +272,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // This adds back in the Workspace permissions. Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ownerWorkspaceActions, - ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop}, + ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent}, })...), Org: map[string][]Permission{}, User: []Permission{}, @@ -291,7 +291,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { User: append(allPermsExcept(ResourceWorkspaceDormant, ResourceUser, ResourceOrganizationMember), Permissions(map[string][]policy.Action{ // Reduced permission set on dormant workspaces. No build, ssh, or exec - ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop}, + ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent}, // Users cannot do create/update/delete on themselves, but they // can read their own details. ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal}, @@ -412,7 +412,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Org: map[string][]Permission{ // Org admins should not have workspace exec perms. organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourceAssignRole), Permissions(map[string][]policy.Action{ - ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop}, + ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent}, ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH), })...), }, @@ -529,6 +529,16 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceType: ResourceWorkspace.Type, Action: policy.ActionDelete, }, + { + Negate: true, + ResourceType: ResourceWorkspace.Type, + Action: policy.ActionCreateAgent, + }, + { + Negate: true, + ResourceType: ResourceWorkspace.Type, + Action: policy.ActionDeleteAgent, + }, }, }, User: []Permission{}, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 6d42a01474d1a..4dfbc8fa2ab31 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -226,6 +226,15 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin}, }, }, + { + Name: "CreateDeleteWorkspaceAgent", + Actions: []policy.Action{policy.ActionCreateAgent, policy.ActionDeleteAgent}, + Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgMemberMe, orgAdmin}, + false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgMemberMeBanWorkspace}, + }, + }, { Name: "Templates", Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, @@ -462,7 +471,7 @@ func TestRolePermissions(t *testing.T) { }, { Name: "WorkspaceDormant", - Actions: append(crud, policy.ActionWorkspaceStop), + Actions: append(crud, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent), Resource: rbac.ResourceWorkspaceDormant.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {orgMemberMe, orgAdmin, owner}, diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 6157281f21356..95792bb8e2a7b 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -49,7 +49,9 @@ const ( ActionApplicationConnect RBACAction = "application_connect" ActionAssign RBACAction = "assign" ActionCreate RBACAction = "create" + ActionCreateAgent RBACAction = "create_agent" ActionDelete RBACAction = "delete" + ActionDeleteAgent RBACAction = "delete_agent" ActionRead RBACAction = "read" ActionReadPersonal RBACAction = "read_personal" ActionSSH RBACAction = "ssh" @@ -97,9 +99,9 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionUse, ActionViewInsights}, ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, ResourceWebpushSubscription: {ActionCreate, ActionDelete, ActionRead}, - ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, + ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionCreateAgent, ActionDelete, ActionDeleteAgent, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, ResourceWorkspaceAgentDevcontainers: {ActionCreate}, ResourceWorkspaceAgentResourceMonitor: {ActionCreate, ActionRead, ActionUpdate}, - ResourceWorkspaceDormant: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, + ResourceWorkspaceDormant: {ActionApplicationConnect, ActionCreate, ActionCreateAgent, ActionDelete, ActionDeleteAgent, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, ResourceWorkspaceProxy: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, } diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index a58a597d1ea2a..6b5d124753bc0 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -169,7 +169,9 @@ Status Code **200** | `action` | `application_connect` | | `action` | `assign` | | `action` | `create` | +| `action` | `create_agent` | | `action` | `delete` | +| `action` | `delete_agent` | | `action` | `read` | | `action` | `read_personal` | | `action` | `ssh` | @@ -336,7 +338,9 @@ Status Code **200** | `action` | `application_connect` | | `action` | `assign` | | `action` | `create` | +| `action` | `create_agent` | | `action` | `delete` | +| `action` | `delete_agent` | | `action` | `read` | | `action` | `read_personal` | | `action` | `ssh` | @@ -503,7 +507,9 @@ Status Code **200** | `action` | `application_connect` | | `action` | `assign` | | `action` | `create` | +| `action` | `create_agent` | | `action` | `delete` | +| `action` | `delete_agent` | | `action` | `read` | | `action` | `read_personal` | | `action` | `ssh` | @@ -639,7 +645,9 @@ Status Code **200** | `action` | `application_connect` | | `action` | `assign` | | `action` | `create` | +| `action` | `create_agent` | | `action` | `delete` | +| `action` | `delete_agent` | | `action` | `read` | | `action` | `read_personal` | | `action` | `ssh` | @@ -997,7 +1005,9 @@ Status Code **200** | `action` | `application_connect` | | `action` | `assign` | | `action` | `create` | +| `action` | `create_agent` | | `action` | `delete` | +| `action` | `delete_agent` | | `action` | `read` | | `action` | `read_personal` | | `action` | `ssh` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 9325d751bc352..86cc4644c2685 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5913,7 +5913,9 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `application_connect` | | `assign` | | `create` | +| `create_agent` | | `delete` | +| `delete_agent` | | `read` | | `read_personal` | | `ssh` | diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 3acb86c079908..885f603c1eb82 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -173,7 +173,9 @@ export const RBACResourceActions: Partial< workspace: { application_connect: "connect to workspace apps via browser", create: "create a new workspace", + create_agent: "create a new workspace agent", delete: "delete workspace", + delete_agent: "delete an existing workspace agent", read: "read workspace data to view on the UI", ssh: "ssh into a given workspace", start: "allows starting a workspace", @@ -191,7 +193,9 @@ export const RBACResourceActions: Partial< workspace_dormant: { application_connect: "connect to workspace apps via browser", create: "create a new workspace", + create_agent: "create a new workspace agent", delete: "delete workspace", + delete_agent: "delete an existing workspace agent", read: "read workspace data to view on the UI", ssh: "ssh into a given workspace", start: "allows starting a workspace", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4e337bd7c65f0..35cd006ec6c55 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2131,7 +2131,9 @@ export type RBACAction = | "application_connect" | "assign" | "create" + | "create_agent" | "delete" + | "delete_agent" | "read" | "read_personal" | "ssh" @@ -2147,7 +2149,9 @@ export const RBACActions: RBACAction[] = [ "application_connect", "assign", "create", + "create_agent", "delete", + "delete_agent", "read", "read_personal", "ssh", From 068a29352e0a0a7e1c36059c9d45940c6d5aa472 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Tue, 20 May 2025 20:28:00 +0000 Subject: [PATCH 07/33] =?UTF-8?q?fmt=20(=F0=9F=A4=9E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/release/docs_update_experiments.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/release/docs_update_experiments.sh b/scripts/release/docs_update_experiments.sh index 360e0daee4649..7d7c178a9d4e9 100755 --- a/scripts/release/docs_update_experiments.sh +++ b/scripts/release/docs_update_experiments.sh @@ -67,18 +67,18 @@ parse_all_experiments() { # Try ExperimentsSafe first, then fall back to ExperimentsAll if needed experiments_var="ExperimentsSafe" experiments_output=$(go doc -all -C "${dir}" ./codersdk "${experiments_var}" 2>/dev/null || true) - + if [[ -z "${experiments_output}" ]]; then # Fall back to ExperimentsAll if ExperimentsSafe is not found experiments_var="ExperimentsAll" experiments_output=$(go doc -all -C "${dir}" ./codersdk "${experiments_var}" 2>/dev/null || true) - + if [[ -z "${experiments_output}" ]]; then log "Warning: Neither ExperimentsSafe nor ExperimentsAll found in ${dir}" return fi fi - + echo "${experiments_output}" | tr -d $'\n\t ' | grep -E -o "${experiments_var}=Experiments\{[^}]*\}" | From 38b093d17e3179e58513cb0b6698a45bfe490d56 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Tue, 20 May 2025 20:35:56 +0000 Subject: [PATCH 08/33] fmt --- docs/install/releases/feature-stages.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install/releases/feature-stages.md b/docs/install/releases/feature-stages.md index 535192efaa95c..216b9c01d28af 100644 --- a/docs/install/releases/feature-stages.md +++ b/docs/install/releases/feature-stages.md @@ -10,7 +10,7 @@ If you encounter an issue with any Coder feature, please submit a ## Feature stages | Feature stage | Stable | Production-ready | Support | Description | -| -------------------------------------- | ------ | ---------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +|----------------------------------------|--------|------------------|-----------------------|-------------------------------------------------------------------------------------------------------------------------------| | [Early Access](#early-access-features) | No | No | GitHub issues | For staging only. Not feature-complete or stable. Disabled by default. | | [Beta](#beta) | No | Not fully | Docs, Discord, GitHub | Publicly available. In active development with minor bugs. Suitable for staging; optional for production. Not covered by SLA. | | [GA](#general-availability-ga) | Yes | Yes | License-based | Stable and tested. Enabled by default. Fully documented. Support based on license. | @@ -66,7 +66,7 @@ You can opt-out of a feature after you've enabled it. | Feature | Description | Available in | -| --------------------- | -------------------------------------------- | ------------ | +|-----------------------|----------------------------------------------|--------------| | `workspace-prebuilds` | Enables the new workspace prebuilds feature. | mainline | From f6e5735c7c0ca5a1197bc02e90adf55279d3c380 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma <17991901+EdwardAngert@users.noreply.github.com> Date: Tue, 20 May 2025 22:24:17 -0300 Subject: [PATCH 09/33] chore: replace MUI icons with Lucide icons - 17 (#17957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. ExpandMoreOutlined → ChevronDownIcon 2. Error/ErrorIcon → CircleAlertIcon 3. CheckCircle → CircleCheckIcon 4. Warning → TriangleAlertIcon --- .../WorkspaceAppStatus/WorkspaceAppStatus.tsx | 12 ++++++------ .../workspaces/WorkspaceTiming/StagesChart.tsx | 6 +++--- site/src/pages/ChatPage/ChatToolInvocation.tsx | 14 ++++++++++---- .../TemplateInsightsPage/IntervalMenu.tsx | 4 ++-- .../TemplateInsightsPage/WeekPicker.tsx | 4 ++-- .../ProvisionerTagsPopover.tsx | 4 ++-- site/src/pages/WorkspacePage/AppStatuses.tsx | 12 ++++++------ 7 files changed, 31 insertions(+), 25 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx index 412df60d9203e..95123ce8734df 100644 --- a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx +++ b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx @@ -1,10 +1,7 @@ import type { Theme } from "@emotion/react"; import { useTheme } from "@emotion/react"; import AppsIcon from "@mui/icons-material/Apps"; -import CheckCircle from "@mui/icons-material/CheckCircle"; -import ErrorIcon from "@mui/icons-material/Error"; import InsertDriveFile from "@mui/icons-material/InsertDriveFile"; -import Warning from "@mui/icons-material/Warning"; import CircularProgress from "@mui/material/CircularProgress"; import type { WorkspaceAppStatus as APIWorkspaceAppStatus, @@ -12,6 +9,9 @@ import type { WorkspaceAgent, WorkspaceApp, } from "api/typesGenerated"; +import { CircleCheckIcon } from "lucide-react"; +import { CircleAlertIcon } from "lucide-react"; +import { TriangleAlertIcon } from "lucide-react"; import { ExternalLinkIcon } from "lucide-react"; import { useAppLink } from "modules/apps/useAppLink"; import type { FC } from "react"; @@ -46,13 +46,13 @@ const getStatusIcon = (theme: Theme, state: APIWorkspaceAppStatus["state"]) => { const color = getStatusColor(theme, state); switch (state) { case "complete": - return ; + return ; case "failure": - return ; + return ; case "working": return ; default: - return ; + return ; } }; diff --git a/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx index 6bf18b084b02b..6ca814bb39afd 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx @@ -1,6 +1,6 @@ import type { Interpolation, Theme } from "@emotion/react"; -import ErrorSharp from "@mui/icons-material/ErrorSharp"; import type { TimingStage } from "api/typesGenerated"; +import { CircleAlertIcon } from "lucide-react"; import { InfoIcon } from "lucide-react"; import type { FC } from "react"; import { Bar, ClickableBar } from "./Chart/Bar"; @@ -159,9 +159,9 @@ export const StagesChart: FC = ({ }} > {t.error && ( - = ({ )} {toolInvocation.state === "result" ? ( hasError ? ( - + ) : ( - + ) ) : null}
= ({ value, onChange }) => { variant="outline" > {insightsIntervals[value].label} - + = ({ value, onChange }) => { aria-haspopup="true" aria-expanded={open ? "true" : undefined} onClick={() => setOpen(true)} - endIcon={} + endIcon={} > Last {numberOfWeeks} weeks diff --git a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx b/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx index 2d76db8f9243d..bb0e3f439ed49 100644 --- a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx +++ b/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx @@ -1,4 +1,3 @@ -import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined"; import Link from "@mui/material/Link"; import useTheme from "@mui/system/useTheme"; import type { ProvisionerDaemon } from "api/typesGenerated"; @@ -9,6 +8,7 @@ import { PopoverContent, PopoverTrigger, } from "components/deprecated/Popover/Popover"; +import { ChevronDownIcon } from "lucide-react"; import { ProvisionerTagsField } from "modules/provisioners/ProvisionerTagsField"; import type { FC } from "react"; import { docs } from "utils/docs"; @@ -31,7 +31,7 @@ export const ProvisionerTagsPopover: FC = ({ color="neutral" css={{ paddingLeft: 0, paddingRight: 0, minWidth: "28px !important" }} > - + Expand provisioner tags diff --git a/site/src/pages/WorkspacePage/AppStatuses.tsx b/site/src/pages/WorkspacePage/AppStatuses.tsx index 60e4a8cecf22e..22dc5257f0e00 100644 --- a/site/src/pages/WorkspacePage/AppStatuses.tsx +++ b/site/src/pages/WorkspacePage/AppStatuses.tsx @@ -1,10 +1,7 @@ import type { Theme } from "@emotion/react"; import { useTheme } from "@emotion/react"; import AppsIcon from "@mui/icons-material/Apps"; -import CheckCircle from "@mui/icons-material/CheckCircle"; -import ErrorIcon from "@mui/icons-material/Error"; import InsertDriveFile from "@mui/icons-material/InsertDriveFile"; -import Warning from "@mui/icons-material/Warning"; import CircularProgress from "@mui/material/CircularProgress"; import Link from "@mui/material/Link"; import Tooltip from "@mui/material/Tooltip"; @@ -15,6 +12,9 @@ import type { WorkspaceApp, } from "api/typesGenerated"; import { formatDistance, formatDistanceToNow } from "date-fns"; +import { CircleCheckIcon } from "lucide-react"; +import { CircleAlertIcon } from "lucide-react"; +import { TriangleAlertIcon } from "lucide-react"; import { ExternalLinkIcon } from "lucide-react"; import { HourglassIcon } from "lucide-react"; import { CircleHelpIcon } from "lucide-react"; @@ -49,9 +49,9 @@ const getStatusIcon = ( : theme.palette.text.disabled; switch (state) { case "complete": - return ; + return ; case "failure": - return ; + return ; case "working": // Use Hourglass for past "working" states, spinner for the current one return isLatest ? ( @@ -60,7 +60,7 @@ const getStatusIcon = ( ); default: - return ; + return ; } }; From 030d77eac6baa2de7db18a671a07aa36c99a9fa3 Mon Sep 17 00:00:00 2001 From: Spike Curtis <17991901+EdwardAngert@users.noreply.github.com> Date: Wed, 21 May 2025 09:28:31 +0400 Subject: [PATCH 10/33] docs: explain coder:// link for RDP (#17901) fixes https://github.com/coder/internal/issues/627 Adds docs for `coder://` URLs for Windows Remote Desktop (RDP). Note that we might want to hold of merging since the URI handling is unreleased in Coder Desktop for Windows. --- .../workspace-access/remote-desktops.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/user-guides/workspace-access/remote-desktops.md b/docs/user-guides/workspace-access/remote-desktops.md index ef8488f5889ff..2fe512b686763 100644 --- a/docs/user-guides/workspace-access/remote-desktops.md +++ b/docs/user-guides/workspace-access/remote-desktops.md @@ -47,6 +47,38 @@ Or use your favorite RDP client to connect to `localhost:3399`. The default username is `Administrator` and password is `coderRDP!`. +### Coder Desktop URI Handling (Beta) + +[Coder Desktop](../desktop) can use a URI handler to directly launch an RDP session without setting up port-forwarding. +The URI format is: + +```text +coder:///v0/open/ws//agent//rdp?username=&password= +``` + +For example: + +```text +coder://coder.example.com/v0/open/ws/myworkspace/agent/main/rdp?username=Administrator&password=coderRDP! +``` + +To include a Coder Desktop button to the workspace dashboard page, add a `coder_app` resource to the template: + +```tf +locals { + server_name = regex("https?:\\/\\/([^\\/]+)", data.coder_workspace.me.access_url)[0] +} + +resource "coder_app" "rdp-coder-desktop" { + agent_id = resource.coder_agent.main.id + slug = "rdp-desktop" + display_name = "RDP with Coder Desktop" + url = "coder://${local.server_name}/v0/open/ws/${data.coder_workspace.me.name}/agent/main/rdp?username=Administrator&password=coderRDP!" + icon = "/icon/desktop.svg" + external = true +} +``` + ## RDP Web Our [WebRDP](https://registry.coder.com/modules/windows-rdp) module in the Coder From 4b347db184573f6e52986ede3a6f59f4ce091858 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <17991901+EdwardAngert@users.noreply.github.com> Date: Wed, 21 May 2025 09:16:00 +0200 Subject: [PATCH 11/33] feat: add Claude.md initial draft (#17785) --- .cursorrules | 28 +++++++------- CLAUDE.md | 104 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 13 deletions(-) create mode 100644 CLAUDE.md diff --git a/.cursorrules b/.cursorrules index ce4412b83f6e9..54966b1dcc89e 100644 --- a/.cursorrules +++ b/.cursorrules @@ -4,7 +4,7 @@ This project is called "Coder" - an application for managing remote development Coder provides a platform for creating, managing, and using remote development environments (also known as Cloud Development Environments or CDEs). It leverages Terraform to define and provision these environments, which are referred to as "workspaces" within the project. The system is designed to be extensible, secure, and provide developers with a seamless remote development experience. -# Core Architecture +## Core Architecture The heart of Coder is a control plane that orchestrates the creation and management of workspaces. This control plane interacts with separate Provisioner processes over gRPC to handle workspace builds. The Provisioners consume workspace definitions and use Terraform to create the actual infrastructure. @@ -12,17 +12,17 @@ The CLI package serves dual purposes - it can be used to launch the control plan The database layer uses PostgreSQL with SQLC for generating type-safe database code. Database migrations are carefully managed to ensure both forward and backward compatibility through paired `.up.sql` and `.down.sql` files. -# API Design +## API Design Coder's API architecture combines REST and gRPC approaches. The REST API is defined in `coderd/coderd.go` and uses Chi for HTTP routing. This provides the primary interface for the frontend and external integrations. Internal communication with Provisioners occurs over gRPC, with service definitions maintained in `.proto` files. This separation allows for efficient binary communication with the components responsible for infrastructure management while providing a standard REST interface for human-facing applications. -# Network Architecture +## Network Architecture Coder implements a secure networking layer based on Tailscale's Wireguard implementation. The `tailnet` package provides connectivity between workspace agents and clients through DERP (Designated Encrypted Relay for Packets) servers when direct connections aren't possible. This creates a secure overlay network allowing access to workspaces regardless of network topology, firewalls, or NAT configurations. -## Tailnet and DERP System +### Tailnet and DERP System The networking system has three key components: @@ -35,7 +35,7 @@ The networking system has three key components: 3. **Direct Connections**: When possible, the system establishes peer-to-peer connections between clients and workspaces using STUN for NAT traversal. This requires both endpoints to send UDP traffic on ephemeral ports. -## Workspace Proxies +### Workspace Proxies Workspace proxies (in the Enterprise edition) provide regional relay points for browser-based connections, reducing latency for geo-distributed teams. Key characteristics: @@ -45,9 +45,10 @@ Workspace proxies (in the Enterprise edition) provide regional relay points for - Managed through the `coder wsproxy` commands - Implemented primarily in the `enterprise/wsproxy/` package -# Agent System +## Agent System The workspace agent runs within each provisioned workspace and provides core functionality including: + - SSH access to workspaces via the `agentssh` package - Port forwarding - Terminal connectivity via the `pty` package for pseudo-terminal support @@ -57,7 +58,7 @@ The workspace agent runs within each provisioned workspace and provides core fun Agents communicate with the control plane using the tailnet system and authenticate using secure tokens. -# Workspace Applications +## Workspace Applications Workspace applications (or "apps") provide browser-based access to services running within workspaces. The system supports: @@ -69,17 +70,17 @@ Workspace applications (or "apps") provide browser-based access to services runn The implementation is primarily in the `coderd/workspaceapps/` directory with components for URL generation, proxying connections, and managing application state. -# Implementation Details +## Implementation Details The project structure separates frontend and backend concerns. React components and pages are organized in the `site/src/` directory, with Jest used for testing. The backend is primarily written in Go, with a strong emphasis on error handling patterns and test coverage. Database interactions are carefully managed through migrations in `coderd/database/migrations/` and queries in `coderd/database/queries/`. All new queries require proper database authorization (dbauthz) implementation to ensure that only users with appropriate permissions can access specific resources. -# Authorization System +## Authorization System The database authorization (dbauthz) system enforces fine-grained access control across all database operations. It uses role-based access control (RBAC) to validate user permissions before executing database operations. The `dbauthz` package wraps the database store and performs authorization checks before returning data. All database operations must pass through this layer to ensure security. -# Testing Framework +## Testing Framework The codebase has a comprehensive testing approach with several key components: @@ -91,7 +92,7 @@ The codebase has a comprehensive testing approach with several key components: 4. **Enterprise Testing**: Enterprise features have dedicated test utilities in the `coderdenttest` package. -# Open Source and Enterprise Components +## Open Source and Enterprise Components The repository contains both open source and enterprise components: @@ -100,9 +101,10 @@ The repository contains both open source and enterprise components: - The boundary between open source and enterprise is managed through a licensing system - The same core codebase supports both editions, with enterprise features conditionally enabled -# Development Philosophy +## Development Philosophy Coder emphasizes clear error handling, with specific patterns required: + - Concise error messages that avoid phrases like "failed to" - Wrapping errors with `%w` to maintain error chains - Using sentinel errors with the "err" prefix (e.g., `errNotFound`) @@ -111,7 +113,7 @@ All tests should run in parallel using `t.Parallel()` to ensure efficient testin Git contributions follow a standard format with commit messages structured as `type: `, where type is one of `feat`, `fix`, or `chore`. -# Development Workflow +## Development Workflow Development can be initiated using `scripts/develop.sh` to start the application after making changes. Database schema updates should be performed through the migration system using `create_migration.sh ` to generate migration files, with each `.up.sql` migration paired with a corresponding `.down.sql` that properly reverts all changes. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000..90d91c9966df7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# Coder Development Guidelines + +Read [cursor rules](.cursorrules). + +## Build/Test/Lint Commands + +### Main Commands + +- `make build` or `make build-fat` - Build all "fat" binaries (includes "server" functionality) +- `make build-slim` - Build "slim" binaries +- `make test` - Run Go tests +- `make test RUN=TestFunctionName` or `go test -v ./path/to/package -run TestFunctionName` - Test single +- `make test-postgres` - Run tests with Postgres database +- `make test-race` - Run tests with Go race detector +- `make test-e2e` - Run end-to-end tests +- `make lint` - Run all linters +- `make fmt` - Format all code +- `make gen` - Generates mocks, database queries and other auto-generated files + +### Frontend Commands (site directory) + +- `pnpm build` - Build frontend +- `pnpm dev` - Run development server +- `pnpm check` - Run code checks +- `pnpm format` - Format frontend code +- `pnpm lint` - Lint frontend code +- `pnpm test` - Run frontend tests + +## Code Style Guidelines + +### Go + +- Follow [Effective Go](https://go.dev/doc/effective_go) and [Go's Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments) +- Use `gofumpt` for formatting +- Create packages when used during implementation +- Validate abstractions against implementations + +### Error Handling + +- Use descriptive error messages +- Wrap errors with context +- Propagate errors appropriately +- Use proper error types +- (`xerrors.Errorf("failed to X: %w", err)`) + +### Naming + +- Use clear, descriptive names +- Abbreviate only when obvious +- Follow Go and TypeScript naming conventions + +### Comments + +- Document exported functions, types, and non-obvious logic +- Follow JSDoc format for TypeScript +- Use godoc format for Go code + +## Commit Style + +- Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) +- Format: `type(scope): message` +- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` +- Keep message titles concise (~70 characters) +- Use imperative, present tense in commit titles + +## Database queries + +- MUST DO! Any changes to database - adding queries, modifying queries should be done in the `coderd\database\queries\*.sql` files. Use `make gen` to generate necessary changes after. +- MUST DO! Queries are grouped in files relating to context - e.g. `prebuilds.sql`, `users.sql`, `provisionerjobs.sql`. +- After making changes to any `coderd\database\queries\*.sql` files you must run `make gen` to generate respective ORM changes. + +## Architecture + +### Core Components + +- **coderd**: Main API service connecting workspaces, provisioners, and users +- **provisionerd**: Execution context for infrastructure-modifying providers +- **Agents**: Services in remote workspaces providing features like SSH and port forwarding +- **Workspaces**: Cloud resources defined by Terraform + +## Sub-modules + +### Template System + +- Templates define infrastructure for workspaces using Terraform +- Environment variables pass context between Coder and templates +- Official modules extend development environments + +### RBAC System + +- Permissions defined at site, organization, and user levels +- Object-Action model protects resources +- Built-in roles: owner, member, auditor, templateAdmin +- Permission format: `?...` + +### Database + +- PostgreSQL 13+ recommended for production +- Migrations managed with `migrate` +- Database authorization through `dbauthz` package + +## Frontend + +For building Frontend refer to [this document](docs/contributing/frontend.md) From a707538e6ffb0161bd75c3ab114516f71607e561 Mon Sep 17 00:00:00 2001 From: Spike Curtis <17991901+EdwardAngert@users.noreply.github.com> Date: Wed, 21 May 2025 11:29:25 +0400 Subject: [PATCH 12/33] chore: ignore 'session shutdown' yamux error in tests (#17964) Fixes flake seen here: https://github.com/coder/coder/actions/runs/15154327939/job/42606133069?pr=17960 Error log dropped when the dRPC server is being shut down right as we are (re)dialing. --- testutil/logger.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/testutil/logger.go b/testutil/logger.go index 47cb835aa16aa..88b6e20bada51 100644 --- a/testutil/logger.go +++ b/testutil/logger.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/hashicorp/yamux" "golang.org/x/xerrors" "cdr.dev/slog" @@ -24,6 +25,11 @@ func IgnoreLoggedError(entry slog.SinkEntry) bool { if !ok { return false } + // Yamux sessions get shut down when we are shutting down tests, so ignoring + // them should reduce flakiness. + if xerrors.Is(err, yamux.ErrSessionShutdown) { + return true + } // Canceled queries usually happen when we're shutting down tests, and so // ignoring them should reduce flakiness. This also includes // context.Canceled and context.DeadlineExceeded errors, even if they are From 1727e42eea9169b27c7ea1052022755cc89197a9 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma <17991901+EdwardAngert@users.noreply.github.com> Date: Wed, 21 May 2025 08:57:15 -0300 Subject: [PATCH 13/33] refactor: update provisioners column copy (#17949) --- .../OrganizationProvisionerKeysPageView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx index 5373636308f15..6d5b1be3552ea 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx @@ -66,7 +66,7 @@ export const OrganizationProvisionerKeysPageView: FC< Name Tags - Provisioners + Active Provisioners Created From 312eacb98fb300b1a31ddf1385f9c3fc71126e43 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <17991901+EdwardAngert@users.noreply.github.com> Date: Wed, 21 May 2025 16:48:51 +0200 Subject: [PATCH 14/33] feat: improve transaction safety in CompleteJob function (#17970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR refactors the CompleteJob function to use database transactions more consistently for better atomicity guarantees. The large function was broken down into three specialized handlers: - completeTemplateImportJob - completeWorkspaceBuildJob - completeTemplateDryRunJob Each handler now uses the Database.InTx wrapper to ensure all database operations for a job completion are performed within a single transaction, preventing partial updates in case of failures. Added comprehensive tests for transaction behavior for each job type. Fixes #17694 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude --- .../provisionerdserver/provisionerdserver.go | 639 ++++++++++-------- .../provisionerdserver_test.go | 222 ++++++ 2 files changed, 569 insertions(+), 292 deletions(-) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 423e9bbe584c6..9c4067137b852 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1340,14 +1340,56 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) switch jobType := completed.Type.(type) { case *proto.CompletedJob_TemplateImport_: - var input TemplateVersionImportJob - err = json.Unmarshal(job.Input, &input) + err = s.completeTemplateImportJob(ctx, job, jobID, jobType, telemetrySnapshot) + if err != nil { + return nil, err + } + case *proto.CompletedJob_WorkspaceBuild_: + err = s.completeWorkspaceBuildJob(ctx, job, jobID, jobType, telemetrySnapshot) + if err != nil { + return nil, err + } + case *proto.CompletedJob_TemplateDryRun_: + err = s.completeTemplateDryRunJob(ctx, job, jobID, jobType, telemetrySnapshot) if err != nil { - return nil, xerrors.Errorf("template version ID is expected: %w", err) + return nil, err + } + default: + if completed.Type == nil { + return nil, xerrors.Errorf("type payload must be provided") } + return nil, xerrors.Errorf("unknown job type %q; ensure coderd and provisionerd versions match", + reflect.TypeOf(completed.Type).String()) + } + + data, err := json.Marshal(provisionersdk.ProvisionerJobLogsNotifyMessage{EndOfLogs: true}) + if err != nil { + return nil, xerrors.Errorf("marshal job log: %w", err) + } + err = s.Pubsub.Publish(provisionersdk.ProvisionerJobLogsNotifyChannel(jobID), data) + if err != nil { + s.Logger.Error(ctx, "failed to publish end of job logs", slog.F("job_id", jobID), slog.Error(err)) + return nil, xerrors.Errorf("publish end of job logs: %w", err) + } + s.Logger.Debug(ctx, "stage CompleteJob done", slog.F("job_id", jobID)) + return &proto.Empty{}, nil +} + +// completeTemplateImportJob handles completion of a template import job. +// All database operations are performed within a transaction. +func (s *server) completeTemplateImportJob(ctx context.Context, job database.ProvisionerJob, jobID uuid.UUID, jobType *proto.CompletedJob_TemplateImport_, telemetrySnapshot *telemetry.Snapshot) error { + var input TemplateVersionImportJob + err := json.Unmarshal(job.Input, &input) + if err != nil { + return xerrors.Errorf("template version ID is expected: %w", err) + } + + // Execute all database operations in a transaction + return s.Database.InTx(func(db database.Store) error { now := s.timeNow() + // Process resources for transition, resources := range map[database.WorkspaceTransition][]*sdkproto.Resource{ database.WorkspaceTransitionStart: jobType.TemplateImport.StartResources, database.WorkspaceTransitionStop: jobType.TemplateImport.StopResources, @@ -1359,11 +1401,13 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) slog.F("resource_type", resource.Type), slog.F("transition", transition)) - if err := InsertWorkspaceResource(ctx, s.Database, jobID, transition, resource, telemetrySnapshot); err != nil { - return nil, xerrors.Errorf("insert resource: %w", err) + if err := InsertWorkspaceResource(ctx, db, jobID, transition, resource, telemetrySnapshot); err != nil { + return xerrors.Errorf("insert resource: %w", err) } } } + + // Process modules for transition, modules := range map[database.WorkspaceTransition][]*sdkproto.Module{ database.WorkspaceTransitionStart: jobType.TemplateImport.StartModules, database.WorkspaceTransitionStop: jobType.TemplateImport.StopModules, @@ -1376,12 +1420,13 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) slog.F("module_key", module.Key), slog.F("transition", transition)) - if err := InsertWorkspaceModule(ctx, s.Database, jobID, transition, module, telemetrySnapshot); err != nil { - return nil, xerrors.Errorf("insert module: %w", err) + if err := InsertWorkspaceModule(ctx, db, jobID, transition, module, telemetrySnapshot); err != nil { + return xerrors.Errorf("insert module: %w", err) } } } + // Process rich parameters for _, richParameter := range jobType.TemplateImport.RichParameters { s.Logger.Info(ctx, "inserting template import job parameter", slog.F("job_id", job.ID.String()), @@ -1391,7 +1436,7 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) ) options, err := json.Marshal(richParameter.Options) if err != nil { - return nil, xerrors.Errorf("marshal parameter options: %w", err) + return xerrors.Errorf("marshal parameter options: %w", err) } var validationMin, validationMax sql.NullInt32 @@ -1408,7 +1453,7 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) } } - _, err = s.Database.InsertTemplateVersionParameter(ctx, database.InsertTemplateVersionParameterParams{ + _, err = db.InsertTemplateVersionParameter(ctx, database.InsertTemplateVersionParameterParams{ TemplateVersionID: input.TemplateVersionID, Name: richParameter.Name, DisplayName: richParameter.DisplayName, @@ -1428,15 +1473,17 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) Ephemeral: richParameter.Ephemeral, }) if err != nil { - return nil, xerrors.Errorf("insert parameter: %w", err) + return xerrors.Errorf("insert parameter: %w", err) } } - err = InsertWorkspacePresetsAndParameters(ctx, s.Logger, s.Database, jobID, input.TemplateVersionID, jobType.TemplateImport.Presets, now) + // Process presets and parameters + err := InsertWorkspacePresetsAndParameters(ctx, s.Logger, db, jobID, input.TemplateVersionID, jobType.TemplateImport.Presets, now) if err != nil { - return nil, xerrors.Errorf("insert workspace presets and parameters: %w", err) + return xerrors.Errorf("insert workspace presets and parameters: %w", err) } + // Process external auth providers var completedError sql.NullString for _, externalAuthProvider := range jobType.TemplateImport.ExternalAuthProviders { @@ -1479,18 +1526,19 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) externalAuthProvidersMessage, err := json.Marshal(externalAuthProviders) if err != nil { - return nil, xerrors.Errorf("failed to serialize external_auth_providers value: %w", err) + return xerrors.Errorf("failed to serialize external_auth_providers value: %w", err) } - err = s.Database.UpdateTemplateVersionExternalAuthProvidersByJobID(ctx, database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams{ + err = db.UpdateTemplateVersionExternalAuthProvidersByJobID(ctx, database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams{ JobID: jobID, ExternalAuthProviders: externalAuthProvidersMessage, UpdatedAt: now, }) if err != nil { - return nil, xerrors.Errorf("update template version external auth providers: %w", err) + return xerrors.Errorf("update template version external auth providers: %w", err) } + // Process terraform values plan := jobType.TemplateImport.Plan moduleFiles := jobType.TemplateImport.ModuleFiles // If there is a plan, or a module files archive we need to insert a @@ -1509,7 +1557,7 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) hash := hex.EncodeToString(hashBytes[:]) // nolint:gocritic // Requires reading "system" files - file, err := s.Database.GetFileByHashAndCreator(dbauthz.AsSystemRestricted(ctx), database.GetFileByHashAndCreatorParams{Hash: hash, CreatedBy: uuid.Nil}) + file, err := db.GetFileByHashAndCreator(dbauthz.AsSystemRestricted(ctx), database.GetFileByHashAndCreatorParams{Hash: hash, CreatedBy: uuid.Nil}) switch { case err == nil: // This set of modules is already cached, which means we can reuse them @@ -1518,10 +1566,10 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) UUID: file.ID, } case !xerrors.Is(err, sql.ErrNoRows): - return nil, xerrors.Errorf("check for cached modules: %w", err) + return xerrors.Errorf("check for cached modules: %w", err) default: // nolint:gocritic // Requires creating a "system" file - file, err = s.Database.InsertFile(dbauthz.AsSystemRestricted(ctx), database.InsertFileParams{ + file, err = db.InsertFile(dbauthz.AsSystemRestricted(ctx), database.InsertFileParams{ ID: uuid.New(), Hash: hash, CreatedBy: uuid.Nil, @@ -1530,7 +1578,7 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) Data: moduleFiles, }) if err != nil { - return nil, xerrors.Errorf("insert template version terraform modules: %w", err) + return xerrors.Errorf("insert template version terraform modules: %w", err) } fileID = uuid.NullUUID{ Valid: true, @@ -1539,7 +1587,7 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) } } - err = s.Database.InsertTemplateVersionTerraformValuesByJobID(ctx, database.InsertTemplateVersionTerraformValuesByJobIDParams{ + err = db.InsertTemplateVersionTerraformValuesByJobID(ctx, database.InsertTemplateVersionTerraformValuesByJobIDParams{ JobID: jobID, UpdatedAt: now, CachedPlan: plan, @@ -1547,11 +1595,12 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) ProvisionerdVersion: s.apiVersion, }) if err != nil { - return nil, xerrors.Errorf("insert template version terraform data: %w", err) + return xerrors.Errorf("insert template version terraform data: %w", err) } } - err = s.Database.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ + // Mark job as completed + err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ ID: jobID, UpdatedAt: now, CompletedAt: sql.NullTime{ @@ -1562,206 +1611,136 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) ErrorCode: sql.NullString{}, }) if err != nil { - return nil, xerrors.Errorf("update provisioner job: %w", err) + return xerrors.Errorf("update provisioner job: %w", err) } s.Logger.Debug(ctx, "marked import job as completed", slog.F("job_id", jobID)) - case *proto.CompletedJob_WorkspaceBuild_: - var input WorkspaceProvisionJob - err = json.Unmarshal(job.Input, &input) - if err != nil { - return nil, xerrors.Errorf("unmarshal job data: %w", err) - } + return nil + }, nil) // End of transaction +} - workspaceBuild, err := s.Database.GetWorkspaceBuildByID(ctx, input.WorkspaceBuildID) - if err != nil { - return nil, xerrors.Errorf("get workspace build: %w", err) - } +// completeWorkspaceBuildJob handles completion of a workspace build job. +// Most database operations are performed within a transaction. +func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.ProvisionerJob, jobID uuid.UUID, jobType *proto.CompletedJob_WorkspaceBuild_, telemetrySnapshot *telemetry.Snapshot) error { + var input WorkspaceProvisionJob + err := json.Unmarshal(job.Input, &input) + if err != nil { + return xerrors.Errorf("unmarshal job data: %w", err) + } - var workspace database.Workspace - var getWorkspaceError error + workspaceBuild, err := s.Database.GetWorkspaceBuildByID(ctx, input.WorkspaceBuildID) + if err != nil { + return xerrors.Errorf("get workspace build: %w", err) + } - err = s.Database.InTx(func(db database.Store) error { - // It's important we use s.timeNow() here because we want to be - // able to customize the current time from within tests. - now := s.timeNow() - - workspace, getWorkspaceError = db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID) - if getWorkspaceError != nil { - s.Logger.Error(ctx, - "fetch workspace for build", - slog.F("workspace_build_id", workspaceBuild.ID), - slog.F("workspace_id", workspaceBuild.WorkspaceID), - ) - return getWorkspaceError - } + var workspace database.Workspace + var getWorkspaceError error - templateScheduleStore := *s.TemplateScheduleStore.Load() + // Execute all database modifications in a transaction + err = s.Database.InTx(func(db database.Store) error { + // It's important we use s.timeNow() here because we want to be + // able to customize the current time from within tests. + now := s.timeNow() - autoStop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ - Database: db, - TemplateScheduleStore: templateScheduleStore, - UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(), - Now: now, - Workspace: workspace.WorkspaceTable(), - // Allowed to be the empty string. - WorkspaceAutostart: workspace.AutostartSchedule.String, - }) - if err != nil { - return xerrors.Errorf("calculate auto stop: %w", err) - } + workspace, getWorkspaceError = db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID) + if getWorkspaceError != nil { + s.Logger.Error(ctx, + "fetch workspace for build", + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("workspace_id", workspaceBuild.WorkspaceID), + ) + return getWorkspaceError + } - if workspace.AutostartSchedule.Valid { - templateScheduleOptions, err := templateScheduleStore.Get(ctx, db, workspace.TemplateID) - if err != nil { - return xerrors.Errorf("get template schedule options: %w", err) - } + templateScheduleStore := *s.TemplateScheduleStore.Load() - nextStartAt, err := schedule.NextAllowedAutostart(now, workspace.AutostartSchedule.String, templateScheduleOptions) - if err == nil { - err = db.UpdateWorkspaceNextStartAt(ctx, database.UpdateWorkspaceNextStartAtParams{ - ID: workspace.ID, - NextStartAt: sql.NullTime{Valid: true, Time: nextStartAt.UTC()}, - }) - if err != nil { - return xerrors.Errorf("update workspace next start at: %w", err) - } - } - } + autoStop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ + Database: db, + TemplateScheduleStore: templateScheduleStore, + UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(), + Now: now, + Workspace: workspace.WorkspaceTable(), + // Allowed to be the empty string. + WorkspaceAutostart: workspace.AutostartSchedule.String, + }) + if err != nil { + return xerrors.Errorf("calculate auto stop: %w", err) + } - err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ - ID: jobID, - UpdatedAt: now, - CompletedAt: sql.NullTime{ - Time: now, - Valid: true, - }, - Error: sql.NullString{}, - ErrorCode: sql.NullString{}, - }) + if workspace.AutostartSchedule.Valid { + templateScheduleOptions, err := templateScheduleStore.Get(ctx, db, workspace.TemplateID) if err != nil { - return xerrors.Errorf("update provisioner job: %w", err) + return xerrors.Errorf("get template schedule options: %w", err) } - err = db.UpdateWorkspaceBuildProvisionerStateByID(ctx, database.UpdateWorkspaceBuildProvisionerStateByIDParams{ - ID: workspaceBuild.ID, - ProvisionerState: jobType.WorkspaceBuild.State, - UpdatedAt: now, - }) - if err != nil { - return xerrors.Errorf("update workspace build provisioner state: %w", err) - } - err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ - ID: workspaceBuild.ID, - Deadline: autoStop.Deadline, - MaxDeadline: autoStop.MaxDeadline, - UpdatedAt: now, - }) - if err != nil { - return xerrors.Errorf("update workspace build deadline: %w", err) - } - - agentTimeouts := make(map[time.Duration]bool) // A set of agent timeouts. - // This could be a bulk insert to improve performance. - for _, protoResource := range jobType.WorkspaceBuild.Resources { - for _, protoAgent := range protoResource.Agents { - dur := time.Duration(protoAgent.GetConnectionTimeoutSeconds()) * time.Second - agentTimeouts[dur] = true - } - err = InsertWorkspaceResource(ctx, db, job.ID, workspaceBuild.Transition, protoResource, telemetrySnapshot) + nextStartAt, err := schedule.NextAllowedAutostart(now, workspace.AutostartSchedule.String, templateScheduleOptions) + if err == nil { + err = db.UpdateWorkspaceNextStartAt(ctx, database.UpdateWorkspaceNextStartAtParams{ + ID: workspace.ID, + NextStartAt: sql.NullTime{Valid: true, Time: nextStartAt.UTC()}, + }) if err != nil { - return xerrors.Errorf("insert provisioner job: %w", err) - } - } - for _, module := range jobType.WorkspaceBuild.Modules { - if err := InsertWorkspaceModule(ctx, db, job.ID, workspaceBuild.Transition, module, telemetrySnapshot); err != nil { - return xerrors.Errorf("insert provisioner job module: %w", err) + return xerrors.Errorf("update workspace next start at: %w", err) } } + } - // On start, we want to ensure that workspace agents timeout statuses - // are propagated. This method is simple and does not protect against - // notifying in edge cases like when a workspace is stopped soon - // after being started. - // - // Agent timeouts could be minutes apart, resulting in an unresponsive - // experience, so we'll notify after every unique timeout seconds. - if !input.DryRun && workspaceBuild.Transition == database.WorkspaceTransitionStart && len(agentTimeouts) > 0 { - timeouts := maps.Keys(agentTimeouts) - slices.Sort(timeouts) - - var updates []<-chan time.Time - for _, d := range timeouts { - s.Logger.Debug(ctx, "triggering workspace notification after agent timeout", - slog.F("workspace_build_id", workspaceBuild.ID), - slog.F("timeout", d), - ) - // Agents are inserted with `dbtime.Now()`, this triggers a - // workspace event approximately after created + timeout seconds. - updates = append(updates, time.After(d)) - } - go func() { - for _, wait := range updates { - select { - case <-s.lifecycleCtx.Done(): - // If the server is shutting down, we don't want to wait around. - s.Logger.Debug(ctx, "stopping notifications due to server shutdown", - slog.F("workspace_build_id", workspaceBuild.ID), - ) - return - case <-wait: - // Wait for the next potential timeout to occur. - msg, err := json.Marshal(wspubsub.WorkspaceEvent{ - Kind: wspubsub.WorkspaceEventKindAgentTimeout, - WorkspaceID: workspace.ID, - }) - if err != nil { - s.Logger.Error(ctx, "marshal workspace update event", slog.Error(err)) - break - } - if err := s.Pubsub.Publish(wspubsub.WorkspaceEventChannel(workspace.OwnerID), msg); err != nil { - if s.lifecycleCtx.Err() != nil { - // If the server is shutting down, we don't want to log this error, nor wait around. - s.Logger.Debug(ctx, "stopping notifications due to server shutdown", - slog.F("workspace_build_id", workspaceBuild.ID), - ) - return - } - s.Logger.Error(ctx, "workspace notification after agent timeout failed", - slog.F("workspace_build_id", workspaceBuild.ID), - slog.Error(err), - ) - } - } - } - }() - } + err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ + ID: jobID, + UpdatedAt: now, + CompletedAt: sql.NullTime{ + Time: now, + Valid: true, + }, + Error: sql.NullString{}, + ErrorCode: sql.NullString{}, + }) + if err != nil { + return xerrors.Errorf("update provisioner job: %w", err) + } + err = db.UpdateWorkspaceBuildProvisionerStateByID(ctx, database.UpdateWorkspaceBuildProvisionerStateByIDParams{ + ID: workspaceBuild.ID, + ProvisionerState: jobType.WorkspaceBuild.State, + UpdatedAt: now, + }) + if err != nil { + return xerrors.Errorf("update workspace build provisioner state: %w", err) + } + err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ + ID: workspaceBuild.ID, + Deadline: autoStop.Deadline, + MaxDeadline: autoStop.MaxDeadline, + UpdatedAt: now, + }) + if err != nil { + return xerrors.Errorf("update workspace build deadline: %w", err) + } - if workspaceBuild.Transition != database.WorkspaceTransitionDelete { - // This is for deleting a workspace! - return nil + agentTimeouts := make(map[time.Duration]bool) // A set of agent timeouts. + // This could be a bulk insert to improve performance. + for _, protoResource := range jobType.WorkspaceBuild.Resources { + for _, protoAgent := range protoResource.Agents { + dur := time.Duration(protoAgent.GetConnectionTimeoutSeconds()) * time.Second + agentTimeouts[dur] = true } - err = db.UpdateWorkspaceDeletedByID(ctx, database.UpdateWorkspaceDeletedByIDParams{ - ID: workspaceBuild.WorkspaceID, - Deleted: true, - }) + err = InsertWorkspaceResource(ctx, db, job.ID, workspaceBuild.Transition, protoResource, telemetrySnapshot) if err != nil { - return xerrors.Errorf("update workspace deleted: %w", err) + return xerrors.Errorf("insert provisioner job: %w", err) + } + } + for _, module := range jobType.WorkspaceBuild.Modules { + if err := InsertWorkspaceModule(ctx, db, job.ID, workspaceBuild.Transition, module, telemetrySnapshot); err != nil { + return xerrors.Errorf("insert provisioner job module: %w", err) } - - return nil - }, nil) - if err != nil { - return nil, xerrors.Errorf("complete job: %w", err) } - // Insert timings outside transaction since it is metadata. + // Insert timings inside the transaction now // nolint:exhaustruct // The other fields are set further down. params := database.InsertProvisionerJobTimingsParams{ JobID: jobID, } - for _, t := range completed.GetWorkspaceBuild().GetTimings() { + for _, t := range jobType.WorkspaceBuild.Timings { if t.Start == nil || t.End == nil { s.Logger.Warn(ctx, "timings entry has nil start or end time", slog.F("entry", t.String())) continue @@ -1780,153 +1759,229 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) params.StartedAt = append(params.StartedAt, t.Start.AsTime()) params.EndedAt = append(params.EndedAt, t.End.AsTime()) } - _, err = s.Database.InsertProvisionerJobTimings(ctx, params) + _, err = db.InsertProvisionerJobTimings(ctx, params) if err != nil { - // Don't fail the transaction for non-critical data. + // Log error but don't fail the whole transaction for non-critical data s.Logger.Warn(ctx, "failed to update provisioner job timings", slog.F("job_id", jobID), slog.Error(err)) } - // audit the outcome of the workspace build - if getWorkspaceError == nil { - // If the workspace has been deleted, notify the owner about it. - if workspaceBuild.Transition == database.WorkspaceTransitionDelete { - s.notifyWorkspaceDeleted(ctx, workspace, workspaceBuild) - } + // On start, we want to ensure that workspace agents timeout statuses + // are propagated. This method is simple and does not protect against + // notifying in edge cases like when a workspace is stopped soon + // after being started. + // + // Agent timeouts could be minutes apart, resulting in an unresponsive + // experience, so we'll notify after every unique timeout seconds. + if !input.DryRun && workspaceBuild.Transition == database.WorkspaceTransitionStart && len(agentTimeouts) > 0 { + timeouts := maps.Keys(agentTimeouts) + slices.Sort(timeouts) + + var updates []<-chan time.Time + for _, d := range timeouts { + s.Logger.Debug(ctx, "triggering workspace notification after agent timeout", + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("timeout", d), + ) + // Agents are inserted with `dbtime.Now()`, this triggers a + // workspace event approximately after created + timeout seconds. + updates = append(updates, time.After(d)) + } + go func() { + for _, wait := range updates { + select { + case <-s.lifecycleCtx.Done(): + // If the server is shutting down, we don't want to wait around. + s.Logger.Debug(ctx, "stopping notifications due to server shutdown", + slog.F("workspace_build_id", workspaceBuild.ID), + ) + return + case <-wait: + // Wait for the next potential timeout to occur. + msg, err := json.Marshal(wspubsub.WorkspaceEvent{ + Kind: wspubsub.WorkspaceEventKindAgentTimeout, + WorkspaceID: workspace.ID, + }) + if err != nil { + s.Logger.Error(ctx, "marshal workspace update event", slog.Error(err)) + break + } + if err := s.Pubsub.Publish(wspubsub.WorkspaceEventChannel(workspace.OwnerID), msg); err != nil { + if s.lifecycleCtx.Err() != nil { + // If the server is shutting down, we don't want to log this error, nor wait around. + s.Logger.Debug(ctx, "stopping notifications due to server shutdown", + slog.F("workspace_build_id", workspaceBuild.ID), + ) + return + } + s.Logger.Error(ctx, "workspace notification after agent timeout failed", + slog.F("workspace_build_id", workspaceBuild.ID), + slog.Error(err), + ) + } + } + } + }() + } - auditor := s.Auditor.Load() - auditAction := auditActionFromTransition(workspaceBuild.Transition) + if workspaceBuild.Transition != database.WorkspaceTransitionDelete { + // This is for deleting a workspace! + return nil + } - previousBuildNumber := workspaceBuild.BuildNumber - 1 - previousBuild, prevBuildErr := s.Database.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{ - WorkspaceID: workspace.ID, - BuildNumber: previousBuildNumber, - }) - if prevBuildErr != nil { - previousBuild = database.WorkspaceBuild{} - } + err = db.UpdateWorkspaceDeletedByID(ctx, database.UpdateWorkspaceDeletedByIDParams{ + ID: workspaceBuild.WorkspaceID, + Deleted: true, + }) + if err != nil { + return xerrors.Errorf("update workspace deleted: %w", err) + } - // We pass the below information to the Auditor so that it - // can form a friendly string for the user to view in the UI. - buildResourceInfo := audit.AdditionalFields{ - WorkspaceName: workspace.Name, - BuildNumber: strconv.FormatInt(int64(workspaceBuild.BuildNumber), 10), - BuildReason: database.BuildReason(string(workspaceBuild.Reason)), - WorkspaceID: workspace.ID, - } + return nil + }, nil) + if err != nil { + return xerrors.Errorf("complete job: %w", err) + } - wriBytes, err := json.Marshal(buildResourceInfo) - if err != nil { - s.Logger.Error(ctx, "marshal resource info for successful job", slog.Error(err)) - } - - bag := audit.BaggageFromContext(ctx) - - audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceBuild]{ - Audit: *auditor, - Log: s.Logger, - UserID: job.InitiatorID, - OrganizationID: workspace.OrganizationID, - RequestID: job.ID, - IP: bag.IP, - Action: auditAction, - Old: previousBuild, - New: workspaceBuild, - Status: http.StatusOK, - AdditionalFields: wriBytes, - }) - } + // Post-transaction operations (operations that do not require transactions or + // are external to the database, like audit logging, notifications, etc.) - if s.PrebuildsOrchestrator != nil && input.PrebuiltWorkspaceBuildStage == sdkproto.PrebuiltWorkspaceBuildStage_CLAIM { - // Track resource replacements, if there are any. - orchestrator := s.PrebuildsOrchestrator.Load() - if resourceReplacements := completed.GetWorkspaceBuild().GetResourceReplacements(); orchestrator != nil && len(resourceReplacements) > 0 { - // Fire and forget. Bind to the lifecycle of the server so shutdowns are handled gracefully. - go (*orchestrator).TrackResourceReplacement(s.lifecycleCtx, workspace.ID, workspaceBuild.ID, resourceReplacements) - } + // audit the outcome of the workspace build + if getWorkspaceError == nil { + // If the workspace has been deleted, notify the owner about it. + if workspaceBuild.Transition == database.WorkspaceTransitionDelete { + s.notifyWorkspaceDeleted(ctx, workspace, workspaceBuild) } - msg, err := json.Marshal(wspubsub.WorkspaceEvent{ - Kind: wspubsub.WorkspaceEventKindStateChange, + auditor := s.Auditor.Load() + auditAction := auditActionFromTransition(workspaceBuild.Transition) + + previousBuildNumber := workspaceBuild.BuildNumber - 1 + previousBuild, prevBuildErr := s.Database.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{ WorkspaceID: workspace.ID, + BuildNumber: previousBuildNumber, }) - if err != nil { - return nil, xerrors.Errorf("marshal workspace update event: %s", err) + if prevBuildErr != nil { + previousBuild = database.WorkspaceBuild{} } - err = s.Pubsub.Publish(wspubsub.WorkspaceEventChannel(workspace.OwnerID), msg) + + // We pass the below information to the Auditor so that it + // can form a friendly string for the user to view in the UI. + buildResourceInfo := audit.AdditionalFields{ + WorkspaceName: workspace.Name, + BuildNumber: strconv.FormatInt(int64(workspaceBuild.BuildNumber), 10), + BuildReason: database.BuildReason(string(workspaceBuild.Reason)), + WorkspaceID: workspace.ID, + } + + wriBytes, err := json.Marshal(buildResourceInfo) if err != nil { - return nil, xerrors.Errorf("update workspace: %w", err) + s.Logger.Error(ctx, "marshal resource info for successful job", slog.Error(err)) + } + + bag := audit.BaggageFromContext(ctx) + + audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceBuild]{ + Audit: *auditor, + Log: s.Logger, + UserID: job.InitiatorID, + OrganizationID: workspace.OrganizationID, + RequestID: job.ID, + IP: bag.IP, + Action: auditAction, + Old: previousBuild, + New: workspaceBuild, + Status: http.StatusOK, + AdditionalFields: wriBytes, + }) + } + + if s.PrebuildsOrchestrator != nil && input.PrebuiltWorkspaceBuildStage == sdkproto.PrebuiltWorkspaceBuildStage_CLAIM { + // Track resource replacements, if there are any. + orchestrator := s.PrebuildsOrchestrator.Load() + if resourceReplacements := jobType.WorkspaceBuild.ResourceReplacements; orchestrator != nil && len(resourceReplacements) > 0 { + // Fire and forget. Bind to the lifecycle of the server so shutdowns are handled gracefully. + go (*orchestrator).TrackResourceReplacement(s.lifecycleCtx, workspace.ID, workspaceBuild.ID, resourceReplacements) } + } - if input.PrebuiltWorkspaceBuildStage == sdkproto.PrebuiltWorkspaceBuildStage_CLAIM { - s.Logger.Info(ctx, "workspace prebuild successfully claimed by user", - slog.F("workspace_id", workspace.ID)) + msg, err := json.Marshal(wspubsub.WorkspaceEvent{ + Kind: wspubsub.WorkspaceEventKindStateChange, + WorkspaceID: workspace.ID, + }) + if err != nil { + return xerrors.Errorf("marshal workspace update event: %s", err) + } + err = s.Pubsub.Publish(wspubsub.WorkspaceEventChannel(workspace.OwnerID), msg) + if err != nil { + return xerrors.Errorf("update workspace: %w", err) + } - err = prebuilds.NewPubsubWorkspaceClaimPublisher(s.Pubsub).PublishWorkspaceClaim(agentsdk.ReinitializationEvent{ - WorkspaceID: workspace.ID, - Reason: agentsdk.ReinitializeReasonPrebuildClaimed, - }) - if err != nil { - s.Logger.Error(ctx, "failed to publish workspace claim event", slog.Error(err)) - } + if input.PrebuiltWorkspaceBuildStage == sdkproto.PrebuiltWorkspaceBuildStage_CLAIM { + s.Logger.Info(ctx, "workspace prebuild successfully claimed by user", + slog.F("workspace_id", workspace.ID)) + + err = prebuilds.NewPubsubWorkspaceClaimPublisher(s.Pubsub).PublishWorkspaceClaim(agentsdk.ReinitializationEvent{ + WorkspaceID: workspace.ID, + Reason: agentsdk.ReinitializeReasonPrebuildClaimed, + }) + if err != nil { + s.Logger.Error(ctx, "failed to publish workspace claim event", slog.Error(err)) } - case *proto.CompletedJob_TemplateDryRun_: + } + + return nil +} + +// completeTemplateDryRunJob handles completion of a template dry-run job. +// All database operations are performed within a transaction. +func (s *server) completeTemplateDryRunJob(ctx context.Context, job database.ProvisionerJob, jobID uuid.UUID, jobType *proto.CompletedJob_TemplateDryRun_, telemetrySnapshot *telemetry.Snapshot) error { + // Execute all database operations in a transaction + return s.Database.InTx(func(db database.Store) error { + now := s.timeNow() + + // Process resources for _, resource := range jobType.TemplateDryRun.Resources { s.Logger.Info(ctx, "inserting template dry-run job resource", slog.F("job_id", job.ID.String()), slog.F("resource_name", resource.Name), slog.F("resource_type", resource.Type)) - err = InsertWorkspaceResource(ctx, s.Database, jobID, database.WorkspaceTransitionStart, resource, telemetrySnapshot) + err := InsertWorkspaceResource(ctx, db, jobID, database.WorkspaceTransitionStart, resource, telemetrySnapshot) if err != nil { - return nil, xerrors.Errorf("insert resource: %w", err) + return xerrors.Errorf("insert resource: %w", err) } } + + // Process modules for _, module := range jobType.TemplateDryRun.Modules { s.Logger.Info(ctx, "inserting template dry-run job module", slog.F("job_id", job.ID.String()), slog.F("module_source", module.Source), ) - if err := InsertWorkspaceModule(ctx, s.Database, jobID, database.WorkspaceTransitionStart, module, telemetrySnapshot); err != nil { - return nil, xerrors.Errorf("insert module: %w", err) + if err := InsertWorkspaceModule(ctx, db, jobID, database.WorkspaceTransitionStart, module, telemetrySnapshot); err != nil { + return xerrors.Errorf("insert module: %w", err) } } - err = s.Database.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ + // Mark job as complete + err := db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ ID: jobID, - UpdatedAt: s.timeNow(), + UpdatedAt: now, CompletedAt: sql.NullTime{ - Time: s.timeNow(), + Time: now, Valid: true, }, Error: sql.NullString{}, ErrorCode: sql.NullString{}, }) if err != nil { - return nil, xerrors.Errorf("update provisioner job: %w", err) + return xerrors.Errorf("update provisioner job: %w", err) } s.Logger.Debug(ctx, "marked template dry-run job as completed", slog.F("job_id", jobID)) - default: - if completed.Type == nil { - return nil, xerrors.Errorf("type payload must be provided") - } - return nil, xerrors.Errorf("unknown job type %q; ensure coderd and provisionerd versions match", - reflect.TypeOf(completed.Type).String()) - } - - data, err := json.Marshal(provisionersdk.ProvisionerJobLogsNotifyMessage{EndOfLogs: true}) - if err != nil { - return nil, xerrors.Errorf("marshal job log: %w", err) - } - err = s.Pubsub.Publish(provisionersdk.ProvisionerJobLogsNotifyChannel(jobID), data) - if err != nil { - s.Logger.Error(ctx, "failed to publish end of job logs", slog.F("job_id", jobID), slog.Error(err)) - return nil, xerrors.Errorf("publish end of job logs: %w", err) - } - - s.Logger.Debug(ctx, "stage CompleteJob done", slog.F("job_id", jobID)) - return &proto.Empty{}, nil + return nil + }, nil) // End of transaction } func (s *server) notifyWorkspaceDeleted(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) { diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index e125db348e701..eb63d84b1df1b 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -20,6 +20,7 @@ import ( "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" "golang.org/x/xerrors" + "google.golang.org/protobuf/types/known/timestamppb" "storj.io/drpc" "cdr.dev/slog/sloggers/slogtest" @@ -1119,6 +1120,227 @@ func TestCompleteJob(t *testing.T) { require.ErrorContains(t, err, "you don't own this job") }) + // Test for verifying transaction behavior on the extracted methods + t.Run("TransactionBehavior", func(t *testing.T) { + t.Parallel() + // Test TemplateImport transaction + t.Run("TemplateImportTransaction", func(t *testing.T) { + t.Parallel() + srv, db, _, pd := setup(t, false, &overrides{}) + jobID := uuid.New() + versionID := uuid.New() + err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ + ID: versionID, + JobID: jobID, + OrganizationID: pd.OrganizationID, + }) + require.NoError(t, err) + job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + OrganizationID: pd.OrganizationID, + ID: jobID, + Provisioner: database.ProvisionerTypeEcho, + Input: []byte(`{"template_version_id": "` + versionID.String() + `"}`), + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeTemplateVersionImport, + }) + require.NoError(t, err) + _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + OrganizationID: pd.OrganizationID, + WorkerID: uuid.NullUUID{ + UUID: pd.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }) + require.NoError(t, err) + + _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_TemplateImport_{ + TemplateImport: &proto.CompletedJob_TemplateImport{ + StartResources: []*sdkproto.Resource{{ + Name: "test-resource", + Type: "aws_instance", + }}, + Plan: []byte("{}"), + }, + }, + }) + require.NoError(t, err) + + // Verify job was marked as completed + completedJob, err := db.GetProvisionerJobByID(ctx, job.ID) + require.NoError(t, err) + require.True(t, completedJob.CompletedAt.Valid, "Job should be marked as completed") + + // Verify resources were created + resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID) + require.NoError(t, err) + require.Len(t, resources, 1, "Expected one resource to be created") + require.Equal(t, "test-resource", resources[0].Name) + }) + + // Test TemplateDryRun transaction + t.Run("TemplateDryRunTransaction", func(t *testing.T) { + t.Parallel() + srv, db, _, pd := setup(t, false, &overrides{}) + job, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: uuid.New(), + Provisioner: database.ProvisionerTypeEcho, + Type: database.ProvisionerJobTypeTemplateVersionDryRun, + StorageMethod: database.ProvisionerStorageMethodFile, + }) + require.NoError(t, err) + _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + WorkerID: uuid.NullUUID{ + UUID: pd.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }) + require.NoError(t, err) + + _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_TemplateDryRun_{ + TemplateDryRun: &proto.CompletedJob_TemplateDryRun{ + Resources: []*sdkproto.Resource{{ + Name: "test-dry-run-resource", + Type: "aws_instance", + }}, + }, + }, + }) + require.NoError(t, err) + + // Verify job was marked as completed + completedJob, err := db.GetProvisionerJobByID(ctx, job.ID) + require.NoError(t, err) + require.True(t, completedJob.CompletedAt.Valid, "Job should be marked as completed") + + // Verify resources were created + resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID) + require.NoError(t, err) + require.Len(t, resources, 1, "Expected one resource to be created") + require.Equal(t, "test-dry-run-resource", resources[0].Name) + }) + + // Test WorkspaceBuild transaction + t.Run("WorkspaceBuildTransaction", func(t *testing.T) { + t.Parallel() + srv, db, ps, pd := setup(t, false, &overrides{}) + + // Create test data + user := dbgen.User(t, db, database.User{}) + template := dbgen.Template(t, db, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + OrganizationID: pd.OrganizationID, + }) + file := dbgen.File(t, db, database.File{CreatedBy: user.ID}) + workspaceTable := dbgen.Workspace(t, db, database.WorkspaceTable{ + TemplateID: template.ID, + OwnerID: user.ID, + OrganizationID: pd.OrganizationID, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: pd.OrganizationID, + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: uuid.New(), + }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspaceTable.ID, + TemplateVersionID: version.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + }) + job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ + FileID: file.ID, + InitiatorID: user.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: build.ID, + })), + OrganizationID: pd.OrganizationID, + }) + _, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + OrganizationID: pd.OrganizationID, + WorkerID: uuid.NullUUID{ + UUID: pd.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }) + require.NoError(t, err) + + // Add a published channel to make sure the workspace event is sent + publishedWorkspace := make(chan struct{}) + closeWorkspaceSubscribe, err := ps.SubscribeWithErr(wspubsub.WorkspaceEventChannel(workspaceTable.OwnerID), + wspubsub.HandleWorkspaceEvent( + func(_ context.Context, e wspubsub.WorkspaceEvent, err error) { + if err != nil { + return + } + if e.Kind == wspubsub.WorkspaceEventKindStateChange && e.WorkspaceID == workspaceTable.ID { + close(publishedWorkspace) + } + })) + require.NoError(t, err) + defer closeWorkspaceSubscribe() + + // The actual test + _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{ + State: []byte{}, + Resources: []*sdkproto.Resource{{ + Name: "test-workspace-resource", + Type: "aws_instance", + }}, + Timings: []*sdkproto.Timing{{ + Stage: "test", + Source: "test-source", + Resource: "test-resource", + Action: "test-action", + Start: timestamppb.Now(), + End: timestamppb.Now(), + }}, + }, + }, + }) + require.NoError(t, err) + + // Wait for workspace notification + select { + case <-publishedWorkspace: + // Success + case <-time.After(testutil.WaitShort): + t.Fatal("Workspace event not published") + } + + // Verify job was marked as completed + completedJob, err := db.GetProvisionerJobByID(ctx, job.ID) + require.NoError(t, err) + require.True(t, completedJob.CompletedAt.Valid, "Job should be marked as completed") + + // Verify resources were created + resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID) + require.NoError(t, err) + require.Len(t, resources, 1, "Expected one resource to be created") + require.Equal(t, "test-workspace-resource", resources[0].Name) + + // Verify timings were recorded + timings, err := db.GetProvisionerJobTimingsByJobID(ctx, job.ID) + require.NoError(t, err) + require.Len(t, timings, 1, "Expected one timing entry to be created") + require.Equal(t, "test", string(timings[0].Stage), "Timing stage should match what was sent") + }) + }) + t.Run("TemplateImport_MissingGitAuth", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, &overrides{}) From df73eca804ded100b0f66d7146c4339cf27483d9 Mon Sep 17 00:00:00 2001 From: Jaayden Halko <17991901+EdwardAngert@users.noreply.github.com> Date: Wed, 21 May 2025 15:55:37 +0100 Subject: [PATCH 15/33] fix: show diagnostics if there are no parameters (#17967) Prefer to show the top level diagnostics inside the parameters section for context but this adds a case to show diagnostics if there are no parameters. Normally, the entire parameters section is hidden if there are no parameters. --- .../CreateWorkspacePageViewExperimental.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index cb4451b53acd7..d4b6a311c02b1 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -489,6 +489,10 @@ export const CreateWorkspacePageViewExperimental: FC< )} + {parameters.length === 0 && diagnostics.length > 0 && ( + + )} + {parameters.length > 0 && (
From 6cc69c0b33fb9d3e56b6db7f89fd6dd4c0eec7ba Mon Sep 17 00:00:00 2001 From: Jaayden Halko <17991901+EdwardAngert@users.noreply.github.com> Date: Wed, 21 May 2025 15:56:01 +0100 Subject: [PATCH 16/33] fix: update textarea to fit content height and set a max height (#17946) --- .../DynamicParameter/DynamicParameter.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 94fa3bc383074..fc74a3a46a005 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -239,18 +239,30 @@ const DebouncedParameterField: FC = ({ prevDebouncedValueRef.current = debouncedLocalValue; }, [debouncedLocalValue, onChangeEvent]); + const textareaRef = useRef(null); + + const resizeTextarea = useEffectEvent(() => { + if (textareaRef.current) { + const textarea = textareaRef.current; + textarea.style.height = `${textarea.scrollHeight}px`; + } + }); + + useEffect(() => { + resizeTextarea(); + }, [resizeTextarea]); switch (parameter.form_type) { - case "textarea": + case "textarea": { return (