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.

+
+## 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 (
+
+ );
+};
diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx
index 4d1e91d9bf3e3..db3fa2f404c53 100644
--- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx
+++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.stories.tsx
@@ -211,6 +211,15 @@ export const Immutable: Story = {
},
};
+export const Ephemeral: Story = {
+ args: {
+ parameter: {
+ ...MockPreviewParameter,
+ ephemeral: true,
+ },
+ },
+};
+
export const AllBadges: Story = {
args: {
parameter: {
diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx
index c3448ac7d7182..9f97d558c8f08 100644
--- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx
+++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx
@@ -36,6 +36,7 @@ import { useDebouncedValue } from "hooks/debounce";
import { useEffectEvent } from "hooks/hookPolyfills";
import {
CircleAlert,
+ Hourglass,
Info,
LinkIcon,
Settings,
@@ -162,6 +163,23 @@ const ParameterLabel: FC = ({
)}
+ {parameter.ephemeral && (
+
+
+
+
+
+
+ Ephemeral
+
+
+
+
+ This parameter only applies for a single workspace start
+
+
+
+ )}
{isPreset && (
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx
index d594351d8dcae..76b100fc96745 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx
@@ -1,5 +1,4 @@
import { useTheme } from "@emotion/react";
-import Button from "@mui/material/Button";
import visuallyHidden from "@mui/utils/visuallyHidden";
import { API } from "api/api";
import type {
@@ -7,6 +6,7 @@ import type {
Workspace,
WorkspaceBuildParameter,
} from "api/typesGenerated";
+import { Button } from "components/Button/Button";
import { FormFields } from "components/Form/Form";
import { TopbarButton } from "components/FullPageLayout/Topbar";
import {
@@ -27,6 +27,7 @@ import { useFormik } from "formik";
import { ChevronDownIcon } from "lucide-react";
import type { FC } from "react";
import { useQuery } from "react-query";
+import { useNavigate } from "react-router-dom";
import { docs } from "utils/docs";
import { getFormHelpers } from "utils/formUtils";
import {
@@ -72,6 +73,7 @@ export const BuildParametersPopover: FC = ({
css={{ ".MuiPaper-root": { width: 304 } }}
>
= ({
};
interface BuildParametersPopoverContentProps {
+ workspace: Workspace;
ephemeralParameters?: TemplateVersionParameter[];
buildParameters?: WorkspaceBuildParameter[];
onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void;
}
const BuildParametersPopoverContent: FC = ({
+ workspace,
ephemeralParameters,
buildParameters,
onSubmit,
}) => {
const theme = useTheme();
const popover = usePopover();
+ const navigate = useNavigate();
+
+ if (
+ !workspace.template_use_classic_parameter_flow &&
+ ephemeralParameters &&
+ ephemeralParameters.length > 0
+ ) {
+ const handleGoToParameters = () => {
+ popover.setOpen(false);
+ navigate(
+ `/@${workspace.owner_name}/${workspace.name}/settings/parameters`,
+ );
+ };
+
+ return (
+
+
+ Ephemeral Parameters
+
+
+ This template has ephemeral parameters that must be configured on the
+ workspace parameters page
+
+
+
+
+
+
+ );
+ }
return (
<>
@@ -206,8 +257,6 @@ const Form: FC = ({
- {standardParameters.map((parameter, index) => {
+ {parameters.map((parameter, index) => {
const currentParameterValueIndex =
form.values.rich_parameter_values?.findIndex(
(p) => p.name === parameter.name,
@@ -260,41 +257,6 @@ export const WorkspaceParametersPageViewExperimental: FC<
)}
- {ephemeralParameters.length > 0 && (
-
-
- Ephemeral Parameters
-
- These parameters only apply for a single workspace start
-
-
-
-
- {ephemeralParameters.map((parameter, index) => {
- const actualIndex = standardParameters.length + index;
- const parameterField = `rich_parameter_values.${actualIndex}`;
- const isDisabled =
- disabled || parameter.styling?.disabled || isSubmitting;
-
- return (
-
- handleChange(parameter, parameterField, value)
- }
- autofill={false}
- disabled={isDisabled}
- value={
- form.values?.rich_parameter_values?.[index]?.value || ""
- }
- />
- );
- })}
-
-
- )}
-