From 183eaa21dc2d856febdbe9ef602f80eba64c2d92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:46:08 +0000 Subject: [PATCH 1/7] ci: bump the github-actions group with 5 updates (#18497) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/docker-base.yaml | 2 +- .github/workflows/docs-ci.yaml | 2 +- .github/workflows/dogfood.yaml | 6 +++--- .github/workflows/release.yaml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0537cc16b7f0c..b0a27b2db4de7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -902,7 +902,7 @@ jobs: # the check to pass. This is desired in PRs, but not in mainline. - name: Publish to Chromatic (non-mainline) if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder' - uses: chromaui/action@c50adf8eaa8c2878af3263499a73077854de39d4 # v12.2.0 + uses: chromaui/action@b5848056bb67ce5f1cccca8e62a37cbd9dd42871 # v13.0.1 env: NODE_OPTIONS: "--max_old_space_size=4096" STORYBOOK: true @@ -934,7 +934,7 @@ jobs: # infinitely "in progress" in mainline unless we re-review each build. - name: Publish to Chromatic (mainline) if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder' - uses: chromaui/action@c50adf8eaa8c2878af3263499a73077854de39d4 # v12.2.0 + uses: chromaui/action@b5848056bb67ce5f1cccca8e62a37cbd9dd42871 # v13.0.1 env: NODE_OPTIONS: "--max_old_space_size=4096" STORYBOOK: true diff --git a/.github/workflows/docker-base.yaml b/.github/workflows/docker-base.yaml index 01e24c62d9bf7..f74e71ba2909b 100644 --- a/.github/workflows/docker-base.yaml +++ b/.github/workflows/docker-base.yaml @@ -60,7 +60,7 @@ jobs: # This uses OIDC authentication, so no auth variables are required. - name: Build base Docker image via depot.dev - uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0 + uses: depot/build-push-action@2583627a84956d07561420dcc1d0eb1f2af3fac0 # v1.15.0 with: project: wl5hnrrkns context: base-build-context diff --git a/.github/workflows/docs-ci.yaml b/.github/workflows/docs-ci.yaml index 3994f8e69d24c..30ac97706d9f8 100644 --- a/.github/workflows/docs-ci.yaml +++ b/.github/workflows/docs-ci.yaml @@ -28,7 +28,7 @@ jobs: - name: Setup Node uses: ./.github/actions/setup-node - - uses: tj-actions/changed-files@d52d20fa3f981cb852b861fd8f55308b5fe29637 # v45.0.7 + - uses: tj-actions/changed-files@666c9d29007687c52e3c7aa2aac6c0ffcadeadc3 # v45.0.7 id: changed-files with: files: | diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml index df73479994516..b5b2447bd44bf 100644 --- a/.github/workflows/dogfood.yaml +++ b/.github/workflows/dogfood.yaml @@ -35,7 +35,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Nix - uses: nixbuild/nix-quick-install-action@5bb6a3b3abe66fd09bbf250dce8ada94f856a703 # v30 + uses: nixbuild/nix-quick-install-action@889f3180bb5f064ee9e3201428d04ae9e41d54ad # v31 - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 with: @@ -72,7 +72,7 @@ jobs: uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Login to DockerHub if: github.ref == 'refs/heads/main' @@ -82,7 +82,7 @@ jobs: password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Build and push Non-Nix image - uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0 + uses: depot/build-push-action@2583627a84956d07561420dcc1d0eb1f2af3fac0 # v1.15.0 with: project: b4q6ltmpzh token: ${{ secrets.DEPOT_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 032961dd2e5c5..69043dc833362 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -364,7 +364,7 @@ jobs: # This uses OIDC authentication, so no auth variables are required. - name: Build base Docker image via depot.dev if: steps.image-base-tag.outputs.tag != '' - uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0 + uses: depot/build-push-action@2583627a84956d07561420dcc1d0eb1f2af3fac0 # v1.15.0 with: project: wl5hnrrkns context: base-build-context From ba08d38563ef22870254967c7274eedcaa9b55f5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:54:51 +0000 Subject: [PATCH 2/7] chore: bump github.com/coder/terraform-provider-coder/v2 from 2.6.0 to 2.7.0 (#18499) [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/coder/terraform-provider-coder/v2&package-manager=go_modules&previous-version=2.6.0&new-version=2.7.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ef52718460cdd..3bf63f08fafd0 100644 --- a/go.mod +++ b/go.mod @@ -101,7 +101,7 @@ require ( github.com/coder/quartz v0.2.1 github.com/coder/retry v1.5.1 github.com/coder/serpent v0.10.0 - github.com/coder/terraform-provider-coder/v2 v2.6.0 + github.com/coder/terraform-provider-coder/v2 v2.7.0 github.com/coder/websocket v1.8.13 github.com/coder/wgtunnel v0.1.13-0.20240522110300-ade90dfb2da0 github.com/coreos/go-oidc/v3 v3.14.1 diff --git a/go.sum b/go.sum index ee7587bfdd7b1..7433167763cc4 100644 --- a/go.sum +++ b/go.sum @@ -928,8 +928,8 @@ github.com/coder/tailscale v1.1.1-0.20250611020837-f14d20d23d8c h1:d/qBIi3Ez7Kko github.com/coder/tailscale v1.1.1-0.20250611020837-f14d20d23d8c/go.mod h1:l7ml5uu7lFh5hY28lGYM4b/oFSmuPHYX6uk4RAu23Lc= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e h1:JNLPDi2P73laR1oAclY6jWzAbucf70ASAvf5mh2cME0= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI= -github.com/coder/terraform-provider-coder/v2 v2.6.0 h1:ybSVxkblpFdanNX7hibex41yvwjswUlA3RPh4BAHjBI= -github.com/coder/terraform-provider-coder/v2 v2.6.0/go.mod h1:WrdLSbihuzH1RZhwrU+qmkqEhUbdZT/sjHHdarm5b5g= +github.com/coder/terraform-provider-coder/v2 v2.7.0 h1:0WV89TuboHnoy4J8MwNnCtNjF3Onx2Ny959FC2ClGQg= +github.com/coder/terraform-provider-coder/v2 v2.7.0/go.mod h1:WrdLSbihuzH1RZhwrU+qmkqEhUbdZT/sjHHdarm5b5g= github.com/coder/trivy v0.0.0-20250527170238-9416a59d7019 h1:MHkv/W7l9eRAN9gOG0qZ1TLRGWIIfNi92273vPAQ8Fs= github.com/coder/trivy v0.0.0-20250527170238-9416a59d7019/go.mod h1:eqk+w9RLBmbd/cB5XfPZFuVn77cf/A6fB7qmEVeSmXk= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= From 659b787b9fee2de9a5b1286684eb8c0da558b556 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 23 Jun 2025 11:31:53 -0500 Subject: [PATCH 3/7] chore: set wsbuilder to use preview parameters (#18474) Use richer `previewtypes.Parameter` for `wsbuilder`. This is a pre-requirement to adding dynamic parameter validation. The richer type contains more information than the `db` parameter, so the conversion is lossless. --- coderd/database/db2sdk/db2sdk.go | 53 ++++++++++++++++++++++++++++++ coderd/dynamicparameters/static.go | 8 +++-- coderd/wsbuilder/wsbuilder.go | 12 ++++--- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 4a7871f21d15d..e926844d2b156 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/render" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -96,6 +97,49 @@ func TemplateVersionParameters(params []database.TemplateVersionParameter) ([]co return out, nil } +func TemplateVersionParameterFromPreview(param previewtypes.Parameter) (codersdk.TemplateVersionParameter, error) { + descriptionPlaintext, err := render.PlaintextFromMarkdown(param.Description) + if err != nil { + return codersdk.TemplateVersionParameter{}, err + } + + sdkParam := codersdk.TemplateVersionParameter{ + Name: param.Name, + DisplayName: param.DisplayName, + Description: param.Description, + DescriptionPlaintext: descriptionPlaintext, + Type: string(param.Type), + FormType: string(param.FormType), + Mutable: param.Mutable, + DefaultValue: param.DefaultValue.AsString(), + Icon: param.Icon, + Required: param.Required, + Ephemeral: param.Ephemeral, + Options: List(param.Options, TemplateVersionParameterOptionFromPreview), + // Validation set after + } + if len(param.Validations) > 0 { + validation := param.Validations[0] + sdkParam.ValidationError = validation.Error + if validation.Monotonic != nil { + sdkParam.ValidationMonotonic = codersdk.ValidationMonotonicOrder(*validation.Monotonic) + } + if validation.Regex != nil { + sdkParam.ValidationRegex = *validation.Regex + } + if validation.Min != nil { + //nolint:gosec // No other choice + sdkParam.ValidationMin = ptr.Ref(int32(*validation.Min)) + } + if validation.Max != nil { + //nolint:gosec // No other choice + sdkParam.ValidationMax = ptr.Ref(int32(*validation.Max)) + } + } + + return sdkParam, nil +} + func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk.TemplateVersionParameter, error) { options, err := templateVersionParameterOptions(param.Options) if err != nil { @@ -299,6 +343,15 @@ func templateVersionParameterOptions(rawOptions json.RawMessage) ([]codersdk.Tem return options, nil } +func TemplateVersionParameterOptionFromPreview(option *previewtypes.ParameterOption) codersdk.TemplateVersionParameterOption { + return codersdk.TemplateVersionParameterOption{ + Name: option.Name, + Description: option.Description, + Value: option.Value.AsString(), + Icon: option.Icon, + } +} + func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp { return codersdk.OAuth2ProviderApp{ ID: dbApp.ID, diff --git a/coderd/dynamicparameters/static.go b/coderd/dynamicparameters/static.go index 14988a2d162c0..fec5de2581aef 100644 --- a/coderd/dynamicparameters/static.go +++ b/coderd/dynamicparameters/static.go @@ -28,6 +28,12 @@ func (r *loader) staticRender(ctx context.Context, db database.Store) (*staticRe } params := db2sdk.List(dbTemplateVersionParameters, TemplateVersionParameter) + + for i, param := range params { + // Update the diagnostics to validate the 'default' value. + // We do not have a user supplied value yet, so we use the default. + params[i].Diagnostics = append(params[i].Diagnostics, previewtypes.Diagnostics(param.Valid(param.Value))...) + } return &staticRender{ staticParams: params, }, nil @@ -137,7 +143,5 @@ func TemplateVersionParameter(it database.TemplateVersionParameter) previewtypes // unfortunate we have to do this, but it will return the default form_type // for a given set of conditions. _, param.FormType, _ = provider.ValidateFormType(provider.OptionType(param.Type), len(param.Options), param.FormType) - - param.Diagnostics = append(param.Diagnostics, previewtypes.Diagnostics(param.Valid(param.Value))...) return param } diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index a996d1594a50d..577ec5eca4577 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -14,11 +14,13 @@ import ( "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/coder/coder/v2/apiversion" + "github.com/coder/coder/v2/coderd/dynamicparameters" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/provisioner/terraform/tfparse" "github.com/coder/coder/v2/provisionersdk" sdkproto "github.com/coder/coder/v2/provisionersdk/proto" + previewtypes "github.com/coder/preview/types" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" @@ -71,7 +73,7 @@ type Builder struct { templateVersion *database.TemplateVersion templateVersionJob *database.ProvisionerJob terraformValues *database.TemplateVersionTerraformValue - templateVersionParameters *[]database.TemplateVersionParameter + templateVersionParameters *[]previewtypes.Parameter templateVersionVariables *[]database.TemplateVersionVariable templateVersionWorkspaceTags *[]database.TemplateVersionWorkspaceTag lastBuild *database.WorkspaceBuild @@ -716,7 +718,7 @@ func (b *Builder) getParameters() (names, values []string, err error) { } for _, templateVersionParameter := range templateVersionParameters { - tvp, err := db2sdk.TemplateVersionParameter(templateVersionParameter) + tvp, err := db2sdk.TemplateVersionParameterFromPreview(templateVersionParameter) if err != nil { return nil, nil, BuildError{http.StatusInternalServerError, "failed to convert template version parameter", err} } @@ -780,7 +782,7 @@ func (b *Builder) getLastBuildParameters() ([]database.WorkspaceBuildParameter, return values, nil } -func (b *Builder) getTemplateVersionParameters() ([]database.TemplateVersionParameter, error) { +func (b *Builder) getTemplateVersionParameters() ([]previewtypes.Parameter, error) { if b.templateVersionParameters != nil { return *b.templateVersionParameters, nil } @@ -792,8 +794,8 @@ func (b *Builder) getTemplateVersionParameters() ([]database.TemplateVersionPara if err != nil && !xerrors.Is(err, sql.ErrNoRows) { return nil, xerrors.Errorf("get template version %s parameters: %w", tvID, err) } - b.templateVersionParameters = &tvp - return tvp, nil + b.templateVersionParameters = ptr.Ref(db2sdk.List(tvp, dynamicparameters.TemplateVersionParameter)) + return *b.templateVersionParameters, nil } func (b *Builder) getTemplateVersionVariables() ([]database.TemplateVersionVariable, error) { From c1b35bf2f6fa71af87dc5222ade2c62fc20bb708 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 23 Jun 2025 11:58:52 -0500 Subject: [PATCH 4/7] chore: use database in current context for file cache (#18490) Using the db.Store when in a TX causes a deadlock for dbmem. In production, this can cause a deadlock if at the current conn pool limit. --- coderd/coderd.go | 2 +- coderd/dynamicparameters/render.go | 4 +- coderd/files/cache.go | 67 +++++++++++++----------------- coderd/files/cache_test.go | 46 +++++++++++--------- coderd/files/closer.go | 6 ++- 5 files changed, 62 insertions(+), 63 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 929c9f44a7a8b..4507cd1dd7605 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -574,7 +574,7 @@ func New(options *Options) *API { TemplateScheduleStore: options.TemplateScheduleStore, UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, AccessControlStore: options.AccessControlStore, - FileCache: files.NewFromStore(options.Database, options.PrometheusRegistry, options.Authorizer), + FileCache: files.New(options.PrometheusRegistry, options.Authorizer), Experiments: experiments, WebpushDispatcher: options.WebPushDispatcher, healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{}, diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go index 8e7df929505f1..733b2f2ab5f5d 100644 --- a/coderd/dynamicparameters/render.go +++ b/coderd/dynamicparameters/render.go @@ -169,14 +169,14 @@ func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache * var templateFS fs.FS var err error - templateFS, err = cache.Acquire(fileCtx, r.job.FileID) + templateFS, err = cache.Acquire(fileCtx, db, r.job.FileID) if err != nil { return nil, xerrors.Errorf("acquire template file: %w", err) } var moduleFilesFS *files.CloseFS if r.terraformValues.CachedModuleFiles.Valid { - moduleFilesFS, err = cache.Acquire(fileCtx, r.terraformValues.CachedModuleFiles.UUID) + moduleFilesFS, err = cache.Acquire(fileCtx, db, r.terraformValues.CachedModuleFiles.UUID) if err != nil { return nil, xerrors.Errorf("acquire module files: %w", err) } diff --git a/coderd/files/cache.go b/coderd/files/cache.go index 170abb10b1ff7..32e03e0b8f209 100644 --- a/coderd/files/cache.go +++ b/coderd/files/cache.go @@ -20,38 +20,15 @@ import ( ) type FileAcquirer interface { - Acquire(ctx context.Context, fileID uuid.UUID) (*CloseFS, error) + Acquire(ctx context.Context, db database.Store, fileID uuid.UUID) (*CloseFS, error) } -// NewFromStore returns a file cache that will fetch files from the provided -// database. -func NewFromStore(store database.Store, registerer prometheus.Registerer, authz rbac.Authorizer) *Cache { - fetch := func(ctx context.Context, fileID uuid.UUID) (CacheEntryValue, error) { - // Make sure the read does not fail due to authorization issues. - // Authz is checked on the Acquire call, so this is safe. - //nolint:gocritic - file, err := store.GetFileByID(dbauthz.AsFileReader(ctx), fileID) - if err != nil { - return CacheEntryValue{}, xerrors.Errorf("failed to read file from database: %w", err) - } - - content := bytes.NewBuffer(file.Data) - return CacheEntryValue{ - Object: file.RBACObject(), - FS: archivefs.FromTarReader(content), - Size: int64(len(file.Data)), - }, nil - } - - return New(fetch, registerer, authz) -} - -func New(fetch fetcher, registerer prometheus.Registerer, authz rbac.Authorizer) *Cache { +// New returns a file cache that will fetch files from a database +func New(registerer prometheus.Registerer, authz rbac.Authorizer) *Cache { return (&Cache{ - lock: sync.Mutex{}, - data: make(map[uuid.UUID]*cacheEntry), - fetcher: fetch, - authz: authz, + lock: sync.Mutex{}, + data: make(map[uuid.UUID]*cacheEntry), + authz: authz, }).registerMetrics(registerer) } @@ -110,9 +87,8 @@ func (c *Cache) registerMetrics(registerer prometheus.Registerer) *Cache { // loaded into memory exactly once. We hold those files until there are no // longer any open connections, and then we remove the value from the map. type Cache struct { - lock sync.Mutex - data map[uuid.UUID]*cacheEntry - fetcher + lock sync.Mutex + data map[uuid.UUID]*cacheEntry authz rbac.Authorizer // metrics @@ -142,8 +118,6 @@ type cacheEntry struct { value *lazy.ValueWithError[CacheEntryValue] } -type fetcher func(context.Context, uuid.UUID) (CacheEntryValue, error) - var _ fs.FS = (*CloseFS)(nil) // CloseFS is a wrapper around fs.FS that implements io.Closer. The Close() @@ -163,12 +137,12 @@ func (f *CloseFS) Close() { f.close() } // // Safety: Every call to Acquire that does not return an error must have a // matching call to Release. -func (c *Cache) Acquire(ctx context.Context, fileID uuid.UUID) (*CloseFS, error) { +func (c *Cache) Acquire(ctx context.Context, db database.Store, fileID uuid.UUID) (*CloseFS, error) { // It's important that this `Load` call occurs outside `prepare`, after the // mutex has been released, or we would continue to hold the lock until the // entire file has been fetched, which may be slow, and would prevent other // files from being fetched in parallel. - it, err := c.prepare(ctx, fileID).Load() + it, err := c.prepare(ctx, db, fileID).Load() if err != nil { c.release(fileID) return nil, err @@ -195,14 +169,14 @@ func (c *Cache) Acquire(ctx context.Context, fileID uuid.UUID) (*CloseFS, error) }, nil } -func (c *Cache) prepare(ctx context.Context, fileID uuid.UUID) *lazy.ValueWithError[CacheEntryValue] { +func (c *Cache) prepare(ctx context.Context, db database.Store, fileID uuid.UUID) *lazy.ValueWithError[CacheEntryValue] { c.lock.Lock() defer c.lock.Unlock() entry, ok := c.data[fileID] if !ok { value := lazy.NewWithError(func() (CacheEntryValue, error) { - val, err := c.fetcher(ctx, fileID) + val, err := fetch(ctx, db, fileID) // Always add to the cache size the bytes of the file loaded. if err == nil { @@ -269,3 +243,20 @@ func (c *Cache) Count() int { return len(c.data) } + +func fetch(ctx context.Context, store database.Store, fileID uuid.UUID) (CacheEntryValue, error) { + // Make sure the read does not fail due to authorization issues. + // Authz is checked on the Acquire call, so this is safe. + //nolint:gocritic + file, err := store.GetFileByID(dbauthz.AsFileReader(ctx), fileID) + if err != nil { + return CacheEntryValue{}, xerrors.Errorf("failed to read file from database: %w", err) + } + + content := bytes.NewBuffer(file.Data) + return CacheEntryValue{ + Object: file.RBACObject(), + FS: archivefs.FromTarReader(content), + Size: int64(len(file.Data)), + }, nil +} diff --git a/coderd/files/cache_test.go b/coderd/files/cache_test.go index 5efb4ba19be28..a5a5dfae268ca 100644 --- a/coderd/files/cache_test.go +++ b/coderd/files/cache_test.go @@ -8,8 +8,8 @@ import ( "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" - "github.com/spf13/afero" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.org/x/sync/errgroup" "cdr.dev/slog/sloggers/slogtest" @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/files" "github.com/coder/coder/v2/coderd/rbac" @@ -58,7 +59,7 @@ func TestCacheRBAC(t *testing.T) { require.Equal(t, 0, cache.Count()) rec.Reset() - _, err := cache.Acquire(nobody, file.ID) + _, err := cache.Acquire(nobody, db, file.ID) require.Error(t, err) require.True(t, rbac.IsUnauthorizedError(err)) @@ -75,18 +76,18 @@ func TestCacheRBAC(t *testing.T) { require.Equal(t, 0, cache.Count()) // Read the file with a file reader to put it into the cache. - a, err := cache.Acquire(cacheReader, file.ID) + a, err := cache.Acquire(cacheReader, db, file.ID) require.NoError(t, err) require.Equal(t, 1, cache.Count()) // "nobody" should not be able to read the file. - _, err = cache.Acquire(nobody, file.ID) + _, err = cache.Acquire(nobody, db, file.ID) require.Error(t, err) require.True(t, rbac.IsUnauthorizedError(err)) require.Equal(t, 1, cache.Count()) // UserReader can - b, err := cache.Acquire(userReader, file.ID) + b, err := cache.Acquire(userReader, db, file.ID) require.NoError(t, err) require.Equal(t, 1, cache.Count()) @@ -110,16 +111,21 @@ func TestConcurrency(t *testing.T) { ctx := dbauthz.AsFileReader(t.Context()) const fileSize = 10 - emptyFS := afero.NewIOFS(afero.NewReadOnlyFs(afero.NewMemMapFs())) var fetches atomic.Int64 reg := prometheus.NewRegistry() - c := files.New(func(_ context.Context, _ uuid.UUID) (files.CacheEntryValue, error) { + + dbM := dbmock.NewMockStore(gomock.NewController(t)) + dbM.EXPECT().GetFileByID(gomock.Any(), gomock.Any()).DoAndReturn(func(mTx context.Context, fileID uuid.UUID) (database.File, error) { fetches.Add(1) - // Wait long enough before returning to make sure that all of the goroutines + // Wait long enough before returning to make sure that all the goroutines // will be waiting in line, ensuring that no one duplicated a fetch. time.Sleep(testutil.IntervalMedium) - return files.CacheEntryValue{FS: emptyFS, Size: fileSize}, nil - }, reg, &coderdtest.FakeAuthorizer{}) + return database.File{ + Data: make([]byte, fileSize), + }, nil + }).AnyTimes() + + c := files.New(reg, &coderdtest.FakeAuthorizer{}) batches := 1000 groups := make([]*errgroup.Group, 0, batches) @@ -137,7 +143,7 @@ func TestConcurrency(t *testing.T) { g.Go(func() error { // We don't bother to Release these references because the Cache will be // released at the end of the test anyway. - _, err := c.Acquire(ctx, id) + _, err := c.Acquire(ctx, dbM, id) return err }) } @@ -164,14 +170,15 @@ func TestRelease(t *testing.T) { ctx := dbauthz.AsFileReader(t.Context()) const fileSize = 10 - emptyFS := afero.NewIOFS(afero.NewReadOnlyFs(afero.NewMemMapFs())) reg := prometheus.NewRegistry() - c := files.New(func(_ context.Context, _ uuid.UUID) (files.CacheEntryValue, error) { - return files.CacheEntryValue{ - FS: emptyFS, - Size: fileSize, + dbM := dbmock.NewMockStore(gomock.NewController(t)) + dbM.EXPECT().GetFileByID(gomock.Any(), gomock.Any()).DoAndReturn(func(mTx context.Context, fileID uuid.UUID) (database.File, error) { + return database.File{ + Data: make([]byte, fileSize), }, nil - }, reg, &coderdtest.FakeAuthorizer{}) + }).AnyTimes() + + c := files.New(reg, &coderdtest.FakeAuthorizer{}) batches := 100 ids := make([]uuid.UUID, 0, batches) @@ -184,9 +191,8 @@ func TestRelease(t *testing.T) { batchSize := 10 for openedIdx, id := range ids { for batchIdx := range batchSize { - it, err := c.Acquire(ctx, id) + it, err := c.Acquire(ctx, dbM, id) require.NoError(t, err) - require.Equal(t, emptyFS, it.FS) releases[id] = append(releases[id], it.Close) // Each time a new file is opened, the metrics should be updated as so: @@ -257,7 +263,7 @@ func cacheAuthzSetup(t *testing.T) (database.Store, *files.Cache, *coderdtest.Re // Dbauthz wrap the db db = dbauthz.New(db, rec, logger, coderdtest.AccessControlStorePointer()) - c := files.NewFromStore(db, reg, rec) + c := files.New(reg, rec) return db, c, rec } diff --git a/coderd/files/closer.go b/coderd/files/closer.go index 9bd98fdd60caf..560786c78f80e 100644 --- a/coderd/files/closer.go +++ b/coderd/files/closer.go @@ -6,6 +6,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" ) // CacheCloser is a cache wrapper used to close all acquired files. @@ -38,7 +40,7 @@ func (c *CacheCloser) Close() { c.closers = nil } -func (c *CacheCloser) Acquire(ctx context.Context, fileID uuid.UUID) (*CloseFS, error) { +func (c *CacheCloser) Acquire(ctx context.Context, db database.Store, fileID uuid.UUID) (*CloseFS, error) { c.mu.Lock() defer c.mu.Unlock() @@ -46,7 +48,7 @@ func (c *CacheCloser) Acquire(ctx context.Context, fileID uuid.UUID) (*CloseFS, return nil, xerrors.New("cache is closed, and cannot acquire new files") } - f, err := c.cache.Acquire(ctx, fileID) + f, err := c.cache.Acquire(ctx, db, fileID) if err != nil { return nil, err } From 7254c08af49c1f7e706ae5343d659f2d3b39067e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 23 Jun 2025 12:17:58 -0500 Subject: [PATCH 5/7] chore: remove parallel queries in the same transaction (#18489) Parallel concurrent queries cannot be run in the same tx Was getting this error: https://stackoverflow.com/questions/78472996/go-postgres-pq-unexpected-parse-response-c-with-queryrow --- coderd/dynamicparameters/render.go | 107 ++++++++++++----------------- 1 file changed, 43 insertions(+), 64 deletions(-) diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go index 733b2f2ab5f5d..d9ae7f9a4a6b5 100644 --- a/coderd/dynamicparameters/render.go +++ b/coderd/dynamicparameters/render.go @@ -9,7 +9,6 @@ import ( "time" "github.com/google/uuid" - "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "github.com/coder/coder/v2/apiversion" @@ -244,8 +243,6 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui return nil // already fetched } - var g errgroup.Group - // You only need to be able to read the organization member to get the owner // data. Only the terraform files can therefore leak more information than the // caller should have access to. All this info should be public assuming you can @@ -266,72 +263,54 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui return xerrors.Errorf("fetch user: %w", err) } - var ownerRoles []previewtypes.WorkspaceOwnerRBACRole - g.Go(func() error { - // nolint:gocritic // This is kind of the wrong query to use here, but it - // matches how the provisioner currently works. We should figure out - // something that needs less escalation but has the correct behavior. - row, err := r.db.GetAuthorizationUserRoles(dbauthz.AsProvisionerd(ctx), ownerID) - if err != nil { - return err - } - roles, err := row.RoleNames() - if err != nil { - return err + // nolint:gocritic // This is kind of the wrong query to use here, but it + // matches how the provisioner currently works. We should figure out + // something that needs less escalation but has the correct behavior. + row, err := r.db.GetAuthorizationUserRoles(dbauthz.AsProvisionerd(ctx), ownerID) + if err != nil { + return xerrors.Errorf("user roles: %w", err) + } + roles, err := row.RoleNames() + if err != nil { + return xerrors.Errorf("expand roles: %w", err) + } + ownerRoles := make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles)) + for _, it := range roles { + if it.OrganizationID != uuid.Nil && it.OrganizationID != r.data.templateVersion.OrganizationID { + continue } - ownerRoles = make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles)) - for _, it := range roles { - if it.OrganizationID != uuid.Nil && it.OrganizationID != r.data.templateVersion.OrganizationID { - continue - } - var orgID string - if it.OrganizationID != uuid.Nil { - orgID = it.OrganizationID.String() - } - ownerRoles = append(ownerRoles, previewtypes.WorkspaceOwnerRBACRole{ - Name: it.Name, - OrgID: orgID, - }) + var orgID string + if it.OrganizationID != uuid.Nil { + orgID = it.OrganizationID.String() } - return nil - }) + ownerRoles = append(ownerRoles, previewtypes.WorkspaceOwnerRBACRole{ + Name: it.Name, + OrgID: orgID, + }) + } - var publicKey string - g.Go(func() error { - // The correct public key has to be sent. This will not be leaked - // unless the template leaks it. - // nolint:gocritic - key, err := r.db.GetGitSSHKey(dbauthz.AsProvisionerd(ctx), ownerID) - if err != nil { - return err - } - publicKey = key.PublicKey - return nil - }) + // The correct public key has to be sent. This will not be leaked + // unless the template leaks it. + // nolint:gocritic + key, err := r.db.GetGitSSHKey(dbauthz.AsProvisionerd(ctx), ownerID) + if err != nil { + return xerrors.Errorf("ssh key: %w", err) + } - var groupNames []string - g.Go(func() error { - // The groups need to be sent to preview. These groups are not exposed to the - // user, unless the template does it through the parameters. Regardless, we need - // the correct groups, and a user might not have read access. - // nolint:gocritic - groups, err := r.db.GetGroups(dbauthz.AsProvisionerd(ctx), database.GetGroupsParams{ - OrganizationID: r.data.templateVersion.OrganizationID, - HasMemberID: ownerID, - }) - if err != nil { - return err - } - groupNames = make([]string, 0, len(groups)) - for _, it := range groups { - groupNames = append(groupNames, it.Group.Name) - } - return nil + // The groups need to be sent to preview. These groups are not exposed to the + // user, unless the template does it through the parameters. Regardless, we need + // the correct groups, and a user might not have read access. + // nolint:gocritic + groups, err := r.db.GetGroups(dbauthz.AsProvisionerd(ctx), database.GetGroupsParams{ + OrganizationID: r.data.templateVersion.OrganizationID, + HasMemberID: ownerID, }) - - err = g.Wait() if err != nil { - return err + return xerrors.Errorf("groups: %w", err) + } + groupNames := make([]string, 0, len(groups)) + for _, it := range groups { + groupNames = append(groupNames, it.Group.Name) } r.currentOwner = &previewtypes.WorkspaceOwner{ @@ -341,7 +320,7 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui Email: mem.Email, LoginType: string(user.LoginType), RBACRoles: ownerRoles, - SSHPublicKey: publicKey, + SSHPublicKey: key.PublicKey, Groups: groupNames, } return nil From 82af2e019d3fb0771c20b4b86191a34ce972546d Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 23 Jun 2025 12:35:15 -0500 Subject: [PATCH 6/7] feat: implement dynamic parameter validation (#18482) # What does this do? This does parameter validation for dynamic parameters in `wsbuilder`. All input parameters are validated in `coder/coder` before being sent to terraform. The heart of this PR is [`ResolveParameters`](https://github.com/coder/coder/blob/b65001e89c0577199a8e470c138c51e91cf2350c/coderd/dynamicparameters/resolver.go#L30-L30). # What else changes? `wsbuilder` now needs to load the terraform files into memory to succeed. This does add a larger memory requirement to workspace builds. # Future work - Sort autostart handling workspaces by template version id. So workspaces with the same template version only load the terraform files once from the db, and store them in the cache. --- cli/server.go | 2 +- coderd/autobuild/lifecycle_executor.go | 7 +- coderd/coderdtest/coderdtest.go | 2 + coderd/dynamicparameters/render.go | 4 +- coderd/dynamicparameters/render_test.go | 35 ++ coderd/dynamicparameters/resolver.go | 189 +++++++++++ coderd/parameters_test.go | 40 ++- coderd/testdata/parameters/modules/main.tf | 44 ++- coderd/util/slice/slice.go | 10 + coderd/workspacebuilds.go | 1 + coderd/workspaces.go | 1 + coderd/wsbuilder/wsbuilder.go | 232 +++++++++---- coderd/wsbuilder/wsbuilder_test.go | 99 +++--- enterprise/coderd/coderd.go | 2 +- enterprise/coderd/dynamicparameters_test.go | 311 ++++++++++++++++++ enterprise/coderd/prebuilds/claim_test.go | 4 +- .../coderd/prebuilds/metricscollector_test.go | 8 +- enterprise/coderd/prebuilds/reconcile.go | 5 + enterprise/coderd/prebuilds/reconcile_test.go | 43 ++- .../testdata/parameters/dynamic/main.tf | 16 +- .../testdata/parameters/ephemeral/main.tf | 25 ++ .../testdata/parameters/numbers/main.tf | 20 ++ .../coderd/testdata/parameters/regex/main.tf | 18 + 23 files changed, 960 insertions(+), 158 deletions(-) create mode 100644 coderd/dynamicparameters/render_test.go create mode 100644 coderd/dynamicparameters/resolver.go create mode 100644 enterprise/coderd/testdata/parameters/ephemeral/main.tf create mode 100644 enterprise/coderd/testdata/parameters/numbers/main.tf create mode 100644 enterprise/coderd/testdata/parameters/regex/main.tf diff --git a/cli/server.go b/cli/server.go index 7d587f5e1ae14..9af9965210d72 100644 --- a/cli/server.go +++ b/cli/server.go @@ -1125,7 +1125,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value()) defer autobuildTicker.Stop() autobuildExecutor := autobuild.NewExecutor( - ctx, options.Database, options.Pubsub, options.PrometheusRegistry, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C, options.NotificationsEnqueuer, coderAPI.Experiments) + ctx, options.Database, options.Pubsub, coderAPI.FileCache, options.PrometheusRegistry, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C, options.NotificationsEnqueuer, coderAPI.Experiments) autobuildExecutor.Run() jobReaperTicker := time.NewTicker(vals.JobReaperDetectorInterval.Value()) diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index b0cba60111335..f71d267028270 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -17,6 +17,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" @@ -35,6 +36,7 @@ type Executor struct { ctx context.Context db database.Store ps pubsub.Pubsub + fileCache *files.Cache templateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] accessControlStore *atomic.Pointer[dbauthz.AccessControlStore] auditor *atomic.Pointer[audit.Auditor] @@ -61,13 +63,14 @@ type Stats struct { } // New returns a new wsactions executor. -func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, reg prometheus.Registerer, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], log slog.Logger, tick <-chan time.Time, enqueuer notifications.Enqueuer, exp codersdk.Experiments) *Executor { +func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, fc *files.Cache, reg prometheus.Registerer, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], log slog.Logger, tick <-chan time.Time, enqueuer notifications.Enqueuer, exp codersdk.Experiments) *Executor { factory := promauto.With(reg) le := &Executor{ //nolint:gocritic // Autostart has a limited set of permissions. ctx: dbauthz.AsAutostart(ctx), db: db, ps: ps, + fileCache: fc, templateScheduleStore: tss, tick: tick, log: log.Named("autobuild"), @@ -276,7 +279,7 @@ func (e *Executor) runOnce(t time.Time) Stats { } } - nextBuild, job, _, err = builder.Build(e.ctx, tx, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) + nextBuild, job, _, err = builder.Build(e.ctx, tx, e.fileCache, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) if err != nil { return xerrors.Errorf("build workspace with transition %q: %w", nextTransition, err) } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index b509db10e3eba..55e62561af60a 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -52,6 +52,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/quartz" "github.com/coder/coder/v2/coderd" @@ -359,6 +360,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can ctx, options.Database, options.Pubsub, + files.New(prometheus.NewRegistry(), options.Authorizer), prometheus.NewRegistry(), &templateScheduleStore, &auditor, diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go index d9ae7f9a4a6b5..ed9b2734b259c 100644 --- a/coderd/dynamicparameters/render.go +++ b/coderd/dynamicparameters/render.go @@ -49,7 +49,7 @@ type loader struct { // Prepare is the entrypoint for this package. It loads the necessary objects & // files from the database and returns a Renderer that can be used to render the // template version's parameters. -func Prepare(ctx context.Context, db database.Store, cache *files.Cache, versionID uuid.UUID, options ...func(r *loader)) (Renderer, error) { +func Prepare(ctx context.Context, db database.Store, cache files.FileAcquirer, versionID uuid.UUID, options ...func(r *loader)) (Renderer, error) { l := &loader{ templateVersionID: versionID, } @@ -137,7 +137,7 @@ func (r *loader) loadData(ctx context.Context, db database.Store) error { // Static parameter rendering is required to support older template versions that // do not have the database state to support dynamic parameters. A constant // warning will be displayed for these template versions. -func (r *loader) Renderer(ctx context.Context, db database.Store, cache *files.Cache) (Renderer, error) { +func (r *loader) Renderer(ctx context.Context, db database.Store, cache files.FileAcquirer) (Renderer, error) { err := r.loadData(ctx, db) if err != nil { return nil, xerrors.Errorf("load data: %w", err) diff --git a/coderd/dynamicparameters/render_test.go b/coderd/dynamicparameters/render_test.go new file mode 100644 index 0000000000000..c71230c14e19b --- /dev/null +++ b/coderd/dynamicparameters/render_test.go @@ -0,0 +1,35 @@ +package dynamicparameters_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/dynamicparameters" +) + +func TestProvisionerVersionSupportsDynamicParameters(t *testing.T) { + t.Parallel() + + for v, dyn := range map[string]bool{ + "": false, + "na": false, + "0.0": false, + "0.10": false, + "1.4": false, + "1.5": false, + "1.6": true, + "1.7": true, + "1.8": true, + "2.0": true, + "2.17": true, + "4.0": true, + } { + t.Run(v, func(t *testing.T) { + t.Parallel() + + does := dynamicparameters.ProvisionerVersionSupportsDynamicParameters(v) + require.Equal(t, dyn, does) + }) + } +} diff --git a/coderd/dynamicparameters/resolver.go b/coderd/dynamicparameters/resolver.go new file mode 100644 index 0000000000000..bda422ad4b840 --- /dev/null +++ b/coderd/dynamicparameters/resolver.go @@ -0,0 +1,189 @@ +package dynamicparameters + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/hashicorp/hcl/v2" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" +) + +type parameterValueSource int + +const ( + sourceDefault parameterValueSource = iota + sourcePrevious + sourceBuild + sourcePreset +) + +type parameterValue struct { + Value string + Source parameterValueSource +} + +//nolint:revive // firstbuild is a control flag to turn on immutable validation +func ResolveParameters( + ctx context.Context, + ownerID uuid.UUID, + renderer Renderer, + firstBuild bool, + previousValues []database.WorkspaceBuildParameter, + buildValues []codersdk.WorkspaceBuildParameter, + presetValues []database.TemplateVersionPresetParameter, +) (map[string]string, hcl.Diagnostics) { + previousValuesMap := slice.ToMapFunc(previousValues, func(p database.WorkspaceBuildParameter) (string, string) { + return p.Name, p.Value + }) + + // Start with previous + values := parameterValueMap(slice.ToMapFunc(previousValues, func(p database.WorkspaceBuildParameter) (string, parameterValue) { + return p.Name, parameterValue{Source: sourcePrevious, Value: p.Value} + })) + + // Add build values (overwrite previous values if they exist) + for _, buildValue := range buildValues { + values[buildValue.Name] = parameterValue{Source: sourceBuild, Value: buildValue.Value} + } + + // Add preset values (overwrite previous and build values if they exist) + for _, preset := range presetValues { + values[preset.Name] = parameterValue{Source: sourcePreset, Value: preset.Value} + } + + // originalValues is going to be used to detect if a user tried to change + // an immutable parameter after the first build. + originalValues := make(map[string]parameterValue, len(values)) + for name, value := range values { + // Store the original values for later use. + originalValues[name] = value + } + + // Render the parameters using the values that were supplied to the previous build. + // + // This is how the form should look to the user on their workspace settings page. + // This is the original form truth that our validations should initially be based on. + output, diags := renderer.Render(ctx, ownerID, values.ValuesMap()) + if diags.HasErrors() { + // Top level diagnostics should break the build. Previous values (and new) should + // always be valid. If there is a case where this is not true, then this has to + // be changed to allow the build to continue with a different set of values. + + return nil, diags + } + + // The user's input now needs to be validated against the parameters. + // Mutability & Ephemeral parameters depend on sequential workspace builds. + // + // To enforce these, the user's input values are trimmed based on the + // mutability and ephemeral parameters defined in the template version. + for _, parameter := range output.Parameters { + // Ephemeral parameters should not be taken from the previous build. + // They must always be explicitly set in every build. + // So remove their values if they are sourced from the previous build. + if parameter.Ephemeral { + v := values[parameter.Name] + if v.Source == sourcePrevious { + delete(values, parameter.Name) + } + } + + // Immutable parameters should also not be allowed to be changed from + // the previous build. Remove any values taken from the preset or + // new build params. This forces the value to be the same as it was before. + // + // We do this so the next form render uses the original immutable value. + if !firstBuild && !parameter.Mutable { + delete(values, parameter.Name) + prev, ok := previousValuesMap[parameter.Name] + if ok { + values[parameter.Name] = parameterValue{ + Value: prev, + Source: sourcePrevious, + } + } + } + } + + // This is the final set of values that will be used. Any errors at this stage + // are fatal. Additional validation for immutability has to be done manually. + output, diags = renderer.Render(ctx, ownerID, values.ValuesMap()) + if diags.HasErrors() { + return nil, diags + } + + // parameterNames is going to be used to remove any excess values that were left + // around without a parameter. + parameterNames := make(map[string]struct{}, len(output.Parameters)) + for _, parameter := range output.Parameters { + parameterNames[parameter.Name] = struct{}{} + + if !firstBuild && !parameter.Mutable { + // Immutable parameters should not be changed after the first build. + // They can match the original value though! + if parameter.Value.AsString() != originalValues[parameter.Name].Value { + var src *hcl.Range + if parameter.Source != nil { + src = ¶meter.Source.HCLBlock().TypeRange + } + + // An immutable parameter was changed, which is not allowed. + // Add the failed diagnostic to the output. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Immutable parameter changed", + Detail: fmt.Sprintf("Parameter %q is not mutable, so it can't be updated after creating a workspace.", parameter.Name), + Subject: src, + }) + } + } + + // TODO: Fix the `hcl.Diagnostics(...)` type casting. It should not be needed. + if hcl.Diagnostics(parameter.Diagnostics).HasErrors() { + // All validation errors are raised here. + diags = diags.Extend(hcl.Diagnostics(parameter.Diagnostics)) + } + + // If the parameter has a value, but it was not set explicitly by the user at any + // build, then save the default value. An example where this is important is if a + // template has a default value of 'region = us-west-2', but the user never sets + // it. If the default value changes to 'region = us-east-1', we want to preserve + // the original value of 'us-west-2' for the existing workspaces. + // + // parameter.Value will be populated from the default at this point. So grab it + // from there. + if _, ok := values[parameter.Name]; !ok && parameter.Value.IsKnown() && parameter.Value.Valid() { + values[parameter.Name] = parameterValue{ + Value: parameter.Value.AsString(), + Source: sourceDefault, + } + } + } + + // Delete any values that do not belong to a parameter. This is to not save + // parameter values that have no effect. These leaky parameter values can cause + // problems in the future, as it makes it challenging to remove values from the + // database + for k := range values { + if _, ok := parameterNames[k]; !ok { + delete(values, k) + } + } + + // Return the values to be saved for the build. + return values.ValuesMap(), diags +} + +type parameterValueMap map[string]parameterValue + +func (p parameterValueMap) ValuesMap() map[string]string { + values := make(map[string]string, len(p)) + for name, paramValue := range p { + values[name] = paramValue.Value + } + return values +} diff --git a/coderd/parameters_test.go b/coderd/parameters_test.go index 794ff8db3354d..a18000673b50e 100644 --- a/coderd/parameters_test.go +++ b/coderd/parameters_test.go @@ -100,10 +100,11 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { require.Equal(t, -1, preview.ID) require.Empty(t, preview.Diagnostics) - require.Len(t, preview.Parameters, 1) - require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name) - require.True(t, preview.Parameters[0].Value.Valid) - require.Equal(t, "CL", preview.Parameters[0].Value.Value) + require.Len(t, preview.Parameters, 2) + coderdtest.AssertParameter(t, "jetbrains_ide", preview.Parameters). + Exists().Value("CL") + coderdtest.AssertParameter(t, "region", preview.Parameters). + Exists().Value("na") }) // OldProvisioners use the static parameters in the dynamic param flow @@ -241,10 +242,11 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { require.Equal(t, -1, preview.ID) require.Empty(t, preview.Diagnostics) - require.Len(t, preview.Parameters, 1) - require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name) - require.True(t, preview.Parameters[0].Value.Valid) - require.Equal(t, "CL", preview.Parameters[0].Value.Value) + require.Len(t, preview.Parameters, 2) + coderdtest.AssertParameter(t, "jetbrains_ide", preview.Parameters). + Exists().Value("CL") + coderdtest.AssertParameter(t, "region", preview.Parameters). + Exists().Value("na") _ = stream.Close(websocket.StatusGoingAway) wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID, func(request *codersdk.CreateWorkspaceRequest) { @@ -253,6 +255,10 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { Name: preview.Parameters[0].Name, Value: "GO", }, + { + Name: preview.Parameters[1].Name, + Value: "eu", + }, } request.EnableDynamicParameters = true }) @@ -260,22 +266,24 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { params, err := setup.client.WorkspaceBuildParameters(ctx, wrk.LatestBuild.ID) require.NoError(t, err) - require.Len(t, params, 1) - require.Equal(t, "jetbrains_ide", params[0].Name) - require.Equal(t, "GO", params[0].Value) + require.ElementsMatch(t, []codersdk.WorkspaceBuildParameter{ + {Name: "jetbrains_ide", Value: "GO"}, {Name: "region", Value: "eu"}, + }, params) + + regionOptions := []string{"na", "af", "sa", "as"} // A helper function to assert params doTransition := func(t *testing.T, trans codersdk.WorkspaceTransition) { t.Helper() - fooVal := coderdtest.RandomUsername(t) + regionVal := regionOptions[0] + regionOptions = regionOptions[1:] // Choose the next region on the next build + bld, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: setup.template.ActiveVersionID, Transition: trans, RichParameterValues: []codersdk.WorkspaceBuildParameter{ - // No validation, so this should work as is. - // Overwrite the value on each transition - {Name: "foo", Value: fooVal}, + {Name: "region", Value: regionVal}, }, EnableDynamicParameters: ptr.Ref(true), }) @@ -286,7 +294,7 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { require.NoError(t, err) require.ElementsMatch(t, latestParams, []codersdk.WorkspaceBuildParameter{ {Name: "jetbrains_ide", Value: "GO"}, - {Name: "foo", Value: fooVal}, + {Name: "region", Value: regionVal}, }) } diff --git a/coderd/testdata/parameters/modules/main.tf b/coderd/testdata/parameters/modules/main.tf index 18f14ece154f2..21bb235574d3f 100644 --- a/coderd/testdata/parameters/modules/main.tf +++ b/coderd/testdata/parameters/modules/main.tf @@ -1,5 +1,47 @@ -terraform {} +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "2.5.3" + } + } +} module "jetbrains_gateway" { source = "jetbrains_gateway" } + +data "coder_parameter" "region" { + name = "region" + display_name = "Where would you like to travel to next?" + type = "string" + form_type = "dropdown" + mutable = true + default = "na" + order = 1000 + + option { + name = "North America" + value = "na" + } + + option { + name = "South America" + value = "sa" + } + + option { + name = "Europe" + value = "eu" + } + + option { + name = "Africa" + value = "af" + } + + option { + name = "Asia" + value = "as" + } +} diff --git a/coderd/util/slice/slice.go b/coderd/util/slice/slice.go index 2a510e24d2b53..bb2011c05d1b2 100644 --- a/coderd/util/slice/slice.go +++ b/coderd/util/slice/slice.go @@ -230,3 +230,13 @@ func Convert[F any, T any](a []F, f func(F) T) []T { } return tmp } + +func ToMapFunc[T any, K comparable, V any](a []T, cnv func(t T) (K, V)) map[K]V { + m := make(map[K]V, len(a)) + + for i := range a { + k, v := cnv(a[i]) + m[k] = v + } + return m +} diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 15614f84b4f70..526be7e4e4f89 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -392,6 +392,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build( ctx, tx, + api.FileCache, func(action policy.Action, object rbac.Objecter) bool { // Special handling for prebuilt workspace deletion if object.RBACObject().Type == rbac.ResourceWorkspace.Type && action == policy.ActionDelete { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index b1520776464c0..58375ae4c4d45 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -724,6 +724,7 @@ func createWorkspace( workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build( ctx, db, + api.FileCache, func(action policy.Action, object rbac.Objecter) bool { return api.Authorize(r, action, object) }, diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 577ec5eca4577..aaccd337ee793 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -13,8 +13,8 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/coder/coder/v2/apiversion" "github.com/coder/coder/v2/coderd/dynamicparameters" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/provisioner/terraform/tfparse" @@ -65,8 +65,9 @@ type Builder struct { templateVersionPresetID uuid.UUID // used during build, makes function arguments less verbose - ctx context.Context - store database.Store + ctx context.Context + store database.Store + fileCache *files.CacheCloser // cache of objects, so we only fetch once template *database.Template @@ -82,7 +83,8 @@ type Builder struct { lastBuildJob *database.ProvisionerJob parameterNames *[]string parameterValues *[]string - templateVersionPresetParameterValues []database.TemplateVersionPresetParameter + templateVersionPresetParameterValues *[]database.TemplateVersionPresetParameter + parameterRender dynamicparameters.Renderer prebuiltWorkspaceBuildStage sdkproto.PrebuiltWorkspaceBuildStage verifyNoLegacyParametersOnce bool @@ -258,6 +260,7 @@ func (e BuildError) Unwrap() error { func (b *Builder) Build( ctx context.Context, store database.Store, + fileCache *files.Cache, authFunc func(action policy.Action, object rbac.Objecter) bool, auditBaggage audit.WorkspaceBuildBaggage, ) ( @@ -269,6 +272,10 @@ func (b *Builder) Build( return nil, nil, nil, xerrors.Errorf("create audit baggage: %w", err) } + b.fileCache = files.NewCacheCloser(fileCache) + // Always close opened files during the build + defer b.fileCache.Close() + // Run the build in a transaction with RepeatableRead isolation, and retries. // RepeatableRead isolation ensures that we get a consistent view of the database while // computing the new build. This simplifies the logic so that we do not need to worry if @@ -592,10 +599,54 @@ func (b *Builder) getTemplateTerraformValues() (*database.TemplateVersionTerrafo } vals, err := b.store.GetTemplateVersionTerraformValues(b.ctx, v.ID) if err != nil { - return nil, xerrors.Errorf("get template version terraform values %s: %w", v.JobID, err) + if !xerrors.Is(err, sql.ErrNoRows) { + return nil, xerrors.Errorf("builder get template version terraform values %s: %w", v.JobID, err) + } + + // Old versions do not have terraform values, so we can ignore ErrNoRows and use an empty value. + vals = database.TemplateVersionTerraformValue{ + TemplateVersionID: v.ID, + UpdatedAt: time.Time{}, + CachedPlan: nil, + CachedModuleFiles: uuid.NullUUID{}, + ProvisionerdVersion: "", + } } b.terraformValues = &vals - return b.terraformValues, err + return b.terraformValues, nil +} + +func (b *Builder) getDynamicParameterRenderer() (dynamicparameters.Renderer, error) { + if b.parameterRender != nil { + return b.parameterRender, nil + } + + tv, err := b.getTemplateVersion() + if err != nil { + return nil, xerrors.Errorf("get template version to get parameters: %w", err) + } + + job, err := b.getTemplateVersionJob() + if err != nil { + return nil, xerrors.Errorf("get template version job to get parameters: %w", err) + } + + tfVals, err := b.getTemplateTerraformValues() + if err != nil { + return nil, xerrors.Errorf("get template version terraform values: %w", err) + } + + renderer, err := dynamicparameters.Prepare(b.ctx, b.store, b.fileCache, tv.ID, + dynamicparameters.WithTemplateVersion(*tv), + dynamicparameters.WithProvisionerJob(*job), + dynamicparameters.WithTerraformValues(*tfVals), + ) + if err != nil { + return nil, xerrors.Errorf("get template version renderer: %w", err) + } + + b.parameterRender = renderer + return renderer, nil } func (b *Builder) getLastBuild() (*database.WorkspaceBuild, error) { @@ -617,6 +668,19 @@ func (b *Builder) getLastBuild() (*database.WorkspaceBuild, error) { return b.lastBuild, nil } +// firstBuild returns true if this is the first build of the workspace, i.e. there are no prior builds. +func (b *Builder) firstBuild() (bool, error) { + _, err := b.getLastBuild() + if xerrors.Is(err, sql.ErrNoRows) { + // first build! + return true, nil + } + if err != nil { + return false, err + } + return false, nil +} + func (b *Builder) getBuildNumber() (int32, error) { bld, err := b.getLastBuild() if xerrors.Is(err, sql.ErrNoRows) { @@ -654,67 +718,91 @@ func (b *Builder) getParameters() (names, values []string, err error) { return *b.parameterNames, *b.parameterValues, nil } - templateVersionParameters, err := b.getTemplateVersionParameters() + // Always reject legacy parameters. + err = b.verifyNoLegacyParameters() if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch template version parameters", err} + return nil, nil, BuildError{http.StatusBadRequest, "Unable to build workspace with unsupported parameters", err} + } + + if b.usingDynamicParameters() { + names, values, err = b.getDynamicParameters() + } else { + names, values, err = b.getClassicParameters() } + + if err != nil { + return nil, nil, xerrors.Errorf("get parameters: %w", err) + } + + b.parameterNames = &names + b.parameterValues = &values + return names, values, nil +} + +func (b *Builder) getDynamicParameters() (names, values []string, err error) { lastBuildParameters, err := b.getLastBuildParameters() if err != nil { return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch last build parameters", err} } - if b.templateVersionPresetID != uuid.Nil { - // Fetch and cache these, since we'll need them to override requested values if a preset was chosen - presetParameters, err := b.store.GetPresetParametersByPresetID(b.ctx, b.templateVersionPresetID) - if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "failed to get preset parameters", err} - } - b.templateVersionPresetParameterValues = presetParameters - } - err = b.verifyNoLegacyParameters() + + presetParameterValues, err := b.getPresetParameterValues() if err != nil { - return nil, nil, BuildError{http.StatusBadRequest, "Unable to build workspace with unsupported parameters", err} + return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch preset parameter values", err} } - lastBuildParameterValues := db2sdk.WorkspaceBuildParameters(lastBuildParameters) - resolver := codersdk.ParameterResolver{ - Rich: lastBuildParameterValues, + render, err := b.getDynamicParameterRenderer() + if err != nil { + return nil, nil, BuildError{http.StatusInternalServerError, "failed to get dynamic parameter renderer", err} } - // Dynamic parameters skip all parameter validation. - // Deleting a workspace also should skip parameter validation. - // Pass the user's input as is. - if b.usingDynamicParameters() { - // TODO: The previous behavior was only to pass param values - // for parameters that exist. Since dynamic params can have - // conditional parameter existence, the static frame of reference - // is not sufficient. So assume the user is correct, or pull in the - // dynamic param code to find the actual parameters. - latestValues := make(map[string]string, len(b.richParameterValues)) - for _, latest := range b.richParameterValues { - latestValues[latest.Name] = latest.Value + firstBuild, err := b.firstBuild() + if err != nil { + return nil, nil, BuildError{http.StatusInternalServerError, "failed to check if first build", err} + } + + buildValues, diagnostics := dynamicparameters.ResolveParameters(b.ctx, b.workspace.OwnerID, render, firstBuild, + lastBuildParameters, + b.richParameterValues, + presetParameterValues) + + if diagnostics.HasErrors() { + // TODO: Improve the error response. The response should include the validations for each failed + // parameter. The response should also indicate it's a validation error or a more general form failure. + // For now, any error is sufficient. + return nil, nil, BuildError{ + Status: http.StatusBadRequest, + Message: fmt.Sprintf("%d errors occurred while resolving parameters", len(diagnostics)), + Wrapped: diagnostics, } + } - // Merge the inputs with values from the previous build. - for _, last := range lastBuildParameterValues { - // TODO: Ideally we use the resolver here and look at parameter - // fields such as 'ephemeral'. This requires loading the terraform - // files. For now, just send the previous inputs as is. - if _, exists := latestValues[last.Name]; exists { - // latestValues take priority, so skip this previous value. - continue - } - names = append(names, last.Name) - values = append(values, last.Value) - } + names = make([]string, 0, len(buildValues)) + values = make([]string, 0, len(buildValues)) + for k, v := range buildValues { + names = append(names, k) + values = append(values, v) + } - for _, value := range b.richParameterValues { - names = append(names, value.Name) - values = append(values, value.Value) - } + return names, values, nil +} + +func (b *Builder) getClassicParameters() (names, values []string, err error) { + templateVersionParameters, err := b.getTemplateVersionParameters() + if err != nil { + return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch template version parameters", err} + } + lastBuildParameters, err := b.getLastBuildParameters() + if err != nil { + return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch last build parameters", err} + } + presetParameterValues, err := b.getPresetParameterValues() + if err != nil { + return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch preset parameter values", err} + } - b.parameterNames = &names - b.parameterValues = &values - return names, values, nil + lastBuildParameterValues := db2sdk.WorkspaceBuildParameters(lastBuildParameters) + resolver := codersdk.ParameterResolver{ + Rich: lastBuildParameterValues, } for _, templateVersionParameter := range templateVersionParameters { @@ -725,7 +813,7 @@ func (b *Builder) getParameters() (names, values []string, err error) { value, err := resolver.ValidateResolve( tvp, - b.findNewBuildParameterValue(templateVersionParameter.Name), + b.findNewBuildParameterValue(templateVersionParameter.Name, presetParameterValues), ) if err != nil { // At this point, we've queried all the data we need from the database, @@ -743,8 +831,8 @@ func (b *Builder) getParameters() (names, values []string, err error) { return names, values, nil } -func (b *Builder) findNewBuildParameterValue(name string) *codersdk.WorkspaceBuildParameter { - for _, v := range b.templateVersionPresetParameterValues { +func (b *Builder) findNewBuildParameterValue(name string, presets []database.TemplateVersionPresetParameter) *codersdk.WorkspaceBuildParameter { + for _, v := range presets { if v.Name == name { return &codersdk.WorkspaceBuildParameter{ Name: v.Name, @@ -949,6 +1037,24 @@ func (b *Builder) getTemplateVersionWorkspaceTags() ([]database.TemplateVersionW return *b.templateVersionWorkspaceTags, nil } +func (b *Builder) getPresetParameterValues() ([]database.TemplateVersionPresetParameter, error) { + if b.templateVersionPresetParameterValues != nil { + return *b.templateVersionPresetParameterValues, nil + } + + if b.templateVersionPresetID == uuid.Nil { + return []database.TemplateVersionPresetParameter{}, nil + } + + // Fetch and cache these, since we'll need them to override requested values if a preset was chosen + presetParameters, err := b.store.GetPresetParametersByPresetID(b.ctx, b.templateVersionPresetID) + if err != nil { + return nil, xerrors.Errorf("failed to get preset parameters: %w", err) + } + b.templateVersionPresetParameterValues = ptr.Ref(presetParameters) + return *b.templateVersionPresetParameterValues, nil +} + // authorize performs build authorization pre-checks using the provided authFunc func (b *Builder) authorize(authFunc func(action policy.Action, object rbac.Objecter) bool) error { // Doing this up front saves a lot of work if the user doesn't have permission. @@ -1117,21 +1223,5 @@ func (b *Builder) usingDynamicParameters() bool { return false } - vals, err := b.getTemplateTerraformValues() - if err != nil { - return false - } - - if !ProvisionerVersionSupportsDynamicParameters(vals.ProvisionerdVersion) { - return false - } - return true } - -func ProvisionerVersionSupportsDynamicParameters(version string) bool { - major, minor, err := apiversion.Parse(version) - // If the api version is not valid or less than 1.6, we need to use the static parameters - useStaticParams := err != nil || major < 1 || (major == 1 && minor < 6) - return !useStaticParams -} diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index f6b4756455f14..f07e2f99f774a 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -8,6 +8,10 @@ import ( "testing" "time" + "github.com/prometheus/client_golang/prometheus" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/coder/v2/provisionersdk" "github.com/google/uuid" @@ -94,11 +98,12 @@ func TestBuilder_NoOptions(t *testing.T) { asrt.Empty(params.Value) }), ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -133,11 +138,12 @@ func TestBuilder_Initiator(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Initiator(otherUserID) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -178,11 +184,12 @@ func TestBuilder_Baggage(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Initiator(otherUserID) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) req.NoError(err) } @@ -216,11 +223,12 @@ func TestBuilder_Reason(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Reason(database.BuildReasonAutostart) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -259,11 +267,12 @@ func TestBuilder_ActiveVersion(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).ActiveVersion() // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -373,11 +382,12 @@ func TestWorkspaceBuildWithTags(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(buildParameters) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -455,11 +465,12 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) t.Run("UsePreviousParameterValues", func(t *testing.T) { @@ -502,11 +513,12 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -533,17 +545,17 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { mDB := expectDB(t, // Inputs withTemplate, - withInactiveVersion(richParameters), + withInactiveVersionNoParams(), withLastBuildFound, withTemplateVersionVariables(inactiveVersionID, nil), - withRichParameters(nil), withParameterSchemas(inactiveJobID, schemas), withWorkspaceTags(inactiveVersionID, nil), ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart) - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) bldErr := wsbuilder.BuildError{} req.ErrorAs(err, &bldErr) asrt.Equal(http.StatusBadRequest, bldErr.Status) @@ -575,11 +587,12 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { // Outputs // no transaction, since we failed fast while validation build parameters ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) bldErr := wsbuilder.BuildError{} req.ErrorAs(err, &bldErr) asrt.Equal(http.StatusBadRequest, bldErr.Status) @@ -639,12 +652,13 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -702,12 +716,13 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -763,13 +778,14 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { }), withBuild, ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) } @@ -829,13 +845,14 @@ func TestWorkspaceBuildWithPreset(t *testing.T) { asrt.Empty(params.Value) }), ) + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). ActiveVersion(). TemplateVersionPresetID(presetID) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -899,8 +916,10 @@ func TestWorkspaceBuildDeleteOrphan(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionDelete).Orphan() + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -974,38 +993,13 @@ func TestWorkspaceBuildDeleteOrphan(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionDelete).Orphan() + fc := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) // nolint: dogsled - _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, fc, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) } -func TestProvisionerVersionSupportsDynamicParameters(t *testing.T) { - t.Parallel() - - for v, dyn := range map[string]bool{ - "": false, - "na": false, - "0.0": false, - "0.10": false, - "1.4": false, - "1.5": false, - "1.6": true, - "1.7": true, - "1.8": true, - "2.0": true, - "2.17": true, - "4.0": true, - } { - t.Run(v, func(t *testing.T) { - t.Parallel() - - does := wsbuilder.ProvisionerVersionSupportsDynamicParameters(v) - require.Equal(t, dyn, does) - }) - } -} - type txExpect func(mTx *dbmock.MockStore) func expectDB(t *testing.T, opts ...txExpect) *dbmock.MockStore { @@ -1052,7 +1046,7 @@ func withInTx(mTx *dbmock.MockStore) { ) } -func withActiveVersion(params []database.TemplateVersionParameter) func(mTx *dbmock.MockStore) { +func withActiveVersionNoParams() func(mTx *dbmock.MockStore) { return func(mTx *dbmock.MockStore) { mTx.EXPECT().GetTemplateVersionByID(gomock.Any(), activeVersionID). Times(1). @@ -1082,6 +1076,12 @@ func withActiveVersion(params []database.TemplateVersionParameter) func(mTx *dbm UpdatedAt: time.Now(), CompletedAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, }, nil) + } +} + +func withActiveVersion(params []database.TemplateVersionParameter) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + withActiveVersionNoParams()(mTx) paramsCall := mTx.EXPECT().GetTemplateVersionParameters(gomock.Any(), activeVersionID). Times(1) if len(params) > 0 { @@ -1092,7 +1092,7 @@ func withActiveVersion(params []database.TemplateVersionParameter) func(mTx *dbm } } -func withInactiveVersion(params []database.TemplateVersionParameter) func(mTx *dbmock.MockStore) { +func withInactiveVersionNoParams() func(mTx *dbmock.MockStore) { return func(mTx *dbmock.MockStore) { mTx.EXPECT().GetTemplateVersionByID(gomock.Any(), inactiveVersionID). Times(1). @@ -1122,6 +1122,13 @@ func withInactiveVersion(params []database.TemplateVersionParameter) func(mTx *d UpdatedAt: time.Now(), CompletedAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, }, nil) + } +} + +func withInactiveVersion(params []database.TemplateVersionParameter) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + withInactiveVersionNoParams()(mTx) + paramsCall := mTx.EXPECT().GetTemplateVersionParameters(gomock.Any(), inactiveVersionID). Times(1) if len(params) > 0 { diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index f46848812a69e..9b168c8e3f366 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -1164,7 +1164,7 @@ func (api *API) setupPrebuilds(featureEnabled bool) (agplprebuilds.Reconciliatio return agplprebuilds.DefaultReconciler, agplprebuilds.DefaultClaimer } - reconciler := prebuilds.NewStoreReconciler(api.Database, api.Pubsub, api.DeploymentValues.Prebuilds, + reconciler := prebuilds.NewStoreReconciler(api.Database, api.Pubsub, api.AGPL.FileCache, api.DeploymentValues.Prebuilds, api.Logger.Named("prebuilds"), quartz.NewReal(), api.PrometheusRegistry, api.NotificationsEnqueuer) return reconciler, prebuilds.NewEnterpriseClaimer(api.Database) } diff --git a/enterprise/coderd/dynamicparameters_test.go b/enterprise/coderd/dynamicparameters_test.go index 60d68fecd87d1..8bbe5da470f42 100644 --- a/enterprise/coderd/dynamicparameters_test.go +++ b/enterprise/coderd/dynamicparameters_test.go @@ -1,15 +1,19 @@ package coderd_test import ( + "context" _ "embed" "os" "testing" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" @@ -17,6 +21,289 @@ import ( "github.com/coder/websocket" ) +func TestDynamicParameterBuild(t *testing.T) { + t.Parallel() + + owner, _, _, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{IncludeProvisionerDaemon: true}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + + orgID := first.OrganizationID + + templateAdmin, templateAdminData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgTemplateAdmin(orgID)) + + coderdtest.CreateGroup(t, owner, orgID, "developer") + coderdtest.CreateGroup(t, owner, orgID, "admin", templateAdminData) + coderdtest.CreateGroup(t, owner, orgID, "auditor") + + // Create a set of templates to test with + numberValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(must(os.ReadFile("testdata/parameters/numbers/main.tf"))), + }) + + regexValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(must(os.ReadFile("testdata/parameters/regex/main.tf"))), + }) + + ephemeralValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(must(os.ReadFile("testdata/parameters/ephemeral/main.tf"))), + }) + + // complexValidation does conditional parameters, conditional options, and more. + complexValidation, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{ + MainTF: string(must(os.ReadFile("testdata/parameters/dynamic/main.tf"))), + }) + + t.Run("NumberValidation", func(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: numberValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "number", Value: `7`}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID) + }) + + t.Run("TooLow", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: numberValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "number", Value: `-10`}, + }, + }) + require.ErrorContains(t, err, "Number must be between 0 and 10") + }) + + t.Run("TooHigh", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: numberValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "number", Value: `15`}, + }, + }) + require.ErrorContains(t, err, "Number must be between 0 and 10") + }) + }) + + t.Run("RegexValidation", func(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: regexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "string", Value: `Hello World!`}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID) + }) + + t.Run("NoValue", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: regexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{}, + }) + require.ErrorContains(t, err, "All messages must start with 'Hello'") + }) + + t.Run("Invalid", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: regexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "string", Value: `Goodbye!`}, + }, + }) + require.ErrorContains(t, err, "All messages must start with 'Hello'") + }) + }) + + t.Run("EphemeralValidation", func(t *testing.T) { + t.Parallel() + + t.Run("OK_EphemeralNoPrevious", func(t *testing.T) { + t.Parallel() + + // Ephemeral params do not take the previous values into account. + ctx := testutil.Context(t, testutil.WaitShort) + wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: ephemeralValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "required", Value: `Hello World!`}, + {Name: "defaulted", Value: `Changed`}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID) + assertWorkspaceBuildParameters(ctx, t, templateAdmin, wrk.LatestBuild.ID, map[string]string{ + "required": "Hello World!", + "defaulted": "Changed", + }) + + bld, err := templateAdmin.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "required", Value: `Hello World, Again!`}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, bld.ID) + assertWorkspaceBuildParameters(ctx, t, templateAdmin, bld.ID, map[string]string{ + "required": "Hello World, Again!", + "defaulted": "original", // Reverts back to the original default value. + }) + }) + + t.Run("Immutable", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: numberValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "number", Value: `7`}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID) + assertWorkspaceBuildParameters(ctx, t, templateAdmin, wrk.LatestBuild.ID, map[string]string{ + "number": "7", + }) + + _, err = templateAdmin.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "number", Value: `8`}, + }, + }) + require.ErrorContains(t, err, `Parameter "number" is not mutable`) + }) + + t.Run("RequiredMissing", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: ephemeralValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{}, + }) + require.ErrorContains(t, err, "Required parameter not provided") + }) + }) + + t.Run("ComplexValidation", func(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: complexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "groups", Value: `["admin"]`}, + {Name: "colors", Value: `["red"]`}, + {Name: "thing", Value: "apple"}, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID) + }) + + t.Run("BadGroup", func(t *testing.T) { + // Template admin is not in the "auditor" group, so this should fail. + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: complexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "groups", Value: `["auditor", "admin"]`}, + {Name: "colors", Value: `["red"]`}, + {Name: "thing", Value: "apple"}, + }, + }) + require.ErrorContains(t, err, "is not a valid option") + }) + + t.Run("BadColor", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: complexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "groups", Value: `["admin"]`}, + {Name: "colors", Value: `["purple"]`}, + }, + }) + require.ErrorContains(t, err, "is not a valid option") + require.ErrorContains(t, err, "purple") + }) + + t.Run("BadThing", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: complexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "groups", Value: `["admin"]`}, + {Name: "colors", Value: `["red"]`}, + {Name: "thing", Value: "leaf"}, + }, + }) + require.ErrorContains(t, err, "must be defined as one of options") + require.ErrorContains(t, err, "leaf") + }) + + t.Run("BadNumber", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + _, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: complexValidation.ID, + Name: coderdtest.RandomUsername(t), + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + {Name: "groups", Value: `["admin"]`}, + {Name: "colors", Value: `["green"]`}, + {Name: "thing", Value: "leaf"}, + {Name: "number", Value: "100"}, + }, + }) + require.ErrorContains(t, err, "Number must be between 0 and 10") + }) + }) +} + // TestDynamicParameterTemplate uses a template with some dynamic elements, and // tests the parameters, values, etc are all as expected. func TestDynamicParameterTemplate(t *testing.T) { @@ -127,3 +414,27 @@ func TestDynamicParameterTemplate(t *testing.T) { coderdtest.AssertParameter(t, "thing", resp.Parameters). Exists().Value("banana").Options("banana", "ocean", "sky") } + +func assertWorkspaceBuildParameters(ctx context.Context, t *testing.T, client *codersdk.Client, buildID uuid.UUID, values map[string]string) { + t.Helper() + + params, err := client.WorkspaceBuildParameters(ctx, buildID) + require.NoError(t, err) + + for name, value := range values { + param, ok := slice.Find(params, func(parameter codersdk.WorkspaceBuildParameter) bool { + return parameter.Name == name + }) + if !ok { + assert.Failf(t, "parameter not found", "expected parameter %q to exist with value %q", name, value) + continue + } + assert.Equalf(t, value, param.Value, "parameter %q should have value %q", name, value) + } + + for _, param := range params { + if _, ok := values[param.Name]; !ok { + assert.Failf(t, "unexpected parameter", "parameter %q should not exist", param.Name) + } + } +} diff --git a/enterprise/coderd/prebuilds/claim_test.go b/enterprise/coderd/prebuilds/claim_test.go index ec1d6dbbd5a98..67c1f0dd21ade 100644 --- a/enterprise/coderd/prebuilds/claim_test.go +++ b/enterprise/coderd/prebuilds/claim_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/quartz" "github.com/coder/coder/v2/coderd/coderdtest" @@ -164,7 +165,8 @@ func TestClaimPrebuild(t *testing.T) { }) defer provisionerCloser.Close() - reconciler := prebuilds.NewStoreReconciler(spy, pubsub, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(spy, pubsub, cache, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(spy) api.AGPL.PrebuildsClaimer.Store(&claimer) diff --git a/enterprise/coderd/prebuilds/metricscollector_test.go b/enterprise/coderd/prebuilds/metricscollector_test.go index 3b8461979c87a..057f310fa2bc2 100644 --- a/enterprise/coderd/prebuilds/metricscollector_test.go +++ b/enterprise/coderd/prebuilds/metricscollector_test.go @@ -13,6 +13,8 @@ import ( prometheus_client "github.com/prometheus/client_model/go" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/quartz" "github.com/coder/coder/v2/coderd/database" @@ -198,7 +200,8 @@ func TestMetricsCollector(t *testing.T) { }) clock := quartz.NewMock(t) db, pubsub := dbtestutil.NewDB(t) - reconciler := prebuilds.NewStoreReconciler(db, pubsub, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(db, pubsub, cache, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) ctx := testutil.Context(t, testutil.WaitLong) createdUsers := []uuid.UUID{database.PrebuildsSystemUserID} @@ -334,7 +337,8 @@ func TestMetricsCollector_DuplicateTemplateNames(t *testing.T) { logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) clock := quartz.NewMock(t) db, pubsub := dbtestutil.NewDB(t) - reconciler := prebuilds.NewStoreReconciler(db, pubsub, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(db, pubsub, cache, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) ctx := testutil.Context(t, testutil.WaitLong) collector := prebuilds.NewMetricsCollector(db, logger, reconciler) diff --git a/enterprise/coderd/prebuilds/reconcile.go b/enterprise/coderd/prebuilds/reconcile.go index 911336d36c426..8240ac78a58bc 100644 --- a/enterprise/coderd/prebuilds/reconcile.go +++ b/enterprise/coderd/prebuilds/reconcile.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/go-multierror" "github.com/prometheus/client_golang/prometheus" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/quartz" "github.com/coder/coder/v2/coderd/audit" @@ -40,6 +41,7 @@ type StoreReconciler struct { store database.Store cfg codersdk.PrebuildsConfig pubsub pubsub.Pubsub + fileCache *files.Cache logger slog.Logger clock quartz.Clock registerer prometheus.Registerer @@ -57,6 +59,7 @@ var _ prebuilds.ReconciliationOrchestrator = &StoreReconciler{} func NewStoreReconciler(store database.Store, ps pubsub.Pubsub, + fileCache *files.Cache, cfg codersdk.PrebuildsConfig, logger slog.Logger, clock quartz.Clock, @@ -66,6 +69,7 @@ func NewStoreReconciler(store database.Store, reconciler := &StoreReconciler{ store: store, pubsub: ps, + fileCache: fileCache, logger: logger, cfg: cfg, clock: clock, @@ -780,6 +784,7 @@ func (c *StoreReconciler) provision( _, provisionerJob, _, err := builder.Build( ctx, db, + c.fileCache, func(_ policy.Action, _ rbac.Objecter) bool { return true // TODO: harden? }, diff --git a/enterprise/coderd/prebuilds/reconcile_test.go b/enterprise/coderd/prebuilds/reconcile_test.go index de8c65546334e..8524786dec404 100644 --- a/enterprise/coderd/prebuilds/reconcile_test.go +++ b/enterprise/coderd/prebuilds/reconcile_test.go @@ -13,7 +13,9 @@ import ( "github.com/stretchr/testify/assert" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/util/slice" @@ -53,7 +55,8 @@ func TestNoReconciliationActionsIfNoPresets(t *testing.T) { ReconciliationInterval: serpent.Duration(testutil.WaitLong), } logger := testutil.Logger(t) - controller := prebuilds.NewStoreReconciler(db, ps, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, ps, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) // given a template version with no presets org := dbgen.Organization(t, db, database.Organization{}) @@ -98,7 +101,8 @@ func TestNoReconciliationActionsIfNoPrebuilds(t *testing.T) { ReconciliationInterval: serpent.Duration(testutil.WaitLong), } logger := testutil.Logger(t) - controller := prebuilds.NewStoreReconciler(db, ps, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, ps, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) // given there are presets, but no prebuilds org := dbgen.Organization(t, db, database.Organization{}) @@ -375,7 +379,8 @@ func TestPrebuildReconciliation(t *testing.T) { if useBrokenPubsub { pubSub = &brokenPublisher{Pubsub: pubSub} } - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) // Run the reconciliation multiple times to ensure idempotency // 8 was arbitrary, but large enough to reasonably trust the result @@ -452,7 +457,8 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, pubSub := dbtestutil.NewDB(t) - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) ownerID := uuid.New() dbgen.User(t, db, database.User{ @@ -577,7 +583,8 @@ func TestPrebuildScheduling(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, pubSub := dbtestutil.NewDB(t) - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) ownerID := uuid.New() dbgen.User(t, db, database.User{ @@ -681,7 +688,8 @@ func TestInvalidPreset(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, pubSub := dbtestutil.NewDB(t) - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) ownerID := uuid.New() dbgen.User(t, db, database.User{ @@ -745,7 +753,8 @@ func TestDeletionOfPrebuiltWorkspaceWithInvalidPreset(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, pubSub := dbtestutil.NewDB(t) - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer()) ownerID := uuid.New() dbgen.User(t, db, database.User{ @@ -841,7 +850,8 @@ func TestSkippingHardLimitedPresets(t *testing.T) { db, pubSub := dbtestutil.NewDB(t) fakeEnqueuer := newFakeEnqueuer() registry := prometheus.NewRegistry() - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, registry, fakeEnqueuer) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, clock, registry, fakeEnqueuer) // Template admin to receive a notification. templateAdmin := dbgen.User(t, db, database.User{ @@ -1003,7 +1013,8 @@ func TestHardLimitedPresetShouldNotBlockDeletion(t *testing.T) { db, pubSub := dbtestutil.NewDB(t) fakeEnqueuer := newFakeEnqueuer() registry := prometheus.NewRegistry() - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, registry, fakeEnqueuer) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, clock, registry, fakeEnqueuer) // Template admin to receive a notification. templateAdmin := dbgen.User(t, db, database.User{ @@ -1215,7 +1226,8 @@ func TestRunLoop(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, pubSub := dbtestutil.NewDB(t) - reconciler := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) ownerID := uuid.New() dbgen.User(t, db, database.User{ @@ -1345,7 +1357,8 @@ func TestFailedBuildBackoff(t *testing.T) { t, &slogtest.Options{IgnoreErrors: true}, ).Leveled(slog.LevelDebug) db, ps := dbtestutil.NewDB(t) - reconciler := prebuilds.NewStoreReconciler(db, ps, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(db, ps, cache, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer()) // Given: an active template version with presets and prebuilds configured. const desiredInstances = 2 @@ -1461,9 +1474,11 @@ func TestReconciliationLock(t *testing.T) { wg.Add(1) go func() { defer wg.Done() + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) reconciler := prebuilds.NewStoreReconciler( db, ps, + cache, codersdk.PrebuildsConfig{}, slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug), quartz.NewMock(t), @@ -1501,7 +1516,8 @@ func TestTrackResourceReplacement(t *testing.T) { fakeEnqueuer := newFakeEnqueuer() registry := prometheus.NewRegistry() - reconciler := prebuilds.NewStoreReconciler(db, ps, codersdk.PrebuildsConfig{}, logger, clock, registry, fakeEnqueuer) + cache := files.New(registry, &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler(db, ps, cache, codersdk.PrebuildsConfig{}, logger, clock, registry, fakeEnqueuer) // Given: a template admin to receive a notification. templateAdmin := dbgen.User(t, db, database.User{ @@ -1656,7 +1672,8 @@ func TestExpiredPrebuildsMultipleActions(t *testing.T) { db, pubSub := dbtestutil.NewDB(t) fakeEnqueuer := newFakeEnqueuer() registry := prometheus.NewRegistry() - controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock, registry, fakeEnqueuer) + cache := files.New(registry, &coderdtest.FakeAuthorizer{}) + controller := prebuilds.NewStoreReconciler(db, pubSub, cache, cfg, logger, clock, registry, fakeEnqueuer) // Set up test environment with a template, version, and preset ownerID := uuid.New() diff --git a/enterprise/coderd/testdata/parameters/dynamic/main.tf b/enterprise/coderd/testdata/parameters/dynamic/main.tf index 615f57dc9c074..a6926f46b66a2 100644 --- a/enterprise/coderd/testdata/parameters/dynamic/main.tf +++ b/enterprise/coderd/testdata/parameters/dynamic/main.tf @@ -1,8 +1,7 @@ terraform { required_providers { coder = { - source = "coder/coder" - version = "2.5.3" + source = "coder/coder" } } } @@ -52,6 +51,7 @@ locals { "red" : ["apple", "ruby"] "yellow" : ["banana"] "blue" : ["ocean", "sky"] + "green" : ["grass", "leaf"] } } @@ -101,3 +101,15 @@ data "coder_parameter" "cool" { order = 102 default = "true" } + +data "coder_parameter" "number" { + count = contains(local.selected, "green") ? 1 : 0 + name = "number" + type = "number" + order = 103 + validation { + error = "Number must be between 0 and 10" + min = 0 + max = 10 + } +} diff --git a/enterprise/coderd/testdata/parameters/ephemeral/main.tf b/enterprise/coderd/testdata/parameters/ephemeral/main.tf new file mode 100644 index 0000000000000..f632fcf11aea4 --- /dev/null +++ b/enterprise/coderd/testdata/parameters/ephemeral/main.tf @@ -0,0 +1,25 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "required" { + name = "required" + type = "string" + mutable = true + ephemeral = true +} + + +data "coder_parameter" "defaulted" { + name = "defaulted" + type = "string" + mutable = true + ephemeral = true + default = "original" +} diff --git a/enterprise/coderd/testdata/parameters/numbers/main.tf b/enterprise/coderd/testdata/parameters/numbers/main.tf new file mode 100644 index 0000000000000..c4950db326419 --- /dev/null +++ b/enterprise/coderd/testdata/parameters/numbers/main.tf @@ -0,0 +1,20 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "number" { + name = "number" + type = "number" + mutable = false + validation { + error = "Number must be between 0 and 10" + min = 0 + max = 10 + } +} diff --git a/enterprise/coderd/testdata/parameters/regex/main.tf b/enterprise/coderd/testdata/parameters/regex/main.tf new file mode 100644 index 0000000000000..9fbaa5e245056 --- /dev/null +++ b/enterprise/coderd/testdata/parameters/regex/main.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "string" { + name = "string" + type = "string" + validation { + error = "All messages must start with 'Hello'" + regex = "^Hello" + } +} From 4699393522f25468ed002bc7f3a259a74960c4ce Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 23 Jun 2025 20:50:44 +0200 Subject: [PATCH 7/7] fix: upsert `coder_app` resources in case they are persistent (#18509) --- coderd/agentapi/subagent.go | 7 +- coderd/database/dbauthz/dbauthz.go | 34 ++-- coderd/database/dbauthz/dbauthz_test.go | 4 +- coderd/database/dbgen/dbgen.go | 2 +- coderd/database/dbmem/dbmem.go | 94 ++++++----- coderd/database/dbmetrics/querymetrics.go | 14 +- coderd/database/dbmock/dbmock.go | 30 ++-- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 155 ++++++++++-------- coderd/database/queries/workspaceapps.sql | 23 ++- .../provisionerdserver/provisionerdserver.go | 6 +- coderd/workspaces_test.go | 87 +++++++++- 12 files changed, 297 insertions(+), 161 deletions(-) diff --git a/coderd/agentapi/subagent.go b/coderd/agentapi/subagent.go index c00bfecc5ff17..1868ad39bd362 100644 --- a/coderd/agentapi/subagent.go +++ b/coderd/agentapi/subagent.go @@ -12,12 +12,13 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/quartz" + agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner" - "github.com/coder/quartz" ) type SubAgentAPI struct { @@ -164,8 +165,8 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create } } - _, err := a.Database.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{ - ID: uuid.New(), + _, err := a.Database.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{ + ID: uuid.New(), // NOTE: we may need to maintain the app's ID here for stability, but for now we'll leave this as-is. CreatedAt: createdAt, AgentID: subAgent.ID, Slug: app.Slug, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index adb2007918f8d..3c6f691616d52 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3938,23 +3938,6 @@ func (q *querier) InsertWorkspaceAgentStats(ctx context.Context, arg database.In return q.db.InsertWorkspaceAgentStats(ctx, arg) } -func (q *querier) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { - // NOTE(DanielleMaywood): - // It is possible for there to exist an agent without a workspace. - // 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.GetWorkspaceByAgentID(ctx, arg.AgentID) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return database.WorkspaceApp{}, err - } - - if err := q.authorizeContext(ctx, policy.ActionUpdate, workspace); err != nil { - return database.WorkspaceApp{}, err - } - - return q.db.InsertWorkspaceApp(ctx, arg) -} - func (q *querier) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { return err @@ -5181,6 +5164,23 @@ func (q *querier) UpsertWorkspaceAgentPortShare(ctx context.Context, arg databas return q.db.UpsertWorkspaceAgentPortShare(ctx, arg) } +func (q *querier) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) { + // NOTE(DanielleMaywood): + // It is possible for there to exist an agent without a workspace. + // 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.GetWorkspaceByAgentID(ctx, arg.AgentID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return database.WorkspaceApp{}, err + } + + if err := q.authorizeContext(ctx, policy.ActionUpdate, workspace); err != nil { + return database.WorkspaceApp{}, err + } + + return q.db.UpsertWorkspaceApp(ctx, arg) +} + func (q *querier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return false, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 0ccd867040116..f5077b7bdcda9 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4114,7 +4114,7 @@ func (s *MethodTestSuite) TestSystemFunctions() { APIKeyScope: database.AgentKeyScopeEnumAll, }).Asserts(ws, policy.ActionCreateAgent) })) - s.Run("InsertWorkspaceApp", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertWorkspaceApp", s.Subtest(func(db database.Store, check *expects) { _ = dbgen.User(s.T(), db, database.User{}) u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) @@ -4130,7 +4130,7 @@ func (s *MethodTestSuite) TestSystemFunctions() { _ = 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}) agent := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - check.Args(database.InsertWorkspaceAppParams{ + check.Args(database.UpsertWorkspaceAppParams{ ID: uuid.New(), AgentID: agent.ID, Health: database.WorkspaceAppHealthDisabled, diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 5b03fd0eb1396..5b94b2e789c5f 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -778,7 +778,7 @@ func ProvisionerKey(t testing.TB, db database.Store, orig database.ProvisionerKe } func WorkspaceApp(t testing.TB, db database.Store, orig database.WorkspaceApp) database.WorkspaceApp { - resource, err := db.InsertWorkspaceApp(genCtx, database.InsertWorkspaceAppParams{ + resource, err := db.UpsertWorkspaceApp(genCtx, database.UpsertWorkspaceAppParams{ ID: takeFirst(orig.ID, uuid.New()), CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), AgentID: takeFirst(orig.AgentID, uuid.New()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index f4403d2f50c75..117148a908979 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -10018,48 +10018,6 @@ func (q *FakeQuerier) InsertWorkspaceAgentStats(_ context.Context, arg database. return nil } -func (q *FakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { - if err := validateDatabaseType(arg); err != nil { - return database.WorkspaceApp{}, err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - if arg.SharingLevel == "" { - arg.SharingLevel = database.AppSharingLevelOwner - } - - if arg.OpenIn == "" { - arg.OpenIn = database.WorkspaceAppOpenInSlimWindow - } - - // nolint:gosimple - workspaceApp := database.WorkspaceApp{ - ID: arg.ID, - AgentID: arg.AgentID, - CreatedAt: arg.CreatedAt, - Slug: arg.Slug, - DisplayName: arg.DisplayName, - Icon: arg.Icon, - Command: arg.Command, - Url: arg.Url, - External: arg.External, - Subdomain: arg.Subdomain, - SharingLevel: arg.SharingLevel, - HealthcheckUrl: arg.HealthcheckUrl, - HealthcheckInterval: arg.HealthcheckInterval, - HealthcheckThreshold: arg.HealthcheckThreshold, - Health: arg.Health, - Hidden: arg.Hidden, - DisplayOrder: arg.DisplayOrder, - OpenIn: arg.OpenIn, - DisplayGroup: arg.DisplayGroup, - } - q.workspaceApps = append(q.workspaceApps, workspaceApp) - return workspaceApp, nil -} - func (q *FakeQuerier) InsertWorkspaceAppStats(_ context.Context, arg database.InsertWorkspaceAppStatsParams) error { err := validateDatabaseType(arg) if err != nil { @@ -13192,6 +13150,58 @@ func (q *FakeQuerier) UpsertWorkspaceAgentPortShare(_ context.Context, arg datab return psl, nil } +func (q *FakeQuerier) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.WorkspaceApp{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + if arg.SharingLevel == "" { + arg.SharingLevel = database.AppSharingLevelOwner + } + if arg.OpenIn == "" { + arg.OpenIn = database.WorkspaceAppOpenInSlimWindow + } + + buildApp := func(id uuid.UUID, createdAt time.Time) database.WorkspaceApp { + return database.WorkspaceApp{ + ID: id, + CreatedAt: createdAt, + AgentID: arg.AgentID, + Slug: arg.Slug, + DisplayName: arg.DisplayName, + Icon: arg.Icon, + Command: arg.Command, + Url: arg.Url, + External: arg.External, + Subdomain: arg.Subdomain, + SharingLevel: arg.SharingLevel, + HealthcheckUrl: arg.HealthcheckUrl, + HealthcheckInterval: arg.HealthcheckInterval, + HealthcheckThreshold: arg.HealthcheckThreshold, + Health: arg.Health, + Hidden: arg.Hidden, + DisplayOrder: arg.DisplayOrder, + OpenIn: arg.OpenIn, + DisplayGroup: arg.DisplayGroup, + } + } + + for i, app := range q.workspaceApps { + if app.ID == arg.ID { + q.workspaceApps[i] = buildApp(app.ID, app.CreatedAt) + return q.workspaceApps[i], nil + } + } + + workspaceApp := buildApp(arg.ID, arg.CreatedAt) + q.workspaceApps = append(q.workspaceApps, workspaceApp) + return workspaceApp, nil +} + func (q *FakeQuerier) UpsertWorkspaceAppAuditSession(_ context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) { err := validateDatabaseType(arg) if err != nil { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 0450776785d42..3a5bfac1ae57b 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2440,13 +2440,6 @@ func (m queryMetricsStore) InsertWorkspaceAgentStats(ctx context.Context, arg da return r0 } -func (m queryMetricsStore) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { - start := time.Now() - app, err := m.s.InsertWorkspaceApp(ctx, arg) - m.queryLatencies.WithLabelValues("InsertWorkspaceApp").Observe(time.Since(start).Seconds()) - return app, err -} - func (m queryMetricsStore) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { start := time.Now() r0 := m.s.InsertWorkspaceAppStats(ctx, arg) @@ -3287,6 +3280,13 @@ func (m queryMetricsStore) UpsertWorkspaceAgentPortShare(ctx context.Context, ar return r0, r1 } +func (m queryMetricsStore) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) { + start := time.Now() + r0, r1 := m.s.UpsertWorkspaceApp(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertWorkspaceApp").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) { start := time.Now() r0, r1 := m.s.UpsertWorkspaceAppAuditSession(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index dbd8f5ca0753c..067ad062e1dd8 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -5150,21 +5150,6 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceAgentStats(ctx, arg any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAgentStats", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAgentStats), ctx, arg) } -// InsertWorkspaceApp mocks base method. -func (m *MockStore) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InsertWorkspaceApp", ctx, arg) - ret0, _ := ret[0].(database.WorkspaceApp) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// InsertWorkspaceApp indicates an expected call of InsertWorkspaceApp. -func (mr *MockStoreMockRecorder) InsertWorkspaceApp(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceApp", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceApp), ctx, arg) -} - // InsertWorkspaceAppStats mocks base method. func (m *MockStore) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { m.ctrl.T.Helper() @@ -6924,6 +6909,21 @@ func (mr *MockStoreMockRecorder) UpsertWorkspaceAgentPortShare(ctx, arg any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceAgentPortShare", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceAgentPortShare), ctx, arg) } +// UpsertWorkspaceApp mocks base method. +func (m *MockStore) UpsertWorkspaceApp(ctx context.Context, arg database.UpsertWorkspaceAppParams) (database.WorkspaceApp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertWorkspaceApp", ctx, arg) + ret0, _ := ret[0].(database.WorkspaceApp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertWorkspaceApp indicates an expected call of UpsertWorkspaceApp. +func (mr *MockStoreMockRecorder) UpsertWorkspaceApp(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceApp", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceApp), ctx, arg) +} + // UpsertWorkspaceAppAuditSession mocks base method. func (m *MockStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (bool, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4cfd0d1c4da5f..06f51785aa1af 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -530,7 +530,6 @@ type sqlcQuerier interface { InsertWorkspaceAgentScriptTimings(ctx context.Context, arg InsertWorkspaceAgentScriptTimingsParams) (WorkspaceAgentScriptTiming, error) InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error) InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error - InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error @@ -671,6 +670,7 @@ type sqlcQuerier interface { UpsertTemplateUsageStats(ctx context.Context) error UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) + UpsertWorkspaceApp(ctx context.Context, arg UpsertWorkspaceAppParams) (WorkspaceApp, error) // // The returned boolean, new_or_stale, can be used to deduce if a new session // was started. This means that a new row was inserted (no previous session) or diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fe32851f0e002..3291e85b7576d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -16673,7 +16673,68 @@ func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt return items, nil } -const insertWorkspaceApp = `-- name: InsertWorkspaceApp :one +const insertWorkspaceAppStatus = `-- name: InsertWorkspaceAppStatus :one +INSERT INTO workspace_app_statuses (id, created_at, workspace_id, agent_id, app_id, state, message, uri) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, created_at, agent_id, app_id, workspace_id, state, message, uri +` + +type InsertWorkspaceAppStatusParams struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + AppID uuid.UUID `db:"app_id" json:"app_id"` + State WorkspaceAppStatusState `db:"state" json:"state"` + Message string `db:"message" json:"message"` + Uri sql.NullString `db:"uri" json:"uri"` +} + +func (q *sqlQuerier) InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error) { + row := q.db.QueryRowContext(ctx, insertWorkspaceAppStatus, + arg.ID, + arg.CreatedAt, + arg.WorkspaceID, + arg.AgentID, + arg.AppID, + arg.State, + arg.Message, + arg.Uri, + ) + var i WorkspaceAppStatus + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.AgentID, + &i.AppID, + &i.WorkspaceID, + &i.State, + &i.Message, + &i.Uri, + ) + return i, err +} + +const updateWorkspaceAppHealthByID = `-- name: UpdateWorkspaceAppHealthByID :exec +UPDATE + workspace_apps +SET + health = $2 +WHERE + id = $1 +` + +type UpdateWorkspaceAppHealthByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + Health WorkspaceAppHealth `db:"health" json:"health"` +} + +func (q *sqlQuerier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceAppHealthByID, arg.ID, arg.Health) + return err +} + +const upsertWorkspaceApp = `-- name: UpsertWorkspaceApp :one INSERT INTO workspace_apps ( id, @@ -16697,10 +16758,29 @@ INSERT INTO display_group ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in, display_group -` - -type InsertWorkspaceAppParams struct { + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) +ON CONFLICT (id) DO UPDATE SET + display_name = EXCLUDED.display_name, + icon = EXCLUDED.icon, + command = EXCLUDED.command, + url = EXCLUDED.url, + external = EXCLUDED.external, + subdomain = EXCLUDED.subdomain, + sharing_level = EXCLUDED.sharing_level, + healthcheck_url = EXCLUDED.healthcheck_url, + healthcheck_interval = EXCLUDED.healthcheck_interval, + healthcheck_threshold = EXCLUDED.healthcheck_threshold, + health = EXCLUDED.health, + display_order = EXCLUDED.display_order, + hidden = EXCLUDED.hidden, + open_in = EXCLUDED.open_in, + display_group = EXCLUDED.display_group, + agent_id = EXCLUDED.agent_id, + slug = EXCLUDED.slug +RETURNING id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in, display_group +` + +type UpsertWorkspaceAppParams struct { ID uuid.UUID `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` AgentID uuid.UUID `db:"agent_id" json:"agent_id"` @@ -16722,8 +16802,8 @@ type InsertWorkspaceAppParams struct { DisplayGroup sql.NullString `db:"display_group" json:"display_group"` } -func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) { - row := q.db.QueryRowContext(ctx, insertWorkspaceApp, +func (q *sqlQuerier) UpsertWorkspaceApp(ctx context.Context, arg UpsertWorkspaceAppParams) (WorkspaceApp, error) { + row := q.db.QueryRowContext(ctx, upsertWorkspaceApp, arg.ID, arg.CreatedAt, arg.AgentID, @@ -16769,67 +16849,6 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace return i, err } -const insertWorkspaceAppStatus = `-- name: InsertWorkspaceAppStatus :one -INSERT INTO workspace_app_statuses (id, created_at, workspace_id, agent_id, app_id, state, message, uri) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING id, created_at, agent_id, app_id, workspace_id, state, message, uri -` - -type InsertWorkspaceAppStatusParams struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - AppID uuid.UUID `db:"app_id" json:"app_id"` - State WorkspaceAppStatusState `db:"state" json:"state"` - Message string `db:"message" json:"message"` - Uri sql.NullString `db:"uri" json:"uri"` -} - -func (q *sqlQuerier) InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error) { - row := q.db.QueryRowContext(ctx, insertWorkspaceAppStatus, - arg.ID, - arg.CreatedAt, - arg.WorkspaceID, - arg.AgentID, - arg.AppID, - arg.State, - arg.Message, - arg.Uri, - ) - var i WorkspaceAppStatus - err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.AgentID, - &i.AppID, - &i.WorkspaceID, - &i.State, - &i.Message, - &i.Uri, - ) - return i, err -} - -const updateWorkspaceAppHealthByID = `-- name: UpdateWorkspaceAppHealthByID :exec -UPDATE - workspace_apps -SET - health = $2 -WHERE - id = $1 -` - -type UpdateWorkspaceAppHealthByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - Health WorkspaceAppHealth `db:"health" json:"health"` -} - -func (q *sqlQuerier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceAppHealthByID, arg.ID, arg.Health) - return err -} - const insertWorkspaceAppStats = `-- name: InsertWorkspaceAppStats :exec INSERT INTO workspace_app_stats ( diff --git a/coderd/database/queries/workspaceapps.sql b/coderd/database/queries/workspaceapps.sql index f3f8e4066970b..9a6fbf9251788 100644 --- a/coderd/database/queries/workspaceapps.sql +++ b/coderd/database/queries/workspaceapps.sql @@ -10,7 +10,7 @@ SELECT * FROM workspace_apps WHERE agent_id = $1 AND slug = $2; -- name: GetWorkspaceAppsCreatedAfter :many SELECT * FROM workspace_apps WHERE created_at > $1 ORDER BY slug ASC; --- name: InsertWorkspaceApp :one +-- name: UpsertWorkspaceApp :one INSERT INTO workspace_apps ( id, @@ -34,7 +34,26 @@ INSERT INTO display_group ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) +ON CONFLICT (id) DO UPDATE SET + display_name = EXCLUDED.display_name, + icon = EXCLUDED.icon, + command = EXCLUDED.command, + url = EXCLUDED.url, + external = EXCLUDED.external, + subdomain = EXCLUDED.subdomain, + sharing_level = EXCLUDED.sharing_level, + healthcheck_url = EXCLUDED.healthcheck_url, + healthcheck_interval = EXCLUDED.healthcheck_interval, + healthcheck_threshold = EXCLUDED.healthcheck_threshold, + health = EXCLUDED.health, + display_order = EXCLUDED.display_order, + hidden = EXCLUDED.hidden, + open_in = EXCLUDED.open_in, + display_group = EXCLUDED.display_group, + agent_id = EXCLUDED.agent_id, + slug = EXCLUDED.slug +RETURNING *; -- name: UpdateWorkspaceAppHealthByID :exec UPDATE diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index a8f7c63a586fe..580520e6e4b4b 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -28,6 +28,7 @@ import ( protobuf "google.golang.org/protobuf/proto" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk/drpcsdk" @@ -2606,7 +2607,8 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. return xerrors.Errorf("parse app uuid: %w", err) } - dbApp, err := db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{ + // If workspace apps are "persistent", the ID will not be regenerated across workspace builds, so we have to upsert. + dbApp, err := db.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{ ID: id, CreatedAt: dbtime.Now(), AgentID: dbAgent.ID, @@ -2635,7 +2637,7 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. OpenIn: openIn, }) if err != nil { - return xerrors.Errorf("insert app: %w", err) + return xerrors.Errorf("upsert app: %w", err) } snapshot.WorkspaceApps = append(snapshot.WorkspaceApps, telemetry.ConvertWorkspaceApp(dbApp)) } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 4f48136abb3ee..74554962fbd1e 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -19,6 +19,8 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog" + "github.com/coder/terraform-provider-coder/v2/provider" + "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" @@ -42,7 +44,6 @@ import ( "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" - "github.com/coder/terraform-provider-coder/v2/provider" ) func TestWorkspace(t *testing.T) { @@ -4614,3 +4615,87 @@ func TestWorkspaceFilterHasAITask(t *testing.T) { require.NoError(t, err) require.Len(t, res.Workspaces, 5) } + +func TestWorkspaceAppUpsertRestart(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + + // Define an app to be created with the workspace + apps := []*proto.App{ + { + Id: uuid.NewString(), + Slug: "test-app", + DisplayName: "Test App", + Command: "test-command", + Url: "http://localhost:8080", + Icon: "/test.svg", + }, + } + + // Create template version with workspace app + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: "test-resource", + Type: "example", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "dev", + Auth: &proto.Agent_Token{}, + Apps: apps, + }}, + }}, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + // Create template and workspace + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Verify initial workspace has the app + workspace, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Len(t, workspace.LatestBuild.Resources[0].Agents, 1) + agent := workspace.LatestBuild.Resources[0].Agents[0] + require.Len(t, agent.Apps, 1) + require.Equal(t, "test-app", agent.Apps[0].Slug) + require.Equal(t, "Test App", agent.Apps[0].DisplayName) + + // Stop the workspace + stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, stopBuild.ID) + + // Restart the workspace (this will trigger upsert for the app) + startBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, startBuild.ID) + + // Verify the workspace restarted successfully + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status) + + // Verify the app is still present after restart (upsert worked) + require.Len(t, workspace.LatestBuild.Resources[0].Agents, 1) + agent = workspace.LatestBuild.Resources[0].Agents[0] + require.Len(t, agent.Apps, 1) + require.Equal(t, "test-app", agent.Apps[0].Slug) + require.Equal(t, "Test App", agent.Apps[0].DisplayName) + + // Verify the provisioner job completed successfully (no error) + require.Equal(t, codersdk.ProvisionerJobSucceeded, workspace.LatestBuild.Job.Status) + require.Empty(t, workspace.LatestBuild.Job.Error) +}