diff --git a/agent/agent_test.go b/agent/agent_test.go index 1f049f08b65f9..8ee15e563f3ce 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -130,7 +130,6 @@ func TestAgent_Stats_SSH(t *testing.T) { t.Parallel() for _, port := range sshPorts { - port := port t.Run(fmt.Sprintf("(:%d)", port), func(t *testing.T) { t.Parallel() @@ -342,7 +341,6 @@ func TestAgent_SessionExec(t *testing.T) { t.Parallel() for _, port := range sshPorts { - port := port t.Run(fmt.Sprintf("(:%d)", port), func(t *testing.T) { t.Parallel() @@ -468,7 +466,6 @@ func TestAgent_SessionTTYShell(t *testing.T) { } for _, port := range sshPorts { - port := port t.Run(fmt.Sprintf("(%d)", port), func(t *testing.T) { t.Parallel() @@ -611,7 +608,6 @@ func TestAgent_Session_TTY_MOTD(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() session := setupSSHSession(t, test.manifest, test.banner, func(fs afero.Fs) { @@ -688,8 +684,6 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) { //nolint:paralleltest // These tests need to swap the banner func. for _, port := range sshPorts { - port := port - sshClient, err := conn.SSHClientOnPort(ctx, port) require.NoError(t, err) t.Cleanup(func() { @@ -697,7 +691,6 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) { }) for i, test := range tests { - test := test t.Run(fmt.Sprintf("(:%d)/%d", port, i), func(t *testing.T) { // Set new banner func and wait for the agent to call it to update the // banner. @@ -1210,7 +1203,6 @@ func TestAgent_CoderEnvVars(t *testing.T) { t.Parallel() for _, key := range []string{"CODER", "CODER_WORKSPACE_NAME", "CODER_WORKSPACE_OWNER_NAME", "CODER_WORKSPACE_AGENT_NAME"} { - key := key t.Run(key, func(t *testing.T) { t.Parallel() @@ -1233,7 +1225,6 @@ func TestAgent_SSHConnectionEnvVars(t *testing.T) { // For some reason this test produces a TTY locally and a non-TTY in CI // so we don't test for the absence of SSH_TTY. for _, key := range []string{"SSH_CONNECTION", "SSH_CLIENT"} { - key := key t.Run(key, func(t *testing.T) { t.Parallel() @@ -1276,7 +1267,6 @@ func TestAgent_SSHConnectionLoginVars(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.key, func(t *testing.T) { t.Parallel() @@ -1796,7 +1786,6 @@ func TestAgent_ReconnectingPTY(t *testing.T) { t.Setenv("LANG", "C") for _, backendType := range backends { - backendType := backendType t.Run(backendType, func(t *testing.T) { if backendType == "Screen" { if runtime.GOOS != "linux" { @@ -2496,7 +2485,6 @@ func TestAgent_Dial(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() 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/containers_internal_test.go b/agent/agentcontainers/containers_internal_test.go index eeb6a5d0374d1..a60dec75cd845 100644 --- a/agent/agentcontainers/containers_internal_test.go +++ b/agent/agentcontainers/containers_internal_test.go @@ -41,7 +41,6 @@ func TestWrapDockerExec(t *testing.T) { }, } for _, tt := range tests { - tt := tt // appease the linter even though this isn't needed anymore t.Run(tt.name, func(t *testing.T) { t.Parallel() actualCmd, actualArgs := wrapDockerExec("my-container", tt.containerUser, tt.cmdArgs[0], tt.cmdArgs[1:]...) @@ -54,7 +53,6 @@ func TestWrapDockerExec(t *testing.T) { func TestConvertDockerPort(t *testing.T) { t.Parallel() - //nolint:paralleltest // variable recapture no longer required for _, tc := range []struct { name string in string @@ -101,7 +99,6 @@ func TestConvertDockerPort(t *testing.T) { expectError: "invalid port", }, } { - //nolint: paralleltest // variable recapture no longer required t.Run(tc.name, func(t *testing.T) { t.Parallel() actualPort, actualNetwork, actualErr := convertDockerPort(tc.in) @@ -151,7 +148,6 @@ func TestConvertDockerVolume(t *testing.T) { expectError: "invalid volume", }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() }) 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) diff --git a/agent/agentssh/x11_internal_test.go b/agent/agentssh/x11_internal_test.go index fdc3c04668663..f49242eb9f730 100644 --- a/agent/agentssh/x11_internal_test.go +++ b/agent/agentssh/x11_internal_test.go @@ -228,7 +228,6 @@ func Test_addXauthEntry(t *testing.T) { require.NoError(t, err) for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/agent/proto/compare_test.go b/agent/proto/compare_test.go index 3c5bdbf93a9e1..1e2645c59d5bc 100644 --- a/agent/proto/compare_test.go +++ b/agent/proto/compare_test.go @@ -67,7 +67,6 @@ func TestLabelsEqual(t *testing.T) { eq: false, }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() require.Equal(t, tc.eq, proto.LabelsEqual(tc.a, tc.b)) diff --git a/agent/proto/resourcesmonitor/queue_test.go b/agent/proto/resourcesmonitor/queue_test.go index a3a8fbc0d0a3a..770cf9e732ac7 100644 --- a/agent/proto/resourcesmonitor/queue_test.go +++ b/agent/proto/resourcesmonitor/queue_test.go @@ -65,8 +65,6 @@ func TestResourceMonitorQueue(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() queue := resourcesmonitor.NewQueue(20) diff --git a/agent/proto/resourcesmonitor/resources_monitor_test.go b/agent/proto/resourcesmonitor/resources_monitor_test.go index ddf3522ecea30..da8ffef293903 100644 --- a/agent/proto/resourcesmonitor/resources_monitor_test.go +++ b/agent/proto/resourcesmonitor/resources_monitor_test.go @@ -195,7 +195,6 @@ func TestPushResourcesMonitoringWithConfig(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/apiversion/apiversion_test.go b/apiversion/apiversion_test.go index 8a18a0bd5ca8e..dfe80bdb731a5 100644 --- a/apiversion/apiversion_test.go +++ b/apiversion/apiversion_test.go @@ -72,7 +72,6 @@ func TestAPIVersionValidate(t *testing.T) { expectedError: "no longer supported", }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/buildinfo/buildinfo_test.go b/buildinfo/buildinfo_test.go index b83c106148e9e..ac9f5cd4dee83 100644 --- a/buildinfo/buildinfo_test.go +++ b/buildinfo/buildinfo_test.go @@ -93,7 +93,6 @@ func TestBuildInfo(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() require.Equal(t, c.expectMatch, buildinfo.VersionsMatch(c.v1, c.v2), diff --git a/cli/agent_internal_test.go b/cli/agent_internal_test.go index 910effb4191c1..02d65baaf623c 100644 --- a/cli/agent_internal_test.go +++ b/cli/agent_internal_test.go @@ -54,7 +54,6 @@ func Test_extractPort(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := extractPort(tt.urlString) diff --git a/cli/autoupdate_test.go b/cli/autoupdate_test.go index 51001d5109755..84647b0553d1c 100644 --- a/cli/autoupdate_test.go +++ b/cli/autoupdate_test.go @@ -62,7 +62,6 @@ func TestAutoUpdate(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/cli/clitest/golden.go b/cli/clitest/golden.go index d4401d6c5d5f9..fd44b523b9c9f 100644 --- a/cli/clitest/golden.go +++ b/cli/clitest/golden.go @@ -71,7 +71,6 @@ ExtractCommandPathsLoop: } for _, tt := range cases { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/cli/cliui/agent_test.go b/cli/cliui/agent_test.go index 966d53578780a..7c3b71a204c3d 100644 --- a/cli/cliui/agent_test.go +++ b/cli/cliui/agent_test.go @@ -369,7 +369,6 @@ func TestAgent(t *testing.T) { wantErr: true, }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -648,7 +647,6 @@ func TestPeerDiagnostics(t *testing.T) { }, } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() r, w := io.Pipe() @@ -852,7 +850,6 @@ func TestConnDiagnostics(t *testing.T) { }, } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() r, w := io.Pipe() diff --git a/cli/cliui/provisionerjob_test.go b/cli/cliui/provisionerjob_test.go index aa31c9b4a40cb..77310e9536321 100644 --- a/cli/cliui/provisionerjob_test.go +++ b/cli/cliui/provisionerjob_test.go @@ -124,8 +124,6 @@ func TestProvisionerJob(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/cli/cliui/resources_internal_test.go b/cli/cliui/resources_internal_test.go index 0c76e18eb1d1f..934322b5e9fb9 100644 --- a/cli/cliui/resources_internal_test.go +++ b/cli/cliui/resources_internal_test.go @@ -40,7 +40,6 @@ func TestRenderAgentVersion(t *testing.T) { }, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() actual := renderAgentVersion(testCase.agentVersion, testCase.serverVersion) diff --git a/cli/cliui/table_test.go b/cli/cliui/table_test.go index 671002d713fcf..4e82707f3fec8 100644 --- a/cli/cliui/table_test.go +++ b/cli/cliui/table_test.go @@ -169,7 +169,6 @@ foo 10 [a, b, c] foo1 11 foo2 12 fo // Test with pointer values. inPtr := make([]*tableTest1, len(in)) for i, v := range in { - v := v inPtr[i] = &v } out, err = cliui.DisplayTable(inPtr, "", nil) diff --git a/cli/cliutil/levenshtein/levenshtein_test.go b/cli/cliutil/levenshtein/levenshtein_test.go index c635ad0564181..a210dd9253434 100644 --- a/cli/cliutil/levenshtein/levenshtein_test.go +++ b/cli/cliutil/levenshtein/levenshtein_test.go @@ -95,7 +95,6 @@ func Test_Levenshtein_Matches(t *testing.T) { Expected: []string{"kubernetes"}, }, } { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() actual := levenshtein.Matches(tt.Needle, tt.MaxDistance, tt.Haystack...) @@ -179,7 +178,6 @@ func Test_Levenshtein_Distance(t *testing.T) { Error: levenshtein.ErrMaxDist.Error(), }, } { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() actual, err := levenshtein.Distance(tt.A, tt.B, tt.MaxDist) diff --git a/cli/cliutil/provisionerwarn_test.go b/cli/cliutil/provisionerwarn_test.go index a737223310d75..878f08f822330 100644 --- a/cli/cliutil/provisionerwarn_test.go +++ b/cli/cliutil/provisionerwarn_test.go @@ -59,7 +59,6 @@ func TestWarnMatchedProvisioners(t *testing.T) { }, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() var w strings.Builder diff --git a/cli/configssh_internal_test.go b/cli/configssh_internal_test.go index acf534e7ae157..0ddfedf077fbb 100644 --- a/cli/configssh_internal_test.go +++ b/cli/configssh_internal_test.go @@ -118,7 +118,6 @@ func Test_sshConfigSplitOnCoderSection(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() @@ -157,7 +156,6 @@ func Test_sshConfigProxyCommandEscape(t *testing.T) { } // nolint:paralleltest // Fixes a flake for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Windows doesn't typically execute via /bin/sh or cmd.exe, so this test is not applicable.") @@ -207,7 +205,6 @@ func Test_sshConfigMatchExecEscape(t *testing.T) { } // nolint:paralleltest // Fixes a flake for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { cmd := "/bin/sh" arg := "-c" @@ -290,7 +287,6 @@ func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() found, err := sshConfigProxyCommandEscape(tt.path, tt.forceUnix) @@ -366,7 +362,6 @@ func Test_sshConfigOptions_addOption(t *testing.T) { } for _, tt := range testCases { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 60c93b8e94f4b..1ffe93a7b838c 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -688,7 +688,6 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() 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/cli/exp_errors_test.go b/cli/exp_errors_test.go index 75272fc86d8d3..61e11dc770afc 100644 --- a/cli/exp_errors_test.go +++ b/cli/exp_errors_test.go @@ -49,7 +49,6 @@ ExtractCommandPathsLoop: } for _, tt := range cases { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() diff --git a/cli/gitauth/askpass_test.go b/cli/gitauth/askpass_test.go index d70e791c97afb..e9213daf37bda 100644 --- a/cli/gitauth/askpass_test.go +++ b/cli/gitauth/askpass_test.go @@ -60,7 +60,6 @@ func TestParse(t *testing.T) { wantHost: "http://wow.io", }, } { - tc := tc t.Run(tc.in, func(t *testing.T) { t.Parallel() user, host, err := gitauth.ParseAskpass(tc.in) diff --git a/cli/notifications_test.go b/cli/notifications_test.go index 5164657c6c1fb..0e8ece285b450 100644 --- a/cli/notifications_test.go +++ b/cli/notifications_test.go @@ -48,7 +48,6 @@ func TestNotifications(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/cli/open_internal_test.go b/cli/open_internal_test.go index 7af4359a56bc2..5c3ec338aca42 100644 --- a/cli/open_internal_test.go +++ b/cli/open_internal_test.go @@ -47,7 +47,6 @@ func Test_resolveAgentAbsPath(t *testing.T) { {"fail with no working directory and rel path on windows", args{relOrAbsPath: "my\\path", agentOS: "windows"}, "", true}, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -156,7 +155,6 @@ func Test_buildAppLinkURL(t *testing.T) { expectedLink: "https://coder.tld/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/", }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() baseURL, err := url.Parse(tt.baseURL) diff --git a/cli/open_test.go b/cli/open_test.go index f7180ab260fbd..698a4d777984b 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -113,7 +113,6 @@ func TestOpenVSCode(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -240,7 +239,6 @@ func TestOpenVSCode_NoAgentDirectory(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -414,8 +412,6 @@ func TestOpenVSCodeDevContainer(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -579,8 +575,6 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/cli/portforward_internal_test.go b/cli/portforward_internal_test.go index 0d1259713dac9..5698363f95e5e 100644 --- a/cli/portforward_internal_test.go +++ b/cli/portforward_internal_test.go @@ -103,7 +103,6 @@ func Test_parsePortForwards(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/cli/portforward_test.go b/cli/portforward_test.go index 0be029748b3c8..e995b31950314 100644 --- a/cli/portforward_test.go +++ b/cli/portforward_test.go @@ -145,7 +145,6 @@ func TestPortForward(t *testing.T) { ) for _, c := range cases { - c := c t.Run(c.name+"_OnePort", func(t *testing.T) { t.Parallel() p1 := setupTestListener(t, c.setupRemote(t)) diff --git a/cli/provisionerjobs_test.go b/cli/provisionerjobs_test.go index 1566147c5311d..b33fd8b984dc7 100644 --- a/cli/provisionerjobs_test.go +++ b/cli/provisionerjobs_test.go @@ -152,7 +152,6 @@ func TestProvisionerJobs(t *testing.T) { {"Member", memberClient, "TemplateVersionImport", prepareTemplateVersionImportJob, false}, {"Member", memberClient, "TemplateVersionImportDryRun", prepareTemplateVersionImportJobDryRun, false}, } { - tt := tt wantMsg := "OK" if !tt.wantCancelled { wantMsg = "FAIL" diff --git a/cli/root_internal_test.go b/cli/root_internal_test.go index f95ab04c1c9ec..9eb3fe7609582 100644 --- a/cli/root_internal_test.go +++ b/cli/root_internal_test.go @@ -76,7 +76,6 @@ func Test_formatExamples(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/cli/schedule_internal_test.go b/cli/schedule_internal_test.go index cdbbb9ca6ce26..dea98f97d09fb 100644 --- a/cli/schedule_internal_test.go +++ b/cli/schedule_internal_test.go @@ -100,7 +100,6 @@ func TestParseCLISchedule(t *testing.T) { expectedError: errInvalidTimeFormat.Error(), }, } { - testCase := testCase //nolint:paralleltest // t.Setenv t.Run(testCase.name, func(t *testing.T) { t.Setenv("TZ", testCase.tzEnv) diff --git a/cli/schedule_test.go b/cli/schedule_test.go index 60fbf19f4db08..02997a9a4c40d 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -341,8 +341,6 @@ func TestScheduleOverride(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.command, func(t *testing.T) { // Given // Set timezone to Asia/Kolkata to surface any timezone-related bugs. diff --git a/cli/server_internal_test.go b/cli/server_internal_test.go index b5417ceb04b8e..263445ccabd6f 100644 --- a/cli/server_internal_test.go +++ b/cli/server_internal_test.go @@ -62,7 +62,6 @@ func Test_configureCipherSuites(t *testing.T) { cipherByName := func(cipher string) *tls.CipherSuite { for _, c := range append(tls.CipherSuites(), tls.InsecureCipherSuites()...) { if cipher == c.Name { - c := c return c } } @@ -173,7 +172,6 @@ func Test_configureCipherSuites(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := context.Background() @@ -245,7 +243,6 @@ func TestRedirectHTTPToHTTPSDeprecation(t *testing.T) { } for _, tc := range testcases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -310,7 +307,6 @@ func TestIsDERPPath(t *testing.T) { }, } for _, tc := range testcases { - tc := tc t.Run(tc.path, func(t *testing.T) { t.Parallel() require.Equal(t, tc.expected, isDERPPath(tc.path)) @@ -363,7 +359,6 @@ func TestEscapePostgresURLUserInfo(t *testing.T) { }, } for _, tc := range testcases { - tc := tc t.Run(tc.input, func(t *testing.T) { t.Parallel() o, err := escapePostgresURLUserInfo(tc.input) diff --git a/cli/server_test.go b/cli/server_test.go index eb3eac8fa5cd4..2d0bbdd24e83b 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -471,7 +471,6 @@ func TestServer(t *testing.T) { expectGithubDefaultProviderConfigured: true, }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { runGitHubProviderTest(t, tc) }) @@ -629,7 +628,6 @@ func TestServer(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) @@ -883,8 +881,6 @@ func TestServer(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/cli/ssh_test.go b/cli/ssh_test.go index bee075283c083..127d57b22ae75 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -1517,7 +1517,6 @@ func TestSSH(t *testing.T) { pty.ExpectMatchContext(ctx, "ping pong") for i, sock := range sockets { - i := i // Start the listener on the "local machine". l, err := net.Listen("unix", sock.local) require.NoError(t, err) @@ -1641,7 +1640,6 @@ func TestSSH(t *testing.T) { } for _, tc := range tcs { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/cli/start_test.go b/cli/start_test.go index 29fa4cdb46e5f..f64a3f0646303 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -343,7 +343,6 @@ func TestStartAutoUpdate(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index d5fe730a14559..b551a4abcdb1d 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -299,7 +299,6 @@ func TestTemplateEdit(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -416,7 +415,6 @@ func TestTemplateEdit(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/cli/templatepull_test.go b/cli/templatepull_test.go index 99f23d12923cd..5d999de15ed02 100644 --- a/cli/templatepull_test.go +++ b/cli/templatepull_test.go @@ -262,8 +262,6 @@ func TestTemplatePull_ToDir(t *testing.T) { // nolint: paralleltest // These tests change the current working dir, and is therefore unsuitable for parallelisation. for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() diff --git a/cli/templatepush_test.go b/cli/templatepush_test.go index e1a7e612f4ed6..f7a31d5e0c25f 100644 --- a/cli/templatepush_test.go +++ b/cli/templatepush_test.go @@ -485,7 +485,6 @@ func TestTemplatePush(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/cli/util_internal_test.go b/cli/util_internal_test.go index 5656bf2c81930..6c42033f7c0bf 100644 --- a/cli/util_internal_test.go +++ b/cli/util_internal_test.go @@ -30,7 +30,6 @@ func TestDurationDisplay(t *testing.T) { {"24h1m1s", "1d"}, {"25h", "1d1h"}, } { - testCase := testCase t.Run(testCase.Duration, func(t *testing.T) { t.Parallel() d, err := time.ParseDuration(testCase.Duration) @@ -71,7 +70,6 @@ func TestExtendedParseDuration(t *testing.T) { {"200y200y200y200y200y", 0, false}, {"9223372036854775807s", 0, false}, } { - testCase := testCase t.Run(testCase.Duration, func(t *testing.T) { t.Parallel() actual, err := extendedParseDuration(testCase.Duration) diff --git a/cli/version_test.go b/cli/version_test.go index 5802fff6f10f0..14214e995f752 100644 --- a/cli/version_test.go +++ b/cli/version_test.go @@ -50,7 +50,6 @@ Full build of Coder, supports the server subcommand. Expected: expectedText, }, } { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) diff --git a/cli/vpndaemon_windows_test.go b/cli/vpndaemon_windows_test.go index 98c63277d4fac..b03f74ee796e5 100644 --- a/cli/vpndaemon_windows_test.go +++ b/cli/vpndaemon_windows_test.go @@ -52,7 +52,6 @@ func TestVPNDaemonRun(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/coderd/agentapi/manifest_internal_test.go b/coderd/agentapi/manifest_internal_test.go index 33e0cb7613099..7853041349126 100644 --- a/coderd/agentapi/manifest_internal_test.go +++ b/coderd/agentapi/manifest_internal_test.go @@ -80,7 +80,6 @@ func Test_vscodeProxyURI(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/agentapi/resources_monitoring_test.go b/coderd/agentapi/resources_monitoring_test.go index 087ccfd24e459..c491d3789355b 100644 --- a/coderd/agentapi/resources_monitoring_test.go +++ b/coderd/agentapi/resources_monitoring_test.go @@ -280,8 +280,6 @@ func TestMemoryResourceMonitor(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -713,8 +711,6 @@ func TestVolumeResourceMonitor(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/agentmetrics/labels_test.go b/coderd/agentmetrics/labels_test.go index b383ca0b25c0d..07f1998fed420 100644 --- a/coderd/agentmetrics/labels_test.go +++ b/coderd/agentmetrics/labels_test.go @@ -43,8 +43,6 @@ func TestValidateAggregationLabels(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() 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/apikey/apikey_test.go b/coderd/apikey/apikey_test.go index ef4d260ddf0a6..198ef11511b3e 100644 --- a/coderd/apikey/apikey_test.go +++ b/coderd/apikey/apikey_test.go @@ -107,7 +107,6 @@ func TestGenerate(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/audit_test.go b/coderd/audit_test.go index 18bcd78b38807..e6fa985038155 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -454,7 +454,6 @@ func TestAuditLogsFilter(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase // Test filtering t.Run(testCase.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/authorize_test.go b/coderd/authorize_test.go index 3af6cfd7d620e..b8084211de60c 100644 --- a/coderd/authorize_test.go +++ b/coderd/authorize_test.go @@ -125,8 +125,6 @@ func TestCheckPermissions(t *testing.T) { } for _, c := range testCases { - c := c - t.Run("CheckAuthorization/"+c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/autobuild/lifecycle_executor_internal_test.go b/coderd/autobuild/lifecycle_executor_internal_test.go index bfe3bb53592b3..2d556d58a2d5e 100644 --- a/coderd/autobuild/lifecycle_executor_internal_test.go +++ b/coderd/autobuild/lifecycle_executor_internal_test.go @@ -153,7 +153,6 @@ func Test_isEligibleForAutostart(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 453de63031a47..65af911122aba 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -177,7 +177,6 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { }, } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() var ( diff --git a/coderd/autobuild/notify/notifier_test.go b/coderd/autobuild/notify/notifier_test.go index aa060406d30a1..4561fd2a45336 100644 --- a/coderd/autobuild/notify/notifier_test.go +++ b/coderd/autobuild/notify/notifier_test.go @@ -83,7 +83,6 @@ func TestNotifier(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) diff --git a/coderd/azureidentity/azureidentity_test.go b/coderd/azureidentity/azureidentity_test.go index bd55ae2538d3a..bd94f836beb3b 100644 --- a/coderd/azureidentity/azureidentity_test.go +++ b/coderd/azureidentity/azureidentity_test.go @@ -47,7 +47,6 @@ func TestValidate(t *testing.T) { vmID: "960a4b4a-dab2-44ef-9b73-7753043b4f16", date: mustTime(time.RFC3339, "2024-04-22T17:32:44Z"), }} { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() vm, err := azureidentity.Validate(context.Background(), tc.payload, azureidentity.Options{ diff --git a/coderd/coderd_internal_test.go b/coderd/coderd_internal_test.go index 34f5738bf90a0..b03985e1e157d 100644 --- a/coderd/coderd_internal_test.go +++ b/coderd/coderd_internal_test.go @@ -32,8 +32,6 @@ func TestStripSlashesMW(t *testing.T) { }) for _, tt := range tests { - tt := tt - t.Run("chi/"+tt.name, func(t *testing.T) { t.Parallel() req := httptest.NewRequest("GET", tt.inputPath, nil) 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/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/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index bfee2f52cbbd9..8e879569e014a 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -119,8 +119,6 @@ func TestProvisionerJobStatus(t *testing.T) { org := dbgen.Organization(t, db, database.Organization{}) for i, tc := range cases { - tc := tc - i := i t.Run(tc.name, func(t *testing.T) { t.Parallel() // Populate standard fields diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go index 815d6629f64f9..5e19f43ab5376 100644 --- a/coderd/database/dbauthz/customroles_test.go +++ b/coderd/database/dbauthz/customroles_test.go @@ -46,7 +46,6 @@ func TestInsertCustomRoles(t *testing.T) { merge := func(u ...interface{}) rbac.Roles { all := make([]rbac.Role, 0) for _, v := range u { - v := v switch t := v.(type) { case rbac.Role: all = append(all, t) @@ -201,8 +200,6 @@ func TestInsertCustomRoles(t *testing.T) { } for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() db := dbmem.New() 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 ba9d1ddf0d7d2..0ccd867040116 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() @@ -5564,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/dbauthz/groupsauth_test.go b/coderd/database/dbauthz/groupsauth_test.go index a9f26e303d644..79f936e103e09 100644 --- a/coderd/database/dbauthz/groupsauth_test.go +++ b/coderd/database/dbauthz/groupsauth_test.go @@ -135,7 +135,6 @@ func TestGroupsAuth(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index 776667ba053cc..29ca421d6f11e 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -458,7 +458,6 @@ type AssertRBAC struct { func values(ins ...any) []reflect.Value { out := make([]reflect.Value, 0) for _, input := range ins { - input := input out = append(out, reflect.ValueOf(input)) } return out diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index ee1c7471808d5..25b44e2930892 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" @@ -75,6 +73,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), @@ -159,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(), @@ -299,6 +298,7 @@ type data struct { telemetryItems []database.TelemetryItem presets []database.TemplateVersionPreset presetParameters []database.TemplateVersionPresetParameter + presetPrebuildSchedules []database.TemplateVersionPresetPrebuildSchedule } func tryPercentileCont(fs []float64, p float64) float64 { @@ -1811,7 +1811,6 @@ func (q *FakeQuerier) CustomRoles(_ context.Context, arg database.CustomRolesPar found := make([]database.CustomRole, 0) for _, role := range q.data.customRoles { - role := role if len(arg.LookupRoles) > 0 { if !slices.ContainsFunc(arg.LookupRoles, func(pair database.NameOrganizationPair) bool { if pair.Name != role.Name { @@ -2779,7 +2778,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. @@ -2885,7 +2919,6 @@ func (q *FakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U roles := make([]string, 0) for _, u := range q.users { if u.ID == userID { - u := u roles = append(roles, u.RBACRoles...) roles = append(roles, "member") user = &u @@ -8088,7 +8121,6 @@ func (q *FakeQuerier) GetWorkspaceByOwnerIDAndName(_ context.Context, arg databa var found *database.WorkspaceTable for _, workspace := range q.workspaces { - workspace := workspace if workspace.OwnerID != arg.OwnerID { continue } @@ -8146,7 +8178,6 @@ func (q *FakeQuerier) GetWorkspaceByWorkspaceAppID(_ context.Context, workspaceA defer q.mutex.RUnlock() for _, workspaceApp := range q.workspaceApps { - workspaceApp := workspaceApp if workspaceApp.ID == workspaceAppID { return q.getWorkspaceByAgentIDNoLock(context.Background(), workspaceApp.AgentID) } @@ -9201,7 +9232,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) { @@ -10379,7 +10420,6 @@ func (q *FakeQuerier) OrganizationMembers(_ context.Context, arg database.Organi continue } - organizationMember := organizationMember user, _ := q.getUserByIDNoLock(organizationMember.UserID) tmp = append(tmp, database.OrganizationMembersRow{ OrganizationMember: organizationMember, diff --git a/coderd/database/dbmem/dbmem_test.go b/coderd/database/dbmem/dbmem_test.go index 11d30e61a895d..c3df828b95c98 100644 --- a/coderd/database/dbmem/dbmem_test.go +++ b/coderd/database/dbmem/dbmem_test.go @@ -188,7 +188,6 @@ func TestProxyByHostname(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index cd843bd97aa7a..f5d84e6532083 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -288,8 +288,6 @@ func TestMigrateUpWithFixtures(t *testing.T) { }) for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() 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..5ae43138bd634 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" @@ -1157,7 +1156,6 @@ func TestProxyByHostname(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -1395,7 +1393,6 @@ func TestGetUsers_IncludeSystem(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -1418,7 +1415,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") @@ -1863,8 +1860,6 @@ func TestReadCustomRoles(t *testing.T) { } for _, tc := range testCases { - tc := tc - t.Run(tc.Name, func(t *testing.T) { t.Parallel() @@ -2507,7 +2502,6 @@ func TestGetProvisionerJobsByIDsWithQueuePosition(t *testing.T) { } for _, tc := range testCases { - tc := tc // Capture loop variable to avoid data races t.Run(tc.name, func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -2948,7 +2942,6 @@ func TestGetUserStatusCounts(t *testing.T) { } for _, tz := range timezones { - tz := tz t.Run(tz, func(t *testing.T) { t.Parallel() @@ -2996,7 +2989,6 @@ func TestGetUserStatusCounts(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -3164,7 +3156,6 @@ func TestGetUserStatusCounts(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -3297,7 +3288,6 @@ func TestGetUserStatusCounts(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/devtunnel/tunnel_test.go b/coderd/devtunnel/tunnel_test.go index ca1c5b7752628..50573c19e5f27 100644 --- a/coderd/devtunnel/tunnel_test.go +++ b/coderd/devtunnel/tunnel_test.go @@ -48,8 +48,6 @@ func TestTunnel(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go new file mode 100644 index 0000000000000..b6a77c4704225 --- /dev/null +++ b/coderd/dynamicparameters/render.go @@ -0,0 +1,342 @@ +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, 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.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) + + 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 moduleFilesFS *files.CloseFS + if r.terraformValues.CachedModuleFiles.Valid { + moduleFilesFS, err = cache.Acquire(fileCtx, r.terraformValues.CachedModuleFiles.UUID) + if err != nil { + return nil, xerrors.Errorf("acquire module files: %w", err) + } + templateFS = files.NewOverlayFS(templateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}}) + } + + closeFiles = false // Caller will have to call close + return &dynamicRenderer{ + data: r, + templateFS: templateFS, + db: db, + ownerErrors: make(map[uuid.UUID]error), + close: cache.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/externalauth/externalauth_internal_test.go b/coderd/externalauth/externalauth_internal_test.go index 26515ff78f215..f50593c019b4f 100644 --- a/coderd/externalauth/externalauth_internal_test.go +++ b/coderd/externalauth/externalauth_internal_test.go @@ -102,7 +102,6 @@ func TestGitlabDefaults(t *testing.T) { }, } for _, c := range tests { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() applyDefaultsToConfig(&c.input) @@ -177,7 +176,6 @@ func Test_bitbucketServerConfigDefaults(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() applyDefaultsToConfig(tt.config) diff --git a/coderd/externalauth/externalauth_test.go b/coderd/externalauth/externalauth_test.go index ec540fba2eac6..81cf5aa1f21e2 100644 --- a/coderd/externalauth/externalauth_test.go +++ b/coderd/externalauth/externalauth_test.go @@ -463,7 +463,6 @@ func TestConvertYAML(t *testing.T) { }}, Error: "device auth url must be provided", }} { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() output, err := externalauth.ConvertConfig(instrument, tc.Input, &url.URL{}) 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 +} diff --git a/coderd/healthcheck/health/model_test.go b/coderd/healthcheck/health/model_test.go index 8b28dc5862517..2ff51652f3275 100644 --- a/coderd/healthcheck/health/model_test.go +++ b/coderd/healthcheck/health/model_test.go @@ -21,7 +21,6 @@ func Test_MessageURL(t *testing.T) { {"default", health.CodeAccessURLFetch, "", "https://coder.com/docs/admin/monitoring/health-check#eacs03"}, {"custom docs base", health.CodeAccessURLFetch, "https://example.com/docs", "https://example.com/docs/admin/monitoring/health-check#eacs03"}, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() uut := health.Message{Code: tt.code} diff --git a/coderd/healthcheck/healthcheck_test.go b/coderd/healthcheck/healthcheck_test.go index 9c744b42d1dca..2b49b3215e251 100644 --- a/coderd/healthcheck/healthcheck_test.go +++ b/coderd/healthcheck/healthcheck_test.go @@ -508,7 +508,6 @@ func TestHealthcheck(t *testing.T) { }, severity: health.SeverityError, }} { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/healthcheck/provisioner_test.go b/coderd/healthcheck/provisioner_test.go index 93871f4a709ad..e2f0c6119ed09 100644 --- a/coderd/healthcheck/provisioner_test.go +++ b/coderd/healthcheck/provisioner_test.go @@ -335,7 +335,6 @@ func TestProvisionerDaemonReport(t *testing.T) { expectedItems: []healthsdk.ProvisionerDaemonsReportItem{}, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/healthcheck/workspaceproxy_internal_test.go b/coderd/healthcheck/workspaceproxy_internal_test.go index 5a7875518df7e..be367ee2061c9 100644 --- a/coderd/healthcheck/workspaceproxy_internal_test.go +++ b/coderd/healthcheck/workspaceproxy_internal_test.go @@ -47,7 +47,6 @@ func Test_WorkspaceProxyReport_appendErrors(t *testing.T) { errs: []string{assert.AnError.Error(), "another error"}, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -85,7 +84,6 @@ func Test_calculateSeverity(t *testing.T) { {2, 0, 0, health.SeverityError}, {2, 0, 1, health.SeverityError}, } { - tt := tt name := fmt.Sprintf("%d total, %d healthy, %d warning -> %s", tt.total, tt.healthy, tt.warning, tt.expected) t.Run(name, func(t *testing.T) { t.Parallel() diff --git a/coderd/healthcheck/workspaceproxy_test.go b/coderd/healthcheck/workspaceproxy_test.go index d5bd5c12210b8..e8fc7a339c408 100644 --- a/coderd/healthcheck/workspaceproxy_test.go +++ b/coderd/healthcheck/workspaceproxy_test.go @@ -172,7 +172,6 @@ func TestWorkspaceProxies(t *testing.T) { expectedSeverity: health.SeverityError, }, } { - tt := tt if tt.name != "Enabled/ProxyWarnings" { continue } diff --git a/coderd/httpapi/cookie_test.go b/coderd/httpapi/cookie_test.go index 4d44cd8f7d130..e653e3653ba14 100644 --- a/coderd/httpapi/cookie_test.go +++ b/coderd/httpapi/cookie_test.go @@ -26,7 +26,6 @@ func TestStripCoderCookies(t *testing.T) { "coder_session_token=ok; oauth_state=wow; oauth_redirect=/", "", }} { - tc := tc t.Run(tc.Input, func(t *testing.T) { t.Parallel() require.Equal(t, tc.Output, httpapi.StripCoderCookies(tc.Input)) diff --git a/coderd/httpapi/json_test.go b/coderd/httpapi/json_test.go index a0a93e884d44f..8cfe8068e7b2e 100644 --- a/coderd/httpapi/json_test.go +++ b/coderd/httpapi/json_test.go @@ -46,8 +46,6 @@ func TestDuration(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.expected, func(t *testing.T) { t.Parallel() @@ -109,8 +107,6 @@ func TestDuration(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.value, func(t *testing.T) { t.Parallel() @@ -153,8 +149,6 @@ func TestDuration(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.value, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 3ee9d92742252..4991dbeb9c46e 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -107,7 +107,6 @@ func TestExtractUserRoles(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/cors_test.go b/coderd/httpmw/cors_test.go index 57111799ff292..4d48d535a23e4 100644 --- a/coderd/httpmw/cors_test.go +++ b/coderd/httpmw/cors_test.go @@ -91,7 +91,6 @@ func TestWorkspaceAppCors(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/csrf_test.go b/coderd/httpmw/csrf_test.go index 9e8094ad50d6d..62e8150fb099f 100644 --- a/coderd/httpmw/csrf_test.go +++ b/coderd/httpmw/csrf_test.go @@ -57,7 +57,6 @@ func TestCSRFExemptList(t *testing.T) { csrfmw := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).(*nosurf.CSRFHandler) for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/hsts_test.go b/coderd/httpmw/hsts_test.go index 3bc3463e69e65..0e36f8993c1dd 100644 --- a/coderd/httpmw/hsts_test.go +++ b/coderd/httpmw/hsts_test.go @@ -77,7 +77,6 @@ func TestHSTS(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/loggermw/logger_internal_test.go b/coderd/httpmw/loggermw/logger_internal_test.go index 53cc9f4eb9462..f372c665fda14 100644 --- a/coderd/httpmw/loggermw/logger_internal_test.go +++ b/coderd/httpmw/loggermw/logger_internal_test.go @@ -247,7 +247,6 @@ func TestRequestLogger_RouteParamsLogging(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() 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/httpmw/patternmatcher/routepatterns_test.go b/coderd/httpmw/patternmatcher/routepatterns_test.go index 58d914d231e90..623c22afbab92 100644 --- a/coderd/httpmw/patternmatcher/routepatterns_test.go +++ b/coderd/httpmw/patternmatcher/routepatterns_test.go @@ -108,7 +108,6 @@ func Test_RoutePatterns(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/realip_test.go b/coderd/httpmw/realip_test.go index 3070070bd90d8..18b870ae379c2 100644 --- a/coderd/httpmw/realip_test.go +++ b/coderd/httpmw/realip_test.go @@ -200,7 +200,6 @@ func TestExtractAddress(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.Name, func(t *testing.T) { t.Parallel() @@ -235,9 +234,6 @@ func TestTrustedOrigins(t *testing.T) { // ipv6: trust an IPv4 network for _, trusted := range []string{"none", "ipv4", "ipv6"} { for _, header := range []string{"Cf-Connecting-Ip", "True-Client-Ip", "X-Real-Ip", "X-Forwarded-For"} { - trusted := trusted - header := header - proto := proto name := fmt.Sprintf("%s-%s-%s", trusted, proto, strings.ToLower(header)) t.Run(name, func(t *testing.T) { @@ -311,7 +307,6 @@ func TestCorruptedHeaders(t *testing.T) { t.Parallel() for _, header := range []string{"Cf-Connecting-Ip", "True-Client-Ip", "X-Real-Ip", "X-Forwarded-For"} { - header := header name := strings.ToLower(header) t.Run(name, func(t *testing.T) { @@ -364,9 +359,6 @@ func TestAddressFamilies(t *testing.T) { for _, clientFamily := range []string{"ipv4", "ipv6"} { for _, proxyFamily := range []string{"ipv4", "ipv6"} { for _, header := range []string{"Cf-Connecting-Ip", "True-Client-Ip", "X-Real-Ip", "X-Forwarded-For"} { - clientFamily := clientFamily - proxyFamily := proxyFamily - header := header name := fmt.Sprintf("%s-%s-%s", strings.ToLower(header), clientFamily, proxyFamily) t.Run(name, func(t *testing.T) { @@ -466,7 +458,6 @@ func TestFilterUntrusted(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.Name, func(t *testing.T) { t.Parallel() @@ -612,7 +603,6 @@ func TestApplicationProxy(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/recover_test.go b/coderd/httpmw/recover_test.go index b76c5b105baf5..d4d4227ff15ef 100644 --- a/coderd/httpmw/recover_test.go +++ b/coderd/httpmw/recover_test.go @@ -52,8 +52,6 @@ func TestRecover(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 33b0c753068f7..85e11cf3975fd 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -316,7 +316,6 @@ func TestWorkspaceAgentByNameParam(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() db, r := setupWorkspaceWithAgents(t, setupConfig{ diff --git a/coderd/idpsync/group_test.go b/coderd/idpsync/group_test.go index 7b0fb70ae8f68..478d6557de551 100644 --- a/coderd/idpsync/group_test.go +++ b/coderd/idpsync/group_test.go @@ -243,7 +243,6 @@ func TestGroupSyncTable(t *testing.T) { } for _, tc := range testCases { - tc := tc // The final test, "AllTogether", cannot run in parallel. // These tests are nearly instant using the memory db, so // this is still fast without being in parallel. @@ -341,8 +340,6 @@ func TestGroupSyncTable(t *testing.T) { }) for _, tc := range testCases { - tc := tc - orgID := uuid.New() SetupOrganization(t, s, db, user, orgID, tc) asserts = append(asserts, func(t *testing.T) { @@ -523,7 +520,6 @@ func TestApplyGroupDifference(t *testing.T) { } for _, tc := range testCase { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() @@ -713,7 +709,6 @@ func TestExpectedGroupEqual(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/idpsync/idpsync_test.go b/coderd/idpsync/idpsync_test.go index 0db5c66bed174..f3dc9c2f07986 100644 --- a/coderd/idpsync/idpsync_test.go +++ b/coderd/idpsync/idpsync_test.go @@ -159,7 +159,6 @@ func TestParseStringSliceClaim(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/idpsync/role_test.go b/coderd/idpsync/role_test.go index f07d97a2b0f31..6df091097b966 100644 --- a/coderd/idpsync/role_test.go +++ b/coderd/idpsync/role_test.go @@ -186,7 +186,6 @@ func TestRoleSyncTable(t *testing.T) { } for _, tc := range testCases { - tc := tc // The final test, "AllTogether", cannot run in parallel. // These tests are nearly instant using the memory db, so // this is still fast without being in parallel. @@ -248,8 +247,6 @@ func TestRoleSyncTable(t *testing.T) { var asserts []func(t *testing.T) for _, tc := range testCases { - tc := tc - orgID := uuid.New() SetupOrganization(t, s, db, user, orgID, tc) asserts = append(asserts, func(t *testing.T) { diff --git a/coderd/inboxnotifications_internal_test.go b/coderd/inboxnotifications_internal_test.go index e7d9a85d3e74f..c99d376bb77e9 100644 --- a/coderd/inboxnotifications_internal_test.go +++ b/coderd/inboxnotifications_internal_test.go @@ -29,8 +29,6 @@ func TestInboxNotifications_ensureNotificationIcon(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/inboxnotifications_test.go b/coderd/inboxnotifications_test.go index 82ae539518ae0..c43149d8c8211 100644 --- a/coderd/inboxnotifications_test.go +++ b/coderd/inboxnotifications_test.go @@ -57,7 +57,6 @@ func TestInboxNotification_Watch(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -393,7 +392,6 @@ func TestInboxNotifications_List(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/insights_internal_test.go b/coderd/insights_internal_test.go index 111bd268e8855..d3302e23cc85b 100644 --- a/coderd/insights_internal_test.go +++ b/coderd/insights_internal_test.go @@ -144,7 +144,6 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -253,8 +252,6 @@ func Test_parseInsightsInterval_week(t *testing.T) { }, } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -323,7 +320,6 @@ func TestLastReportIntervalHasAtLeastSixDays(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 693bb48811acc..ded030351a3b3 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -550,8 +550,6 @@ func TestTemplateInsights_Golden(t *testing.T) { // Prepare all the templates. for _, template := range templates { - template := template - var parameters []*proto.RichParameter for _, parameter := range template.parameters { var options []*proto.RichParameterOption @@ -582,10 +580,7 @@ func TestTemplateInsights_Golden(t *testing.T) { ) var resources []*proto.Resource for _, user := range users { - user := user for _, workspace := range user.workspaces { - workspace := workspace - if workspace.template != template { continue } @@ -1246,7 +1241,6 @@ func TestTemplateInsights_Golden(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -1261,7 +1255,6 @@ func TestTemplateInsights_Golden(t *testing.T) { _, _ = <-events, <-events for _, req := range tt.requests { - req := req t.Run(req.name, func(t *testing.T) { t.Parallel() @@ -1489,8 +1482,6 @@ func TestUserActivityInsights_Golden(t *testing.T) { // Prepare all the templates. for _, template := range templates { - template := template - // Prepare all workspace resources (agents and apps). var ( createWorkspaces []func(uuid.UUID) @@ -1498,10 +1489,7 @@ func TestUserActivityInsights_Golden(t *testing.T) { ) var resources []*proto.Resource for _, user := range users { - user := user for _, workspace := range user.workspaces { - workspace := workspace - if workspace.template != template { continue } @@ -2031,7 +2019,6 @@ func TestUserActivityInsights_Golden(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -2046,7 +2033,6 @@ func TestUserActivityInsights_Golden(t *testing.T) { _, _ = <-events, <-events for _, req := range tt.requests { - req := req t.Run(req.name, func(t *testing.T) { t.Parallel() @@ -2159,8 +2145,6 @@ func TestTemplateInsights_RBAC(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(fmt.Sprintf("with interval=%q", tt.interval), func(t *testing.T) { t.Parallel() @@ -2279,9 +2263,6 @@ func TestGenericInsights_RBAC(t *testing.T) { } for endpointName, endpoint := range endpoints { - endpointName := endpointName - endpoint := endpoint - t.Run(fmt.Sprintf("With%sEndpoint", endpointName), func(t *testing.T) { t.Parallel() @@ -2291,8 +2272,6 @@ func TestGenericInsights_RBAC(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run("AsOwner", func(t *testing.T) { t.Parallel() diff --git a/coderd/jobreaper/detector_test.go b/coderd/jobreaper/detector_test.go index 28457aeeca3a8..4078f92c03a36 100644 --- a/coderd/jobreaper/detector_test.go +++ b/coderd/jobreaper/detector_test.go @@ -844,8 +844,6 @@ func TestDetectorPushesLogs(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/jwtutils/jwt_test.go b/coderd/jwtutils/jwt_test.go index a2126092ff015..9a9ae8d3f44fb 100644 --- a/coderd/jwtutils/jwt_test.go +++ b/coderd/jwtutils/jwt_test.go @@ -149,12 +149,9 @@ func TestClaims(t *testing.T) { } for _, tt := range types { - tt := tt - t.Run(tt.Name, func(t *testing.T) { t.Parallel() for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index 53852f41c904b..4582187a33651 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -202,7 +202,6 @@ func TestCache_BuildTime(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/notifications/dispatch/inbox_test.go b/coderd/notifications/dispatch/inbox_test.go index a06b698e9769a..744623ed2c99f 100644 --- a/coderd/notifications/dispatch/inbox_test.go +++ b/coderd/notifications/dispatch/inbox_test.go @@ -69,7 +69,6 @@ func TestInbox(t *testing.T) { } for _, tc := range tests { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index b3e087a9e7d8f..ec9edee4c8514 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1283,8 +1283,6 @@ func TestNotificationTemplates_Golden(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -2006,8 +2004,6 @@ func TestNotificationTargetMatrix(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/notifications/render/gotmpl_test.go b/coderd/notifications/render/gotmpl_test.go index 25e52cc07f671..c49cab7b991fd 100644 --- a/coderd/notifications/render/gotmpl_test.go +++ b/coderd/notifications/render/gotmpl_test.go @@ -68,8 +68,6 @@ func TestGoTemplate(t *testing.T) { } for _, tc := range tests { - tc := tc // unnecessary as of go1.22 but the linter is outdated - t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index f5311be173bac..e081d3e1483db 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -151,7 +151,6 @@ func TestOAuth2ProviderApps(t *testing.T) { require.NoError(t, err) for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -661,7 +660,6 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) { }, } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -804,7 +802,6 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { }, } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -996,7 +993,6 @@ func TestOAuth2ProviderRevoke(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/coderd/pagination_internal_test.go b/coderd/pagination_internal_test.go index adcfde6bbb641..18d98c2fab319 100644 --- a/coderd/pagination_internal_test.go +++ b/coderd/pagination_internal_test.go @@ -110,7 +110,6 @@ func TestPagination(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() rw := httptest.NewRecorder() 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/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/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..8a1a10451323a 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, @@ -420,7 +420,6 @@ func TestInProgressActions(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -467,7 +466,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 +509,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, @@ -650,7 +649,6 @@ func TestExpiredPrebuilds(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -685,13 +683,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 +725,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 +772,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 +796,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 +810,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 +865,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 +873,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 +889,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 +993,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 +1014,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/coderd/presets_test.go b/coderd/presets_test.go index dc47b10cfd36f..1239cb655a63f 100644 --- a/coderd/presets_test.go +++ b/coderd/presets_test.go @@ -78,7 +78,6 @@ func TestTemplateVersionPresets(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) diff --git a/coderd/prometheusmetrics/aggregator_test.go b/coderd/prometheusmetrics/aggregator_test.go index 0930f186bd328..6cbe5514b1c2e 100644 --- a/coderd/prometheusmetrics/aggregator_test.go +++ b/coderd/prometheusmetrics/aggregator_test.go @@ -587,8 +587,6 @@ func TestLabelsAggregation(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/prometheusmetrics/prometheusmetrics_internal_test.go b/coderd/prometheusmetrics/prometheusmetrics_internal_test.go index 5eaf1d92ed67f..3a6ecec5c12ec 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_internal_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_internal_test.go @@ -29,8 +29,6 @@ func TestFilterAcceptableAgentLabels(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/prometheusmetrics/prometheusmetrics_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index 34309042c5f55..1ce6b72347999 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -99,7 +99,6 @@ func TestActiveUsers(t *testing.T) { }, Count: 2, }} { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() registry := prometheus.NewRegistry() @@ -161,7 +160,6 @@ func TestUsers(t *testing.T) { }, Count: map[database.UserStatus]int{database.UserStatusActive: 3}, }} { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) @@ -250,7 +248,6 @@ func TestWorkspaceLatestBuildTotals(t *testing.T) { codersdk.ProvisionerJobRunning: 1, }, }} { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() registry := prometheus.NewRegistry() @@ -327,7 +324,6 @@ func TestWorkspaceLatestBuildStatuses(t *testing.T) { codersdk.ProvisionerJobRunning: 1, }, }} { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() registry := prometheus.NewRegistry() @@ -660,8 +656,6 @@ func TestExperimentsMetric(t *testing.T) { } for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() reg := prometheus.NewRegistry() diff --git a/coderd/promoauth/oauth2_test.go b/coderd/promoauth/oauth2_test.go index 9e31d90944f36..5aeec4f0fb949 100644 --- a/coderd/promoauth/oauth2_test.go +++ b/coderd/promoauth/oauth2_test.go @@ -155,7 +155,6 @@ func TestGithubRateLimits(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/provisionerdserver/acquirer_test.go b/coderd/provisionerdserver/acquirer_test.go index e90fb3df0198a..817bae45bbd60 100644 --- a/coderd/provisionerdserver/acquirer_test.go +++ b/coderd/provisionerdserver/acquirer_test.go @@ -466,7 +466,6 @@ func TestAcquirer_MatchTags(t *testing.T) { }, } for _, tt := range testCases { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 695437068f50f..dfa767b297f4c 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -150,7 +150,6 @@ func TestAcquireJob(t *testing.T) { }}, } for _, tc := range cases { - tc := tc t.Run(tc.name+"_InitiatorNotFound", func(t *testing.T) { t.Parallel() srv, db, _, pd := setup(t, false, nil) @@ -176,7 +175,6 @@ func TestAcquireJob(t *testing.T) { sdkproto.PrebuiltWorkspaceBuildStage_CREATE, sdkproto.PrebuiltWorkspaceBuildStage_CLAIM, } { - prebuiltWorkspaceBuildStage := prebuiltWorkspaceBuildStage t.Run(tc.name+"_WorkspaceBuildJob_Stage"+prebuiltWorkspaceBuildStage.String(), func(t *testing.T) { t.Parallel() // Set the max session token lifetime so we can assert we @@ -1709,8 +1707,6 @@ func TestCompleteJob(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -2134,8 +2130,6 @@ func TestCompleteJob(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -2579,7 +2573,6 @@ func TestInsertWorkspacePresetsAndParameters(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/provisionerjobs_internal_test.go b/coderd/provisionerjobs_internal_test.go index f3bc2eb1dea99..bc94836028ce4 100644 --- a/coderd/provisionerjobs_internal_test.go +++ b/coderd/provisionerjobs_internal_test.go @@ -132,7 +132,6 @@ func TestConvertProvisionerJob_Unit(t *testing.T) { }, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() actual := convertProvisionerJob(database.GetProvisionerJobsByIDsWithQueuePositionRow{ diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 9c09837c7915d..838c7bce1c5e8 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -243,7 +243,6 @@ func TestFilter(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() actor := tc.Actor @@ -1135,7 +1134,6 @@ func testAuthorize(t *testing.T, name string, subject Subject, sets ...[]authTes authorizer := NewAuthorizer(prometheus.NewRegistry()) for _, cases := range sets { for i, c := range cases { - c := c caseName := fmt.Sprintf("%s/%d", name, i) t.Run(caseName, func(t *testing.T) { t.Parallel() 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/object_test.go b/coderd/rbac/object_test.go index ea6031f2ccae8..ff579b48c03af 100644 --- a/coderd/rbac/object_test.go +++ b/coderd/rbac/object_test.go @@ -165,7 +165,6 @@ func TestObjectEqual(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() 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/regosql/compile_test.go b/coderd/rbac/regosql/compile_test.go index 208cb920ad1f7..07e8e7245a53e 100644 --- a/coderd/rbac/regosql/compile_test.go +++ b/coderd/rbac/regosql/compile_test.go @@ -265,7 +265,6 @@ neq(input.object.owner, ""); } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() part := partialQueries(tc.Queries...) diff --git a/coderd/rbac/regosql/sqltypes/equality_test.go b/coderd/rbac/regosql/sqltypes/equality_test.go index 17a3d7f45eed1..37922064466de 100644 --- a/coderd/rbac/regosql/sqltypes/equality_test.go +++ b/coderd/rbac/regosql/sqltypes/equality_test.go @@ -114,7 +114,6 @@ func TestEquality(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/regosql/sqltypes/member_test.go b/coderd/rbac/regosql/sqltypes/member_test.go index 0fedcc176c49f..e933989d7b0df 100644 --- a/coderd/rbac/regosql/sqltypes/member_test.go +++ b/coderd/rbac/regosql/sqltypes/member_test.go @@ -92,7 +92,6 @@ func TestMembership(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() 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_internal_test.go b/coderd/rbac/roles_internal_test.go index 3f2d0d89fe455..f851280a0417e 100644 --- a/coderd/rbac/roles_internal_test.go +++ b/coderd/rbac/roles_internal_test.go @@ -229,7 +229,6 @@ func TestRoleByName(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Role.Identifier.String(), func(t *testing.T) { role, err := RoleByName(c.Role.Identifier) require.NoError(t, err, "role exists") diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 5738edfe8caa2..7d6e9d67c923c 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" @@ -35,7 +37,6 @@ func (a authSubject) Subjects() []authSubject { return []authSubject{a} } func TestBuiltInRoles(t *testing.T) { t.Parallel() for _, r := range rbac.SiteBuiltInRoles() { - r := r t.Run(r.Identifier.String(), func(t *testing.T) { t.Parallel() require.NoError(t, r.Valid(), "invalid role") @@ -43,7 +44,6 @@ func TestBuiltInRoles(t *testing.T) { } for _, r := range rbac.OrganizationRoles(uuid.New()) { - r := r t.Run(r.Identifier.String(), func(t *testing.T) { t.Parallel() require.NoError(t, r.Valid(), "invalid role") @@ -496,6 +496,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", @@ -885,7 +894,6 @@ func TestRolePermissions(t *testing.T) { passed := true // nolint:tparallel,paralleltest for _, c := range testCases { - c := c // nolint:tparallel,paralleltest // These share the same remainingPermissions map t.Run(c.Name, func(t *testing.T) { remainingSubjs := make(map[string]struct{}) @@ -984,7 +992,6 @@ func TestIsOrgRole(t *testing.T) { // nolint:paralleltest for _, c := range testCases { - c := c t.Run(c.Identifier.String(), func(t *testing.T) { t.Parallel() ok := c.Identifier.IsOrgRole() @@ -1081,7 +1088,6 @@ func TestChangeSet(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/subject_test.go b/coderd/rbac/subject_test.go index e2a2f24932c36..c1462b073ec35 100644 --- a/coderd/rbac/subject_test.go +++ b/coderd/rbac/subject_test.go @@ -119,7 +119,6 @@ func TestSubjectEqual(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/render/markdown_test.go b/coderd/render/markdown_test.go index 40f3dae137633..4095cac3f07e7 100644 --- a/coderd/render/markdown_test.go +++ b/coderd/render/markdown_test.go @@ -79,8 +79,6 @@ func TestHTML(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go index 8b4fe969e59d7..85cc7b533a6ea 100644 --- a/coderd/schedule/autostop_test.go +++ b/coderd/schedule/autostop_test.go @@ -486,8 +486,6 @@ func TestCalculateAutoStop(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -622,7 +620,6 @@ func TestFindWeek(t *testing.T) { } for _, tz := range timezones { - tz := tz t.Run("Loc/"+tz, func(t *testing.T) { t.Parallel() diff --git a/coderd/schedule/cron/cron_test.go b/coderd/schedule/cron/cron_test.go index d3be423eace00..05e8ac21af9de 100644 --- a/coderd/schedule/cron/cron_test.go +++ b/coderd/schedule/cron/cron_test.go @@ -141,7 +141,6 @@ func Test_Weekly(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() actual, err := cron.Weekly(testCase.spec) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 5739ecab77525..dd879839e552f 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -297,7 +297,6 @@ func TestSearchWorkspace(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() // TODO: Replace this with the mock database. @@ -382,7 +381,6 @@ func TestSearchAudit(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() // Do not use a real database, this is only used for an @@ -550,7 +548,6 @@ func TestSearchUsers(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() values, errs := searchquery.Users(c.Query) @@ -622,7 +619,6 @@ func TestSearchTemplates(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() // Do not use a real database, this is only used for an diff --git a/coderd/tailnet_test.go b/coderd/tailnet_test.go index 28265404c3eae..55b212237479f 100644 --- a/coderd/tailnet_test.go +++ b/coderd/tailnet_test.go @@ -257,7 +257,6 @@ func TestServerTailnet_ReverseProxy(t *testing.T) { port := ":4444" for i, ag := range agents { - i := i ln, err := ag.TailnetConn().Listen("tcp", port) require.NoError(t, err) wln := &wrappedListener{Listener: ln} diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 498f97362c15b..9338e87d6d31c 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -544,7 +544,6 @@ func TestRecordTelemetryStatus(t *testing.T) { {name: "Telemetry was disabled still disabled", recordedTelemetryEnabled: "false", telemetryEnabled: false, shouldReport: false}, {name: "Telemetry was disabled still disabled, invalid value", recordedTelemetryEnabled: "invalid", telemetryEnabled: false, shouldReport: false}, } { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index e4027a1f14605..1ad06bae38aee 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -604,7 +604,6 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { }, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -1393,7 +1392,6 @@ func TestPaginatedTemplateVersions(t *testing.T) { file, err := client.Upload(egCtx, codersdk.ContentTypeTar, bytes.NewReader(data)) require.NoError(t, err) for i := 0; i < total; i++ { - i := i eg.Go(func() error { templateVersion, err := client.CreateTemplateVersion(egCtx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ Name: uuid.NewString(), @@ -1466,7 +1464,6 @@ func TestPaginatedTemplateVersions(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/tracing/httpmw_test.go b/coderd/tracing/httpmw_test.go index 0583b29159ee5..ba1e2b879c345 100644 --- a/coderd/tracing/httpmw_test.go +++ b/coderd/tracing/httpmw_test.go @@ -77,8 +77,6 @@ func Test_Middleware(t *testing.T) { } for _, c := range cases { - c := c - name := strings.ReplaceAll(strings.TrimPrefix(c.path, "/"), "/", "_") t.Run(name, func(t *testing.T) { t.Parallel() diff --git a/coderd/updatecheck/updatecheck_test.go b/coderd/updatecheck/updatecheck_test.go index 3e21309c5ff71..725ceb44d9d6f 100644 --- a/coderd/updatecheck/updatecheck_test.go +++ b/coderd/updatecheck/updatecheck_test.go @@ -112,7 +112,6 @@ func TestChecker_Latest(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/updatecheck_test.go b/coderd/updatecheck_test.go index c81dc0821a152..a81dcd63a2091 100644 --- a/coderd/updatecheck_test.go +++ b/coderd/updatecheck_test.go @@ -51,8 +51,6 @@ func TestUpdateCheck_NewVersion(t *testing.T) { }, } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 6d224818a6a46..4c9412fda3fb7 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -1473,7 +1473,6 @@ func TestUserOIDC(t *testing.T) { }, }, } { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() opts := []oidctest.FakeIDPOpt{ diff --git a/coderd/userpassword/userpassword_test.go b/coderd/userpassword/userpassword_test.go index 41eebf49c974d..83a3bb532e606 100644 --- a/coderd/userpassword/userpassword_test.go +++ b/coderd/userpassword/userpassword_test.go @@ -27,7 +27,6 @@ func TestUserPasswordValidate(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() err := userpassword.Validate(tt.password) @@ -93,7 +92,6 @@ func TestUserPasswordCompare(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() if tt.shouldHash { diff --git a/coderd/users_test.go b/coderd/users_test.go index 2e8eb5f3e842e..bd0f138b6a339 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1777,7 +1777,6 @@ func TestUsersFilter(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() @@ -2461,7 +2460,6 @@ func TestPaginatedUsers(t *testing.T) { eg, _ := errgroup.WithContext(ctx) // Create users for i := 0; i < total; i++ { - i := i eg.Go(func() error { email := fmt.Sprintf("%d@coder.com", i) username := fmt.Sprintf("user%d", i) @@ -2519,7 +2517,6 @@ func TestPaginatedUsers(t *testing.T) { {name: "username search", limit: 3, allUsers: specialUsers, opt: usernameSearch}, } for _, tt := range tests { - tt := tt t.Run(fmt.Sprintf("%s %d", tt.name, tt.limit), func(t *testing.T) { t.Parallel() diff --git a/coderd/util/maps/maps_test.go b/coderd/util/maps/maps_test.go index 543c100c210a5..f8ad8ddbc4b36 100644 --- a/coderd/util/maps/maps_test.go +++ b/coderd/util/maps/maps_test.go @@ -70,7 +70,6 @@ func TestSubset(t *testing.T) { expected: true, }, } { - tc := tc t.Run("#"+strconv.Itoa(idx), func(t *testing.T) { t.Parallel() 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/coderd/util/strings/strings_test.go b/coderd/util/strings/strings_test.go index 2db9c9e236e43..5172fb08e1e69 100644 --- a/coderd/util/strings/strings_test.go +++ b/coderd/util/strings/strings_test.go @@ -30,7 +30,6 @@ func TestTruncate(t *testing.T) { {"foo", 0, ""}, {"foo", -1, ""}, } { - tt := tt t.Run(tt.expected, func(t *testing.T) { t.Parallel() actual := strings.Truncate(tt.s, tt.n) diff --git a/coderd/util/xio/limitwriter_test.go b/coderd/util/xio/limitwriter_test.go index 90d83f81e7d9e..552b38f71f487 100644 --- a/coderd/util/xio/limitwriter_test.go +++ b/coderd/util/xio/limitwriter_test.go @@ -107,7 +107,6 @@ func TestLimitWriter(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 6d53bd3df1140..b3fb53c228ef8 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1065,7 +1065,6 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { }, }, } { - tc := tc t.Run("OK_"+tc.name, func(t *testing.T) { t.Parallel() @@ -1340,7 +1339,6 @@ func TestWorkspaceAgentContainers(t *testing.T) { }, }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -1692,7 +1690,6 @@ func TestWorkspaceAgent_LifecycleState(t *testing.T) { } //nolint:paralleltest // No race between setting the state and getting the workspace. for _, tt := range tests { - tt := tt t.Run(string(tt.state), func(t *testing.T) { state, err := agentsdk.ProtoFromLifecycleState(tt.state) if tt.wantErr { diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index 4e48e60d2d47f..050e5d2fb9a6e 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -500,8 +500,6 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { } for _, c := range cases { - c := c - if c.name == "Path" && appHostIsPrimary { // Workspace application auth does not apply to path apps // served from the primary access URL as no smuggling needs @@ -1686,8 +1684,6 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps/appurl/appurl_test.go b/coderd/workspaceapps/appurl/appurl_test.go index 3924949cb30ad..9dfdb4452cdb9 100644 --- a/coderd/workspaceapps/appurl/appurl_test.go +++ b/coderd/workspaceapps/appurl/appurl_test.go @@ -56,7 +56,6 @@ func TestApplicationURLString(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() @@ -158,7 +157,6 @@ func TestParseSubdomainAppURL(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() @@ -378,7 +376,6 @@ func TestCompileHostnamePattern(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -390,7 +387,6 @@ func TestCompileHostnamePattern(t *testing.T) { require.Equal(t, expected, regex.String(), "generated regex does not match") for i, m := range c.matchCases { - m := m t.Run(fmt.Sprintf("MatchCase%d", i), func(t *testing.T) { t.Parallel() @@ -459,7 +455,6 @@ func TestConvertAppURLForCSP(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() require.Equal(t, c.expected, appurl.ConvertAppHostForCSP(c.host, c.wildcard)) diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index 597d1daadfa54..a1f3fb452fbe5 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -270,8 +270,6 @@ func Test_ResolveRequest(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps/request_test.go b/coderd/workspaceapps/request_test.go index fbabc840745e9..f1b0df6ae064a 100644 --- a/coderd/workspaceapps/request_test.go +++ b/coderd/workspaceapps/request_test.go @@ -272,7 +272,6 @@ func Test_RequestValidate(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() req := c.req diff --git a/coderd/workspaceapps/stats_test.go b/coderd/workspaceapps/stats_test.go index 51a6d9eebf169..c98be2eb79142 100644 --- a/coderd/workspaceapps/stats_test.go +++ b/coderd/workspaceapps/stats_test.go @@ -280,7 +280,6 @@ func TestStatsCollector(t *testing.T) { // Run tests. for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps/token_test.go b/coderd/workspaceapps/token_test.go index db070268fa196..94ee128bd9079 100644 --- a/coderd/workspaceapps/token_test.go +++ b/coderd/workspaceapps/token_test.go @@ -273,8 +273,6 @@ func Test_TokenMatchesRequest(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 91950ac855a1f..8db2858e01e32 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -57,7 +57,6 @@ func TestGetAppHost(t *testing.T) { }, } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -182,7 +181,6 @@ func TestWorkspaceApplicationAuth(t *testing.T) { } for _, c := range cases { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() 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/workspaces_test.go b/coderd/workspaces_test.go index daabb12c25e14..1d02622d455e8 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -652,7 +652,6 @@ func TestWorkspace(t *testing.T) { } for _, tc := range testCases { - tc := tc // Capture range variable t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -1802,7 +1801,6 @@ func TestWorkspaceFilter(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() workspaces, err := client.Workspaces(ctx, c.Filter) @@ -2583,7 +2581,6 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() var ( @@ -2763,7 +2760,6 @@ func TestWorkspaceUpdateTTL(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() @@ -2864,8 +2860,6 @@ func TestWorkspaceUpdateTTL(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.name, func(t *testing.T) { t.Parallel() diff --git a/coderd/workspacestats/activitybump_test.go b/coderd/workspacestats/activitybump_test.go index ccee299a46548..d778e2fbd0f8a 100644 --- a/coderd/workspacestats/activitybump_test.go +++ b/coderd/workspacestats/activitybump_test.go @@ -158,9 +158,7 @@ func Test_ActivityBumpWorkspace(t *testing.T) { expectedBump: 0, }, } { - tt := tt for _, tz := range timezones { - tz := tz t.Run(tt.name+"/"+tz, func(t *testing.T) { t.Parallel() nextAutostart := tt.nextAutostart 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/agentsdk/logs_test.go b/codersdk/agentsdk/logs_test.go index 2b3b934c8db3c..05e4bc574efde 100644 --- a/codersdk/agentsdk/logs_test.go +++ b/codersdk/agentsdk/logs_test.go @@ -168,7 +168,6 @@ func TestStartupLogsWriter_Write(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -253,7 +252,6 @@ func TestStartupLogsSender(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/codersdk/client_internal_test.go b/codersdk/client_internal_test.go index 0650c3c32097d..cfd8bdbf26086 100644 --- a/codersdk/client_internal_test.go +++ b/codersdk/client_internal_test.go @@ -76,8 +76,6 @@ func TestIsConnectionErr(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -298,7 +296,6 @@ func Test_readBodyAsError(t *testing.T) { } for _, c := range tests { - c := c t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/codersdk/deployment_internal_test.go b/codersdk/deployment_internal_test.go index 09ee7f2a2cc71..d350447fd638a 100644 --- a/codersdk/deployment_internal_test.go +++ b/codersdk/deployment_internal_test.go @@ -28,8 +28,6 @@ func TestRemoveTrailingVersionInfo(t *testing.T) { } for _, tc := range testCases { - tc := tc - stripped := removeTrailingVersionInfo(tc.Version) require.Equal(t, tc.ExpectedAfterStrippingInfo, stripped) } diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 1d2af676596d3..c18e5775f7ae9 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -196,7 +196,6 @@ func TestSSHConfig_ParseOptions(t *testing.T) { } for _, tt := range testCases { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() c := codersdk.SSHConfig{ @@ -277,7 +276,6 @@ func TestTimezoneOffsets(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() @@ -524,8 +522,6 @@ func TestFeatureComparison(t *testing.T) { } for _, tc := range testCases { - tc := tc - t.Run(tc.Name, func(t *testing.T) { t.Parallel() @@ -619,8 +615,6 @@ func TestNotificationsCanBeDisabled(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/codersdk/healthsdk/healthsdk_test.go b/codersdk/healthsdk/healthsdk_test.go index 517837e20a2de..4a062da03f24d 100644 --- a/codersdk/healthsdk/healthsdk_test.go +++ b/codersdk/healthsdk/healthsdk_test.go @@ -148,7 +148,6 @@ func TestSummarize(t *testing.T) { }, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() actual := tt.br.Summarize(tt.pfx, tt.docsURL) diff --git a/codersdk/healthsdk/interfaces_internal_test.go b/codersdk/healthsdk/interfaces_internal_test.go index f870e543166e1..e5c3978383b35 100644 --- a/codersdk/healthsdk/interfaces_internal_test.go +++ b/codersdk/healthsdk/interfaces_internal_test.go @@ -160,7 +160,6 @@ func Test_generateInterfacesReport(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() r := generateInterfacesReport(&tc.state) diff --git a/codersdk/name_test.go b/codersdk/name_test.go index 487f3778ac70e..b4903846c4c23 100644 --- a/codersdk/name_test.go +++ b/codersdk/name_test.go @@ -60,7 +60,6 @@ func TestUsernameValid(t *testing.T) { {"123456789012345678901234567890123123456789012345678901234567890123", false}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Username, func(t *testing.T) { t.Parallel() valid := codersdk.NameValid(testCase.Username) @@ -115,7 +114,6 @@ func TestTemplateDisplayNameValid(t *testing.T) { {"12345678901234567890123456789012345678901234567890123456789012345", false}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() valid := codersdk.DisplayNameValid(testCase.Name) @@ -156,7 +154,6 @@ func TestTemplateVersionNameValid(t *testing.T) { {"!!!!1 ?????", false}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() valid := codersdk.TemplateVersionNameValid(testCase.Name) @@ -197,7 +194,6 @@ func TestFrom(t *testing.T) { {"", ""}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.From, func(t *testing.T) { t.Parallel() converted := codersdk.UsernameFrom(testCase.From) @@ -243,7 +239,6 @@ func TestUserRealNameValid(t *testing.T) { {strings.Repeat("a", 129), false}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() err := codersdk.UserRealNameValid(testCase.Name) @@ -277,7 +272,6 @@ func TestGroupNameValid(t *testing.T) { {random256String, false}, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() err := codersdk.GroupNameValid(testCase.Name) diff --git a/codersdk/pagination_test.go b/codersdk/pagination_test.go index 53a3fcaebceb4..e5bb8002743f9 100644 --- a/codersdk/pagination_test.go +++ b/codersdk/pagination_test.go @@ -42,7 +42,6 @@ func TestPagination_asRequestOption(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() 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/codersdk/richparameters_test.go b/codersdk/richparameters_test.go index 5635a82beb6c6..66f23416115bd 100644 --- a/codersdk/richparameters_test.go +++ b/codersdk/richparameters_test.go @@ -322,7 +322,6 @@ func TestRichParameterValidation(t *testing.T) { } for _, tc := range tests { - tc := tc t.Run(tc.parameterName+"-"+tc.value, func(t *testing.T) { t.Parallel() diff --git a/codersdk/time_test.go b/codersdk/time_test.go index a2d3b20622ba7..fd5314538d3d9 100644 --- a/codersdk/time_test.go +++ b/codersdk/time_test.go @@ -47,7 +47,6 @@ func TestNullTime_MarshalJSON(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -104,7 +103,6 @@ func TestNullTime_UnmarshalJSON(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -145,7 +143,6 @@ func TestNullTime_IsZero(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/codersdk/workspacedisplaystatus_internal_test.go b/codersdk/workspacedisplaystatus_internal_test.go index 2b910c89835fb..68e718a5f4cde 100644 --- a/codersdk/workspacedisplaystatus_internal_test.go +++ b/codersdk/workspacedisplaystatus_internal_test.go @@ -90,7 +90,6 @@ func TestWorkspaceDisplayStatus(t *testing.T) { }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() if got := WorkspaceDisplayStatus(tt.jobStatus, tt.transition); got != tt.want { diff --git a/cryptorand/strings_test.go b/cryptorand/strings_test.go index 8557667457a6c..4a24f907a2dc8 100644 --- a/cryptorand/strings_test.go +++ b/cryptorand/strings_test.go @@ -92,7 +92,6 @@ func TestStringCharset(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.Name, func(t *testing.T) { t.Parallel() 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/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/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) diff --git a/enterprise/audit/audit_test.go b/enterprise/audit/audit_test.go index 6d825306c3346..bf9393612d65c 100644 --- a/enterprise/audit/audit_test.go +++ b/enterprise/audit/audit_test.go @@ -84,7 +84,6 @@ func TestAuditor(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go index d5c191c8907fa..afbd1b37844cc 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -417,7 +417,6 @@ func runDiffTests(t *testing.T, tests []diffTest) { t.Helper() for _, test := range tests { - test := test typName := reflect.TypeOf(test.left).Name() t.Run(typName+"/"+test.name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/cli/start_test.go b/enterprise/cli/start_test.go index dd86b20d44fb6..b68a95ae1131d 100644 --- a/enterprise/cli/start_test.go +++ b/enterprise/cli/start_test.go @@ -121,11 +121,9 @@ func TestStart(t *testing.T) { } for _, cmd := range []string{"start", "restart"} { - cmd := cmd t.Run(cmd, func(t *testing.T) { t.Parallel() for _, c := range cases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/authorize_test.go b/enterprise/coderd/authorize_test.go index 670df18916aaa..d64cdb58c2e8e 100644 --- a/enterprise/coderd/authorize_test.go +++ b/enterprise/coderd/authorize_test.go @@ -96,8 +96,6 @@ func TestCheckACLPermissions(t *testing.T) { } for _, c := range testCases { - c := c - t.Run("CheckAuthorization/"+c.Name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 446fce042d70f..1c8c863db040b 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -292,8 +292,6 @@ func TestEntitlements_Prebuilds(t *testing.T) { } for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -618,7 +616,6 @@ func TestSCIMDisabled(t *testing.T) { } for _, p := range checkPaths { - p := p t.Run(p, func(t *testing.T) { t.Parallel() 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/enidpsync/organizations_test.go b/enterprise/coderd/enidpsync/organizations_test.go index b2e120592b582..d2a5aafece558 100644 --- a/enterprise/coderd/enidpsync/organizations_test.go +++ b/enterprise/coderd/enidpsync/organizations_test.go @@ -296,7 +296,6 @@ func TestOrganizationSync(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) 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/httpmw/provisionerdaemon_test.go b/enterprise/coderd/httpmw/provisionerdaemon_test.go index 84da7f546fa35..4d9575c72491a 100644 --- a/enterprise/coderd/httpmw/provisionerdaemon_test.go +++ b/enterprise/coderd/httpmw/provisionerdaemon_test.go @@ -126,7 +126,6 @@ func TestExtractProvisionerDaemonAuthenticated(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() routeCtx := chi.NewRouteContext() diff --git a/enterprise/coderd/insights_test.go b/enterprise/coderd/insights_test.go index 044c5988eb036..d38eefc593926 100644 --- a/enterprise/coderd/insights_test.go +++ b/enterprise/coderd/insights_test.go @@ -34,7 +34,6 @@ func TestTemplateInsightsWithTemplateAdminACL(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(fmt.Sprintf("with interval=%q", tt.interval), func(t *testing.T) { t.Parallel() @@ -94,7 +93,6 @@ func TestTemplateInsightsWithRole(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(fmt.Sprintf("with interval=%q role=%q", tt.interval, tt.role), func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 184a611c40949..bf6d6448205e0 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -848,8 +848,6 @@ func TestLicenseEntitlements(t *testing.T) { } for _, tc := range testCases { - tc := tc - t.Run(tc.Name, func(t *testing.T) { t.Parallel() 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/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/claim_test.go b/enterprise/coderd/prebuilds/claim_test.go index 83933f3a98cd3..ec1d6dbbd5a98 100644 --- a/enterprise/coderd/prebuilds/claim_test.go +++ b/enterprise/coderd/prebuilds/claim_test.go @@ -128,7 +128,6 @@ func TestClaimPrebuild(t *testing.T) { for name, tc := range cases { // Ensure that prebuilt workspaces can be claimed in non-default organizations: for _, useDefaultOrg := range []bool{true, false} { - tc := tc t.Run(name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/prebuilds/membership_test.go b/enterprise/coderd/prebuilds/membership_test.go index 6caa7178d9d60..82d2abf92a4d8 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" ) @@ -57,7 +56,6 @@ func TestReconcileAll(t *testing.T) { } for _, tc := range tests { - tc := tc // capture t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -74,14 +72,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 +89,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 +100,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..3b8461979c87a 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,27 +168,20 @@ 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}, }, } for _, test := range tests { - test := test // capture for parallel for _, transition := range test.transitions { - transition := transition // capture for parallel for _, jobStatus := range test.jobStatuses { - jobStatus := jobStatus // capture for parallel for _, initiatorID := range test.initiatorIDs { - initiatorID := initiatorID // capture for parallel for _, ownerID := range test.ownerIDs { - ownerID := ownerID // capture for parallel for _, templateDeleted := range test.templateDeleted { - templateDeleted := templateDeleted // capture for parallel for _, eligible := range test.eligible { - eligible := eligible // capture for parallel t.Run(fmt.Sprintf("%v/transition:%s/jobStatus:%s", test.name, transition, jobStatus), func(t *testing.T) { t.Parallel() @@ -209,7 +201,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{ @@ -260,7 +252,6 @@ func TestMetricsCollector(t *testing.T) { require.Equal(t, 1, len(presets)) for _, preset := range presets { - preset := preset // capture for parallel labels := map[string]string{ "template_name": template.Name, "preset_name": preset.Name, @@ -327,8 +318,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 a9f8bd014b3e9..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) } @@ -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( @@ -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..de8c65546334e 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" @@ -309,7 +308,6 @@ func TestPrebuildReconciliation(t *testing.T) { }, } for _, tc := range testCases { - tc := tc // capture for parallel for _, templateVersionActive := range tc.templateVersionActive { for _, prebuildLatestTransition := range tc.prebuildLatestTransitions { for _, prebuildJobStatus := range tc.prebuildJobStatuses { @@ -2021,7 +2019,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/provisionerdaemons_test.go b/enterprise/coderd/provisionerdaemons_test.go index cdc6267d90971..a94a60ffff3c2 100644 --- a/enterprise/coderd/provisionerdaemons_test.go +++ b/enterprise/coderd/provisionerdaemons_test.go @@ -921,7 +921,6 @@ func TestGetProvisionerDaemons(t *testing.T) { }, } for _, tt := range testCases { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) diff --git a/enterprise/coderd/provisionerkeys_test.go b/enterprise/coderd/provisionerkeys_test.go index e3f5839bf8b02..daca6625d620f 100644 --- a/enterprise/coderd/provisionerkeys_test.go +++ b/enterprise/coderd/provisionerkeys_test.go @@ -167,7 +167,6 @@ func TestGetProvisionerKey(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 57b66a368248c..70c432755f7fa 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -517,7 +517,6 @@ func TestListRoles(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go index 712fa032c8c1b..4af06042b031f 100644 --- a/enterprise/coderd/schedule/template_test.go +++ b/enterprise/coderd/schedule/template_test.go @@ -191,8 +191,6 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -778,8 +776,6 @@ func TestTemplateTTL(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index b6c2048190e9a..6c7a20f85a642 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -470,8 +470,6 @@ func TestTemplates(t *testing.T) { } for _, c := range cases { - c := c - // nolint: paralleltest // context is from parent t.Run t.Run(c.Name, func(t *testing.T) { _, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ 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" +} diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index 267e1168f84cf..46207f319dbe1 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -902,7 +902,6 @@ func TestGroupSync(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() runner := setupOIDCTest(t, oidcTestConfig{ diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go index 5aa1ab1e8215c..7cfef59fa9e5f 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -426,7 +426,6 @@ func TestGrantSiteRoles(t *testing.T) { } for _, c := range testCases { - c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) diff --git a/enterprise/coderd/workspaceproxy_internal_test.go b/enterprise/coderd/workspaceproxy_internal_test.go index 9654e0ecc3e2f..1bb84b4026ca6 100644 --- a/enterprise/coderd/workspaceproxy_internal_test.go +++ b/enterprise/coderd/workspaceproxy_internal_test.go @@ -47,7 +47,6 @@ func Test_validateProxyURL(t *testing.T) { } for _, tt := range testcases { - tt := tt t.Run(tt.Name, func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index ce86151f9b883..e8a29ccc33959 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, @@ -1953,7 +1952,6 @@ func TestWorkspaceTagsTerraform(t *testing.T) { }`, }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { client, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ diff --git a/enterprise/provisionerd/remoteprovisioners_test.go b/enterprise/provisionerd/remoteprovisioners_test.go index 5d0de5ae396b7..7b89d696ee20e 100644 --- a/enterprise/provisionerd/remoteprovisioners_test.go +++ b/enterprise/provisionerd/remoteprovisioners_test.go @@ -33,7 +33,6 @@ func TestRemoteConnector_Mainline(t *testing.T) { {name: "Smokescreen", smokescreen: true}, } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) diff --git a/enterprise/wsproxy/wsproxy_test.go b/enterprise/wsproxy/wsproxy_test.go index 65de627a1fb06..99cb5c60545f9 100644 --- a/enterprise/wsproxy/wsproxy_test.go +++ b/enterprise/wsproxy/wsproxy_test.go @@ -282,7 +282,6 @@ resourceLoop: // Connect to each region. for _, r := range connInfo.DERPMap.Regions { - r := r if len(r.Nodes) == 1 && r.Nodes[0].STUNOnly { // Skip STUN-only regions. continue @@ -654,7 +653,6 @@ func TestWorkspaceProxyDERPMeshProbe(t *testing.T) { replicaPingDone = [count]bool{} ) for i := range proxies { - i := i proxies[i] = coderdenttest.NewWorkspaceProxyReplica(t, api, client, &coderdenttest.ProxyOptions{ Name: "proxy-1", Token: sessionToken, diff --git a/examples/examples_test.go b/examples/examples_test.go index 1a558b6506c73..779835eec66d5 100644 --- a/examples/examples_test.go +++ b/examples/examples_test.go @@ -19,7 +19,6 @@ func TestTemplate(t *testing.T) { require.NoError(t, err, "error listing examples, run \"make gen\" to ensure examples are up to date") require.NotEmpty(t, list) for _, eg := range list { - eg := eg t.Run(eg.ID, func(t *testing.T) { t.Parallel() assert.NotEmpty(t, eg.ID, "example ID should not be empty") diff --git a/helm/coder/tests/chart_test.go b/helm/coder/tests/chart_test.go index 638b9e5005d6f..a11d631a2f247 100644 --- a/helm/coder/tests/chart_test.go +++ b/helm/coder/tests/chart_test.go @@ -163,10 +163,7 @@ func TestRenderChart(t *testing.T) { require.NoError(t, err, "failed to build Helm dependencies") for _, tc := range testCases { - tc := tc - for _, ns := range namespaces { - tc := tc tc.namespace = ns t.Run(tc.namespace+"/"+tc.name, func(t *testing.T) { @@ -213,14 +210,12 @@ func TestUpdateGoldenFiles(t *testing.T) { require.NoError(t, err, "failed to build Helm dependencies") for _, tc := range testCases { - tc := tc if tc.expectedError != "" { t.Logf("skipping test case %q with render error", tc.name) continue } for _, ns := range namespaces { - tc := tc tc.namespace = ns valuesPath := tc.valuesFilePath() diff --git a/helm/provisioner/tests/chart_test.go b/helm/provisioner/tests/chart_test.go index a6f3ba7370bac..8b0cc5cabaa1e 100644 --- a/helm/provisioner/tests/chart_test.go +++ b/helm/provisioner/tests/chart_test.go @@ -141,9 +141,7 @@ func TestRenderChart(t *testing.T) { require.NoError(t, err, "failed to build Helm dependencies") for _, tc := range testCases { - tc := tc for _, ns := range namespaces { - tc := tc tc.namespace = ns t.Run(tc.namespace+"/"+tc.name, func(t *testing.T) { @@ -190,14 +188,12 @@ func TestUpdateGoldenFiles(t *testing.T) { require.NoError(t, err, "failed to build Helm dependencies") for _, tc := range testCases { - tc := tc if tc.expectedError != "" { t.Logf("skipping test case %q with render error", tc.name) continue } for _, ns := range namespaces { - tc := tc tc.namespace = ns valuesPath := tc.valuesFilePath() diff --git a/provisioner/terraform/diagnostic_test.go b/provisioner/terraform/diagnostic_test.go index 8727256b75376..0fd353ae540a5 100644 --- a/provisioner/terraform/diagnostic_test.go +++ b/provisioner/terraform/diagnostic_test.go @@ -47,8 +47,6 @@ func TestFormatDiagnostic(t *testing.T) { } for name, tc := range tests { - tc := tc - t.Run(name, func(t *testing.T) { t.Parallel() diff --git a/provisioner/terraform/executor_internal_test.go b/provisioner/terraform/executor_internal_test.go index 97cb5285372f2..a39d8758893b8 100644 --- a/provisioner/terraform/executor_internal_test.go +++ b/provisioner/terraform/executor_internal_test.go @@ -157,8 +157,6 @@ func TestOnlyDataResources(t *testing.T) { } for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { t.Parallel() diff --git a/provisioner/terraform/parse_test.go b/provisioner/terraform/parse_test.go index 7d176cb886469..d2a505235f688 100644 --- a/provisioner/terraform/parse_test.go +++ b/provisioner/terraform/parse_test.go @@ -376,7 +376,6 @@ func TestParse(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index 505fd2df41400..dc4545d137e89 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -312,7 +312,6 @@ func TestProvision_Cancel(t *testing.T) { }, } for _, tt := range tests { - tt := tt // below we exec fake_cancel.sh, which causes the kernel to execute it, and if more than // one process tries to do this, it can cause "text file busy" // nolint: paralleltest @@ -1067,7 +1066,6 @@ func TestProvision(t *testing.T) { } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.Name, func(t *testing.T) { t.Parallel() diff --git a/provisioner/terraform/resources_test.go b/provisioner/terraform/resources_test.go index 772256032be3c..d21c0a9c573ff 100644 --- a/provisioner/terraform/resources_test.go +++ b/provisioner/terraform/resources_test.go @@ -930,8 +930,6 @@ func TestConvertResources(t *testing.T) { }, }, } { - folderName := folderName - expected := expected t.Run(folderName, func(t *testing.T) { t.Parallel() dir := filepath.Join(filepath.Dir(filename), "testdata", "resources", folderName) @@ -1114,7 +1112,6 @@ func TestAppSlugValidation(t *testing.T) { //nolint:paralleltest for i, c := range cases { - c := c t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) { // Change the first app slug to match the current case. for _, resource := range tfPlan.PlannedValues.RootModule.Resources { @@ -1191,7 +1188,6 @@ func TestAgentNameInvalid(t *testing.T) { //nolint:paralleltest for i, c := range cases { - c := c t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) { // Change the first agent name to match the current case. for _, resource := range tfPlan.PlannedValues.RootModule.Resources { @@ -1343,7 +1339,6 @@ func TestInstanceTypeAssociation(t *testing.T) { ResourceType: "azurerm_windows_virtual_machine", InstanceTypeKey: "size", }} { - tc := tc t.Run(tc.ResourceType, func(t *testing.T) { t.Parallel() ctx, logger := ctxAndLogger(t) @@ -1402,7 +1397,6 @@ func TestInstanceIDAssociation(t *testing.T) { ResourceType: "azurerm_windows_virtual_machine", InstanceIDKey: "virtual_machine_id", }} { - tc := tc t.Run(tc.ResourceType, func(t *testing.T) { t.Parallel() ctx, logger := ctxAndLogger(t) diff --git a/provisioner/terraform/tfparse/tfparse_test.go b/provisioner/terraform/tfparse/tfparse_test.go index ceefc484b2169..41182b9aa2dac 100644 --- a/provisioner/terraform/tfparse/tfparse_test.go +++ b/provisioner/terraform/tfparse/tfparse_test.go @@ -587,7 +587,6 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { expectTags: map[string]string{"foo": "bar", "a": "1"}, }, } { - tc := tc t.Run(tc.name+"/tar", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) diff --git a/provisionersdk/provisionertags_test.go b/provisionersdk/provisionertags_test.go index 70e05473eecc0..070285aea6c50 100644 --- a/provisionersdk/provisionertags_test.go +++ b/provisionersdk/provisionertags_test.go @@ -185,7 +185,6 @@ func TestMutateTags(t *testing.T) { }, }, } { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() got := provisionersdk.MutateTags(tt.userID, tt.tags...) diff --git a/pty/ptytest/ptytest_test.go b/pty/ptytest/ptytest_test.go index 2b1b5570b18ea..29011ba9e7e61 100644 --- a/pty/ptytest/ptytest_test.go +++ b/pty/ptytest/ptytest_test.go @@ -56,7 +56,6 @@ func TestPtytest(t *testing.T) { {name: "10241 large output", output: strings.Repeat(".", 10241)}, // 1024 * 10 + 1 } for _, tt := range tests { - tt := tt // nolint:paralleltest // Avoid parallel test to more easily identify the issue. t.Run(tt.name, func(t *testing.T) { cmd := &serpent.Command{ diff --git a/scaletest/agentconn/config_test.go b/scaletest/agentconn/config_test.go index 5f5cdf7c53da7..412d7f6926119 100644 --- a/scaletest/agentconn/config_test.go +++ b/scaletest/agentconn/config_test.go @@ -167,8 +167,6 @@ func Test_Config(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/scaletest/createworkspaces/config_test.go b/scaletest/createworkspaces/config_test.go index 6a3d9e8104624..3965e9f9dfb69 100644 --- a/scaletest/createworkspaces/config_test.go +++ b/scaletest/createworkspaces/config_test.go @@ -63,8 +63,6 @@ func Test_UserConfig(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() @@ -177,8 +175,6 @@ func Test_Config(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/scaletest/harness/strategies.go b/scaletest/harness/strategies.go index 24bb04e871880..7d5067a4e1eb3 100644 --- a/scaletest/harness/strategies.go +++ b/scaletest/harness/strategies.go @@ -122,7 +122,6 @@ var _ ExecutionStrategy = TimeoutExecutionStrategyWrapper{} func (t TimeoutExecutionStrategyWrapper) Run(ctx context.Context, fns []TestFn) ([]error, error) { newFns := make([]TestFn, len(fns)) for i, fn := range fns { - fn := fn newFns[i] = func(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, t.Timeout) defer cancel() diff --git a/scaletest/harness/strategies_test.go b/scaletest/harness/strategies_test.go index 0858b5bf71da1..b18036a7931d3 100644 --- a/scaletest/harness/strategies_test.go +++ b/scaletest/harness/strategies_test.go @@ -186,8 +186,6 @@ func strategyTestData(count int, runFn func(ctx context.Context, i int, logs io. fns = make([]harness.TestFn, count) ) for i := 0; i < count; i++ { - i := i - runs[i] = harness.NewTestRun("test", strconv.Itoa(i), testFns{ RunFn: func(ctx context.Context, id string, logs io.Writer) error { if runFn != nil { diff --git a/scaletest/placebo/config_test.go b/scaletest/placebo/config_test.go index 8e3a40000a02e..84458c28a8d8e 100644 --- a/scaletest/placebo/config_test.go +++ b/scaletest/placebo/config_test.go @@ -98,8 +98,6 @@ func Test_Config(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/scaletest/reconnectingpty/config_test.go b/scaletest/reconnectingpty/config_test.go index e0712b4631097..1b7646ad744d9 100644 --- a/scaletest/reconnectingpty/config_test.go +++ b/scaletest/reconnectingpty/config_test.go @@ -61,8 +61,6 @@ func Test_Config(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/scaletest/workspacebuild/config_test.go b/scaletest/workspacebuild/config_test.go index b9c427f104f3d..c80b48df9055c 100644 --- a/scaletest/workspacebuild/config_test.go +++ b/scaletest/workspacebuild/config_test.go @@ -78,8 +78,6 @@ func Test_Config(t *testing.T) { } for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { t.Parallel() diff --git a/scripts/apitypings/main_test.go b/scripts/apitypings/main_test.go index 0ac20363327c3..1bb89c7ba5423 100644 --- a/scripts/apitypings/main_test.go +++ b/scripts/apitypings/main_test.go @@ -31,7 +31,6 @@ func TestGeneration(t *testing.T) { // Only test directories continue } - f := f t.Run(f.Name(), func(t *testing.T) { t.Parallel() dir := filepath.Join(".", "testdata", f.Name()) diff --git a/scripts/release/main_internal_test.go b/scripts/release/main_internal_test.go index ce83e7169a35b..587d327272af5 100644 --- a/scripts/release/main_internal_test.go +++ b/scripts/release/main_internal_test.go @@ -115,7 +115,6 @@ Enjoy. } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() if diff := cmp.Diff(removeMainlineBlurb(tt.body), tt.want); diff != "" { @@ -167,7 +166,6 @@ func Test_release_autoversion(t *testing.T) { require.NoError(t, err) for _, file := range files { - file := file t.Run(file, func(t *testing.T) { t.Parallel() diff --git a/scripts/releasemigrations/main.go b/scripts/releasemigrations/main.go index a06be904b004d..249d1891f9c29 100644 --- a/scripts/releasemigrations/main.go +++ b/scripts/releasemigrations/main.go @@ -230,7 +230,6 @@ func hasMigrationDiff(dir string, a, b string) ([]string, error) { migrations := strings.Split(strings.TrimSpace(string(output)), "\n") filtered := make([]string, 0, len(migrations)) for _, migration := range migrations { - migration := migration if strings.Contains(migration, "fixtures") { continue } diff --git a/scripts/typegen/main.go b/scripts/typegen/main.go index 0e7457406102e..acbaa9c2c35fe 100644 --- a/scripts/typegen/main.go +++ b/scripts/typegen/main.go @@ -243,7 +243,6 @@ func generateRbacObjects(templateSource string) ([]byte, error) { var out bytes.Buffer list := make([]Definition, 0) for t, v := range policy.RBACPermissions { - v := v list = append(list, Definition{ PermissionDefinition: v, Type: t, diff --git a/site/site_test.go b/site/site_test.go index d257bd9519b3d..f7301debba2be 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -498,7 +498,6 @@ func TestServingBin(t *testing.T) { } //nolint // Parallel test detection issue. for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() 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", 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 = ({