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. ![JetBrains Toolbox Coder Provider certificate path](../../../images/user-guides/jetbrains/toolbox/certificate.png) + +## 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`). Screenshot 2025-06-18 at 14 35 54 Screenshot 2025-06-18 at 14 35 43 ![Screenshot 2025-06-18 at 14 41 41](https://github.com/user-attachments/assets/52f1ab99-f3bf-4540-91ac-e385c632de8c) ## 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 ( + !isOpen && onClose()}> + + + Ephemeral Parameters Detected + + This workspace template has{" "} + + {ephemeralParameters.length} + {" "} + ephemeral parameters that will be reset to their default values + + +
    + {ephemeralParameters.map((param) => ( +
  • +

    + {param.display_name || param.name} +

    + {param.description && ( +

    + {param.description} +

    + )} +
  • + ))} +
+
+ + Would you like to go to the workspace parameters page to review and + update these parameters before continuing? + +
+ + + + +
+
+ ); +}; 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 +

+ +
+
    + {ephemeralParameters.map((param) => ( +
  • +

    + {param.display_name || param.name} +

    + {param.description && ( +

    + {param.description} +

    + )} +
  • + ))} +
+
+ + +
+ ); + } return ( <> @@ -206,8 +257,6 @@ const Form: FC = ({