From da5d5ba96a6900c38924da0c5b519234871289f4 Mon Sep 17 00:00:00 2001
From: Yevhenii Shcherbina
Date: Fri, 20 Jun 2025 10:06:06 -0400
Subject: [PATCH 1/9] fix: implement prebuild schedules methods for dbmem
(#18469)
Follow-up to https://github.com/coder/coder/pull/18126
---
coderd/database/dbauthz/dbauthz_test.go | 6 +--
coderd/database/dbmem/dbmem.go | 51 ++++++++++++++++++++++++-
2 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index ba9d1ddf0d7d2..931d86e46dd11 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -999,8 +999,7 @@ func (s *MethodTestSuite) TestOrganization() {
PresetID: preset.ID,
}
check.Args(arg).
- Asserts(rbac.ResourceTemplate, policy.ActionUpdate).
- ErrorsWithInMemDB(dbmem.ErrUnimplemented)
+ Asserts(rbac.ResourceTemplate, policy.ActionUpdate)
}))
s.Run("DeleteOrganizationMember", s.Subtest(func(db database.Store, check *expects) {
o := dbgen.Organization(s.T(), db, database.Organization{})
@@ -4942,8 +4941,7 @@ func (s *MethodTestSuite) TestPrebuilds() {
s.Run("GetActivePresetPrebuildSchedules", s.Subtest(func(db database.Store, check *expects) {
check.Args().
Asserts(rbac.ResourceTemplate.All(), policy.ActionRead).
- Returns([]database.TemplateVersionPresetPrebuildSchedule{}).
- ErrorsWithInMemDB(dbmem.ErrUnimplemented)
+ Returns([]database.TemplateVersionPresetPrebuildSchedule{})
}))
s.Run("GetPresetsByTemplateVersionID", s.Subtest(func(db database.Store, check *expects) {
ctx := context.Background()
diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go
index ee1c7471808d5..1ca21e5a56de3 100644
--- a/coderd/database/dbmem/dbmem.go
+++ b/coderd/database/dbmem/dbmem.go
@@ -75,6 +75,7 @@ func New() database.Store {
parameterSchemas: make([]database.ParameterSchema, 0),
presets: make([]database.TemplateVersionPreset, 0),
presetParameters: make([]database.TemplateVersionPresetParameter, 0),
+ presetPrebuildSchedules: make([]database.TemplateVersionPresetPrebuildSchedule, 0),
provisionerDaemons: make([]database.ProvisionerDaemon, 0),
provisionerJobs: make([]database.ProvisionerJob, 0),
provisionerJobLogs: make([]database.ProvisionerJobLog, 0),
@@ -299,6 +300,7 @@ type data struct {
telemetryItems []database.TelemetryItem
presets []database.TemplateVersionPreset
presetParameters []database.TemplateVersionPresetParameter
+ presetPrebuildSchedules []database.TemplateVersionPresetPrebuildSchedule
}
func tryPercentileCont(fs []float64, p float64) float64 {
@@ -2779,7 +2781,42 @@ func (q *FakeQuerier) GetAPIKeysLastUsedAfter(_ context.Context, after time.Time
}
func (q *FakeQuerier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
- return nil, ErrUnimplemented
+ q.mutex.RLock()
+ defer q.mutex.RUnlock()
+
+ var activeSchedules []database.TemplateVersionPresetPrebuildSchedule
+
+ // Create a map of active template version IDs for quick lookup
+ activeTemplateVersions := make(map[uuid.UUID]bool)
+ for _, template := range q.templates {
+ if !template.Deleted && template.Deprecated == "" {
+ activeTemplateVersions[template.ActiveVersionID] = true
+ }
+ }
+
+ // Create a map of presets for quick lookup
+ presetMap := make(map[uuid.UUID]database.TemplateVersionPreset)
+ for _, preset := range q.presets {
+ presetMap[preset.ID] = preset
+ }
+
+ // Filter preset prebuild schedules to only include those for active template versions
+ for _, schedule := range q.presetPrebuildSchedules {
+ // Look up the preset using the map
+ preset, exists := presetMap[schedule.PresetID]
+ if !exists {
+ continue
+ }
+
+ // Check if preset's template version is active
+ if !activeTemplateVersions[preset.TemplateVersionID] {
+ continue
+ }
+
+ activeSchedules = append(activeSchedules, schedule)
+ }
+
+ return activeSchedules, nil
}
// nolint:revive // It's not a control flag, it's a filter.
@@ -9201,7 +9238,17 @@ func (q *FakeQuerier) InsertPresetPrebuildSchedule(ctx context.Context, arg data
return database.TemplateVersionPresetPrebuildSchedule{}, err
}
- return database.TemplateVersionPresetPrebuildSchedule{}, ErrUnimplemented
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+
+ presetPrebuildSchedule := database.TemplateVersionPresetPrebuildSchedule{
+ ID: uuid.New(),
+ PresetID: arg.PresetID,
+ CronExpression: arg.CronExpression,
+ DesiredInstances: arg.DesiredInstances,
+ }
+ q.presetPrebuildSchedules = append(q.presetPrebuildSchedules, presetPrebuildSchedule)
+ return presetPrebuildSchedule, nil
}
func (q *FakeQuerier) InsertProvisionerJob(_ context.Context, arg database.InsertProvisionerJobParams) (database.ProvisionerJob, error) {
From 8e3022ed9e42061e6e1d259899be5f4324b1906c Mon Sep 17 00:00:00 2001
From: Yevhenii Shcherbina
Date: Fri, 20 Jun 2025 10:08:47 -0400
Subject: [PATCH 2/9] docs: add documentation for prebuild scheduling feature
(#18462)
Follow-up to https://github.com/coder/coder/pull/18126
Changes:
- address issue mentioned here:
https://github.com/coder/coder/pull/18126#discussion_r2144557600
- add docs for prebuilds scheduling
---------
Co-authored-by: Danny Kopping
Co-authored-by: Atif Ali
---
coderd/prebuilds/preset_snapshot.go | 4 +-
coderd/prebuilds/preset_snapshot_test.go | 42 +++----
.../prebuilt-workspaces.md | 106 +++++++++++++++++-
enterprise/coderd/prebuilds/reconcile.go | 2 +-
4 files changed, 124 insertions(+), 30 deletions(-)
diff --git a/coderd/prebuilds/preset_snapshot.go b/coderd/prebuilds/preset_snapshot.go
index beb2b7452def8..be9299c8f5bdf 100644
--- a/coderd/prebuilds/preset_snapshot.go
+++ b/coderd/prebuilds/preset_snapshot.go
@@ -267,14 +267,14 @@ func (p PresetSnapshot) CalculateState() *ReconciliationState {
// - ActionTypeBackoff: Only BackoffUntil is set, indicating when to retry
// - ActionTypeCreate: Only Create is set, indicating how many prebuilds to create
// - ActionTypeDelete: Only DeleteIDs is set, containing IDs of prebuilds to delete
-func (p PresetSnapshot) CalculateActions(clock quartz.Clock, backoffInterval time.Duration) ([]*ReconciliationActions, error) {
+func (p PresetSnapshot) CalculateActions(backoffInterval time.Duration) ([]*ReconciliationActions, error) {
// TODO: align workspace states with how we represent them on the FE and the CLI
// right now there's some slight differences which can lead to additional prebuilds being created
// TODO: add mechanism to prevent prebuilds being reconciled from being claimable by users; i.e. if a prebuild is
// about to be deleted, it should not be deleted if it has been claimed - beware of TOCTOU races!
- actions, needsBackoff := p.needsBackoffPeriod(clock, backoffInterval)
+ actions, needsBackoff := p.needsBackoffPeriod(p.clock, backoffInterval)
if needsBackoff {
return actions, nil
}
diff --git a/coderd/prebuilds/preset_snapshot_test.go b/coderd/prebuilds/preset_snapshot_test.go
index eacd264fb519a..0f7774f3a9155 100644
--- a/coderd/prebuilds/preset_snapshot_test.go
+++ b/coderd/prebuilds/preset_snapshot_test.go
@@ -86,12 +86,12 @@ func TestNoPrebuilds(t *testing.T) {
preset(true, 0, current),
}
- snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t))
+ snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, clock, testutil.Logger(t))
ps, err := snapshot.FilterByPreset(current.presetID)
require.NoError(t, err)
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{ /*all zero values*/ }, *state)
@@ -108,12 +108,12 @@ func TestNetNew(t *testing.T) {
preset(true, 1, current),
}
- snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t))
+ snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, nil, nil, nil, clock, testutil.Logger(t))
ps, err := snapshot.FilterByPreset(current.presetID)
require.NoError(t, err)
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
@@ -156,7 +156,7 @@ func TestOutdatedPrebuilds(t *testing.T) {
// THEN: we should identify that this prebuild is outdated and needs to be deleted.
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
Actual: 1,
@@ -174,7 +174,7 @@ func TestOutdatedPrebuilds(t *testing.T) {
// THEN: we should not be blocked from creating a new prebuild while the outdate one deletes.
state = ps.CalculateState()
- actions, err = ps.CalculateActions(clock, backoffInterval)
+ actions, err = ps.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{Desired: 1}, *state)
validateActions(t, []*prebuilds.ReconciliationActions{
@@ -223,7 +223,7 @@ func TestDeleteOutdatedPrebuilds(t *testing.T) {
// THEN: we should identify that this prebuild is outdated and needs to be deleted.
// Despite the fact that deletion of another outdated prebuild is already in progress.
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
Actual: 1,
@@ -467,7 +467,7 @@ func TestInProgressActions(t *testing.T) {
// THEN: we should identify that this prebuild is in progress.
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
tc.checkFn(*state, actions)
})
@@ -510,7 +510,7 @@ func TestExtraneous(t *testing.T) {
// THEN: an extraneous prebuild is detected and marked for deletion.
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
Actual: 2, Desired: 1, Extraneous: 1, Eligible: 2,
@@ -685,13 +685,13 @@ func TestExpiredPrebuilds(t *testing.T) {
}
// WHEN: calculating the current preset's state.
- snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, nil, nil, nil, quartz.NewMock(t), testutil.Logger(t))
+ snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, nil, nil, nil, clock, testutil.Logger(t))
ps, err := snapshot.FilterByPreset(current.presetID)
require.NoError(t, err)
// THEN: we should identify that this prebuild is expired.
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
tc.checkFn(running, *state, actions)
})
@@ -727,7 +727,7 @@ func TestDeprecated(t *testing.T) {
// THEN: all running prebuilds should be deleted because the template is deprecated.
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
Actual: 1,
@@ -774,13 +774,13 @@ func TestLatestBuildFailed(t *testing.T) {
}
// WHEN: calculating the current preset's state.
- snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, backoffs, nil, quartz.NewMock(t), testutil.Logger(t))
+ snapshot := prebuilds.NewGlobalSnapshot(presets, nil, running, inProgress, backoffs, nil, clock, testutil.Logger(t))
psCurrent, err := snapshot.FilterByPreset(current.presetID)
require.NoError(t, err)
// THEN: reconciliation should backoff.
state := psCurrent.CalculateState()
- actions, err := psCurrent.CalculateActions(clock, backoffInterval)
+ actions, err := psCurrent.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
Actual: 0, Desired: 1,
@@ -798,7 +798,7 @@ func TestLatestBuildFailed(t *testing.T) {
// THEN: it should NOT be in backoff because all is OK.
state = psOther.CalculateState()
- actions, err = psOther.CalculateActions(clock, backoffInterval)
+ actions, err = psOther.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
Actual: 1, Desired: 1, Eligible: 1,
@@ -812,7 +812,7 @@ func TestLatestBuildFailed(t *testing.T) {
psCurrent, err = snapshot.FilterByPreset(current.presetID)
require.NoError(t, err)
state = psCurrent.CalculateState()
- actions, err = psCurrent.CalculateActions(clock, backoffInterval)
+ actions, err = psCurrent.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
Actual: 0, Desired: 1,
@@ -867,7 +867,7 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) {
},
}
- snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, inProgress, nil, nil, quartz.NewMock(t), testutil.Logger(t))
+ snapshot := prebuilds.NewGlobalSnapshot(presets, nil, nil, inProgress, nil, nil, clock, testutil.Logger(t))
// Nothing has to be created for preset 1.
{
@@ -875,7 +875,7 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) {
require.NoError(t, err)
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
@@ -891,7 +891,7 @@ func TestMultiplePresetsPerTemplateVersion(t *testing.T) {
require.NoError(t, err)
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
@@ -995,7 +995,7 @@ func TestPrebuildScheduling(t *testing.T) {
require.NoError(t, err)
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
@@ -1016,7 +1016,7 @@ func TestPrebuildScheduling(t *testing.T) {
require.NoError(t, err)
state := ps.CalculateState()
- actions, err := ps.CalculateActions(clock, backoffInterval)
+ actions, err := ps.CalculateActions(backoffInterval)
require.NoError(t, err)
validateState(t, prebuilds.ReconciliationState{
diff --git a/docs/admin/templates/extending-templates/prebuilt-workspaces.md b/docs/admin/templates/extending-templates/prebuilt-workspaces.md
index 361a75f4b9ff4..08a404e040159 100644
--- a/docs/admin/templates/extending-templates/prebuilt-workspaces.md
+++ b/docs/admin/templates/extending-templates/prebuilt-workspaces.md
@@ -12,6 +12,7 @@ Prebuilt workspaces are:
- Created and maintained automatically by Coder to match your specified preset configurations.
- Claimed transparently when developers create workspaces.
- Monitored and replaced automatically to maintain your desired pool size.
+- Automatically scaled based on time-based schedules to optimize resource usage.
## Relationship to workspace presets
@@ -111,6 +112,105 @@ prebuilt workspace can remain before it is considered expired and eligible for c
Expired prebuilt workspaces are removed during the reconciliation loop to avoid stale environments and resource waste.
New prebuilt workspaces are only created to maintain the desired count if needed.
+### Scheduling
+
+Prebuilt workspaces support time-based scheduling to scale the number of instances up or down.
+This allows you to reduce resource costs during off-hours while maintaining availability during peak usage times.
+
+Configure scheduling by adding a `scheduling` block within your `prebuilds` configuration:
+
+```tf
+data "coder_workspace_preset" "goland" {
+ name = "GoLand: Large"
+ parameters {
+ jetbrains_ide = "GO"
+ cpus = 8
+ memory = 16
+ }
+
+ prebuilds {
+ instances = 0 # default to 0 instances
+
+ scheduling {
+ timezone = "UTC" # only a single timezone may be used for simplicity
+
+ # scale to 3 instances during the work week
+ schedule {
+ cron = "* 8-18 * * 1-5" # from 8AM-6:59PM, Mon-Fri, UTC
+ instances = 3 # scale to 3 instances
+ }
+
+ # scale to 1 instance on Saturdays for urgent support queries
+ schedule {
+ cron = "* 8-14 * * 6" # from 8AM-2:59PM, Sat, UTC
+ instances = 1 # scale to 1 instance
+ }
+ }
+ }
+}
+```
+
+**Scheduling configuration:**
+
+- **`timezone`**: The timezone for all cron expressions (required). Only a single timezone is supported per scheduling configuration.
+- **`schedule`**: One or more schedule blocks defining when to scale to specific instance counts.
+ - **`cron`**: Cron expression interpreted as continuous time ranges (required).
+ - **`instances`**: Number of prebuilt workspaces to maintain during this schedule (required).
+
+**How scheduling works:**
+
+1. The reconciliation loop evaluates all active schedules every reconciliation interval (`CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL`).
+2. The schedule that matches the current time becomes active. Overlapping schedules are disallowed by validation rules.
+3. If no schedules match the current time, the base `instances` count is used.
+4. The reconciliation loop automatically creates or destroys prebuilt workspaces to match the target count.
+
+**Cron expression format:**
+
+Cron expressions follow the format: `* HOUR DOM MONTH DAY-OF-WEEK`
+
+- `*` (minute): Must always be `*` to ensure the schedule covers entire hours rather than specific minute intervals
+- `HOUR`: 0-23, range (e.g., 8-18 for 8AM-6:59PM), or `*`
+- `DOM` (day-of-month): 1-31, range, or `*`
+- `MONTH`: 1-12, range, or `*`
+- `DAY-OF-WEEK`: 0-6 (Sunday=0, Saturday=6), range (e.g., 1-5 for Monday to Friday), or `*`
+
+**Important notes about cron expressions:**
+
+- **Minutes must always be `*`**: To ensure the schedule covers entire hours
+- **Time ranges are continuous**: A range like `8-18` means from 8AM to 6:59PM (inclusive of both start and end hours)
+- **Weekday ranges**: `1-5` means Monday through Friday (Monday=1, Friday=5)
+- **No overlapping schedules**: The validation system prevents overlapping schedules.
+
+**Example schedules:**
+
+```tf
+# Business hours only (8AM-6:59PM, Mon-Fri)
+schedule {
+ cron = "* 8-18 * * 1-5"
+ instances = 5
+}
+
+# 24/7 coverage with reduced capacity overnight and on weekends
+schedule {
+ cron = "* 8-18 * * 1-5" # Business hours (8AM-6:59PM, Mon-Fri)
+ instances = 10
+}
+schedule {
+ cron = "* 19-23,0-7 * * 1,5" # Evenings and nights (7PM-11:59PM, 12AM-7:59AM, Mon-Fri)
+ instances = 2
+}
+schedule {
+ cron = "* * * * 6,0" # Weekends
+ instances = 2
+}
+
+# Weekend support (10AM-4:59PM, Sat-Sun)
+schedule {
+ cron = "* 10-16 * * 6,0"
+ instances = 1
+}
+```
+
### Template updates and the prebuilt workspace lifecycle
Prebuilt workspaces are not updated after they are provisioned.
@@ -195,12 +295,6 @@ The prebuilt workspaces feature has these current limitations:
[View issue](https://github.com/coder/internal/issues/364)
-- **Autoscaling**
-
- Prebuilt workspaces remain running until claimed. There's no automated mechanism to reduce instances during off-hours.
-
- [View issue](https://github.com/coder/internal/issues/312)
-
### Monitoring and observability
#### Available metrics
diff --git a/enterprise/coderd/prebuilds/reconcile.go b/enterprise/coderd/prebuilds/reconcile.go
index a9f8bd014b3e9..e9d228ee7a965 100644
--- a/enterprise/coderd/prebuilds/reconcile.go
+++ b/enterprise/coderd/prebuilds/reconcile.go
@@ -518,7 +518,7 @@ func (c *StoreReconciler) CalculateActions(ctx context.Context, snapshot prebuil
return nil, ctx.Err()
}
- return snapshot.CalculateActions(c.clock, c.cfg.ReconciliationBackoffInterval.Value())
+ return snapshot.CalculateActions(c.cfg.ReconciliationBackoffInterval.Value())
}
func (c *StoreReconciler) WithReconciliationLock(
From 9c1feffdedc722b71dcee52e82baa9fcd29fc3c7 Mon Sep 17 00:00:00 2001
From: Atif Ali
Date: Fri, 20 Jun 2025 20:00:56 +0500
Subject: [PATCH 3/9] docs: add troubleshooting section to JetBrains Toolbox
docs (#18394)
---
.../workspace-access/jetbrains/toolbox.md | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/docs/user-guides/workspace-access/jetbrains/toolbox.md b/docs/user-guides/workspace-access/jetbrains/toolbox.md
index 52de09330346a..a2955b678298f 100644
--- a/docs/user-guides/workspace-access/jetbrains/toolbox.md
+++ b/docs/user-guides/workspace-access/jetbrains/toolbox.md
@@ -55,3 +55,29 @@ To connect to a Coder deployment that uses internal certificates, configure the
1. Select **Settings**.
1. Add your certificate path in the **CA Path** field.

+
+## Troubleshooting
+
+If you encounter issues connecting to your Coder workspace via JetBrains Toolbox, follow these steps to enable and capture debug logs:
+
+### Enable Debug Logging
+
+1. Open Toolbox
+1. Navigate to the **Toolbox App Menu (hexagonal menu icon) > Settings > Advanced**.
+1. In the screen that appears, select `DEBUG` for the Log level: section.
+1. Hit the back button at the top.
+1. Retry the same operation
+
+### Capture Debug Logs
+
+1. Access logs via **Toolbox App Menu > About > Show log files**.
+2. Locate the log file named `jetbrains-toolbox.log` and attach it to your support ticket.
+3. If you need to capture logs for a specific workspace, you can also generate a ZIP file using the Workspace action menu, available either on the main Workspaces page in Coder view or within the individual workspace view, under the option labeled **Collect logs**.
+
+> [!Workspace]
+> Toolbox does not persist log level configuration between restarts.
+
+## Additional Resources
+
+- [JetBrains Toolbox documentation](https://www.jetbrains.com/help/toolbox-app)
+- [Coder JetBrains Toolbox Plugin Github](https://github.com/coder/coder-jetbrains-toolbox)
From d61353f4687f2a71c7eb5bd17b88f0a5a120e1b0 Mon Sep 17 00:00:00 2001
From: Danielle Maywood
Date: Fri, 20 Jun 2025 16:34:30 +0100
Subject: [PATCH 4/9] fix(agent/agentcontainers): read WorkspaceFolder from
config (#18467)
Instead of exec'ing `pwd` inside of the container, we instead read
`WorkspaceFolder` from the outcome of `read-configuration`.
---
agent/agentcontainers/api.go | 31 +++++----------------
agent/agentcontainers/api_test.go | 35 ++++++------------------
agent/agentcontainers/devcontainercli.go | 5 ++++
3 files changed, 20 insertions(+), 51 deletions(-)
diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go
index ddf98e38bdb48..ef2b7aa7ebcd2 100644
--- a/agent/agentcontainers/api.go
+++ b/agent/agentcontainers/api.go
@@ -1,11 +1,9 @@
package agentcontainers
import (
- "bytes"
"context"
"errors"
"fmt"
- "io"
"net/http"
"os"
"path"
@@ -1114,27 +1112,6 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
if proc.agent.ID == uuid.Nil || maybeRecreateSubAgent {
subAgentConfig.Architecture = arch
- // Detect workspace folder by executing `pwd` in the container.
- // NOTE(mafredri): This is a quick and dirty way to detect the
- // workspace folder inside the container. In the future we will
- // rely more on `devcontainer read-configuration`.
- var pwdBuf bytes.Buffer
- err = api.dccli.Exec(ctx, dc.WorkspaceFolder, dc.ConfigPath, "pwd", []string{},
- WithExecOutput(&pwdBuf, io.Discard),
- WithExecContainerID(container.ID),
- )
- if err != nil {
- return xerrors.Errorf("check workspace folder in container: %w", err)
- }
- directory := strings.TrimSpace(pwdBuf.String())
- if directory == "" {
- logger.Warn(ctx, "detected workspace folder is empty, using default workspace folder",
- slog.F("default_workspace_folder", DevcontainerDefaultContainerWorkspaceFolder),
- )
- directory = DevcontainerDefaultContainerWorkspaceFolder
- }
- subAgentConfig.Directory = directory
-
displayAppsMap := map[codersdk.DisplayApp]bool{
// NOTE(DanielleMaywood):
// We use the same defaults here as set in terraform-provider-coder.
@@ -1146,7 +1123,10 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
codersdk.DisplayAppPortForward: true,
}
- var appsWithPossibleDuplicates []SubAgentApp
+ var (
+ appsWithPossibleDuplicates []SubAgentApp
+ workspaceFolder = DevcontainerDefaultContainerWorkspaceFolder
+ )
if err := func() error {
var (
@@ -1167,6 +1147,8 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
return err
}
+ workspaceFolder = config.Workspace.WorkspaceFolder
+
// NOTE(DanielleMaywood):
// We only want to take an agent name specified in the root customization layer.
// This restricts the ability for a feature to specify the agent name. We may revisit
@@ -1241,6 +1223,7 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
subAgentConfig.DisplayApps = displayApps
subAgentConfig.Apps = apps
+ subAgentConfig.Directory = workspaceFolder
}
deleteSubAgent := proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig)
diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go
index d0141ea590826..4e3e9e4077cd7 100644
--- a/agent/agentcontainers/api_test.go
+++ b/agent/agentcontainers/api_test.go
@@ -1262,6 +1262,11 @@ func TestAPI(t *testing.T) {
deleteErrC: make(chan error, 1),
}
fakeDCCLI = &fakeDevcontainerCLI{
+ readConfig: agentcontainers.DevcontainerConfig{
+ Workspace: agentcontainers.DevcontainerWorkspace{
+ WorkspaceFolder: "/workspaces/coder",
+ },
+ },
execErrC: make(chan func(cmd string, args ...string) error, 1),
readConfigErrC: make(chan func(envs []string) error, 1),
}
@@ -1273,8 +1278,8 @@ func TestAPI(t *testing.T) {
Running: true,
CreatedAt: time.Now(),
Labels: map[string]string{
- agentcontainers.DevcontainerLocalFolderLabel: "/workspaces",
- agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json",
+ agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/coder",
+ agentcontainers.DevcontainerConfigFileLabel: "/home/coder/coder/.devcontainer/devcontainer.json",
},
}
)
@@ -1320,11 +1325,6 @@ func TestAPI(t *testing.T) {
// Allow initial agent creation and injection to succeed.
testutil.RequireSend(ctx, t, fakeSAC.createErrC, nil)
- testutil.RequireSend(ctx, t, fakeDCCLI.execErrC, func(cmd string, args ...string) error {
- assert.Equal(t, "pwd", cmd)
- assert.Empty(t, args)
- return nil
- }) // Exec pwd.
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
@@ -1350,7 +1350,7 @@ func TestAPI(t *testing.T) {
// Verify agent was created.
require.Len(t, fakeSAC.created, 1)
assert.Equal(t, "test-container", fakeSAC.created[0].Name)
- assert.Equal(t, "/workspaces", fakeSAC.created[0].Directory)
+ assert.Equal(t, "/workspaces/coder", fakeSAC.created[0].Directory)
assert.Len(t, fakeSAC.deleted, 0)
t.Log("Agent injected successfully, now testing reinjection into the same container...")
@@ -1467,11 +1467,6 @@ func TestAPI(t *testing.T) {
testutil.RequireSend(ctx, t, fakeSAC.deleteErrC, nil)
// Expect the agent to be recreated.
testutil.RequireSend(ctx, t, fakeSAC.createErrC, nil)
- testutil.RequireSend(ctx, t, fakeDCCLI.execErrC, func(cmd string, args ...string) error {
- assert.Equal(t, "pwd", cmd)
- assert.Empty(t, args)
- return nil
- }) // Exec pwd.
testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error {
assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container")
assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace")
@@ -1814,7 +1809,6 @@ func TestAPI(t *testing.T) {
},
},
},
- execErrC: make(chan func(cmd string, args ...string) error, 1),
}
testContainer = codersdk.WorkspaceAgentContainer{
@@ -1861,15 +1855,9 @@ func TestAPI(t *testing.T) {
// Close before api.Close() defer to avoid deadlock after test.
defer close(fSAC.createErrC)
- defer close(fDCCLI.execErrC)
// Given: We allow agent creation and injection to succeed.
testutil.RequireSend(ctx, t, fSAC.createErrC, nil)
- testutil.RequireSend(ctx, t, fDCCLI.execErrC, func(cmd string, args ...string) error {
- assert.Equal(t, "pwd", cmd)
- assert.Empty(t, args)
- return nil
- })
// Wait until the ticker has been registered.
tickerTrap.MustWait(ctx).MustRelease(ctx)
@@ -1913,7 +1901,6 @@ func TestAPI(t *testing.T) {
},
},
readConfigErrC: make(chan func(envs []string) error, 2),
- execErrC: make(chan func(cmd string, args ...string) error, 1),
}
testContainer = codersdk.WorkspaceAgentContainer{
@@ -1960,16 +1947,10 @@ func TestAPI(t *testing.T) {
// Close before api.Close() defer to avoid deadlock after test.
defer close(fSAC.createErrC)
- defer close(fDCCLI.execErrC)
defer close(fDCCLI.readConfigErrC)
// Given: We allow agent creation and injection to succeed.
testutil.RequireSend(ctx, t, fSAC.createErrC, nil)
- testutil.RequireSend(ctx, t, fDCCLI.execErrC, func(cmd string, args ...string) error {
- assert.Equal(t, "pwd", cmd)
- assert.Empty(t, args)
- return nil
- })
testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error {
// We expect the wrong workspace agent name passed in first.
assert.Contains(t, env, "CODER_WORKSPACE_AGENT_NAME=test-container")
diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go
index e302ff07d6dd9..e87c3362c6e54 100644
--- a/agent/agentcontainers/devcontainercli.go
+++ b/agent/agentcontainers/devcontainercli.go
@@ -22,6 +22,7 @@ import (
type DevcontainerConfig struct {
MergedConfiguration DevcontainerMergedConfiguration `json:"mergedConfiguration"`
Configuration DevcontainerConfiguration `json:"configuration"`
+ Workspace DevcontainerWorkspace `json:"workspace"`
}
type DevcontainerMergedConfiguration struct {
@@ -46,6 +47,10 @@ type CoderCustomization struct {
Name string `json:"name,omitempty"`
}
+type DevcontainerWorkspace struct {
+ WorkspaceFolder string `json:"workspaceFolder"`
+}
+
// DevcontainerCLI is an interface for the devcontainer CLI.
type DevcontainerCLI interface {
Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error)
From 72f7d70bab132d8efd79f72b5244146b42f06184 Mon Sep 17 00:00:00 2001
From: Susana Ferreira
Date: Fri, 20 Jun 2025 17:36:32 +0100
Subject: [PATCH 5/9] feat: allow TemplateAdmin to delete prebuilds via auth
layer (#18333)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description
This PR adds support for deleting prebuilt workspaces via the
authorization layer. It introduces special-case handling to ensure that
`prebuilt_workspace` permissions are evaluated when attempting to delete
a prebuilt workspace, falling back to the standard `workspace` resource
as needed.
Prebuilt workspaces are a subset of workspaces, identified by having
`owner_id` set to `PREBUILD_SYSTEM_USER`.
This means:
* A user with `prebuilt_workspace.delete` permission is allowed to
**delete only prebuilt workspaces**.
* A user with `workspace.delete` permission can **delete both normal and
prebuilt workspaces**.
⚠️ This implementation is scoped to **deletion operations only**. No
other operations are currently supported for the `prebuilt_workspace`
resource.
To delete a workspace, users must have the following permissions:
* `workspace.read`: to read the current workspace state
* `update`: to modify workspace metadata and related resources during
deletion (e.g., updating the `deleted` field in the database)
* `delete`: to perform the actual deletion of the workspace
## Changes
* Introduced `authorizeWorkspace()` helper to handle prebuilt workspace
authorization logic.
* Ensured both `prebuilt_workspace` and `workspace` permissions are
checked.
* Added comments to clarify the current behavior and limitations.
* Moved `SystemUserID` constant from the `prebuilds` package to the
`database` package `PrebuildsSystemUserID` to resolve an import cycle
(commit
https://github.com/coder/coder/pull/18333/commits/f24e4ab4b6f0a56726fd04be2d7302c9fdb52d53).
* Update middleware `ExtractOrganizationMember` to include system user
members.
---
cli/delete_test.go | 230 ++++++++++++++++++
coderd/apidoc/docs.go | 2 +
coderd/apidoc/swagger.json | 2 +
coderd/database/constants.go | 5 +
coderd/database/dbauthz/dbauthz.go | 42 +++-
coderd/database/dbauthz/dbauthz_test.go | 60 +++++
coderd/database/dbmem/dbmem.go | 6 +-
coderd/database/modelmethods.go | 36 +++
coderd/database/querier_test.go | 3 +-
coderd/httpmw/organizationparam.go | 2 +-
coderd/prebuilds/id.go | 5 -
coderd/rbac/object_gen.go | 9 +
coderd/rbac/policy/policy.go | 14 ++
coderd/rbac/roles.go | 26 +-
coderd/rbac/roles_test.go | 11 +
coderd/workspacebuilds.go | 10 +
coderd/wsbuilder/wsbuilder.go | 13 +-
codersdk/rbacresources_gen.go | 2 +
docs/reference/api/members.md | 5 +
docs/reference/api/schemas.md | 1 +
enterprise/coderd/groups_test.go | 4 +-
enterprise/coderd/prebuilds/claim.go | 2 +-
.../coderd/prebuilds/membership_test.go | 13 +-
.../coderd/prebuilds/metricscollector_test.go | 33 ++-
enterprise/coderd/prebuilds/reconcile.go | 8 +-
enterprise/coderd/prebuilds/reconcile_test.go | 3 +-
enterprise/coderd/workspaces_test.go | 3 +-
site/src/api/rbacresourcesGenerated.ts | 4 +
site/src/api/typesGenerated.ts | 2 +
29 files changed, 493 insertions(+), 63 deletions(-)
create mode 100644 coderd/database/constants.go
delete mode 100644 coderd/prebuilds/id.go
diff --git a/cli/delete_test.go b/cli/delete_test.go
index 1d4dc8dfb40ad..ecd1c6996df1d 100644
--- a/cli/delete_test.go
+++ b/cli/delete_test.go
@@ -2,9 +2,18 @@ package cli_test
import (
"context"
+ "database/sql"
"fmt"
"io"
"testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbgen"
+ "github.com/coder/coder/v2/coderd/database/pubsub"
+ "github.com/coder/quartz"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -209,4 +218,225 @@ func TestDelete(t *testing.T) {
cancel()
<-doneChan
})
+
+ t.Run("Prebuilt workspace delete permissions", func(t *testing.T) {
+ t.Parallel()
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("this test requires postgres")
+ }
+
+ clock := quartz.NewMock(t)
+ ctx := testutil.Context(t, testutil.WaitSuperLong)
+
+ // Setup
+ db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
+ client, _ := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{
+ Database: db,
+ Pubsub: pb,
+ IncludeProvisionerDaemon: true,
+ })
+ owner := coderdtest.CreateFirstUser(t, client)
+ orgID := owner.OrganizationID
+
+ // Given a template version with a preset and a template
+ version := coderdtest.CreateTemplateVersion(t, client, orgID, nil)
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+ preset := setupTestDBPreset(t, db, version.ID)
+ template := coderdtest.CreateTemplate(t, client, orgID, version.ID)
+
+ cases := []struct {
+ name string
+ client *codersdk.Client
+ expectedPrebuiltDeleteErrMsg string
+ expectedWorkspaceDeleteErrMsg string
+ }{
+ // Users with the OrgAdmin role should be able to delete both normal and prebuilt workspaces
+ {
+ name: "OrgAdmin",
+ client: func() *codersdk.Client {
+ client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.ScopedRoleOrgAdmin(orgID))
+ return client
+ }(),
+ },
+ // Users with the TemplateAdmin role should be able to delete prebuilt workspaces, but not normal workspaces
+ {
+ name: "TemplateAdmin",
+ client: func() *codersdk.Client {
+ client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.RoleTemplateAdmin())
+ return client
+ }(),
+ expectedWorkspaceDeleteErrMsg: "unexpected status code 403: You do not have permission to delete this workspace.",
+ },
+ // Users with the OrgTemplateAdmin role should be able to delete prebuilt workspaces, but not normal workspaces
+ {
+ name: "OrgTemplateAdmin",
+ client: func() *codersdk.Client {
+ client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.ScopedRoleOrgTemplateAdmin(orgID))
+ return client
+ }(),
+ expectedWorkspaceDeleteErrMsg: "unexpected status code 403: You do not have permission to delete this workspace.",
+ },
+ // Users with the Member role should not be able to delete prebuilt or normal workspaces
+ {
+ name: "Member",
+ client: func() *codersdk.Client {
+ client, _ := coderdtest.CreateAnotherUser(t, client, orgID, rbac.RoleMember())
+ return client
+ }(),
+ expectedPrebuiltDeleteErrMsg: "unexpected status code 404: Resource not found or you do not have access to this resource",
+ expectedWorkspaceDeleteErrMsg: "unexpected status code 404: Resource not found or you do not have access to this resource",
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Create one prebuilt workspace (owned by system user) and one normal workspace (owned by a user)
+ // Each workspace is persisted in the DB along with associated workspace jobs and builds.
+ dbPrebuiltWorkspace := setupTestDBWorkspace(t, clock, db, pb, orgID, database.PrebuildsSystemUserID, template.ID, version.ID, preset.ID)
+ userWorkspaceOwner, err := client.User(context.Background(), "testUser")
+ require.NoError(t, err)
+ dbUserWorkspace := setupTestDBWorkspace(t, clock, db, pb, orgID, userWorkspaceOwner.ID, template.ID, version.ID, preset.ID)
+
+ assertWorkspaceDelete := func(
+ runClient *codersdk.Client,
+ workspace database.Workspace,
+ workspaceOwner string,
+ expectedErr string,
+ ) {
+ t.Helper()
+
+ // Attempt to delete the workspace as the test client
+ inv, root := clitest.New(t, "delete", workspaceOwner+"/"+workspace.Name, "-y")
+ clitest.SetupConfig(t, runClient, root)
+ doneChan := make(chan struct{})
+ pty := ptytest.New(t).Attach(inv)
+ var runErr error
+ go func() {
+ defer close(doneChan)
+ runErr = inv.Run()
+ }()
+
+ // Validate the result based on the expected error message
+ if expectedErr != "" {
+ <-doneChan
+ require.Error(t, runErr)
+ require.Contains(t, runErr.Error(), expectedErr)
+ } else {
+ pty.ExpectMatch("has been deleted")
+ <-doneChan
+
+ // When running with the race detector on, we sometimes get an EOF.
+ if runErr != nil {
+ assert.ErrorIs(t, runErr, io.EOF)
+ }
+
+ // Verify that the workspace is now marked as deleted
+ _, err := client.Workspace(context.Background(), workspace.ID)
+ require.ErrorContains(t, err, "was deleted")
+ }
+ }
+
+ // Ensure at least one prebuilt workspace is reported as running in the database
+ testutil.Eventually(ctx, t, func(ctx context.Context) (done bool) {
+ running, err := db.GetRunningPrebuiltWorkspaces(ctx)
+ if !assert.NoError(t, err) || !assert.GreaterOrEqual(t, len(running), 1) {
+ return false
+ }
+ return true
+ }, testutil.IntervalMedium, "running prebuilt workspaces timeout")
+
+ runningWorkspaces, err := db.GetRunningPrebuiltWorkspaces(ctx)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, len(runningWorkspaces), 1)
+
+ // Get the full prebuilt workspace object from the DB
+ prebuiltWorkspace, err := db.GetWorkspaceByID(ctx, dbPrebuiltWorkspace.ID)
+ require.NoError(t, err)
+
+ // Assert the prebuilt workspace deletion
+ assertWorkspaceDelete(tc.client, prebuiltWorkspace, "prebuilds", tc.expectedPrebuiltDeleteErrMsg)
+
+ // Get the full user workspace object from the DB
+ userWorkspace, err := db.GetWorkspaceByID(ctx, dbUserWorkspace.ID)
+ require.NoError(t, err)
+
+ // Assert the user workspace deletion
+ assertWorkspaceDelete(tc.client, userWorkspace, userWorkspaceOwner.Username, tc.expectedWorkspaceDeleteErrMsg)
+ })
+ }
+ })
+}
+
+func setupTestDBPreset(
+ t *testing.T,
+ db database.Store,
+ templateVersionID uuid.UUID,
+) database.TemplateVersionPreset {
+ t.Helper()
+
+ preset := dbgen.Preset(t, db, database.InsertPresetParams{
+ TemplateVersionID: templateVersionID,
+ Name: "preset-test",
+ DesiredInstances: sql.NullInt32{
+ Valid: true,
+ Int32: 1,
+ },
+ })
+ dbgen.PresetParameter(t, db, database.InsertPresetParametersParams{
+ TemplateVersionPresetID: preset.ID,
+ Names: []string{"test"},
+ Values: []string{"test"},
+ })
+
+ return preset
+}
+
+func setupTestDBWorkspace(
+ t *testing.T,
+ clock quartz.Clock,
+ db database.Store,
+ ps pubsub.Pubsub,
+ orgID uuid.UUID,
+ ownerID uuid.UUID,
+ templateID uuid.UUID,
+ templateVersionID uuid.UUID,
+ presetID uuid.UUID,
+) database.WorkspaceTable {
+ t.Helper()
+
+ workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
+ TemplateID: templateID,
+ OrganizationID: orgID,
+ OwnerID: ownerID,
+ Deleted: false,
+ CreatedAt: time.Now().Add(-time.Hour * 2),
+ })
+ job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
+ InitiatorID: ownerID,
+ CreatedAt: time.Now().Add(-time.Hour * 2),
+ StartedAt: sql.NullTime{Time: clock.Now().Add(-time.Hour * 2), Valid: true},
+ CompletedAt: sql.NullTime{Time: clock.Now().Add(-time.Hour), Valid: true},
+ OrganizationID: orgID,
+ })
+ workspaceBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
+ WorkspaceID: workspace.ID,
+ InitiatorID: ownerID,
+ TemplateVersionID: templateVersionID,
+ JobID: job.ID,
+ TemplateVersionPresetID: uuid.NullUUID{UUID: presetID, Valid: true},
+ Transition: database.WorkspaceTransitionStart,
+ CreatedAt: clock.Now(),
+ })
+ dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{
+ {
+ WorkspaceBuildID: workspaceBuild.ID,
+ Name: "test",
+ Value: "test",
+ },
+ })
+
+ return workspace
}
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 05e61dbec9296..1d175333c1271 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -15259,6 +15259,7 @@ const docTemplate = `{
"oauth2_app_secret",
"organization",
"organization_member",
+ "prebuilt_workspace",
"provisioner_daemon",
"provisioner_jobs",
"replicas",
@@ -15298,6 +15299,7 @@ const docTemplate = `{
"ResourceOauth2AppSecret",
"ResourceOrganization",
"ResourceOrganizationMember",
+ "ResourcePrebuiltWorkspace",
"ResourceProvisionerDaemon",
"ResourceProvisionerJobs",
"ResourceReplicas",
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 8577c080a7ecf..9d00a7ba34c30 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -13851,6 +13851,7 @@
"oauth2_app_secret",
"organization",
"organization_member",
+ "prebuilt_workspace",
"provisioner_daemon",
"provisioner_jobs",
"replicas",
@@ -13890,6 +13891,7 @@
"ResourceOauth2AppSecret",
"ResourceOrganization",
"ResourceOrganizationMember",
+ "ResourcePrebuiltWorkspace",
"ResourceProvisionerDaemon",
"ResourceProvisionerJobs",
"ResourceReplicas",
diff --git a/coderd/database/constants.go b/coderd/database/constants.go
new file mode 100644
index 0000000000000..931e0d7e0983d
--- /dev/null
+++ b/coderd/database/constants.go
@@ -0,0 +1,5 @@
+package database
+
+import "github.com/google/uuid"
+
+var PrebuildsSystemUserID = uuid.MustParse("c42fdf75-3097-471c-8c33-fb52454d81c0")
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index 8d470aa13473b..adb2007918f8d 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -21,7 +21,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi/httpapiconstraints"
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
- "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
@@ -150,6 +149,30 @@ func (q *querier) authorizeContext(ctx context.Context, action policy.Action, ob
return nil
}
+// authorizePrebuiltWorkspace handles authorization for workspace resource types.
+// prebuilt_workspaces are a subset of workspaces, currently limited to
+// supporting delete operations. Therefore, if the action is delete or
+// update and the workspace is a prebuild, a prebuilt-specific authorization
+// is attempted first. If that fails, it falls back to normal workspace
+// authorization.
+// Note: Delete operations of workspaces requires both update and delete
+// permissions.
+func (q *querier) authorizePrebuiltWorkspace(ctx context.Context, action policy.Action, workspace database.Workspace) error {
+ var prebuiltErr error
+ // Special handling for prebuilt_workspace deletion authorization check
+ if (action == policy.ActionUpdate || action == policy.ActionDelete) && workspace.IsPrebuild() {
+ // Try prebuilt-specific authorization first
+ if prebuiltErr = q.authorizeContext(ctx, action, workspace.AsPrebuild()); prebuiltErr == nil {
+ return nil
+ }
+ }
+ // Fallback to normal workspace authorization check
+ if err := q.authorizeContext(ctx, action, workspace); err != nil {
+ return xerrors.Errorf("authorize context: %w", errors.Join(prebuiltErr, err))
+ }
+ return nil
+}
+
type authContextKey struct{}
// ActorFromContext returns the authorization subject from the context.
@@ -399,7 +422,7 @@ var (
subjectPrebuildsOrchestrator = rbac.Subject{
Type: rbac.SubjectTypePrebuildsOrchestrator,
FriendlyName: "Prebuilds Orchestrator",
- ID: prebuilds.SystemUserID.String(),
+ ID: database.PrebuildsSystemUserID.String(),
Roles: rbac.Roles([]rbac.Role{
{
Identifier: rbac.RoleIdentifier{Name: "prebuilds-orchestrator"},
@@ -412,6 +435,12 @@ var (
policy.ActionCreate, policy.ActionDelete, policy.ActionRead, policy.ActionUpdate,
policy.ActionWorkspaceStart, policy.ActionWorkspaceStop,
},
+ // PrebuiltWorkspaces are a subset of Workspaces.
+ // Explicitly setting PrebuiltWorkspace permissions for clarity.
+ // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
+ rbac.ResourcePrebuiltWorkspace.Type: {
+ policy.ActionUpdate, policy.ActionDelete,
+ },
// Should be able to add the prebuilds system user as a member to any organization that needs prebuilds.
rbac.ResourceOrganizationMember.Type: {
policy.ActionCreate,
@@ -3953,8 +3982,9 @@ func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertW
action = policy.ActionWorkspaceStop
}
- if err = q.authorizeContext(ctx, action, w); err != nil {
- return xerrors.Errorf("authorize context: %w", err)
+ // Special handling for prebuilt workspace deletion
+ if err := q.authorizePrebuiltWorkspace(ctx, action, w); err != nil {
+ return err
}
// If we're starting a workspace we need to check the template.
@@ -3993,8 +4023,8 @@ func (q *querier) InsertWorkspaceBuildParameters(ctx context.Context, arg databa
return err
}
- err = q.authorizeContext(ctx, policy.ActionUpdate, workspace)
- if err != nil {
+ // Special handling for prebuilt workspace deletion
+ if err := q.authorizePrebuiltWorkspace(ctx, policy.ActionUpdate, workspace); err != nil {
return err
}
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index 931d86e46dd11..0ccd867040116 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -5562,3 +5562,63 @@ func (s *MethodTestSuite) TestChat() {
}).Asserts(c, policy.ActionUpdate)
}))
}
+
+func (s *MethodTestSuite) TestAuthorizePrebuiltWorkspace() {
+ s.Run("PrebuildDelete/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) {
+ u := dbgen.User(s.T(), db, database.User{})
+ o := dbgen.Organization(s.T(), db, database.Organization{})
+ tpl := dbgen.Template(s.T(), db, database.Template{
+ OrganizationID: o.ID,
+ CreatedBy: u.ID,
+ })
+ w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{
+ TemplateID: tpl.ID,
+ OrganizationID: o.ID,
+ OwnerID: database.PrebuildsSystemUserID,
+ })
+ pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{
+ OrganizationID: o.ID,
+ })
+ tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{
+ TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true},
+ OrganizationID: o.ID,
+ CreatedBy: u.ID,
+ })
+ check.Args(database.InsertWorkspaceBuildParams{
+ WorkspaceID: w.ID,
+ Transition: database.WorkspaceTransitionDelete,
+ Reason: database.BuildReasonInitiator,
+ TemplateVersionID: tv.ID,
+ JobID: pj.ID,
+ }).Asserts(w.AsPrebuild(), policy.ActionDelete)
+ }))
+ s.Run("PrebuildUpdate/InsertWorkspaceBuildParameters", s.Subtest(func(db database.Store, check *expects) {
+ u := dbgen.User(s.T(), db, database.User{})
+ o := dbgen.Organization(s.T(), db, database.Organization{})
+ tpl := dbgen.Template(s.T(), db, database.Template{
+ OrganizationID: o.ID,
+ CreatedBy: u.ID,
+ })
+ w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{
+ TemplateID: tpl.ID,
+ OrganizationID: o.ID,
+ OwnerID: database.PrebuildsSystemUserID,
+ })
+ pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{
+ OrganizationID: o.ID,
+ })
+ tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{
+ TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true},
+ OrganizationID: o.ID,
+ CreatedBy: u.ID,
+ })
+ wb := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{
+ JobID: pj.ID,
+ WorkspaceID: w.ID,
+ TemplateVersionID: tv.ID,
+ })
+ check.Args(database.InsertWorkspaceBuildParametersParams{
+ WorkspaceBuildID: wb.ID,
+ }).Asserts(w.AsPrebuild(), policy.ActionUpdate)
+ }))
+}
diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go
index 1ca21e5a56de3..7095fad25f854 100644
--- a/coderd/database/dbmem/dbmem.go
+++ b/coderd/database/dbmem/dbmem.go
@@ -23,11 +23,9 @@ import (
"golang.org/x/exp/maps"
"golang.org/x/xerrors"
- "github.com/coder/coder/v2/coderd/notifications/types"
- "github.com/coder/coder/v2/coderd/prebuilds"
-
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
+ "github.com/coder/coder/v2/coderd/notifications/types"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/regosql"
"github.com/coder/coder/v2/coderd/util/slice"
@@ -160,7 +158,7 @@ func New() database.Store {
q.mutex.Lock()
// We can't insert this user using the interface, because it's a system user.
q.data.users = append(q.data.users, database.User{
- ID: prebuilds.SystemUserID,
+ ID: database.PrebuildsSystemUserID,
Email: "prebuilds@coder.com",
Username: "prebuilds",
CreatedAt: dbtime.Now(),
diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go
index b3f6deed9eff0..cb16d8c4995b6 100644
--- a/coderd/database/modelmethods.go
+++ b/coderd/database/modelmethods.go
@@ -229,6 +229,24 @@ func (w Workspace) RBACObject() rbac.Object {
return w.WorkspaceTable().RBACObject()
}
+// IsPrebuild returns true if the workspace is a prebuild workspace.
+// A workspace is considered a prebuild if its owner is the prebuild system user.
+func (w Workspace) IsPrebuild() bool {
+ return w.OwnerID == PrebuildsSystemUserID
+}
+
+// AsPrebuild returns the RBAC object corresponding to the workspace type.
+// If the workspace is a prebuild, it returns a prebuilt_workspace RBAC object.
+// Otherwise, it returns a normal workspace RBAC object.
+func (w Workspace) AsPrebuild() rbac.Object {
+ if w.IsPrebuild() {
+ return rbac.ResourcePrebuiltWorkspace.WithID(w.ID).
+ InOrg(w.OrganizationID).
+ WithOwner(w.OwnerID.String())
+ }
+ return w.RBACObject()
+}
+
func (w WorkspaceTable) RBACObject() rbac.Object {
if w.DormantAt.Valid {
return w.DormantRBAC()
@@ -246,6 +264,24 @@ func (w WorkspaceTable) DormantRBAC() rbac.Object {
WithOwner(w.OwnerID.String())
}
+// IsPrebuild returns true if the workspace is a prebuild workspace.
+// A workspace is considered a prebuild if its owner is the prebuild system user.
+func (w WorkspaceTable) IsPrebuild() bool {
+ return w.OwnerID == PrebuildsSystemUserID
+}
+
+// AsPrebuild returns the RBAC object corresponding to the workspace type.
+// If the workspace is a prebuild, it returns a prebuilt_workspace RBAC object.
+// Otherwise, it returns a normal workspace RBAC object.
+func (w WorkspaceTable) AsPrebuild() rbac.Object {
+ if w.IsPrebuild() {
+ return rbac.ResourcePrebuiltWorkspace.WithID(w.ID).
+ InOrg(w.OrganizationID).
+ WithOwner(w.OwnerID.String())
+ }
+ return w.RBACObject()
+}
+
func (m OrganizationMember) RBACObject() rbac.Object {
return rbac.ResourceOrganizationMember.
WithID(m.UserID).
diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go
index 74ac5b0a20caf..600fb8269909a 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -27,7 +27,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/migrations"
"github.com/coder/coder/v2/coderd/httpmw"
- "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/coderd/provisionerdserver"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
@@ -1418,7 +1417,7 @@ func TestGetUsers_IncludeSystem(t *testing.T) {
for _, u := range users {
if u.IsSystem {
foundSystemUser = true
- require.Equal(t, prebuilds.SystemUserID, u.ID)
+ require.Equal(t, database.PrebuildsSystemUserID, u.ID)
} else {
foundRegularUser = true
require.Equalf(t, other.ID.String(), u.ID.String(), "found unexpected regular user")
diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go
index efedc3a764591..c12772a4de4e4 100644
--- a/coderd/httpmw/organizationparam.go
+++ b/coderd/httpmw/organizationparam.go
@@ -180,7 +180,7 @@ func ExtractOrganizationMember(ctx context.Context, auth func(r *http.Request, a
organizationMembers, err := db.OrganizationMembers(ctx, database.OrganizationMembersParams{
OrganizationID: orgID,
UserID: user.ID,
- IncludeSystem: false,
+ IncludeSystem: true,
})
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
diff --git a/coderd/prebuilds/id.go b/coderd/prebuilds/id.go
deleted file mode 100644
index 7c2bbe79b7a6f..0000000000000
--- a/coderd/prebuilds/id.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package prebuilds
-
-import "github.com/google/uuid"
-
-var SystemUserID = uuid.MustParse("c42fdf75-3097-471c-8c33-fb52454d81c0")
diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go
index f19d90894dd55..a5c696fb2a491 100644
--- a/coderd/rbac/object_gen.go
+++ b/coderd/rbac/object_gen.go
@@ -222,6 +222,14 @@ var (
Type: "organization_member",
}
+ // ResourcePrebuiltWorkspace
+ // Valid Actions
+ // - "ActionDelete" :: delete prebuilt workspace
+ // - "ActionUpdate" :: update prebuilt workspace settings
+ ResourcePrebuiltWorkspace = Object{
+ Type: "prebuilt_workspace",
+ }
+
// ResourceProvisionerDaemon
// Valid Actions
// - "ActionCreate" :: create a provisioner daemon/key
@@ -389,6 +397,7 @@ func AllResources() []Objecter {
ResourceOauth2AppSecret,
ResourceOrganization,
ResourceOrganizationMember,
+ ResourcePrebuiltWorkspace,
ResourceProvisionerDaemon,
ResourceProvisionerJobs,
ResourceReplicas,
diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go
index 160062283f857..733a70bcafd0e 100644
--- a/coderd/rbac/policy/policy.go
+++ b/coderd/rbac/policy/policy.go
@@ -102,6 +102,20 @@ var RBACPermissions = map[string]PermissionDefinition{
"workspace_dormant": {
Actions: workspaceActions,
},
+ "prebuilt_workspace": {
+ // Prebuilt_workspace actions currently apply only to delete operations.
+ // To successfully delete a prebuilt workspace, a user must have the following permissions:
+ // * workspace.read: to read the current workspace state
+ // * update: to modify workspace metadata and related resources during deletion
+ // (e.g., updating the deleted field in the database)
+ // * delete: to perform the actual deletion of the workspace
+ // If the user lacks prebuilt_workspace update or delete permissions,
+ // the authorization will always fall back to the corresponding permissions on workspace.
+ Actions: map[Action]ActionDefinition{
+ ActionUpdate: actDef("update prebuilt workspace settings"),
+ ActionDelete: actDef("delete prebuilt workspace"),
+ },
+ },
"workspace_proxy": {
Actions: map[Action]ActionDefinition{
ActionCreate: actDef("create a workspace proxy"),
diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go
index 28ddc38462ce9..8acdf7486ddd2 100644
--- a/coderd/rbac/roles.go
+++ b/coderd/rbac/roles.go
@@ -270,11 +270,15 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Site: append(
// Workspace dormancy and workspace are omitted.
// Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec
- allPermsExcept(ResourceWorkspaceDormant, ResourceWorkspace),
+ allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace),
// This adds back in the Workspace permissions.
Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: ownerWorkspaceActions,
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent},
+ // PrebuiltWorkspaces are a subset of Workspaces.
+ // Explicitly setting PrebuiltWorkspace permissions for clarity.
+ // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
+ ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
})...),
Org: map[string][]Permission{},
User: []Permission{},
@@ -290,7 +294,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ResourceWorkspaceProxy.Type: {policy.ActionRead},
}),
Org: map[string][]Permission{},
- User: append(allPermsExcept(ResourceWorkspaceDormant, ResourceUser, ResourceOrganizationMember),
+ User: append(allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceUser, ResourceOrganizationMember),
Permissions(map[string][]policy.Action{
// Reduced permission set on dormant workspaces. No build, ssh, or exec
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent},
@@ -335,8 +339,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ResourceAssignOrgRole.Type: {policy.ActionRead},
ResourceTemplate.Type: ResourceTemplate.AvailableActions(),
// CRUD all files, even those they did not upload.
- ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
- ResourceWorkspace.Type: {policy.ActionRead},
+ ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
+ ResourceWorkspace.Type: {policy.ActionRead},
+ ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
// CRUD to provisioner daemons for now.
ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
// Needs to read all organizations since
@@ -413,9 +418,13 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
}),
Org: map[string][]Permission{
// Org admins should not have workspace exec perms.
- organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourceAssignRole), Permissions(map[string][]policy.Action{
+ organizationID.String(): append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole), Permissions(map[string][]policy.Action{
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent},
ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH),
+ // PrebuiltWorkspaces are a subset of Workspaces.
+ // Explicitly setting PrebuiltWorkspace permissions for clarity.
+ // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
+ ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
})...),
},
User: []Permission{},
@@ -493,9 +502,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Site: []Permission{},
Org: map[string][]Permission{
organizationID.String(): Permissions(map[string][]policy.Action{
- ResourceTemplate.Type: ResourceTemplate.AvailableActions(),
- ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
- ResourceWorkspace.Type: {policy.ActionRead},
+ ResourceTemplate.Type: ResourceTemplate.AvailableActions(),
+ ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
+ ResourceWorkspace.Type: {policy.ActionRead},
+ ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
// Assigning template perms requires this permission.
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go
index 5738edfe8caa2..a1b7c7c15d03a 100644
--- a/coderd/rbac/roles_test.go
+++ b/coderd/rbac/roles_test.go
@@ -5,6 +5,8 @@ import (
"fmt"
"testing"
+ "github.com/coder/coder/v2/coderd/database"
+
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
@@ -496,6 +498,15 @@ func TestRolePermissions(t *testing.T) {
false: {setOtherOrg, userAdmin, templateAdmin, memberMe, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
+ {
+ Name: "PrebuiltWorkspace",
+ Actions: []policy.Action{policy.ActionUpdate, policy.ActionDelete},
+ Resource: rbac.ResourcePrebuiltWorkspace.WithID(uuid.New()).InOrg(orgID).WithOwner(database.PrebuildsSystemUserID.String()),
+ AuthorizeMap: map[bool][]hasAuthSubjects{
+ true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin},
+ false: {setOtherOrg, userAdmin, memberMe, orgUserAdmin, orgAuditor, orgMemberMe},
+ },
+ },
// Some admin style resources
{
Name: "Licenses",
diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go
index b05f69bb0ad9a..74946d46dcd9f 100644
--- a/coderd/workspacebuilds.go
+++ b/coderd/workspacebuilds.go
@@ -392,6 +392,16 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
ctx,
tx,
func(action policy.Action, object rbac.Objecter) bool {
+ // Special handling for prebuilt workspace deletion
+ if object.RBACObject().Type == rbac.ResourceWorkspace.Type && action == policy.ActionDelete {
+ if workspaceObj, ok := object.(database.Workspace); ok {
+ // Try prebuilt-specific authorization first
+ if auth := api.Authorize(r, action, workspaceObj.AsPrebuild()); auth {
+ return auth
+ }
+ }
+ }
+ // Fallback to default authorization
return api.Authorize(r, action, object)
},
audit.WorkspaceBuildBaggageFromRequest(r),
diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go
index 9605df58014de..b52a20ac1e9db 100644
--- a/coderd/wsbuilder/wsbuilder.go
+++ b/coderd/wsbuilder/wsbuilder.go
@@ -918,7 +918,18 @@ func (b *Builder) authorize(authFunc func(action policy.Action, object rbac.Obje
msg := fmt.Sprintf("Transition %q not supported.", b.trans)
return BuildError{http.StatusBadRequest, msg, xerrors.New(msg)}
}
- if !authFunc(action, b.workspace) {
+
+ // Special handling for prebuilt workspace deletion
+ authorized := false
+ if action == policy.ActionDelete && b.workspace.IsPrebuild() && authFunc(action, b.workspace.AsPrebuild()) {
+ authorized = true
+ }
+ // Fallback to default authorization
+ if !authorized && authFunc(action, b.workspace) {
+ authorized = true
+ }
+
+ if !authorized {
if authFunc(policy.ActionRead, b.workspace) {
// If the user can read the workspace, but not delete/create/update. Show
// a more helpful error. They are allowed to know the workspace exists.
diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go
index 95792bb8e2a7b..1304218ad7bea 100644
--- a/codersdk/rbacresources_gen.go
+++ b/codersdk/rbacresources_gen.go
@@ -28,6 +28,7 @@ const (
ResourceOauth2AppSecret RBACResource = "oauth2_app_secret"
ResourceOrganization RBACResource = "organization"
ResourceOrganizationMember RBACResource = "organization_member"
+ ResourcePrebuiltWorkspace RBACResource = "prebuilt_workspace"
ResourceProvisionerDaemon RBACResource = "provisioner_daemon"
ResourceProvisionerJobs RBACResource = "provisioner_jobs"
ResourceReplicas RBACResource = "replicas"
@@ -91,6 +92,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{
ResourceOauth2AppSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceOrganization: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceOrganizationMember: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
+ ResourcePrebuiltWorkspace: {ActionDelete, ActionUpdate},
ResourceProvisionerDaemon: {ActionCreate, ActionDelete, ActionRead, ActionUpdate},
ResourceProvisionerJobs: {ActionCreate, ActionRead, ActionUpdate},
ResourceReplicas: {ActionRead},
diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md
index 6b5d124753bc0..40921e40b70ee 100644
--- a/docs/reference/api/members.md
+++ b/docs/reference/api/members.md
@@ -206,6 +206,7 @@ Status Code **200**
| `resource_type` | `oauth2_app_secret` |
| `resource_type` | `organization` |
| `resource_type` | `organization_member` |
+| `resource_type` | `prebuilt_workspace` |
| `resource_type` | `provisioner_daemon` |
| `resource_type` | `provisioner_jobs` |
| `resource_type` | `replicas` |
@@ -375,6 +376,7 @@ Status Code **200**
| `resource_type` | `oauth2_app_secret` |
| `resource_type` | `organization` |
| `resource_type` | `organization_member` |
+| `resource_type` | `prebuilt_workspace` |
| `resource_type` | `provisioner_daemon` |
| `resource_type` | `provisioner_jobs` |
| `resource_type` | `replicas` |
@@ -544,6 +546,7 @@ Status Code **200**
| `resource_type` | `oauth2_app_secret` |
| `resource_type` | `organization` |
| `resource_type` | `organization_member` |
+| `resource_type` | `prebuilt_workspace` |
| `resource_type` | `provisioner_daemon` |
| `resource_type` | `provisioner_jobs` |
| `resource_type` | `replicas` |
@@ -682,6 +685,7 @@ Status Code **200**
| `resource_type` | `oauth2_app_secret` |
| `resource_type` | `organization` |
| `resource_type` | `organization_member` |
+| `resource_type` | `prebuilt_workspace` |
| `resource_type` | `provisioner_daemon` |
| `resource_type` | `provisioner_jobs` |
| `resource_type` | `replicas` |
@@ -1042,6 +1046,7 @@ Status Code **200**
| `resource_type` | `oauth2_app_secret` |
| `resource_type` | `organization` |
| `resource_type` | `organization_member` |
+| `resource_type` | `prebuilt_workspace` |
| `resource_type` | `provisioner_daemon` |
| `resource_type` | `provisioner_jobs` |
| `resource_type` | `replicas` |
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 44f4665ba6f49..8f548478e27a6 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -6329,6 +6329,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| `oauth2_app_secret` |
| `organization` |
| `organization_member` |
+| `prebuilt_workspace` |
| `provisioner_daemon` |
| `provisioner_jobs` |
| `replicas` |
diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go
index 028aa3328535f..f87a9193f5fa4 100644
--- a/enterprise/coderd/groups_test.go
+++ b/enterprise/coderd/groups_test.go
@@ -6,8 +6,6 @@ import (
"testing"
"time"
- "github.com/coder/coder/v2/coderd/prebuilds"
-
"github.com/google/uuid"
"github.com/stretchr/testify/require"
@@ -833,7 +831,7 @@ func TestGroup(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
// nolint:gocritic // "This client is operating as the owner user" is fine in this case.
- prebuildsUser, err := client.User(ctx, prebuilds.SystemUserID.String())
+ prebuildsUser, err := client.User(ctx, database.PrebuildsSystemUserID.String())
require.NoError(t, err)
// The 'Everyone' group always has an ID that matches the organization ID.
group, err := userAdminClient.Group(ctx, user.OrganizationID)
diff --git a/enterprise/coderd/prebuilds/claim.go b/enterprise/coderd/prebuilds/claim.go
index f040ee756e678..b6a85ae1fc094 100644
--- a/enterprise/coderd/prebuilds/claim.go
+++ b/enterprise/coderd/prebuilds/claim.go
@@ -47,7 +47,7 @@ func (c EnterpriseClaimer) Claim(
}
func (EnterpriseClaimer) Initiator() uuid.UUID {
- return prebuilds.SystemUserID
+ return database.PrebuildsSystemUserID
}
var _ prebuilds.Claimer = &EnterpriseClaimer{}
diff --git a/enterprise/coderd/prebuilds/membership_test.go b/enterprise/coderd/prebuilds/membership_test.go
index 6caa7178d9d60..229814e5bf764 100644
--- a/enterprise/coderd/prebuilds/membership_test.go
+++ b/enterprise/coderd/prebuilds/membership_test.go
@@ -12,7 +12,6 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
- agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/enterprise/coderd/prebuilds"
)
@@ -74,14 +73,14 @@ func TestReconcileAll(t *testing.T) {
// dbmem doesn't ensure membership to the default organization
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: defaultOrg.ID,
- UserID: agplprebuilds.SystemUserID,
+ UserID: database.PrebuildsSystemUserID,
})
}
- dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: unrelatedOrg.ID, UserID: agplprebuilds.SystemUserID})
+ dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: unrelatedOrg.ID, UserID: database.PrebuildsSystemUserID})
if tc.preExistingMembership {
// System user already a member of both orgs.
- dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: targetOrg.ID, UserID: agplprebuilds.SystemUserID})
+ dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: targetOrg.ID, UserID: database.PrebuildsSystemUserID})
}
presets := []database.GetTemplatePresetsWithPrebuildsRow{newPresetRow(unrelatedOrg.ID)}
@@ -91,7 +90,7 @@ func TestReconcileAll(t *testing.T) {
// Verify memberships before reconciliation.
preReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{
- UserID: agplprebuilds.SystemUserID,
+ UserID: database.PrebuildsSystemUserID,
})
require.NoError(t, err)
expectedMembershipsBefore := []uuid.UUID{defaultOrg.ID, unrelatedOrg.ID}
@@ -102,11 +101,11 @@ func TestReconcileAll(t *testing.T) {
// Reconcile
reconciler := prebuilds.NewStoreMembershipReconciler(db, clock)
- require.NoError(t, reconciler.ReconcileAll(ctx, agplprebuilds.SystemUserID, presets))
+ require.NoError(t, reconciler.ReconcileAll(ctx, database.PrebuildsSystemUserID, presets))
// Verify memberships after reconciliation.
postReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{
- UserID: agplprebuilds.SystemUserID,
+ UserID: database.PrebuildsSystemUserID,
})
require.NoError(t, err)
expectedMembershipsAfter := expectedMembershipsBefore
diff --git a/enterprise/coderd/prebuilds/metricscollector_test.go b/enterprise/coderd/prebuilds/metricscollector_test.go
index dce9e07dd110f..7befffe9d3a05 100644
--- a/enterprise/coderd/prebuilds/metricscollector_test.go
+++ b/enterprise/coderd/prebuilds/metricscollector_test.go
@@ -20,7 +20,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
- agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/prebuilds"
"github.com/coder/coder/v2/testutil"
@@ -55,8 +54,8 @@ func TestMetricsCollector(t *testing.T) {
name: "prebuild provisioned but not completed",
transitions: allTransitions,
jobStatuses: allJobStatusesExcept(database.ProvisionerJobStatusPending, database.ProvisionerJobStatusRunning, database.ProvisionerJobStatusCanceling),
- initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
- ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID},
+ ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID},
metrics: []metricCheck{
{prebuilds.MetricCreatedCount, ptr.To(1.0), true},
{prebuilds.MetricClaimedCount, ptr.To(0.0), true},
@@ -72,8 +71,8 @@ func TestMetricsCollector(t *testing.T) {
name: "prebuild running",
transitions: []database.WorkspaceTransition{database.WorkspaceTransitionStart},
jobStatuses: []database.ProvisionerJobStatus{database.ProvisionerJobStatusSucceeded},
- initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
- ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID},
+ ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID},
metrics: []metricCheck{
{prebuilds.MetricCreatedCount, ptr.To(1.0), true},
{prebuilds.MetricClaimedCount, ptr.To(0.0), true},
@@ -89,8 +88,8 @@ func TestMetricsCollector(t *testing.T) {
name: "prebuild failed",
transitions: allTransitions,
jobStatuses: []database.ProvisionerJobStatus{database.ProvisionerJobStatusFailed},
- initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
- ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID, uuid.New()},
+ initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID},
+ ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID, uuid.New()},
metrics: []metricCheck{
{prebuilds.MetricCreatedCount, ptr.To(1.0), true},
{prebuilds.MetricFailedCount, ptr.To(1.0), true},
@@ -105,8 +104,8 @@ func TestMetricsCollector(t *testing.T) {
name: "prebuild eligible",
transitions: []database.WorkspaceTransition{database.WorkspaceTransitionStart},
jobStatuses: []database.ProvisionerJobStatus{database.ProvisionerJobStatusSucceeded},
- initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
- ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID},
+ ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID},
metrics: []metricCheck{
{prebuilds.MetricCreatedCount, ptr.To(1.0), true},
{prebuilds.MetricClaimedCount, ptr.To(0.0), true},
@@ -122,8 +121,8 @@ func TestMetricsCollector(t *testing.T) {
name: "prebuild ineligible",
transitions: allTransitions,
jobStatuses: allJobStatusesExcept(database.ProvisionerJobStatusSucceeded),
- initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
- ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID},
+ ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID},
metrics: []metricCheck{
{prebuilds.MetricCreatedCount, ptr.To(1.0), true},
{prebuilds.MetricClaimedCount, ptr.To(0.0), true},
@@ -139,7 +138,7 @@ func TestMetricsCollector(t *testing.T) {
name: "prebuild claimed",
transitions: allTransitions,
jobStatuses: allJobStatuses,
- initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID},
ownerIDs: []uuid.UUID{uuid.New()},
metrics: []metricCheck{
{prebuilds.MetricCreatedCount, ptr.To(1.0), true},
@@ -169,8 +168,8 @@ func TestMetricsCollector(t *testing.T) {
name: "deleted templates should not be included in exported metrics",
transitions: allTransitions,
jobStatuses: allJobStatuses,
- initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
- ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID, uuid.New()},
+ initiatorIDs: []uuid.UUID{database.PrebuildsSystemUserID},
+ ownerIDs: []uuid.UUID{database.PrebuildsSystemUserID, uuid.New()},
metrics: nil,
templateDeleted: []bool{true},
eligible: []bool{false},
@@ -209,7 +208,7 @@ func TestMetricsCollector(t *testing.T) {
reconciler := prebuilds.NewStoreReconciler(db, pubsub, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), prometheus.NewRegistry(), newNoopEnqueuer())
ctx := testutil.Context(t, testutil.WaitLong)
- createdUsers := []uuid.UUID{agplprebuilds.SystemUserID}
+ createdUsers := []uuid.UUID{database.PrebuildsSystemUserID}
for _, user := range slices.Concat(test.ownerIDs, test.initiatorIDs) {
if !slices.Contains(createdUsers, user) {
dbgen.User(t, db, database.User{
@@ -327,8 +326,8 @@ func TestMetricsCollector_DuplicateTemplateNames(t *testing.T) {
test := testCase{
transition: database.WorkspaceTransitionStart,
jobStatus: database.ProvisionerJobStatusSucceeded,
- initiatorID: agplprebuilds.SystemUserID,
- ownerID: agplprebuilds.SystemUserID,
+ initiatorID: database.PrebuildsSystemUserID,
+ ownerID: database.PrebuildsSystemUserID,
metrics: []metricCheck{
{prebuilds.MetricCreatedCount, ptr.To(1.0), true},
{prebuilds.MetricClaimedCount, ptr.To(0.0), true},
diff --git a/enterprise/coderd/prebuilds/reconcile.go b/enterprise/coderd/prebuilds/reconcile.go
index e9d228ee7a965..911336d36c426 100644
--- a/enterprise/coderd/prebuilds/reconcile.go
+++ b/enterprise/coderd/prebuilds/reconcile.go
@@ -265,7 +265,7 @@ func (c *StoreReconciler) ReconcileAll(ctx context.Context) error {
}
membershipReconciler := NewStoreMembershipReconciler(c.store, c.clock)
- err = membershipReconciler.ReconcileAll(ctx, prebuilds.SystemUserID, snapshot.Presets)
+ err = membershipReconciler.ReconcileAll(ctx, database.PrebuildsSystemUserID, snapshot.Presets)
if err != nil {
return xerrors.Errorf("reconcile prebuild membership: %w", err)
}
@@ -676,7 +676,7 @@ func (c *StoreReconciler) createPrebuiltWorkspace(ctx context.Context, prebuiltW
ID: prebuiltWorkspaceID,
CreatedAt: now,
UpdatedAt: now,
- OwnerID: prebuilds.SystemUserID,
+ OwnerID: database.PrebuildsSystemUserID,
OrganizationID: template.OrganizationID,
TemplateID: template.ID,
Name: name,
@@ -718,7 +718,7 @@ func (c *StoreReconciler) deletePrebuiltWorkspace(ctx context.Context, prebuiltW
return xerrors.Errorf("failed to get template: %w", err)
}
- if workspace.OwnerID != prebuilds.SystemUserID {
+ if workspace.OwnerID != database.PrebuildsSystemUserID {
return xerrors.Errorf("prebuilt workspace is not owned by prebuild user anymore, probably it was claimed")
}
@@ -761,7 +761,7 @@ func (c *StoreReconciler) provision(
builder := wsbuilder.New(workspace, transition).
Reason(database.BuildReasonInitiator).
- Initiator(prebuilds.SystemUserID).
+ Initiator(database.PrebuildsSystemUserID).
MarkPrebuild()
if transition != database.WorkspaceTransitionDelete {
diff --git a/enterprise/coderd/prebuilds/reconcile_test.go b/enterprise/coderd/prebuilds/reconcile_test.go
index 702a0769b548f..540ec1088ac0c 100644
--- a/enterprise/coderd/prebuilds/reconcile_test.go
+++ b/enterprise/coderd/prebuilds/reconcile_test.go
@@ -33,7 +33,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/pubsub"
- agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/prebuilds"
"github.com/coder/coder/v2/testutil"
@@ -2021,7 +2020,7 @@ func setupTestDBPrebuild(
opts ...prebuildOption,
) (database.WorkspaceTable, database.WorkspaceBuild) {
t.Helper()
- return setupTestDBWorkspace(t, clock, db, ps, transition, prebuildStatus, orgID, preset, templateID, templateVersionID, agplprebuilds.SystemUserID, agplprebuilds.SystemUserID, opts...)
+ return setupTestDBWorkspace(t, clock, db, ps, transition, prebuildStatus, orgID, preset, templateID, templateVersionID, database.PrebuildsSystemUserID, database.PrebuildsSystemUserID, opts...)
}
func setupTestDBWorkspace(
diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go
index ce86151f9b883..a1be6a3701393 100644
--- a/enterprise/coderd/workspaces_test.go
+++ b/enterprise/coderd/workspaces_test.go
@@ -32,7 +32,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications"
- "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/coderd/provisionerdserver"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
@@ -496,7 +495,7 @@ func TestCreateUserWorkspace(t *testing.T) {
}).Do()
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
- OwnerID: prebuilds.SystemUserID,
+ OwnerID: database.PrebuildsSystemUserID,
TemplateID: tv.Template.ID,
}).Seed(database.WorkspaceBuild{
TemplateVersionID: tv.TemplateVersion.ID,
diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts
index 885f603c1eb82..3ec6a3accee32 100644
--- a/site/src/api/rbacresourcesGenerated.ts
+++ b/site/src/api/rbacresourcesGenerated.ts
@@ -123,6 +123,10 @@ export const RBACResourceActions: Partial<
read: "read member",
update: "update an organization member",
},
+ prebuilt_workspace: {
+ delete: "delete prebuilt workspace",
+ update: "update prebuilt workspace settings",
+ },
provisioner_daemon: {
create: "create a provisioner daemon/key",
delete: "delete a provisioner daemon/key",
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index d668018976f1e..98338c24bb2d8 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -2192,6 +2192,7 @@ export type RBACResource =
| "oauth2_app_secret"
| "organization"
| "organization_member"
+ | "prebuilt_workspace"
| "provisioner_daemon"
| "provisioner_jobs"
| "replicas"
@@ -2231,6 +2232,7 @@ export const RBACResources: RBACResource[] = [
"oauth2_app_secret",
"organization",
"organization_member",
+ "prebuilt_workspace",
"provisioner_daemon",
"provisioner_jobs",
"replicas",
From 9b5d49967c906c281b83f1e3f450f3ec43c23b3f Mon Sep 17 00:00:00 2001
From: Steven Masley
Date: Fri, 20 Jun 2025 13:00:39 -0500
Subject: [PATCH 6/9] chore: refactor dynamic parameters into dedicated package
(#18420)
This PR extracts dynamic parameter rendering logic from
coderd/parameters.go into a new coderd/dynamicparameters package. Partly
for organization and maintainability, but primarily to be reused in
`wsbuilder` to be leveraged as validation.
---
coderd/coderdtest/dynamicparameters.go | 129 ++++++
coderd/coderdtest/stream.go | 25 ++
coderd/dynamicparameters/render.go | 340 ++++++++++++++
coderd/dynamicparameters/static.go | 143 ++++++
coderd/parameters.go | 417 ++----------------
coderd/parameters_test.go | 39 +-
coderd/util/slice/slice.go | 13 +
enterprise/coderd/dynamicparameters_test.go | 129 ++++++
enterprise/coderd/parameters_test.go | 12 +-
.../testdata/parameters/dynamic/main.tf | 103 +++++
10 files changed, 941 insertions(+), 409 deletions(-)
create mode 100644 coderd/coderdtest/dynamicparameters.go
create mode 100644 coderd/coderdtest/stream.go
create mode 100644 coderd/dynamicparameters/render.go
create mode 100644 coderd/dynamicparameters/static.go
create mode 100644 enterprise/coderd/dynamicparameters_test.go
create mode 100644 enterprise/coderd/testdata/parameters/dynamic/main.tf
diff --git a/coderd/coderdtest/dynamicparameters.go b/coderd/coderdtest/dynamicparameters.go
new file mode 100644
index 0000000000000..b5bb34a0e3468
--- /dev/null
+++ b/coderd/coderdtest/dynamicparameters.go
@@ -0,0 +1,129 @@
+package coderdtest
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/coder/coder/v2/coderd/util/ptr"
+ "github.com/coder/coder/v2/coderd/util/slice"
+ "github.com/coder/coder/v2/codersdk"
+ "github.com/coder/coder/v2/provisioner/echo"
+ "github.com/coder/coder/v2/provisionersdk/proto"
+)
+
+type DynamicParameterTemplateParams struct {
+ MainTF string
+ Plan json.RawMessage
+ ModulesArchive []byte
+
+ // StaticParams is used if the provisioner daemon version does not support dynamic parameters.
+ StaticParams []*proto.RichParameter
+}
+
+func DynamicParameterTemplate(t *testing.T, client *codersdk.Client, org uuid.UUID, args DynamicParameterTemplateParams) (codersdk.Template, codersdk.TemplateVersion) {
+ t.Helper()
+
+ files := echo.WithExtraFiles(map[string][]byte{
+ "main.tf": []byte(args.MainTF),
+ })
+ files.ProvisionPlan = []*proto.Response{{
+ Type: &proto.Response_Plan{
+ Plan: &proto.PlanComplete{
+ Plan: args.Plan,
+ ModuleFiles: args.ModulesArchive,
+ Parameters: args.StaticParams,
+ },
+ },
+ }}
+
+ version := CreateTemplateVersion(t, client, org, files)
+ AwaitTemplateVersionJobCompleted(t, client, version.ID)
+ tpl := CreateTemplate(t, client, org, version.ID)
+
+ var err error
+ tpl, err = client.UpdateTemplateMeta(t.Context(), tpl.ID, codersdk.UpdateTemplateMeta{
+ UseClassicParameterFlow: ptr.Ref(false),
+ })
+ require.NoError(t, err)
+
+ return tpl, version
+}
+
+type ParameterAsserter struct {
+ Name string
+ Params []codersdk.PreviewParameter
+ t *testing.T
+}
+
+func AssertParameter(t *testing.T, name string, params []codersdk.PreviewParameter) *ParameterAsserter {
+ return &ParameterAsserter{
+ Name: name,
+ Params: params,
+ t: t,
+ }
+}
+
+func (a *ParameterAsserter) find(name string) *codersdk.PreviewParameter {
+ a.t.Helper()
+ for _, p := range a.Params {
+ if p.Name == name {
+ return &p
+ }
+ }
+
+ assert.Fail(a.t, "parameter not found", "expected parameter %q to exist", a.Name)
+ return nil
+}
+
+func (a *ParameterAsserter) NotExists() *ParameterAsserter {
+ a.t.Helper()
+
+ names := slice.Convert(a.Params, func(p codersdk.PreviewParameter) string {
+ return p.Name
+ })
+
+ assert.NotContains(a.t, names, a.Name)
+ return a
+}
+
+func (a *ParameterAsserter) Exists() *ParameterAsserter {
+ a.t.Helper()
+
+ names := slice.Convert(a.Params, func(p codersdk.PreviewParameter) string {
+ return p.Name
+ })
+
+ assert.Contains(a.t, names, a.Name)
+ return a
+}
+
+func (a *ParameterAsserter) Value(expected string) *ParameterAsserter {
+ a.t.Helper()
+
+ p := a.find(a.Name)
+ if p == nil {
+ return a
+ }
+
+ assert.Equal(a.t, expected, p.Value.Value)
+ return a
+}
+
+func (a *ParameterAsserter) Options(expected ...string) *ParameterAsserter {
+ a.t.Helper()
+
+ p := a.find(a.Name)
+ if p == nil {
+ return a
+ }
+
+ optValues := slice.Convert(p.Options, func(p codersdk.PreviewParameterOption) string {
+ return p.Value.Value
+ })
+ assert.ElementsMatch(a.t, expected, optValues, "parameter %q options", a.Name)
+ return a
+}
diff --git a/coderd/coderdtest/stream.go b/coderd/coderdtest/stream.go
new file mode 100644
index 0000000000000..83bcce2ed29db
--- /dev/null
+++ b/coderd/coderdtest/stream.go
@@ -0,0 +1,25 @@
+package coderdtest
+
+import "github.com/coder/coder/v2/codersdk/wsjson"
+
+// SynchronousStream returns a function that assumes the stream is synchronous.
+// Meaning each request sent assumes exactly one response will be received.
+// The function will block until the response is received or an error occurs.
+//
+// This should not be used in production code, as it does not handle edge cases.
+// The second function `pop` can be used to retrieve the next response from the
+// stream without sending a new request. This is useful for dynamic parameters
+func SynchronousStream[R any, W any](stream *wsjson.Stream[R, W]) (do func(W) (R, error), pop func() R) {
+ rec := stream.Chan()
+
+ return func(req W) (R, error) {
+ err := stream.Send(req)
+ if err != nil {
+ return *new(R), err
+ }
+
+ return <-rec, nil
+ }, func() R {
+ return <-rec
+ }
+}
diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go
new file mode 100644
index 0000000000000..9c4c73f87e5bc
--- /dev/null
+++ b/coderd/dynamicparameters/render.go
@@ -0,0 +1,340 @@
+package dynamicparameters
+
+import (
+ "context"
+ "io/fs"
+ "log/slog"
+ "sync"
+
+ "github.com/google/uuid"
+ "golang.org/x/sync/errgroup"
+ "golang.org/x/xerrors"
+
+ "github.com/coder/coder/v2/apiversion"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbauthz"
+ "github.com/coder/coder/v2/coderd/files"
+ "github.com/coder/preview"
+ previewtypes "github.com/coder/preview/types"
+
+ "github.com/hashicorp/hcl/v2"
+)
+
+// Renderer is able to execute and evaluate terraform with the given inputs.
+// It may use the database to fetch additional state, such as a user's groups,
+// roles, etc. Therefore, it requires an authenticated `ctx`.
+//
+// 'Close()' **must** be called once the renderer is no longer needed.
+// Forgetting to do so will result in a memory leak.
+type Renderer interface {
+ Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics)
+ Close()
+}
+
+var ErrTemplateVersionNotReady = xerrors.New("template version job not finished")
+
+// loader is used to load the necessary coder objects for rendering a template
+// version's parameters. The output is a Renderer, which is the object that uses
+// the cached objects to render the template version's parameters.
+type loader struct {
+ templateVersionID uuid.UUID
+
+ // cache of objects
+ templateVersion *database.TemplateVersion
+ job *database.ProvisionerJob
+ terraformValues *database.TemplateVersionTerraformValue
+}
+
+// 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) {
+ l := &loader{
+ templateVersionID: versionID,
+ }
+
+ for _, opt := range options {
+ opt(l)
+ }
+
+ return l.Renderer(ctx, db, cache)
+}
+
+func WithTemplateVersion(tv database.TemplateVersion) func(r *loader) {
+ return func(r *loader) {
+ if tv.ID == r.templateVersionID {
+ r.templateVersion = &tv
+ }
+ }
+}
+
+func WithProvisionerJob(job database.ProvisionerJob) func(r *loader) {
+ return func(r *loader) {
+ r.job = &job
+ }
+}
+
+func WithTerraformValues(values database.TemplateVersionTerraformValue) func(r *loader) {
+ return func(r *loader) {
+ if values.TemplateVersionID == r.templateVersionID {
+ r.terraformValues = &values
+ }
+ }
+}
+
+func (r *loader) loadData(ctx context.Context, db database.Store) error {
+ if r.templateVersion == nil {
+ tv, err := db.GetTemplateVersionByID(ctx, r.templateVersionID)
+ if err != nil {
+ return xerrors.Errorf("template version: %w", err)
+ }
+ r.templateVersion = &tv
+ }
+
+ if r.job == nil {
+ job, err := db.GetProvisionerJobByID(ctx, r.templateVersion.JobID)
+ if err != nil {
+ return xerrors.Errorf("provisioner job: %w", err)
+ }
+ r.job = &job
+ }
+
+ if !r.job.CompletedAt.Valid {
+ return ErrTemplateVersionNotReady
+ }
+
+ if r.terraformValues == nil {
+ values, err := db.GetTemplateVersionTerraformValues(ctx, r.templateVersion.ID)
+ if err != nil {
+ return xerrors.Errorf("template version terraform values: %w", err)
+ }
+ r.terraformValues = &values
+ }
+
+ return nil
+}
+
+// Renderer returns a Renderer that can be used to render the template version's
+// parameters. It automatically determines whether to use a static or dynamic
+// renderer based on the template version's state.
+//
+// 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) {
+ err := r.loadData(ctx, db)
+ if err != nil {
+ return nil, xerrors.Errorf("load data: %w", err)
+ }
+
+ if !ProvisionerVersionSupportsDynamicParameters(r.terraformValues.ProvisionerdVersion) {
+ return r.staticRender(ctx, db)
+ }
+
+ return r.dynamicRenderer(ctx, db, cache)
+}
+
+// Renderer caches all the necessary files when rendering a template version's
+// parameters. It must be closed after use to release the cached files.
+func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache *files.Cache) (*dynamicRenderer, error) {
+ // If they can read the template version, then they can read the file for
+ // parameter loading purposes.
+ //nolint:gocritic
+ fileCtx := dbauthz.AsFileReader(ctx)
+ templateFS, err := cache.Acquire(fileCtx, r.job.FileID)
+ if err != nil {
+ return nil, xerrors.Errorf("acquire template file: %w", err)
+ }
+
+ var terraformFS fs.FS = templateFS
+ var moduleFilesFS *files.CloseFS
+ if r.terraformValues.CachedModuleFiles.Valid {
+ moduleFilesFS, err = cache.Acquire(fileCtx, r.terraformValues.CachedModuleFiles.UUID)
+ if err != nil {
+ templateFS.Close()
+ return nil, xerrors.Errorf("acquire module files: %w", err)
+ }
+ terraformFS = files.NewOverlayFS(templateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
+ }
+
+ return &dynamicRenderer{
+ data: r,
+ templateFS: terraformFS,
+ db: db,
+ ownerErrors: make(map[uuid.UUID]error),
+ close: func() {
+ // Up to 2 files are cached, and must be released when rendering is complete.
+ // TODO: Might be smart to always call release when the context is
+ // canceled.
+ templateFS.Close()
+ if moduleFilesFS != nil {
+ moduleFilesFS.Close()
+ }
+ },
+ }, nil
+}
+
+type dynamicRenderer struct {
+ db database.Store
+ data *loader
+ templateFS fs.FS
+
+ ownerErrors map[uuid.UUID]error
+ currentOwner *previewtypes.WorkspaceOwner
+
+ once sync.Once
+ close func()
+}
+
+func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
+ // Always start with the cached error, if we have one.
+ ownerErr := r.ownerErrors[ownerID]
+ if ownerErr == nil {
+ ownerErr = r.getWorkspaceOwnerData(ctx, ownerID)
+ }
+
+ if ownerErr != nil || r.currentOwner == nil {
+ r.ownerErrors[ownerID] = ownerErr
+ return nil, hcl.Diagnostics{
+ {
+ Severity: hcl.DiagError,
+ Summary: "Failed to fetch workspace owner",
+ Detail: "Please check your permissions or the user may not exist.",
+ Extra: previewtypes.DiagnosticExtra{
+ Code: "owner_not_found",
+ },
+ },
+ }
+ }
+
+ input := preview.Input{
+ PlanJSON: r.data.terraformValues.CachedPlan,
+ ParameterValues: values,
+ Owner: *r.currentOwner,
+ // Do not emit parser logs to coderd output logs.
+ // TODO: Returning this logs in the output would benefit the caller.
+ // Unsure how large the logs can be, so for now we just discard them.
+ Logger: slog.New(slog.DiscardHandler),
+ }
+
+ return preview.Preview(ctx, input, r.templateFS)
+}
+
+func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uuid.UUID) error {
+ if r.currentOwner != nil && r.currentOwner.ID == ownerID.String() {
+ 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
+ // read the user though.
+ mem, err := database.ExpectOne(r.db.OrganizationMembers(ctx, database.OrganizationMembersParams{
+ OrganizationID: r.data.templateVersion.OrganizationID,
+ UserID: ownerID,
+ IncludeSystem: false,
+ }))
+ if err != nil {
+ return err
+ }
+
+ // User data is required for the form. Org member is checked above
+ // nolint:gocritic
+ user, err := r.db.GetUserByID(dbauthz.AsProvisionerd(ctx), mem.OrganizationMember.UserID)
+ if err != nil {
+ 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
+ }
+ 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,
+ })
+ }
+ return nil
+ })
+
+ 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
+ })
+
+ 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
+ })
+
+ err = g.Wait()
+ if err != nil {
+ return err
+ }
+
+ r.currentOwner = &previewtypes.WorkspaceOwner{
+ ID: mem.OrganizationMember.UserID.String(),
+ Name: mem.Username,
+ FullName: mem.Name,
+ Email: mem.Email,
+ LoginType: string(user.LoginType),
+ RBACRoles: ownerRoles,
+ SSHPublicKey: publicKey,
+ Groups: groupNames,
+ }
+ return nil
+}
+
+func (r *dynamicRenderer) Close() {
+ r.once.Do(r.close)
+}
+
+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/dynamicparameters/static.go b/coderd/dynamicparameters/static.go
new file mode 100644
index 0000000000000..14988a2d162c0
--- /dev/null
+++ b/coderd/dynamicparameters/static.go
@@ -0,0 +1,143 @@
+package dynamicparameters
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/google/uuid"
+ "github.com/hashicorp/hcl/v2"
+ "golang.org/x/xerrors"
+
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/db2sdk"
+ "github.com/coder/coder/v2/coderd/util/ptr"
+ sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
+ "github.com/coder/preview"
+ previewtypes "github.com/coder/preview/types"
+ "github.com/coder/terraform-provider-coder/v2/provider"
+)
+
+type staticRender struct {
+ staticParams []previewtypes.Parameter
+}
+
+func (r *loader) staticRender(ctx context.Context, db database.Store) (*staticRender, error) {
+ dbTemplateVersionParameters, err := db.GetTemplateVersionParameters(ctx, r.templateVersionID)
+ if err != nil {
+ return nil, xerrors.Errorf("template version parameters: %w", err)
+ }
+
+ params := db2sdk.List(dbTemplateVersionParameters, TemplateVersionParameter)
+ return &staticRender{
+ staticParams: params,
+ }, nil
+}
+
+func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
+ params := r.staticParams
+ for i := range params {
+ param := ¶ms[i]
+ paramValue, ok := values[param.Name]
+ if ok {
+ param.Value = previewtypes.StringLiteral(paramValue)
+ } else {
+ param.Value = param.DefaultValue
+ }
+ param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value))
+ }
+
+ return &preview.Output{
+ Parameters: params,
+ }, hcl.Diagnostics{
+ {
+ // Only a warning because the form does still work.
+ Severity: hcl.DiagWarning,
+ Summary: "This template version is missing required metadata to support dynamic parameters.",
+ Detail: "To restore full functionality, please re-import the terraform as a new template version.",
+ },
+ }
+}
+
+func (*staticRender) Close() {}
+
+func TemplateVersionParameter(it database.TemplateVersionParameter) previewtypes.Parameter {
+ param := previewtypes.Parameter{
+ ParameterData: previewtypes.ParameterData{
+ Name: it.Name,
+ DisplayName: it.DisplayName,
+ Description: it.Description,
+ Type: previewtypes.ParameterType(it.Type),
+ FormType: provider.ParameterFormType(it.FormType),
+ Styling: previewtypes.ParameterStyling{},
+ Mutable: it.Mutable,
+ DefaultValue: previewtypes.StringLiteral(it.DefaultValue),
+ Icon: it.Icon,
+ Options: make([]*previewtypes.ParameterOption, 0),
+ Validations: make([]*previewtypes.ParameterValidation, 0),
+ Required: it.Required,
+ Order: int64(it.DisplayOrder),
+ Ephemeral: it.Ephemeral,
+ Source: nil,
+ },
+ // Always use the default, since we used to assume the empty string
+ Value: previewtypes.StringLiteral(it.DefaultValue),
+ Diagnostics: make(previewtypes.Diagnostics, 0),
+ }
+
+ if it.ValidationError != "" || it.ValidationRegex != "" || it.ValidationMonotonic != "" {
+ var reg *string
+ if it.ValidationRegex != "" {
+ reg = ptr.Ref(it.ValidationRegex)
+ }
+
+ var vMin *int64
+ if it.ValidationMin.Valid {
+ vMin = ptr.Ref(int64(it.ValidationMin.Int32))
+ }
+
+ var vMax *int64
+ if it.ValidationMax.Valid {
+ vMax = ptr.Ref(int64(it.ValidationMax.Int32))
+ }
+
+ var monotonic *string
+ if it.ValidationMonotonic != "" {
+ monotonic = ptr.Ref(it.ValidationMonotonic)
+ }
+
+ param.Validations = append(param.Validations, &previewtypes.ParameterValidation{
+ Error: it.ValidationError,
+ Regex: reg,
+ Min: vMin,
+ Max: vMax,
+ Monotonic: monotonic,
+ })
+ }
+
+ var protoOptions []*sdkproto.RichParameterOption
+ err := json.Unmarshal(it.Options, &protoOptions)
+ if err != nil {
+ param.Diagnostics = append(param.Diagnostics, &hcl.Diagnostic{
+ Severity: hcl.DiagError,
+ Summary: "Failed to parse json parameter options",
+ Detail: err.Error(),
+ })
+ }
+
+ for _, opt := range protoOptions {
+ param.Options = append(param.Options, &previewtypes.ParameterOption{
+ Name: opt.Name,
+ Description: opt.Description,
+ Value: previewtypes.StringLiteral(opt.Value),
+ Icon: opt.Icon,
+ })
+ }
+
+ // Take the form type from the ValidateFormType function. This is a bit
+ // 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/parameters.go b/coderd/parameters.go
index dacd8de812ab8..4b8b13486934f 100644
--- a/coderd/parameters.go
+++ b/coderd/parameters.go
@@ -2,31 +2,18 @@ package coderd
import (
"context"
- "database/sql"
- "encoding/json"
- "io/fs"
"net/http"
"time"
"github.com/google/uuid"
- "github.com/hashicorp/hcl/v2"
- "golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
- "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
- "github.com/coder/coder/v2/coderd/database/dbauthz"
- "github.com/coder/coder/v2/coderd/files"
+ "github.com/coder/coder/v2/coderd/dynamicparameters"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
- "github.com/coder/coder/v2/coderd/util/ptr"
- "github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
- sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
- "github.com/coder/preview"
- previewtypes "github.com/coder/preview/types"
- "github.com/coder/terraform-provider-coder/v2/provider"
"github.com/coder/websocket"
)
@@ -81,292 +68,54 @@ func (api *API) templateVersionDynamicParametersWebsocket(rw http.ResponseWriter
})(rw, r)
}
+// The `listen` control flag determines whether to open a websocket connection to
+// handle the request or not. This same function is used to 'evaluate' a template
+// as a single invocation, or to 'listen' for a back and forth interaction with
+// the user to update the form as they type.
+//
+//nolint:revive // listen is a control flag
func (api *API) templateVersionDynamicParameters(listen bool, initial codersdk.DynamicParametersRequest) func(rw http.ResponseWriter, r *http.Request) {
return func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
templateVersion := httpmw.TemplateVersionParam(r)
- // Check that the job has completed successfully
- job, err := api.Database.GetProvisionerJobByID(ctx, templateVersion.JobID)
- if httpapi.Is404Error(err) {
- httpapi.ResourceNotFound(rw)
- return
- }
+ renderer, err := dynamicparameters.Prepare(ctx, api.Database, api.FileCache, templateVersion.ID,
+ dynamicparameters.WithTemplateVersion(templateVersion),
+ )
if err != nil {
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Internal error fetching provisioner job.",
- Detail: err.Error(),
- })
- return
- }
- if !job.CompletedAt.Valid {
- httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{
- Message: "Template version job has not finished",
- })
- return
- }
+ if httpapi.Is404Error(err) {
+ httpapi.ResourceNotFound(rw)
+ return
+ }
+
+ if xerrors.Is(err, dynamicparameters.ErrTemplateVersionNotReady) {
+ httpapi.Write(ctx, rw, http.StatusTooEarly, codersdk.Response{
+ Message: "Template version job has not finished",
+ })
+ return
+ }
- tf, err := api.Database.GetTemplateVersionTerraformValues(ctx, templateVersion.ID)
- if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Failed to retrieve Terraform values for template version",
+ Message: "Internal error fetching template version data.",
Detail: err.Error(),
})
return
}
+ defer renderer.Close()
- if wsbuilder.ProvisionerVersionSupportsDynamicParameters(tf.ProvisionerdVersion) {
- api.handleDynamicParameters(listen, rw, r, tf, templateVersion, initial)
+ if listen {
+ api.handleParameterWebsocket(rw, r, initial, renderer)
} else {
- api.handleStaticParameters(listen, rw, r, templateVersion.ID, initial)
- }
- }
-}
-
-type previewFunction func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics)
-
-// nolint:revive
-func (api *API) handleDynamicParameters(listen bool, rw http.ResponseWriter, r *http.Request, tf database.TemplateVersionTerraformValue, templateVersion database.TemplateVersion, initial codersdk.DynamicParametersRequest) {
- var (
- ctx = r.Context()
- apikey = httpmw.APIKey(r)
- )
-
- // nolint:gocritic // We need to fetch the templates files for the Terraform
- // evaluator, and the user likely does not have permission.
- fileCtx := dbauthz.AsFileReader(ctx)
- fileID, err := api.Database.GetFileIDByTemplateVersionID(fileCtx, templateVersion.ID)
- if err != nil {
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Internal error finding template version Terraform.",
- Detail: err.Error(),
- })
- return
- }
-
- // Add the file first. Calling `Release` if it fails is a no-op, so this is safe.
- var templateFS fs.FS
- closeableTemplateFS, err := api.FileCache.Acquire(fileCtx, fileID)
- if err != nil {
- httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
- Message: "Internal error fetching template version Terraform.",
- Detail: err.Error(),
- })
- return
- }
- defer closeableTemplateFS.Close()
- // templateFS does not implement the Close method. For it to be later merged with
- // the module files, we need to convert it to an OverlayFS.
- templateFS = closeableTemplateFS
-
- // Having the Terraform plan available for the evaluation engine is helpful
- // for populating values from data blocks, but isn't strictly required. If
- // we don't have a cached plan available, we just use an empty one instead.
- plan := json.RawMessage("{}")
- if len(tf.CachedPlan) > 0 {
- plan = tf.CachedPlan
- }
-
- if tf.CachedModuleFiles.Valid {
- moduleFilesFS, err := api.FileCache.Acquire(fileCtx, tf.CachedModuleFiles.UUID)
- if err != nil {
- httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
- Message: "Internal error fetching Terraform modules.",
- Detail: err.Error(),
- })
- return
- }
- defer moduleFilesFS.Close()
-
- templateFS = files.NewOverlayFS(closeableTemplateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
- }
-
- owner, err := getWorkspaceOwnerData(ctx, api.Database, apikey.UserID, templateVersion.OrganizationID)
- if err != nil {
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Internal error fetching workspace owner.",
- Detail: err.Error(),
- })
- return
- }
-
- input := preview.Input{
- PlanJSON: plan,
- ParameterValues: map[string]string{},
- Owner: owner,
- }
-
- // failedOwners keeps track of which owners failed to fetch from the database.
- // This prevents db spam on repeated requests for the same failed owner.
- failedOwners := make(map[uuid.UUID]error)
- failedOwnerDiag := hcl.Diagnostics{
- {
- Severity: hcl.DiagError,
- Summary: "Failed to fetch workspace owner",
- Detail: "Please check your permissions or the user may not exist.",
- Extra: previewtypes.DiagnosticExtra{
- Code: "owner_not_found",
- },
- },
- }
-
- dynamicRender := func(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
- if ownerID == uuid.Nil {
- // Default to the authenticated user
- // Nice for testing
- ownerID = apikey.UserID
- }
-
- if _, ok := failedOwners[ownerID]; ok {
- // If it has failed once, assume it will fail always.
- // Re-open the websocket to try again.
- return nil, failedOwnerDiag
- }
-
- // Update the input values with the new values.
- input.ParameterValues = values
-
- // Update the owner if there is a change
- if input.Owner.ID != ownerID.String() {
- owner, err = getWorkspaceOwnerData(ctx, api.Database, ownerID, templateVersion.OrganizationID)
- if err != nil {
- failedOwners[ownerID] = err
- return nil, failedOwnerDiag
- }
- input.Owner = owner
- }
-
- return preview.Preview(ctx, input, templateFS)
- }
- if listen {
- api.handleParameterWebsocket(rw, r, initial, dynamicRender)
- } else {
- api.handleParameterEvaluate(rw, r, initial, dynamicRender)
- }
-}
-
-// nolint:revive
-func (api *API) handleStaticParameters(listen bool, rw http.ResponseWriter, r *http.Request, version uuid.UUID, initial codersdk.DynamicParametersRequest) {
- ctx := r.Context()
- dbTemplateVersionParameters, err := api.Database.GetTemplateVersionParameters(ctx, version)
- if err != nil {
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "Failed to retrieve template version parameters",
- Detail: err.Error(),
- })
- return
- }
-
- params := make([]previewtypes.Parameter, 0, len(dbTemplateVersionParameters))
- for _, it := range dbTemplateVersionParameters {
- param := previewtypes.Parameter{
- ParameterData: previewtypes.ParameterData{
- Name: it.Name,
- DisplayName: it.DisplayName,
- Description: it.Description,
- Type: previewtypes.ParameterType(it.Type),
- FormType: "", // ooooof
- Styling: previewtypes.ParameterStyling{},
- Mutable: it.Mutable,
- DefaultValue: previewtypes.StringLiteral(it.DefaultValue),
- Icon: it.Icon,
- Options: make([]*previewtypes.ParameterOption, 0),
- Validations: make([]*previewtypes.ParameterValidation, 0),
- Required: it.Required,
- Order: int64(it.DisplayOrder),
- Ephemeral: it.Ephemeral,
- Source: nil,
- },
- // Always use the default, since we used to assume the empty string
- Value: previewtypes.StringLiteral(it.DefaultValue),
- Diagnostics: nil,
- }
-
- if it.ValidationError != "" || it.ValidationRegex != "" || it.ValidationMonotonic != "" {
- var reg *string
- if it.ValidationRegex != "" {
- reg = ptr.Ref(it.ValidationRegex)
- }
-
- var vMin *int64
- if it.ValidationMin.Valid {
- vMin = ptr.Ref(int64(it.ValidationMin.Int32))
- }
-
- var vMax *int64
- if it.ValidationMax.Valid {
- vMin = ptr.Ref(int64(it.ValidationMax.Int32))
- }
-
- var monotonic *string
- if it.ValidationMonotonic != "" {
- monotonic = ptr.Ref(it.ValidationMonotonic)
- }
-
- param.Validations = append(param.Validations, &previewtypes.ParameterValidation{
- Error: it.ValidationError,
- Regex: reg,
- Min: vMin,
- Max: vMax,
- Monotonic: monotonic,
- })
- }
-
- var protoOptions []*sdkproto.RichParameterOption
- _ = json.Unmarshal(it.Options, &protoOptions) // Not going to make this fatal
- for _, opt := range protoOptions {
- param.Options = append(param.Options, &previewtypes.ParameterOption{
- Name: opt.Name,
- Description: opt.Description,
- Value: previewtypes.StringLiteral(opt.Value),
- Icon: opt.Icon,
- })
+ api.handleParameterEvaluate(rw, r, initial, renderer)
}
-
- // Take the form type from the ValidateFormType function. This is a bit
- // 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 = previewtypes.Diagnostics(param.Valid(param.Value))
- params = append(params, param)
- }
-
- staticRender := func(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
- for i := range params {
- param := ¶ms[i]
- paramValue, ok := values[param.Name]
- if ok {
- param.Value = previewtypes.StringLiteral(paramValue)
- } else {
- param.Value = param.DefaultValue
- }
- param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value))
- }
-
- return &preview.Output{
- Parameters: params,
- }, hcl.Diagnostics{
- {
- // Only a warning because the form does still work.
- Severity: hcl.DiagWarning,
- Summary: "This template version is missing required metadata to support dynamic parameters.",
- Detail: "To restore full functionality, please re-import the terraform as a new template version.",
- },
- }
- }
- if listen {
- api.handleParameterWebsocket(rw, r, initial, staticRender)
- } else {
- api.handleParameterEvaluate(rw, r, initial, staticRender)
}
}
-func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render previewFunction) {
+func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render dynamicparameters.Renderer) {
ctx := r.Context()
// Send an initial form state, computed without any user input.
- result, diagnostics := render(ctx, initial.OwnerID, initial.Inputs)
+ result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs)
response := codersdk.DynamicParametersResponse{
ID: 0,
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
@@ -378,7 +127,7 @@ func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, ini
httpapi.Write(ctx, rw, http.StatusOK, response)
}
-func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render previewFunction) {
+func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request, initial codersdk.DynamicParametersRequest, render dynamicparameters.Renderer) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
@@ -398,7 +147,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
)
// Send an initial form state, computed without any user input.
- result, diagnostics := render(ctx, initial.OwnerID, initial.Inputs)
+ result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs)
response := codersdk.DynamicParametersResponse{
ID: -1, // Always start with -1.
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
@@ -415,6 +164,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
// As the user types into the form, reprocess the state using their input,
// and respond with updates.
updates := stream.Chan()
+ ownerID := initial.OwnerID
for {
select {
case <-ctx.Done():
@@ -426,7 +176,15 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
return
}
- result, diagnostics := render(ctx, update.OwnerID, update.Inputs)
+ // Take a nil uuid to mean the previous owner ID.
+ // This just removes the need to constantly send who you are.
+ if update.OwnerID == uuid.Nil {
+ update.OwnerID = ownerID
+ }
+
+ ownerID = update.OwnerID
+
+ result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs)
response := codersdk.DynamicParametersResponse{
ID: update.ID,
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
@@ -442,98 +200,3 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
}
}
}
-
-func getWorkspaceOwnerData(
- ctx context.Context,
- db database.Store,
- ownerID uuid.UUID,
- organizationID uuid.UUID,
-) (previewtypes.WorkspaceOwner, error) {
- var g errgroup.Group
-
- // TODO: @emyrk we should only need read access on the org member, not the
- // site wide user object. Figure out a better way to handle this.
- user, err := db.GetUserByID(ctx, ownerID)
- if err != nil {
- return previewtypes.WorkspaceOwner{}, 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 := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), ownerID)
- if err != nil {
- return err
- }
- roles, err := row.RoleNames()
- if err != nil {
- return err
- }
- ownerRoles = make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles))
- for _, it := range roles {
- if it.OrganizationID != uuid.Nil && it.OrganizationID != organizationID {
- continue
- }
- var orgID string
- if it.OrganizationID != uuid.Nil {
- orgID = it.OrganizationID.String()
- }
- ownerRoles = append(ownerRoles, previewtypes.WorkspaceOwnerRBACRole{
- Name: it.Name,
- OrgID: orgID,
- })
- }
- return nil
- })
-
- 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 := db.GetGitSSHKey(dbauthz.AsSystemRestricted(ctx), ownerID)
- if err != nil {
- return err
- }
- publicKey = key.PublicKey
- return nil
- })
-
- 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 := db.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{
- OrganizationID: 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
- })
-
- err = g.Wait()
- if err != nil {
- return previewtypes.WorkspaceOwner{}, err
- }
-
- return previewtypes.WorkspaceOwner{
- ID: user.ID.String(),
- Name: user.Username,
- FullName: user.Name,
- Email: user.Email,
- LoginType: string(user.LoginType),
- RBACRoles: ownerRoles,
- SSHPublicKey: publicKey,
- Groups: groupNames,
- }, nil
-}
diff --git a/coderd/parameters_test.go b/coderd/parameters_test.go
index 3c792c2ce9a7a..794ff8db3354d 100644
--- a/coderd/parameters_test.go
+++ b/coderd/parameters_test.go
@@ -203,11 +203,16 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
provisionerDaemonVersion: provProto.CurrentVersion.String(),
mainTF: dynamicParametersTerraformSource,
modulesArchive: modulesArchive,
- expectWebsocketError: true,
})
- // This is checked in setupDynamicParamsTest. Just doing this in the
- // test to make it obvious what this test is doing.
- require.Zero(t, setup.api.FileCache.Count())
+
+ stream := setup.stream
+ previews := stream.Chan()
+
+ // Assert the failed owner
+ ctx := testutil.Context(t, testutil.WaitShort)
+ preview := testutil.RequireReceive(ctx, t, previews)
+ require.Len(t, preview.Diagnostics, 1)
+ require.Equal(t, preview.Diagnostics[0].Summary, "Failed to fetch workspace owner")
})
t.Run("RebuildParameters", func(t *testing.T) {
@@ -363,28 +368,12 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
owner := coderdtest.CreateFirstUser(t, ownerClient)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
- files := echo.WithExtraFiles(map[string][]byte{
- "main.tf": args.mainTF,
- })
- files.ProvisionPlan = []*proto.Response{{
- Type: &proto.Response_Plan{
- Plan: &proto.PlanComplete{
- Plan: args.plan,
- ModuleFiles: args.modulesArchive,
- Parameters: args.static,
- },
- },
- }}
-
- version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, files)
- coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID)
- tpl := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
-
- var err error
- tpl, err = templateAdmin.UpdateTemplateMeta(t.Context(), tpl.ID, codersdk.UpdateTemplateMeta{
- UseClassicParameterFlow: ptr.Ref(false),
+ tpl, version := coderdtest.DynamicParameterTemplate(t, templateAdmin, owner.OrganizationID, coderdtest.DynamicParameterTemplateParams{
+ MainTF: string(args.mainTF),
+ Plan: args.plan,
+ ModulesArchive: args.modulesArchive,
+ StaticParams: args.static,
})
- require.NoError(t, err)
ctx := testutil.Context(t, testutil.WaitShort)
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, codersdk.Me, version.ID)
diff --git a/coderd/util/slice/slice.go b/coderd/util/slice/slice.go
index f3811650786b7..2a510e24d2b53 100644
--- a/coderd/util/slice/slice.go
+++ b/coderd/util/slice/slice.go
@@ -217,3 +217,16 @@ func CountConsecutive[T comparable](needle T, haystack ...T) int {
return max(maxLength, curLength)
}
+
+// Convert converts a slice of type F to a slice of type T using the provided function f.
+func Convert[F any, T any](a []F, f func(F) T) []T {
+ if a == nil {
+ return []T{}
+ }
+
+ tmp := make([]T, 0, len(a))
+ for _, v := range a {
+ tmp = append(tmp, f(v))
+ }
+ return tmp
+}
diff --git a/enterprise/coderd/dynamicparameters_test.go b/enterprise/coderd/dynamicparameters_test.go
new file mode 100644
index 0000000000000..60d68fecd87d1
--- /dev/null
+++ b/enterprise/coderd/dynamicparameters_test.go
@@ -0,0 +1,129 @@
+package coderd_test
+
+import (
+ _ "embed"
+ "os"
+ "testing"
+
+ "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/codersdk"
+ "github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
+ "github.com/coder/coder/v2/enterprise/coderd/license"
+ "github.com/coder/coder/v2/testutil"
+ "github.com/coder/websocket"
+)
+
+// TestDynamicParameterTemplate uses a template with some dynamic elements, and
+// tests the parameters, values, etc are all as expected.
+func TestDynamicParameterTemplate(t *testing.T) {
+ t.Parallel()
+
+ owner, _, api, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{IncludeProvisionerDaemon: true},
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureTemplateRBAC: 1,
+ },
+ },
+ })
+
+ orgID := first.OrganizationID
+
+ _, userData := coderdtest.CreateAnotherUser(t, owner, orgID)
+ templateAdmin, templateAdminData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgTemplateAdmin(orgID))
+ userAdmin, userAdminData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgUserAdmin(orgID))
+ _, auditorData := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgAuditor(orgID))
+
+ coderdtest.CreateGroup(t, owner, orgID, "developer", auditorData, userData)
+ coderdtest.CreateGroup(t, owner, orgID, "admin", templateAdminData, userAdminData)
+ coderdtest.CreateGroup(t, owner, orgID, "auditor", auditorData, templateAdminData, userAdminData)
+
+ dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/dynamic/main.tf")
+ require.NoError(t, err)
+
+ _, version := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{
+ MainTF: string(dynamicParametersTerraformSource),
+ Plan: nil,
+ ModulesArchive: nil,
+ StaticParams: nil,
+ })
+
+ _ = userAdmin
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, userData.ID.String(), version.ID)
+ require.NoError(t, err)
+ defer func() {
+ _ = stream.Close(websocket.StatusNormalClosure)
+
+ // Wait until the cache ends up empty. This verifies the cache does not
+ // leak any files.
+ require.Eventually(t, func() bool {
+ return api.AGPL.FileCache.Count() == 0
+ }, testutil.WaitShort, testutil.IntervalFast, "file cache should be empty after the test")
+ }()
+
+ // Initial response
+ preview, pop := coderdtest.SynchronousStream(stream)
+ init := pop()
+ require.Len(t, init.Diagnostics, 0, "no top level diags")
+ coderdtest.AssertParameter(t, "isAdmin", init.Parameters).
+ Exists().Value("false")
+ coderdtest.AssertParameter(t, "adminonly", init.Parameters).
+ NotExists()
+ coderdtest.AssertParameter(t, "groups", init.Parameters).
+ Exists().Options(database.EveryoneGroup, "developer")
+
+ // Switch to an admin
+ resp, err := preview(codersdk.DynamicParametersRequest{
+ ID: 1,
+ Inputs: map[string]string{
+ "colors": `["red"]`,
+ "thing": "apple",
+ },
+ OwnerID: userAdminData.ID,
+ })
+ require.NoError(t, err)
+ require.Equal(t, resp.ID, 1)
+ require.Len(t, resp.Diagnostics, 0, "no top level diags")
+
+ coderdtest.AssertParameter(t, "isAdmin", resp.Parameters).
+ Exists().Value("true")
+ coderdtest.AssertParameter(t, "adminonly", resp.Parameters).
+ Exists()
+ coderdtest.AssertParameter(t, "groups", resp.Parameters).
+ Exists().Options(database.EveryoneGroup, "admin", "auditor")
+ coderdtest.AssertParameter(t, "colors", resp.Parameters).
+ Exists().Value(`["red"]`)
+ coderdtest.AssertParameter(t, "thing", resp.Parameters).
+ Exists().Value("apple").Options("apple", "ruby")
+ coderdtest.AssertParameter(t, "cool", resp.Parameters).
+ NotExists()
+
+ // Try some other colors
+ resp, err = preview(codersdk.DynamicParametersRequest{
+ ID: 2,
+ Inputs: map[string]string{
+ "colors": `["yellow", "blue"]`,
+ "thing": "banana",
+ },
+ OwnerID: userAdminData.ID,
+ })
+ require.NoError(t, err)
+ require.Equal(t, resp.ID, 2)
+ require.Len(t, resp.Diagnostics, 0, "no top level diags")
+
+ coderdtest.AssertParameter(t, "cool", resp.Parameters).
+ Exists()
+ coderdtest.AssertParameter(t, "isAdmin", resp.Parameters).
+ Exists().Value("true")
+ coderdtest.AssertParameter(t, "colors", resp.Parameters).
+ Exists().Value(`["yellow", "blue"]`)
+ coderdtest.AssertParameter(t, "thing", resp.Parameters).
+ Exists().Value("banana").Options("banana", "ocean", "sky")
+}
diff --git a/enterprise/coderd/parameters_test.go b/enterprise/coderd/parameters_test.go
index 93f5057206527..bda9e3c59e021 100644
--- a/enterprise/coderd/parameters_test.go
+++ b/enterprise/coderd/parameters_test.go
@@ -31,7 +31,7 @@ func TestDynamicParametersOwnerGroups(t *testing.T) {
Options: &coderdtest.Options{IncludeProvisionerDaemon: true},
},
)
- templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
+ templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(owner.OrganizationID))
_, noGroupUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
// Create the group to be asserted
@@ -79,10 +79,10 @@ func TestDynamicParametersOwnerGroups(t *testing.T) {
require.NoError(t, err)
defer stream.Close(websocket.StatusGoingAway)
- previews := stream.Chan()
+ previews, pop := coderdtest.SynchronousStream(stream)
// Should automatically send a form state with all defaulted/empty values
- preview := testutil.RequireReceive(ctx, t, previews)
+ preview := pop()
require.Equal(t, -1, preview.ID)
require.Empty(t, preview.Diagnostics)
require.Equal(t, "group", preview.Parameters[0].Name)
@@ -90,12 +90,11 @@ func TestDynamicParametersOwnerGroups(t *testing.T) {
require.Equal(t, database.EveryoneGroup, preview.Parameters[0].Value.Value)
// Send a new value, and see it reflected
- err = stream.Send(codersdk.DynamicParametersRequest{
+ preview, err = previews(codersdk.DynamicParametersRequest{
ID: 1,
Inputs: map[string]string{"group": group.Name},
})
require.NoError(t, err)
- preview = testutil.RequireReceive(ctx, t, previews)
require.Equal(t, 1, preview.ID)
require.Empty(t, preview.Diagnostics)
require.Equal(t, "group", preview.Parameters[0].Name)
@@ -103,12 +102,11 @@ func TestDynamicParametersOwnerGroups(t *testing.T) {
require.Equal(t, group.Name, preview.Parameters[0].Value.Value)
// Back to default
- err = stream.Send(codersdk.DynamicParametersRequest{
+ preview, err = previews(codersdk.DynamicParametersRequest{
ID: 3,
Inputs: map[string]string{},
})
require.NoError(t, err)
- preview = testutil.RequireReceive(ctx, t, previews)
require.Equal(t, 3, preview.ID)
require.Empty(t, preview.Diagnostics)
require.Equal(t, "group", preview.Parameters[0].Name)
diff --git a/enterprise/coderd/testdata/parameters/dynamic/main.tf b/enterprise/coderd/testdata/parameters/dynamic/main.tf
new file mode 100644
index 0000000000000..615f57dc9c074
--- /dev/null
+++ b/enterprise/coderd/testdata/parameters/dynamic/main.tf
@@ -0,0 +1,103 @@
+terraform {
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = "2.5.3"
+ }
+ }
+}
+
+data "coder_workspace_owner" "me" {}
+
+locals {
+ isAdmin = contains(data.coder_workspace_owner.me.groups, "admin")
+}
+
+data "coder_parameter" "isAdmin" {
+ name = "isAdmin"
+ type = "bool"
+ form_type = "switch"
+ default = local.isAdmin
+ order = 1
+}
+
+data "coder_parameter" "adminonly" {
+ count = local.isAdmin ? 1 : 0
+ name = "adminonly"
+ form_type = "input"
+ type = "string"
+ default = "I am an admin!"
+ order = 2
+}
+
+
+data "coder_parameter" "groups" {
+ name = "groups"
+ type = "list(string)"
+ form_type = "multi-select"
+ default = jsonencode([data.coder_workspace_owner.me.groups[0]])
+ order = 50
+
+ dynamic "option" {
+ for_each = data.coder_workspace_owner.me.groups
+ content {
+ name = option.value
+ value = option.value
+ }
+ }
+}
+
+locals {
+ colors = {
+ "red" : ["apple", "ruby"]
+ "yellow" : ["banana"]
+ "blue" : ["ocean", "sky"]
+ }
+}
+
+data "coder_parameter" "colors" {
+ name = "colors"
+ type = "list(string)"
+ form_type = "multi-select"
+ order = 100
+
+ dynamic "option" {
+ for_each = keys(local.colors)
+ content {
+ name = option.value
+ value = option.value
+ }
+ }
+}
+
+locals {
+ selected = jsondecode(data.coder_parameter.colors.value)
+ things = flatten([
+ for color in local.selected : local.colors[color]
+ ])
+}
+
+data "coder_parameter" "thing" {
+ name = "thing"
+ type = "string"
+ form_type = "dropdown"
+ order = 101
+
+ dynamic "option" {
+ for_each = local.things
+ content {
+ name = option.value
+ value = option.value
+ }
+ }
+}
+
+// Cool people like blue. Idk what to tell you.
+data "coder_parameter" "cool" {
+ count = contains(local.selected, "blue") ? 1 : 0
+ name = "cool"
+ type = "bool"
+ form_type = "switch"
+ order = 102
+ default = "true"
+}
From 556b095d0fac0e29624a880bef311d27637c91fe Mon Sep 17 00:00:00 2001
From: Steven Masley
Date: Fri, 20 Jun 2025 13:25:33 -0500
Subject: [PATCH 7/9] chore: add cacheCloser to cleanup all opened files
(#18473)
---
coderd/dynamicparameters/render.go | 34 +++++++++---------
coderd/files/cache.go | 4 +++
coderd/files/closer.go | 57 ++++++++++++++++++++++++++++++
3 files changed, 79 insertions(+), 16 deletions(-)
create mode 100644 coderd/files/closer.go
diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go
index 9c4c73f87e5bc..b6a77c4704225 100644
--- a/coderd/dynamicparameters/render.go
+++ b/coderd/dynamicparameters/render.go
@@ -131,46 +131,48 @@ func (r *loader) Renderer(ctx context.Context, db database.Store, cache *files.C
return r.staticRender(ctx, db)
}
- return r.dynamicRenderer(ctx, db, cache)
+ return r.dynamicRenderer(ctx, db, files.NewCacheCloser(cache))
}
// Renderer caches all the necessary files when rendering a template version's
// parameters. It must be closed after use to release the cached files.
-func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache *files.Cache) (*dynamicRenderer, error) {
+func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache *files.CacheCloser) (*dynamicRenderer, error) {
+ closeFiles := true // If the function returns with no error, this will toggle to false.
+ defer func() {
+ if closeFiles {
+ cache.Close()
+ }
+ }()
+
// If they can read the template version, then they can read the file for
// parameter loading purposes.
//nolint:gocritic
fileCtx := dbauthz.AsFileReader(ctx)
- templateFS, err := cache.Acquire(fileCtx, r.job.FileID)
+
+ var templateFS fs.FS
+ var err error
+
+ templateFS, err = cache.Acquire(fileCtx, r.job.FileID)
if err != nil {
return nil, xerrors.Errorf("acquire template file: %w", err)
}
- var terraformFS fs.FS = templateFS
var moduleFilesFS *files.CloseFS
if r.terraformValues.CachedModuleFiles.Valid {
moduleFilesFS, err = cache.Acquire(fileCtx, r.terraformValues.CachedModuleFiles.UUID)
if err != nil {
- templateFS.Close()
return nil, xerrors.Errorf("acquire module files: %w", err)
}
- terraformFS = files.NewOverlayFS(templateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
+ templateFS = files.NewOverlayFS(templateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
}
+ closeFiles = false // Caller will have to call close
return &dynamicRenderer{
data: r,
- templateFS: terraformFS,
+ templateFS: templateFS,
db: db,
ownerErrors: make(map[uuid.UUID]error),
- close: func() {
- // Up to 2 files are cached, and must be released when rendering is complete.
- // TODO: Might be smart to always call release when the context is
- // canceled.
- templateFS.Close()
- if moduleFilesFS != nil {
- moduleFilesFS.Close()
- }
- },
+ close: cache.Close,
}, nil
}
diff --git a/coderd/files/cache.go b/coderd/files/cache.go
index 6e4dc9383b6f1..170abb10b1ff7 100644
--- a/coderd/files/cache.go
+++ b/coderd/files/cache.go
@@ -19,6 +19,10 @@ import (
"github.com/coder/coder/v2/coderd/util/lazy"
)
+type FileAcquirer interface {
+ Acquire(ctx context.Context, 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 {
diff --git a/coderd/files/closer.go b/coderd/files/closer.go
new file mode 100644
index 0000000000000..9bd98fdd60caf
--- /dev/null
+++ b/coderd/files/closer.go
@@ -0,0 +1,57 @@
+package files
+
+import (
+ "context"
+ "sync"
+
+ "github.com/google/uuid"
+ "golang.org/x/xerrors"
+)
+
+// CacheCloser is a cache wrapper used to close all acquired files.
+// This is a more simple interface to use if opening multiple files at once.
+type CacheCloser struct {
+ cache FileAcquirer
+
+ closers []func()
+ mu sync.Mutex
+}
+
+func NewCacheCloser(cache FileAcquirer) *CacheCloser {
+ return &CacheCloser{
+ cache: cache,
+ closers: make([]func(), 0),
+ }
+}
+
+func (c *CacheCloser) Close() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ for _, doClose := range c.closers {
+ doClose()
+ }
+
+ // Prevent further acquisitions
+ c.cache = nil
+ // Remove any references
+ c.closers = nil
+}
+
+func (c *CacheCloser) Acquire(ctx context.Context, fileID uuid.UUID) (*CloseFS, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.cache == nil {
+ return nil, xerrors.New("cache is closed, and cannot acquire new files")
+ }
+
+ f, err := c.cache.Acquire(ctx, fileID)
+ if err != nil {
+ return nil, err
+ }
+
+ c.closers = append(c.closers, f.close)
+
+ return f, nil
+}
From 4fe0a4bca205da1de7e36f6800009b79ba33e817 Mon Sep 17 00:00:00 2001
From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com>
Date: Fri, 20 Jun 2025 15:04:36 -0400
Subject: [PATCH 8/9] feat: add ephemeral parameter dialog for workspace
start/restart (#18413)
resolves #17709
FYI, blink created a first draft which was heavily modified.
## Summary
This PR implements ephemeral parameter handling for workspace
start/restart operations when templates use dynamic parameters
(`use_classic_parameter_flow = false`).

## Changes
### 1. EphemeralParametersDialog Component
- **New**: `site/src/components/EphemeralParametersDialog/`
- Shows a dialog when starting/restarting workspaces with ephemeral
parameters
- Lists ephemeral parameters with names and descriptions
- Provides options to continue without setting values or navigate to
parameters page
### 2. WorkspaceReadyPage Updates
- Added `checkEphemeralParameters()` function using
`API.getDynamicParameters`
- Modified `handleStart` and `handleRestart` to check for ephemeral
parameters
- Only triggers for templates with `use_classic_parameter_flow = false`
- Shows dialog if ephemeral parameters exist, otherwise proceeds
normally
### 3. BuildParametersPopover Updates
- Added special UI for non-classic parameter flow templates with
ephemeral parameters
- Lists ephemeral parameters with descriptions
- Explains that users must use the workspace parameters page
- Provides direct link to `WorkspaceParametersPageExperimental`
---------
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: jaaydenh <1858163+jaaydenh@users.noreply.github.com>
Co-authored-by: Jaayden Halko
---
site/src/components/Badge/Badge.tsx | 2 +
.../EphemeralParametersDialog.tsx | 86 +++++++++++
.../DynamicParameter.stories.tsx | 9 ++
.../DynamicParameter/DynamicParameter.tsx | 18 +++
.../BuildParametersPopover.tsx | 55 ++++++-
.../WorkspacePage/WorkspaceReadyPage.tsx | 142 +++++++++++++++---
...orkspaceParametersPageViewExperimental.tsx | 42 +-----
site/src/testHelpers/entities.ts | 2 +-
8 files changed, 290 insertions(+), 66 deletions(-)
create mode 100644 site/src/components/EphemeralParametersDialog/EphemeralParametersDialog.tsx
diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx
index 3b2a5d5897eb3..0d11c96d30433 100644
--- a/site/src/components/Badge/Badge.tsx
+++ b/site/src/components/Badge/Badge.tsx
@@ -22,6 +22,8 @@ const badgeVariants = cva(
"border border-solid border-border-warning bg-surface-orange text-content-warning shadow",
destructive:
"border border-solid border-border-destructive bg-surface-red text-highlight-red shadow",
+ green:
+ "border border-solid border-surface-green bg-surface-green text-highlight-green shadow",
},
size: {
xs: "text-2xs font-regular h-5 [&_svg]:hidden rounded px-1.5",
diff --git a/site/src/components/EphemeralParametersDialog/EphemeralParametersDialog.tsx b/site/src/components/EphemeralParametersDialog/EphemeralParametersDialog.tsx
new file mode 100644
index 0000000000000..d1713d920f4a9
--- /dev/null
+++ b/site/src/components/EphemeralParametersDialog/EphemeralParametersDialog.tsx
@@ -0,0 +1,86 @@
+import type { TemplateVersionParameter } from "api/typesGenerated";
+import { Button } from "components/Button/Button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "components/Dialog/Dialog";
+import type { FC } from "react";
+import { useNavigate } from "react-router-dom";
+
+interface EphemeralParametersDialogProps {
+ open: boolean;
+ onClose: () => void;
+ onContinue: () => void;
+ ephemeralParameters: TemplateVersionParameter[];
+ workspaceOwner: string;
+ workspaceName: string;
+ templateVersionId: string;
+}
+
+export const EphemeralParametersDialog: FC = ({
+ open,
+ onClose,
+ onContinue,
+ ephemeralParameters,
+ workspaceOwner,
+ workspaceName,
+ templateVersionId,
+}) => {
+ const navigate = useNavigate();
+
+ const handleGoToParameters = () => {
+ onClose();
+ navigate(
+ `/@${workspaceOwner}/${workspaceName}/settings/parameters?templateVersionId=${templateVersionId}`,
+ );
+ };
+
+ return (
+
+ );
+};
diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx
index 4d1e91d9bf3e3..db3fa2f404c53 100644
--- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx
+++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx
@@ -211,6 +211,15 @@ export const Immutable: Story = {
},
};
+export const Ephemeral: Story = {
+ args: {
+ parameter: {
+ ...MockPreviewParameter,
+ ephemeral: true,
+ },
+ },
+};
+
export const AllBadges: Story = {
args: {
parameter: {
diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx
index c3448ac7d7182..9f97d558c8f08 100644
--- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx
+++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx
@@ -36,6 +36,7 @@ import { useDebouncedValue } from "hooks/debounce";
import { useEffectEvent } from "hooks/hookPolyfills";
import {
CircleAlert,
+ Hourglass,
Info,
LinkIcon,
Settings,
@@ -162,6 +163,23 @@ const ParameterLabel: FC = ({
)}
+ {parameter.ephemeral && (
+
+
+
+
+
+
+ Ephemeral
+
+
+
+
+ This parameter only applies for a single workspace start
+
+
+
+ )}
{isPreset && (
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx
index d594351d8dcae..76b100fc96745 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx
@@ -1,5 +1,4 @@
import { useTheme } from "@emotion/react";
-import Button from "@mui/material/Button";
import visuallyHidden from "@mui/utils/visuallyHidden";
import { API } from "api/api";
import type {
@@ -7,6 +6,7 @@ import type {
Workspace,
WorkspaceBuildParameter,
} from "api/typesGenerated";
+import { Button } from "components/Button/Button";
import { FormFields } from "components/Form/Form";
import { TopbarButton } from "components/FullPageLayout/Topbar";
import {
@@ -27,6 +27,7 @@ import { useFormik } from "formik";
import { ChevronDownIcon } from "lucide-react";
import type { FC } from "react";
import { useQuery } from "react-query";
+import { useNavigate } from "react-router-dom";
import { docs } from "utils/docs";
import { getFormHelpers } from "utils/formUtils";
import {
@@ -72,6 +73,7 @@ export const BuildParametersPopover: FC = ({
css={{ ".MuiPaper-root": { width: 304 } }}
>
= ({
};
interface BuildParametersPopoverContentProps {
+ workspace: Workspace;
ephemeralParameters?: TemplateVersionParameter[];
buildParameters?: WorkspaceBuildParameter[];
onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void;
}
const BuildParametersPopoverContent: FC = ({
+ workspace,
ephemeralParameters,
buildParameters,
onSubmit,
}) => {
const theme = useTheme();
const popover = usePopover();
+ const navigate = useNavigate();
+
+ if (
+ !workspace.template_use_classic_parameter_flow &&
+ ephemeralParameters &&
+ ephemeralParameters.length > 0
+ ) {
+ const handleGoToParameters = () => {
+ popover.setOpen(false);
+ navigate(
+ `/@${workspace.owner_name}/${workspace.name}/settings/parameters`,
+ );
+ };
+
+ return (
+
+
+ Ephemeral Parameters
+
+
+ This template has ephemeral parameters that must be configured on the
+ workspace parameters page
+
+
+
+
+
+
+ );
+ }
return (
<>
@@ -206,8 +257,6 @@ const Form: FC = ({
- {standardParameters.map((parameter, index) => {
+ {parameters.map((parameter, index) => {
const currentParameterValueIndex =
form.values.rich_parameter_values?.findIndex(
(p) => p.name === parameter.name,
@@ -260,41 +257,6 @@ export const WorkspaceParametersPageViewExperimental: FC<
)}
- {ephemeralParameters.length > 0 && (
-
-
- Ephemeral Parameters
-
- These parameters only apply for a single workspace start
-
-
-
-
- {ephemeralParameters.map((parameter, index) => {
- const actualIndex = standardParameters.length + index;
- const parameterField = `rich_parameter_values.${actualIndex}`;
- const isDisabled =
- disabled || parameter.styling?.disabled || isSubmitting;
-
- return (
-
- handleChange(parameter, parameterField, value)
- }
- autofill={false}
- disabled={isDisabled}
- value={
- form.values?.rich_parameter_values?.[index]?.value || ""
- }
- />
- );
- })}
-
-
- )}
-