diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 040054eb84cbc..a877037c9898d 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -34,7 +34,7 @@ env:
jobs:
# build-dylib is a separate job to build the dylib on macOS.
build-dylib:
- runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest' }}
+ runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest' }}
steps:
# Harden Runner doesn't work on macOS.
- name: Checkout
@@ -279,9 +279,9 @@ jobs:
env:
EV_SIGNING_CERT: ${{ secrets.EV_SIGNING_CERT }}
- - name: Test migrations from current ref to main
- run: |
- POSTGRES_VERSION=13 make test-migrations
+# - name: Test migrations from current ref to main
+# run: |
+# POSTGRES_VERSION=13 make test-migrations
# Setup GCloud for signing Windows binaries.
- name: Authenticate to Google Cloud
diff --git a/agent/agent.go b/agent/agent.go
index a7434b90d4854..7ce80a33803e9 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -36,6 +36,9 @@ import (
"tailscale.com/util/clientmetric"
"cdr.dev/slog"
+
+ "github.com/coder/retry"
+
"github.com/coder/clistat"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec"
@@ -53,7 +56,6 @@ import (
"github.com/coder/coder/v2/tailnet"
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
"github.com/coder/quartz"
- "github.com/coder/retry"
)
const (
@@ -365,9 +367,11 @@ func (a *agent) runLoop() {
if ctx.Err() != nil {
// Context canceled errors may come from websocket pings, so we
// don't want to use `errors.Is(err, context.Canceled)` here.
+ a.logger.Warn(ctx, "runLoop exited with error", slog.Error(ctx.Err()))
return
}
if a.isClosed() {
+ a.logger.Warn(ctx, "runLoop exited because agent is closed")
return
}
if errors.Is(err, io.EOF) {
@@ -1048,7 +1052,11 @@ func (a *agent) run() (retErr error) {
return a.statsReporter.reportLoop(ctx, aAPI)
})
- return connMan.wait()
+ err = connMan.wait()
+ if err != nil {
+ a.logger.Warn(context.Background(), "connection manager errored", slog.Error(err))
+ }
+ return err
}
// handleManifest returns a function that fetches and processes the manifest
@@ -2005,7 +2013,7 @@ func (a *apiConnRoutineManager) startAgentAPI(
a.eg.Go(func() error {
logger.Debug(ctx, "starting agent routine")
err := f(ctx, a.aAPI)
- if xerrors.Is(err, context.Canceled) && ctx.Err() != nil {
+ if errors.Is(err, context.Canceled) && ctx.Err() != nil {
logger.Debug(ctx, "swallowing context canceled")
// Don't propagate context canceled errors to the error group, because we don't want the
// graceful context being canceled to halt the work of routines with
@@ -2042,7 +2050,7 @@ func (a *apiConnRoutineManager) startTailnetAPI(
a.eg.Go(func() error {
logger.Debug(ctx, "starting tailnet routine")
err := f(ctx, a.tAPI)
- if xerrors.Is(err, context.Canceled) && ctx.Err() != nil {
+ if errors.Is(err, context.Canceled) && ctx.Err() != nil {
logger.Debug(ctx, "swallowing context canceled")
// Don't propagate context canceled errors to the error group, because we don't want the
// graceful context being canceled to halt the work of routines with
diff --git a/agent/agenttest/client.go b/agent/agenttest/client.go
index a1d14e32a2c55..e0a3bc4eddd53 100644
--- a/agent/agenttest/client.go
+++ b/agent/agenttest/client.go
@@ -21,6 +21,7 @@ import (
"tailscale.com/tailcfg"
"cdr.dev/slog"
+
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
diff --git a/agent/metrics.go b/agent/metrics.go
index 1755e43a1a365..d0307a647a239 100644
--- a/agent/metrics.go
+++ b/agent/metrics.go
@@ -20,6 +20,7 @@ type agentMetrics struct {
// took to run. This is reported once per agent.
startupScriptSeconds *prometheus.GaugeVec
currentConnections *prometheus.GaugeVec
+ manifestsReceived prometheus.Counter
}
func newAgentMetrics(registerer prometheus.Registerer) *agentMetrics {
@@ -54,11 +55,20 @@ func newAgentMetrics(registerer prometheus.Registerer) *agentMetrics {
}, []string{"connection_type"})
registerer.MustRegister(currentConnections)
+ manifestsReceived := prometheus.NewCounter(prometheus.CounterOpts{
+ Namespace: "coderd",
+ Subsystem: "agentstats",
+ Name: "manifests_received",
+ Help: "The number of manifests this agent has received from the control plane.",
+ })
+ registerer.MustRegister(manifestsReceived)
+
return &agentMetrics{
connectionsTotal: connectionsTotal,
reconnectingPTYErrors: reconnectingPTYErrors,
startupScriptSeconds: startupScriptSeconds,
currentConnections: currentConnections,
+ manifestsReceived: manifestsReceived,
}
}
diff --git a/agent/reaper/reaper_unix.go b/agent/reaper/reaper_unix.go
index 35ce9bfaa1c48..5a7c7d2f51efa 100644
--- a/agent/reaper/reaper_unix.go
+++ b/agent/reaper/reaper_unix.go
@@ -3,6 +3,7 @@
package reaper
import (
+ "fmt"
"os"
"os/signal"
"syscall"
@@ -29,6 +30,10 @@ func catchSignals(pid int, sigs []os.Signal) {
s := <-sc
sig, ok := s.(syscall.Signal)
if ok {
+ // TODO:
+ // Tried using a logger here but the I/O streams are already closed at this point...
+ // Why is os.Stderr still working then?
+ _, _ = fmt.Fprintf(os.Stderr, "reaper caught %q signal, killing process %v\n", sig.String(), pid)
_ = syscall.Kill(pid, sig)
}
}
diff --git a/cli/agent.go b/cli/agent.go
index 18c4542a6c3a0..f8252c9b8a699 100644
--- a/cli/agent.go
+++ b/cli/agent.go
@@ -19,12 +19,16 @@ import (
"golang.org/x/xerrors"
"gopkg.in/natefinch/lumberjack.v2"
+ "github.com/coder/retry"
+
"github.com/prometheus/client_golang/prometheus"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"cdr.dev/slog/sloggers/slogjson"
"cdr.dev/slog/sloggers/slogstackdriver"
+ "github.com/coder/serpent"
+
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec"
@@ -34,7 +38,6 @@ import (
"github.com/coder/coder/v2/cli/clilog"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
- "github.com/coder/serpent"
)
func (r *RootCmd) workspaceAgent() *serpent.Command {
@@ -63,8 +66,10 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
// This command isn't useful to manually execute.
Hidden: true,
Handler: func(inv *serpent.Invocation) error {
- ctx, cancel := context.WithCancel(inv.Context())
- defer cancel()
+ ctx, cancel := context.WithCancelCause(inv.Context())
+ defer func() {
+ cancel(xerrors.New("defer"))
+ }()
var (
ignorePorts = map[int]string{}
@@ -281,7 +286,6 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
return xerrors.Errorf("add executable to $PATH: %w", err)
}
- prometheusRegistry := prometheus.NewRegistry()
subsystemsRaw := inv.Environ.Get(agent.EnvAgentSubsystem)
subsystems := []codersdk.AgentSubsystem{}
for _, s := range strings.Split(subsystemsRaw, ",") {
@@ -328,46 +332,90 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
containerLister = agentcontainers.NewDocker(execer)
}
- agnt := agent.New(agent.Options{
- Client: client,
- Logger: logger,
- LogDir: logDir,
- ScriptDataDir: scriptDataDir,
- // #nosec G115 - Safe conversion as tailnet listen port is within uint16 range (0-65535)
- TailnetListenPort: uint16(tailnetListenPort),
- ExchangeToken: func(ctx context.Context) (string, error) {
- if exchangeToken == nil {
- return client.SDK.SessionToken(), nil
+ // TODO: timeout ok?
+ reinitCtx, reinitCancel := context.WithTimeout(context.Background(), time.Hour*24)
+ defer reinitCancel()
+ reinitEvents := make(chan agentsdk.ReinitializationResponse)
+
+ go func() {
+ // Retry to wait for reinit, main context cancels the retrier.
+ for retrier := retry.New(100*time.Millisecond, 10*time.Second); retrier.Wait(ctx); {
+ select {
+ case <-reinitCtx.Done():
+ return
+ default:
}
- resp, err := exchangeToken(ctx)
+
+ err := client.WaitForReinit(reinitCtx, reinitEvents)
if err != nil {
- return "", err
+ logger.Error(ctx, "failed to wait for reinit instructions, will retry", slog.Error(err))
}
- client.SetSessionToken(resp.SessionToken)
- return resp.SessionToken, nil
- },
- EnvironmentVariables: environmentVariables,
- IgnorePorts: ignorePorts,
- SSHMaxTimeout: sshMaxTimeout,
- Subsystems: subsystems,
-
- PrometheusRegistry: prometheusRegistry,
- BlockFileTransfer: blockFileTransfer,
- Execer: execer,
- ContainerLister: containerLister,
-
- ExperimentalDevcontainersEnabled: experimentalDevcontainersEnabled,
- })
-
- promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
- prometheusSrvClose := ServeHandler(ctx, logger, promHandler, prometheusAddress, "prometheus")
- defer prometheusSrvClose()
-
- debugSrvClose := ServeHandler(ctx, logger, agnt.HTTPDebug(), debugAddress, "debug")
- defer debugSrvClose()
-
- <-ctx.Done()
- return agnt.Close()
+ }
+ }()
+
+ var (
+ lastErr error
+ mustExit bool
+ )
+ for {
+ prometheusRegistry := prometheus.NewRegistry()
+
+ agnt := agent.New(agent.Options{
+ Client: client,
+ Logger: logger,
+ LogDir: logDir,
+ ScriptDataDir: scriptDataDir,
+ // #nosec G115 - Safe conversion as tailnet listen port is within uint16 range (0-65535)
+ TailnetListenPort: uint16(tailnetListenPort),
+ ExchangeToken: func(ctx context.Context) (string, error) {
+ if exchangeToken == nil {
+ return client.SDK.SessionToken(), nil
+ }
+ resp, err := exchangeToken(ctx)
+ if err != nil {
+ return "", err
+ }
+ client.SetSessionToken(resp.SessionToken)
+ return resp.SessionToken, nil
+ },
+ EnvironmentVariables: environmentVariables,
+ IgnorePorts: ignorePorts,
+ SSHMaxTimeout: sshMaxTimeout,
+ Subsystems: subsystems,
+
+ PrometheusRegistry: prometheusRegistry,
+ BlockFileTransfer: blockFileTransfer,
+ Execer: execer,
+ ContainerLister: containerLister,
+ ExperimentalDevcontainersEnabled: experimentalDevcontainersEnabled,
+ })
+
+ promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
+ prometheusSrvClose := ServeHandler(ctx, logger, promHandler, prometheusAddress, "prometheus")
+
+ debugSrvClose := ServeHandler(ctx, logger, agnt.HTTPDebug(), debugAddress, "debug")
+
+ select {
+ case <-ctx.Done():
+ logger.Warn(ctx, "agent shutting down", slog.Error(ctx.Err()), slog.F("cause", context.Cause(ctx)))
+ mustExit = true
+ case event := <-reinitEvents:
+ logger.Warn(ctx, "agent received instruction to reinitialize",
+ slog.F("message", event.Message), slog.F("reason", event.Reason))
+ }
+
+ lastErr = agnt.Close()
+ debugSrvClose()
+ prometheusSrvClose()
+
+ if mustExit {
+ reinitCancel()
+ break
+ }
+
+ logger.Info(ctx, "reinitializing...")
+ }
+ return lastErr
},
}
diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden
index 1cefe8767f3b0..817093c17a9b0 100644
--- a/cli/testdata/coder_server_--help.golden
+++ b/cli/testdata/coder_server_--help.golden
@@ -670,6 +670,12 @@ workspaces stopping during the day due to template scheduling.
must be *. Only one hour and minute can be specified (ranges or comma
separated values are not supported).
+WORKSPACE PREBUILDS OPTIONS:
+Configure how workspace prebuilds behave.
+
+ --workspace-prebuilds-reconciliation-interval duration, $CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL (default: 15s)
+ How often to reconcile workspace prebuilds state.
+
⚠️ DANGEROUS OPTIONS:
--dangerous-allow-path-app-sharing bool, $CODER_DANGEROUS_ALLOW_PATH_APP_SHARING
Allow workspace apps that are not served from subdomains to be shared.
diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden
index 911270a579457..e0ea86ef6432d 100644
--- a/cli/testdata/server-config.yaml.golden
+++ b/cli/testdata/server-config.yaml.golden
@@ -688,3 +688,15 @@ notifications:
# How often to query the database for queued notifications.
# (default: 15s, type: duration)
fetchInterval: 15s
+# Configure how workspace prebuilds behave.
+workspace_prebuilds:
+ # How often to reconcile workspace prebuilds state.
+ # (default: 15s, type: duration)
+ reconciliation_interval: 15s
+ # Interval to increase reconciliation backoff by when unrecoverable errors occur.
+ # (default: 15s, type: duration)
+ reconciliation_backoff_interval: 15s
+ # Interval to look back to determine number of failed builds, which influences
+ # backoff.
+ # (default: 1h0m0s, type: duration)
+ reconciliation_backoff_lookback_period: 1h0m0s
diff --git a/coderd/agentapi/api.go b/coderd/agentapi/api.go
index 1b2b8d92a10ef..c1bd25b3e6514 100644
--- a/coderd/agentapi/api.go
+++ b/coderd/agentapi/api.go
@@ -109,6 +109,8 @@ func New(opts Options) *API {
Database: opts.Database,
DerpMapFn: opts.DerpMapFn,
WorkspaceID: opts.WorkspaceID,
+ Log: opts.Log.Named("manifests"),
+ Pubsub: opts.Pubsub,
}
api.AnnouncementBannerAPI = &AnnouncementBannerAPI{
diff --git a/coderd/agentapi/manifest.go b/coderd/agentapi/manifest.go
index db8a0af3946a9..f760e24ca6c90 100644
--- a/coderd/agentapi/manifest.go
+++ b/coderd/agentapi/manifest.go
@@ -8,6 +8,10 @@ import (
"strings"
"time"
+ "cdr.dev/slog"
+
+ "github.com/coder/coder/v2/coderd/database/pubsub"
+
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
@@ -35,6 +39,8 @@ type ManifestAPI struct {
AgentFn func(context.Context) (database.WorkspaceAgent, error)
Database database.Store
DerpMapFn func() *tailcfg.DERPMap
+ Pubsub pubsub.Pubsub
+ Log slog.Logger
}
func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 62f91a858247d..59b37a3db0589 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -8252,6 +8252,31 @@ const docTemplate = `{
}
}
},
+ "/workspaceagents/me/reinit": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Agents"
+ ],
+ "summary": "Get workspace agent reinitialization",
+ "operationId": "get-workspace-agent-reinitialization",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/agentsdk.ReinitializationResponse"
+ }
+ }
+ }
+ }
+ },
"/workspaceagents/me/rpc": {
"get": {
"security": [
@@ -10297,6 +10322,26 @@ const docTemplate = `{
}
}
},
+ "agentsdk.ReinitializationReason": {
+ "type": "string",
+ "enum": [
+ "prebuild_claimed"
+ ],
+ "x-enum-varnames": [
+ "ReinitializeReasonPrebuildClaimed"
+ ]
+ },
+ "agentsdk.ReinitializationResponse": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "reason": {
+ "$ref": "#/definitions/agentsdk.ReinitializationReason"
+ }
+ }
+ },
"coderd.SCIMUser": {
"type": "object",
"properties": {
@@ -11453,6 +11498,9 @@ const docTemplate = `{
"autostart_schedule": {
"type": "string"
},
+ "claim_prebuild_if_available": {
+ "type": "boolean"
+ },
"enable_dynamic_parameters": {
"type": "boolean"
},
@@ -11926,6 +11974,9 @@ const docTemplate = `{
"workspace_hostname_suffix": {
"type": "string"
},
+ "workspace_prebuilds": {
+ "$ref": "#/definitions/codersdk.PrebuildsConfig"
+ },
"write_config": {
"type": "boolean"
}
@@ -12004,6 +12055,7 @@ const docTemplate = `{
"auto-fill-parameters",
"notifications",
"workspace-usage",
+ "workspace-prebuilds",
"web-push",
"dynamic-parameters"
],
@@ -12013,6 +12065,7 @@ const docTemplate = `{
"ExperimentExample": "This isn't used for anything.",
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
"ExperimentWebPush": "Enables web push notifications through the browser.",
+ "ExperimentWorkspacePrebuilds": "Enables the new workspace prebuilds feature.",
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking."
},
"x-enum-varnames": [
@@ -12020,6 +12073,7 @@ const docTemplate = `{
"ExperimentAutoFillParameters",
"ExperimentNotifications",
"ExperimentWorkspaceUsage",
+ "ExperimentWorkspacePrebuilds",
"ExperimentWebPush",
"ExperimentDynamicParameters"
]
@@ -13654,6 +13708,23 @@ const docTemplate = `{
}
}
},
+ "codersdk.PrebuildsConfig": {
+ "type": "object",
+ "properties": {
+ "reconciliation_backoff_interval": {
+ "description": "ReconciliationBackoffInterval specifies the amount of time to increase the backoff interval\nwhen errors occur during reconciliation.",
+ "type": "integer"
+ },
+ "reconciliation_backoff_lookback": {
+ "description": "ReconciliationBackoffLookback determines the time window to look back when calculating\nthe number of failed prebuilds, which influences the backoff strategy.",
+ "type": "integer"
+ },
+ "reconciliation_interval": {
+ "description": "ReconciliationInterval defines how often the workspace prebuilds state should be reconciled.",
+ "type": "integer"
+ }
+ }
+ },
"codersdk.Preset": {
"type": "object",
"properties": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index e9e0470462b39..d75ba38b8c901 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -7295,6 +7295,27 @@
}
}
},
+ "/workspaceagents/me/reinit": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": ["application/json"],
+ "tags": ["Agents"],
+ "summary": "Get workspace agent reinitialization",
+ "operationId": "get-workspace-agent-reinitialization",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/agentsdk.ReinitializationResponse"
+ }
+ }
+ }
+ }
+ },
"/workspaceagents/me/rpc": {
"get": {
"security": [
@@ -9134,6 +9155,22 @@
}
}
},
+ "agentsdk.ReinitializationReason": {
+ "type": "string",
+ "enum": ["prebuild_claimed"],
+ "x-enum-varnames": ["ReinitializeReasonPrebuildClaimed"]
+ },
+ "agentsdk.ReinitializationResponse": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "reason": {
+ "$ref": "#/definitions/agentsdk.ReinitializationReason"
+ }
+ }
+ },
"coderd.SCIMUser": {
"type": "object",
"properties": {
@@ -10211,6 +10248,9 @@
"autostart_schedule": {
"type": "string"
},
+ "claim_prebuild_if_available": {
+ "type": "boolean"
+ },
"enable_dynamic_parameters": {
"type": "boolean"
},
@@ -10684,6 +10724,9 @@
"workspace_hostname_suffix": {
"type": "string"
},
+ "workspace_prebuilds": {
+ "$ref": "#/definitions/codersdk.PrebuildsConfig"
+ },
"write_config": {
"type": "boolean"
}
@@ -10758,6 +10801,7 @@
"auto-fill-parameters",
"notifications",
"workspace-usage",
+ "workspace-prebuilds",
"web-push",
"dynamic-parameters"
],
@@ -10767,6 +10811,7 @@
"ExperimentExample": "This isn't used for anything.",
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
"ExperimentWebPush": "Enables web push notifications through the browser.",
+ "ExperimentWorkspacePrebuilds": "Enables the new workspace prebuilds feature.",
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking."
},
"x-enum-varnames": [
@@ -10774,6 +10819,7 @@
"ExperimentAutoFillParameters",
"ExperimentNotifications",
"ExperimentWorkspaceUsage",
+ "ExperimentWorkspacePrebuilds",
"ExperimentWebPush",
"ExperimentDynamicParameters"
]
@@ -12346,6 +12392,23 @@
}
}
},
+ "codersdk.PrebuildsConfig": {
+ "type": "object",
+ "properties": {
+ "reconciliation_backoff_interval": {
+ "description": "ReconciliationBackoffInterval specifies the amount of time to increase the backoff interval\nwhen errors occur during reconciliation.",
+ "type": "integer"
+ },
+ "reconciliation_backoff_lookback": {
+ "description": "ReconciliationBackoffLookback determines the time window to look back when calculating\nthe number of failed prebuilds, which influences the backoff strategy.",
+ "type": "integer"
+ },
+ "reconciliation_interval": {
+ "description": "ReconciliationInterval defines how often the workspace prebuilds state should be reconciled.",
+ "type": "integer"
+ }
+ }
+ },
"codersdk.Preset": {
"type": "object",
"properties": {
diff --git a/coderd/coderd.go b/coderd/coderd.go
index 4a9e3e61d9cf5..a28255abc8d07 100644
--- a/coderd/coderd.go
+++ b/coderd/coderd.go
@@ -19,6 +19,8 @@ import (
"sync/atomic"
"time"
+ "github.com/coder/coder/v2/coderd/prebuilds"
+
"github.com/andybalholm/brotli"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -45,7 +47,6 @@ import (
"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/files"
"github.com/coder/coder/v2/coderd/idpsync"
- "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/coderd/webpush"
@@ -1277,6 +1278,7 @@ func New(options *Options) *API {
r.Get("/external-auth", api.workspaceAgentsExternalAuth)
r.Get("/gitsshkey", api.agentGitSSHKey)
r.Post("/log-source", api.workspaceAgentPostLogSource)
+ r.Get("/reinit", api.workspaceAgentReinit)
})
r.Route("/{workspaceagent}", func(r chi.Router) {
r.Use(
diff --git a/coderd/database/migrations/000308_system_user.up.sql b/coderd/database/migrations/000308_system_user.up.sql
index c024a9587f774..925a47cec1e79 100644
--- a/coderd/database/migrations/000308_system_user.up.sql
+++ b/coderd/database/migrations/000308_system_user.up.sql
@@ -43,7 +43,6 @@ CREATE VIEW group_members_expanded AS
WHERE (users.deleted = false);
COMMENT ON VIEW group_members_expanded IS 'Joins group members with user information, organization ID, group name. Includes both regular group members and organization members (as part of the "Everyone" group).';
--- TODO: do we *want* to use the default org here? how do we handle multi-org?
WITH default_org AS (SELECT id
FROM organizations
WHERE is_default = true
diff --git a/coderd/database/migrations/testdata/fixtures/000291_workspace_parameter_presets.up.sql b/coderd/database/migrations/testdata/fixtures/000293_workspace_parameter_presets.up.sql
similarity index 100%
rename from coderd/database/migrations/testdata/fixtures/000291_workspace_parameter_presets.up.sql
rename to coderd/database/migrations/testdata/fixtures/000293_workspace_parameter_presets.up.sql
diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go
index 4a2edb4451c34..d8c14bece9384 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -1341,7 +1341,6 @@ func TestUserLastSeenFilter(t *testing.T) {
LastSeenBefore: now.Add(time.Hour * -24),
})
require.NoError(t, err)
- database.ConvertUserRows(beforeToday)
requireUsersMatch(t, []database.User{yesterday, lastWeek}, beforeToday, "before today")
@@ -4366,5 +4365,6 @@ func TestGetPresetsBackoff(t *testing.T) {
func requireUsersMatch(t testing.TB, expected []database.User, found []database.GetUsersRow, msg string) {
t.Helper()
- require.ElementsMatch(t, expected, database.ConvertUserRows(found), msg)
+ foundUsers := database.ConvertUserRows(found)
+ require.ElementsMatch(t, expected, foundUsers, msg)
}
diff --git a/coderd/members_test.go b/coderd/members_test.go
index 0d133bb27aef8..1565a7bf0b536 100644
--- a/coderd/members_test.go
+++ b/coderd/members_test.go
@@ -53,6 +53,7 @@ func TestListMembers(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
+
t.Parallel()
owner := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, owner)
diff --git a/coderd/prebuilds/api.go b/coderd/prebuilds/api.go
index ebc4c39c89b50..099fe60b64d9d 100644
--- a/coderd/prebuilds/api.go
+++ b/coderd/prebuilds/api.go
@@ -5,6 +5,8 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
+
+ "github.com/coder/coder/v2/coderd/database"
)
var ErrNoClaimablePrebuiltWorkspaces = xerrors.New("no claimable prebuilt workspaces found")
@@ -24,11 +26,35 @@ type ReconciliationOrchestrator interface {
Stop(ctx context.Context, cause error)
}
+// Reconciler defines the core operations for managing prebuilds.
+// It provides both high-level orchestration (ReconcileAll) and lower-level operations
+// for more fine-grained control (SnapshotState, ReconcilePreset, CalculateActions).
+// All database operations must be performed within repeatable-read transactions
+// to ensure consistency.
type Reconciler interface {
// ReconcileAll orchestrates the reconciliation of all prebuilds across all templates.
// It takes a global snapshot of the system state and then reconciles each preset
// in parallel, creating or deleting prebuilds as needed to reach their desired states.
+ // For more fine-grained control, you can use the lower-level methods SnapshotState
+ // and ReconcilePreset directly.
ReconcileAll(ctx context.Context) error
+
+ // SnapshotState captures the current state of all prebuilds across templates.
+ // It creates a global database snapshot that can be viewed as a collection of PresetSnapshots,
+ // each representing the state of prebuilds for a specific preset.
+ // MUST be called inside a repeatable-read transaction.
+ SnapshotState(ctx context.Context, store database.Store) (*GlobalSnapshot, error)
+
+ // ReconcilePreset handles a single PresetSnapshot, determining and executing
+ // the required actions (creating or deleting prebuilds) based on the current state.
+ // MUST be called inside a repeatable-read transaction.
+ ReconcilePreset(ctx context.Context, snapshot PresetSnapshot) error
+
+ // CalculateActions determines what actions are needed to reconcile a preset's prebuilds
+ // to their desired state. This includes creating new prebuilds, deleting excess ones,
+ // or waiting due to backoff periods.
+ // MUST be called inside a repeatable-read transaction.
+ CalculateActions(ctx context.Context, state PresetSnapshot) (*ReconciliationActions, error)
}
type Claimer interface {
diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go
index 9362d2f3e5a85..e9020983b003e 100644
--- a/coderd/provisionerdserver/provisionerdserver.go
+++ b/coderd/provisionerdserver/provisionerdserver.go
@@ -16,6 +16,8 @@ import (
"sync/atomic"
"time"
+ "github.com/coder/coder/v2/codersdk/agentsdk"
+
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
semconv "go.opentelemetry.io/otel/semconv/v1.14.0"
@@ -501,6 +503,17 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
for _, group := range ownerGroups {
ownerGroupNames = append(ownerGroupNames, group.Group.Name)
}
+ var runningWorkspaceAgentToken string
+ if input.RunningWorkspaceAgentID != uuid.Nil {
+ agent, err := s.Database.GetWorkspaceAgentByID(ctx, input.RunningWorkspaceAgentID)
+ if err != nil {
+ s.Logger.Warn(ctx, "failed to retrieve running workspace agent by ID; this may affect prebuilds",
+ slog.F("workspace_agent_id", input.RunningWorkspaceAgentID),
+ slog.F("job_id", job.ID))
+ } else {
+ runningWorkspaceAgentToken = agent.AuthToken.String()
+ }
+ }
msg, err := json.Marshal(wspubsub.WorkspaceEvent{
Kind: wspubsub.WorkspaceEventKindStateChange,
@@ -646,6 +659,7 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
WorkspaceOwnerLoginType: string(owner.LoginType),
WorkspaceOwnerRbacRoles: ownerRbacRoles,
IsPrebuild: input.IsPrebuild,
+ RunningWorkspaceAgentToken: runningWorkspaceAgentToken,
},
LogLevel: input.LogLevel,
},
@@ -1733,6 +1747,17 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob)
if err != nil {
return nil, xerrors.Errorf("update workspace: %w", err)
}
+
+ if input.PrebuildClaimedByUser != uuid.Nil {
+ channel := agentsdk.PrebuildClaimedChannel(workspace.ID)
+ s.Logger.Info(ctx, "workspace prebuild successfully claimed by user",
+ slog.F("user", input.PrebuildClaimedByUser.String()),
+ slog.F("workspace_id", workspace.ID),
+ slog.F("channel", channel))
+ if err := s.Pubsub.Publish(channel, []byte(input.PrebuildClaimedByUser.String())); err != nil {
+ s.Logger.Error(ctx, "failed to publish message to workspace agent to pull new manifest", slog.Error(err))
+ }
+ }
case *proto.CompletedJob_TemplateDryRun_:
for _, resource := range jobType.TemplateDryRun.Resources {
s.Logger.Info(ctx, "inserting template dry-run job resource",
@@ -2475,7 +2500,14 @@ type WorkspaceProvisionJob struct {
DryRun bool `json:"dry_run"`
IsPrebuild bool `json:"is_prebuild,omitempty"`
PrebuildClaimedByUser uuid.UUID `json:"prebuild_claimed_by,omitempty"`
- LogLevel string `json:"log_level,omitempty"`
+ // RunningWorkspaceAgentID is *only* used for prebuilds. We pass it down when we want to rebuild a prebuilt workspace
+ // but not generate a new agent token. The provisionerdserver will retrieve this token and push it down to
+ // the provisioner (and ultimately to the `coder_agent` resource in the Terraform provider) where it will be
+ // reused. Context: the agent token is often used in immutable attributes of workspace resource (e.g. VM/container)
+ // to initialize the agent, so if that value changes it will necessitate a replacement of that resource, thus
+ // obviating the whole point of the prebuild.
+ RunningWorkspaceAgentID uuid.UUID `json:"running_workspace_agent_id"`
+ LogLevel string `json:"log_level,omitempty"`
}
// TemplateVersionDryRunJob is the payload for the "template_version_dry_run" job type.
diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go
index 1388b61030d38..388e2eadd4063 100644
--- a/coderd/workspaceagents.go
+++ b/coderd/workspaceagents.go
@@ -1154,6 +1154,105 @@ func (api *API) workspaceAgentPostLogSource(rw http.ResponseWriter, r *http.Requ
httpapi.Write(ctx, rw, http.StatusCreated, apiSource)
}
+// @Summary Get workspace agent reinitialization
+// @ID get-workspace-agent-reinitialization
+// @Security CoderSessionToken
+// @Produce json
+// @Tags Agents
+// @Success 200 {object} agentsdk.ReinitializationResponse
+// @Router /workspaceagents/me/reinit [get]
+func (api *API) workspaceAgentReinit(rw http.ResponseWriter, r *http.Request) {
+ // Allow us to interrupt watch via cancel.
+ ctx, cancel := context.WithCancel(r.Context())
+ defer cancel()
+ r = r.WithContext(ctx) // Rewire context for SSE cancellation.
+
+ workspaceAgent := httpmw.WorkspaceAgent(r)
+ log := api.Logger.Named("workspace_agent_reinit_watcher").With(
+ slog.F("workspace_agent_id", workspaceAgent.ID),
+ )
+
+ workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
+ if err != nil {
+ log.Error(ctx, "failed to retrieve workspace from agent token", slog.Error(err))
+ httpapi.InternalServerError(rw, xerrors.New("failed to determine workspace from agent token"))
+ }
+
+ log.Info(ctx, "agent waiting for reinit instruction")
+
+ prebuildClaims := make(chan uuid.UUID, 1)
+ cancelSub, err := api.Pubsub.Subscribe(agentsdk.PrebuildClaimedChannel(workspace.ID), func(inner context.Context, id []byte) {
+ select {
+ case <-ctx.Done():
+ return
+ case <-inner.Done():
+ return
+ default:
+ }
+
+ parsed, err := uuid.ParseBytes(id)
+ if err != nil {
+ log.Error(ctx, "invalid prebuild claimed channel payload", slog.F("input", string(id)))
+ return
+ }
+ prebuildClaims <- parsed
+ })
+ if err != nil {
+ log.Error(ctx, "failed to subscribe to prebuild claimed channel", slog.Error(err))
+ httpapi.InternalServerError(rw, xerrors.New("failed to subscribe to prebuild claimed channel"))
+ return
+ }
+ defer cancelSub()
+
+ sseSendEvent, sseSenderClosed, err := httpapi.ServerSentEventSender(rw, r)
+ if err != nil {
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error setting up server-sent events.",
+ Detail: err.Error(),
+ })
+ return
+ }
+ // Prevent handler from returning until the sender is closed.
+ defer func() {
+ cancel()
+ <-sseSenderClosed
+ }()
+ // Synchronize cancellation from SSE -> context, this lets us simplify the
+ // cancellation logic.
+ go func() {
+ select {
+ case <-ctx.Done():
+ case <-sseSenderClosed:
+ cancel()
+ }
+ }()
+
+ // An initial ping signals to the request that the server is now ready
+ // and the client can begin servicing a channel with data.
+ _ = sseSendEvent(codersdk.ServerSentEvent{
+ Type: codersdk.ServerSentEventTypePing,
+ })
+
+ // Expand with future use-cases for agent reinitialization.
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case user := <-prebuildClaims:
+ err = sseSendEvent(codersdk.ServerSentEvent{
+ Type: codersdk.ServerSentEventTypeData,
+ Data: agentsdk.ReinitializationResponse{
+ Message: fmt.Sprintf("prebuild claimed by user: %s", user),
+ Reason: agentsdk.ReinitializeReasonPrebuildClaimed,
+ },
+ })
+ if err != nil {
+ log.Warn(ctx, "failed to send SSE response to trigger reinit", slog.Error(err))
+ }
+ }
+ }
+}
+
// convertProvisionedApps converts applications that are in the middle of provisioning process.
// It means that they may not have an agent or workspace assigned (dry-run job).
func convertProvisionedApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp {
diff --git a/coderd/workspaces.go b/coderd/workspaces.go
index 12b3787acf3d8..5cae3dffca59e 100644
--- a/coderd/workspaces.go
+++ b/coderd/workspaces.go
@@ -637,6 +637,8 @@ func createWorkspace(
provisionerJob *database.ProvisionerJob
workspaceBuild *database.WorkspaceBuild
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
+
+ runningWorkspaceAgentID uuid.UUID
)
err = api.Database.InTx(func(db database.Store) error {
@@ -683,6 +685,16 @@ func createWorkspace(
// Prebuild found!
workspaceID = claimedWorkspace.ID
initiatorID = prebuildsClaimer.Initiator()
+ agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, claimedWorkspace.ID)
+ if err != nil {
+ // TODO: comment about best-effort, workspace can be restarted if this fails...
+ api.Logger.Error(ctx, "failed to retrieve running agents of claimed prebuilt workspace",
+ slog.F("workspace_id", claimedWorkspace.ID), slog.Error(err))
+ }
+ if len(agents) >= 1 {
+ // TODO: handle multiple agents
+ runningWorkspaceAgentID = agents[0].ID
+ }
}
// We have to refetch the workspace for the joined in fields.
@@ -698,13 +710,15 @@ func createWorkspace(
Initiator(initiatorID).
ActiveVersion().
RichParameterValues(req.RichParameterValues).
- TemplateVersionPresetID(req.TemplateVersionPresetID)
+ TemplateVersionPresetID(req.TemplateVersionPresetID).
+ RunningWorkspaceAgentID(runningWorkspaceAgentID)
if req.TemplateVersionID != uuid.Nil {
builder = builder.VersionID(req.TemplateVersionID)
}
if req.TemplateVersionPresetID != uuid.Nil {
builder = builder.TemplateVersionPresetID(req.TemplateVersionPresetID)
}
+
if claimedWorkspace != nil {
builder = builder.MarkPrebuildClaimedBy(owner.ID)
}
diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go
index 942829004309c..03c394bcda30e 100644
--- a/coderd/wsbuilder/wsbuilder.go
+++ b/coderd/wsbuilder/wsbuilder.go
@@ -76,8 +76,9 @@ type Builder struct {
parameterValues *[]string
templateVersionPresetParameterValues []database.TemplateVersionPresetParameter
- prebuild bool
- prebuildClaimedBy uuid.UUID
+ prebuild bool
+ prebuildClaimedBy uuid.UUID
+ runningWorkspaceAgentID uuid.UUID
verifyNoLegacyParametersOnce bool
}
@@ -186,6 +187,13 @@ func (b Builder) MarkPrebuildClaimedBy(userID uuid.UUID) Builder {
return b
}
+// RunningWorkspaceAgentID is only used for prebuilds; see the associated field in `provisionerdserver.WorkspaceProvisionJob`.
+func (b Builder) RunningWorkspaceAgentID(id uuid.UUID) Builder {
+ // nolint: revive
+ b.runningWorkspaceAgentID = id
+ return b
+}
+
func (b Builder) UsingDynamicParameters() Builder {
b.dynamicParametersEnabled = true
return b
@@ -322,10 +330,11 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object
workspaceBuildID := uuid.New()
input, err := json.Marshal(provisionerdserver.WorkspaceProvisionJob{
- WorkspaceBuildID: workspaceBuildID,
- LogLevel: b.logLevel,
- IsPrebuild: b.prebuild,
- PrebuildClaimedByUser: b.prebuildClaimedBy,
+ WorkspaceBuildID: workspaceBuildID,
+ LogLevel: b.logLevel,
+ IsPrebuild: b.prebuild,
+ PrebuildClaimedByUser: b.prebuildClaimedBy,
+ RunningWorkspaceAgentID: b.runningWorkspaceAgentID,
})
if err != nil {
return nil, nil, nil, BuildError{
@@ -629,6 +638,15 @@ func (b *Builder) getParameters() (names, values []string, err error) {
}
func (b *Builder) findNewBuildParameterValue(name string) *codersdk.WorkspaceBuildParameter {
+ // If a preset has been chosen during workspace creation, we must ensure that the parameters defined by the preset
+ // are set *exactly* as they as defined. For example, if a preset is chosen but a different value for one of the
+ // parameters (defined in that preset) are set explicitly, we need to ignore those values.
+ //
+ // If this were to occur, a workspace build's parameters and its associated preset's parameters would not match,
+ // causing inconsistency, and would be especially problematic for prebuilds. When a prebuild is claimed, we assume
+ // that the prebuild was built using *at least* the parameters defined in the selected preset.
+ //
+ // This should generally not happen, but this is added protection against that situation.
for _, v := range b.templateVersionPresetParameterValues {
if v.Name == name {
return &codersdk.WorkspaceBuildParameter{
@@ -647,6 +665,11 @@ func (b *Builder) findNewBuildParameterValue(name string) *codersdk.WorkspaceBui
}
func (b *Builder) getLastBuildParameters() ([]database.WorkspaceBuildParameter, error) {
+ // TODO: exclude preset params from this list instead of returning nothing?
+ if b.prebuildClaimedBy != uuid.Nil {
+ return nil, nil
+ }
+
if b.lastBuildParameters != nil {
return *b.lastBuildParameters, nil
}
diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go
index 109d14b84d050..da6a22bed69fc 100644
--- a/codersdk/agentsdk/agentsdk.go
+++ b/codersdk/agentsdk/agentsdk.go
@@ -19,12 +19,13 @@ import (
"tailscale.com/tailcfg"
"cdr.dev/slog"
+ "github.com/coder/websocket"
+
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/apiversion"
"github.com/coder/coder/v2/codersdk"
drpcsdk "github.com/coder/coder/v2/codersdk/drpc"
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
- "github.com/coder/websocket"
)
// ExternalLogSourceID is the statically-defined ID of a log-source that
@@ -686,3 +687,70 @@ func LogsNotifyChannel(agentID uuid.UUID) string {
type LogsNotifyMessage struct {
CreatedAfter int64 `json:"created_after"`
}
+
+type ReinitializationReason string
+
+const (
+ ReinitializeReasonPrebuildClaimed ReinitializationReason = "prebuild_claimed"
+)
+
+type ReinitializationResponse struct {
+ Message string `json:"message"`
+ Reason ReinitializationReason `json:"reason"`
+}
+
+// TODO: maybe place this somewhere else?
+func PrebuildClaimedChannel(id uuid.UUID) string {
+ return fmt.Sprintf("prebuild_claimed_%s", id)
+}
+
+// WaitForReinit polls a SSE endpoint, and receives an event back under the following conditions:
+// - ping: ignored, keepalive
+// - prebuild claimed: a prebuilt workspace is claimed, so the agent must reinitialize.
+// NOTE: the caller is responsible for closing the events chan.
+func (c *Client) WaitForReinit(ctx context.Context, events chan<- ReinitializationResponse) error {
+ // TODO: allow configuring httpclient
+ c.SDK.HTTPClient.Timeout = time.Hour * 24
+
+ res, err := c.SDK.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/reinit", nil)
+ if err != nil {
+ return xerrors.Errorf("execute request: %w", err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return codersdk.ReadBodyAsError(res)
+ }
+
+ nextEvent := codersdk.ServerSentEventReader(ctx, res.Body)
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ sse, err := nextEvent()
+ if err != nil {
+ return xerrors.Errorf("failed to read server-sent event: %w", err)
+ }
+ if sse.Type != codersdk.ServerSentEventTypeData {
+ continue
+ }
+ var reinitResp ReinitializationResponse
+ b, ok := sse.Data.([]byte)
+ if !ok {
+ return xerrors.Errorf("expected data as []byte, got %T", sse.Data)
+ }
+ err = json.Unmarshal(b, &reinitResp)
+ if err != nil {
+ return xerrors.Errorf("unmarshal reinit response: %w", err)
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case events <- reinitResp:
+ }
+ }
+}
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 864a883330776..88a6f7d8f68fe 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -81,6 +81,7 @@ const (
FeatureControlSharedPorts FeatureName = "control_shared_ports"
FeatureCustomRoles FeatureName = "custom_roles"
FeatureMultipleOrganizations FeatureName = "multiple_organizations"
+ FeatureWorkspacePrebuilds FeatureName = "workspace_prebuilds"
)
// FeatureNames must be kept in-sync with the Feature enum above.
@@ -103,6 +104,7 @@ var FeatureNames = []FeatureName{
FeatureControlSharedPorts,
FeatureCustomRoles,
FeatureMultipleOrganizations,
+ FeatureWorkspacePrebuilds,
}
// Humanize returns the feature name in a human-readable format.
@@ -132,6 +134,7 @@ func (n FeatureName) AlwaysEnable() bool {
FeatureHighAvailability: true,
FeatureCustomRoles: true,
FeatureMultipleOrganizations: true,
+ FeatureWorkspacePrebuilds: true,
}[n]
}
@@ -393,6 +396,7 @@ type DeploymentValues struct {
TermsOfServiceURL serpent.String `json:"terms_of_service_url,omitempty" typescript:",notnull"`
Notifications NotificationsConfig `json:"notifications,omitempty" typescript:",notnull"`
AdditionalCSPPolicy serpent.StringArray `json:"additional_csp_policy,omitempty" typescript:",notnull"`
+ Prebuilds PrebuildsConfig `json:"workspace_prebuilds,omitempty" typescript:",notnull"`
WorkspaceHostnameSuffix serpent.String `json:"workspace_hostname_suffix,omitempty" typescript:",notnull"`
Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
@@ -1034,6 +1038,11 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
Parent: &deploymentGroupNotifications,
YAML: "webhook",
}
+ deploymentGroupPrebuilds = serpent.Group{
+ Name: "Workspace Prebuilds",
+ YAML: "workspace_prebuilds",
+ Description: "Configure how workspace prebuilds behave.",
+ }
deploymentGroupInbox = serpent.Group{
Name: "Inbox",
Parent: &deploymentGroupNotifications,
@@ -3029,6 +3038,41 @@ Write out the current server config as YAML to stdout.`,
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
Hidden: true, // Hidden because most operators should not need to modify this.
},
+ {
+ Name: "Reconciliation Interval",
+ Description: "How often to reconcile workspace prebuilds state.",
+ Flag: "workspace-prebuilds-reconciliation-interval",
+ Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL",
+ Value: &c.Prebuilds.ReconciliationInterval,
+ Default: (time.Second * 15).String(),
+ Group: &deploymentGroupPrebuilds,
+ YAML: "reconciliation_interval",
+ Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
+ },
+ {
+ Name: "Reconciliation Backoff Interval",
+ Description: "Interval to increase reconciliation backoff by when unrecoverable errors occur.",
+ Flag: "workspace-prebuilds-reconciliation-backoff-interval",
+ Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_BACKOFF_INTERVAL",
+ Value: &c.Prebuilds.ReconciliationBackoffInterval,
+ Default: (time.Second * 15).String(),
+ Group: &deploymentGroupPrebuilds,
+ YAML: "reconciliation_backoff_interval",
+ Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
+ Hidden: true,
+ },
+ {
+ Name: "Reconciliation Backoff Lookback Period",
+ Description: "Interval to look back to determine number of failed builds, which influences backoff.",
+ Flag: "workspace-prebuilds-reconciliation-backoff-lookback-period",
+ Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_BACKOFF_LOOKBACK_PERIOD",
+ Value: &c.Prebuilds.ReconciliationBackoffLookback,
+ Default: (time.Hour).String(), // TODO: use https://pkg.go.dev/github.com/jackc/pgtype@v1.12.0#Interval
+ Group: &deploymentGroupPrebuilds,
+ YAML: "reconciliation_backoff_lookback_period",
+ Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
+ Hidden: true,
+ },
// Push notifications.
}
@@ -3254,6 +3298,7 @@ const (
ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature.
ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events.
ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking.
+ ExperimentWorkspacePrebuilds Experiment = "workspace-prebuilds" // Enables the new workspace prebuilds feature.
ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser.
ExperimentDynamicParameters Experiment = "dynamic-parameters" // Enables dynamic parameters when creating a workspace.
)
@@ -3262,6 +3307,10 @@ const (
// users to opt-in to via --experimental='*'.
// Experiments that are not ready for consumption by all users should
// not be included here and will be essentially hidden.
+var ExperimentsAll = Experiments{
+ ExperimentDynamicParameters,
+ ExperimentWorkspacePrebuilds,
+}
var ExperimentsSafe = Experiments{}
// Experiments is a list of experiments.
diff --git a/codersdk/organizations.go b/codersdk/organizations.go
index dd2eab50cf57e..f3970bd41607d 100644
--- a/codersdk/organizations.go
+++ b/codersdk/organizations.go
@@ -224,10 +224,11 @@ type CreateWorkspaceRequest struct {
TTLMillis *int64 `json:"ttl_ms,omitempty"`
// RichParameterValues allows for additional parameters to be provided
// during the initial provision.
- RichParameterValues []WorkspaceBuildParameter `json:"rich_parameter_values,omitempty"`
- AutomaticUpdates AutomaticUpdates `json:"automatic_updates,omitempty"`
- TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"`
- EnableDynamicParameters bool `json:"enable_dynamic_parameters,omitempty"`
+ RichParameterValues []WorkspaceBuildParameter `json:"rich_parameter_values,omitempty"`
+ AutomaticUpdates AutomaticUpdates `json:"automatic_updates,omitempty"`
+ TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"`
+ ClaimPrebuildIfAvailable bool `json:"claim_prebuild_if_available,omitempty"`
+ EnableDynamicParameters bool `json:"enable_dynamic_parameters,omitempty"`
}
func (c *Client) OrganizationByName(ctx context.Context, name string) (Organization, error) {
diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md
index 853cb67e38bfd..b9b0c8973f64b 100644
--- a/docs/reference/api/agents.md
+++ b/docs/reference/api/agents.md
@@ -470,6 +470,38 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaceagents/me/logs \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+## Get workspace agent reinitialization
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/reinit \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`GET /workspaceagents/me/reinit`
+
+### Example responses
+
+> 200 Response
+
+```json
+{
+ "message": "string",
+ "reason": "prebuild_claimed"
+}
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------|
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [agentsdk.ReinitializationResponse](schemas.md#agentsdkreinitializationresponse) |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
## Get workspace agent by ID
### Code samples
diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md
index 0db339a5baec9..3c27ddb6dea1d 100644
--- a/docs/reference/api/general.md
+++ b/docs/reference/api/general.md
@@ -519,6 +519,11 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"wgtunnel_host": "string",
"wildcard_access_url": "string",
"workspace_hostname_suffix": "string",
+ "workspace_prebuilds": {
+ "reconciliation_backoff_interval": 0,
+ "reconciliation_backoff_lookback": 0,
+ "reconciliation_interval": 0
+ },
"write_config": true
},
"options": [
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index dd8ffd1971cb8..15f6b83c9ca26 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -182,6 +182,36 @@
| `icon` | string | false | | |
| `id` | string | false | | ID is a unique identifier for the log source. It is scoped to a workspace agent, and can be statically defined inside code to prevent duplicate sources from being created for the same agent. |
+## agentsdk.ReinitializationReason
+
+```json
+"prebuild_claimed"
+```
+
+### Properties
+
+#### Enumerated Values
+
+| Value |
+|--------------------|
+| `prebuild_claimed` |
+
+## agentsdk.ReinitializationResponse
+
+```json
+{
+ "message": "string",
+ "reason": "prebuild_claimed"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-----------|--------------------------------------------------------------------|----------|--------------|-------------|
+| `message` | string | false | | |
+| `reason` | [agentsdk.ReinitializationReason](#agentsdkreinitializationreason) | false | | |
+
## coderd.SCIMUser
```json
@@ -1462,6 +1492,7 @@ None
{
"automatic_updates": "always",
"autostart_schedule": "string",
+ "claim_prebuild_if_available": true,
"enable_dynamic_parameters": true,
"name": "string",
"rich_parameter_values": [
@@ -1481,17 +1512,18 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
### Properties
-| Name | Type | Required | Restrictions | Description |
-|------------------------------|-------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------|
-| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | |
-| `autostart_schedule` | string | false | | |
-| `enable_dynamic_parameters` | boolean | false | | |
-| `name` | string | true | | |
-| `rich_parameter_values` | array of [codersdk.WorkspaceBuildParameter](#codersdkworkspacebuildparameter) | false | | Rich parameter values allows for additional parameters to be provided during the initial provision. |
-| `template_id` | string | false | | Template ID specifies which template should be used for creating the workspace. |
-| `template_version_id` | string | false | | Template version ID can be used to specify a specific version of a template for creating the workspace. |
-| `template_version_preset_id` | string | false | | |
-| `ttl_ms` | integer | false | | |
+| Name | Type | Required | Restrictions | Description |
+|-------------------------------|-------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------|
+| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | |
+| `autostart_schedule` | string | false | | |
+| `claim_prebuild_if_available` | boolean | false | | |
+| `enable_dynamic_parameters` | boolean | false | | |
+| `name` | string | true | | |
+| `rich_parameter_values` | array of [codersdk.WorkspaceBuildParameter](#codersdkworkspacebuildparameter) | false | | Rich parameter values allows for additional parameters to be provided during the initial provision. |
+| `template_id` | string | false | | Template ID specifies which template should be used for creating the workspace. |
+| `template_version_id` | string | false | | Template version ID can be used to specify a specific version of a template for creating the workspace. |
+| `template_version_preset_id` | string | false | | |
+| `ttl_ms` | integer | false | | |
## codersdk.CryptoKey
@@ -2170,6 +2202,11 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"wgtunnel_host": "string",
"wildcard_access_url": "string",
"workspace_hostname_suffix": "string",
+ "workspace_prebuilds": {
+ "reconciliation_backoff_interval": 0,
+ "reconciliation_backoff_lookback": 0,
+ "reconciliation_interval": 0
+ },
"write_config": true
},
"options": [
@@ -2650,6 +2687,11 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"wgtunnel_host": "string",
"wildcard_access_url": "string",
"workspace_hostname_suffix": "string",
+ "workspace_prebuilds": {
+ "reconciliation_backoff_interval": 0,
+ "reconciliation_backoff_lookback": 0,
+ "reconciliation_interval": 0
+ },
"write_config": true
}
```
@@ -2719,6 +2761,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `wgtunnel_host` | string | false | | |
| `wildcard_access_url` | string | false | | |
| `workspace_hostname_suffix` | string | false | | |
+| `workspace_prebuilds` | [codersdk.PrebuildsConfig](#codersdkprebuildsconfig) | false | | |
| `write_config` | boolean | false | | |
## codersdk.DisplayApp
@@ -2815,6 +2858,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `auto-fill-parameters` |
| `notifications` |
| `workspace-usage` |
+| `workspace-prebuilds` |
| `web-push` |
| `dynamic-parameters` |
@@ -4659,6 +4703,24 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| `address` | [serpent.HostPort](#serpenthostport) | false | | |
| `enable` | boolean | false | | |
+## codersdk.PrebuildsConfig
+
+```json
+{
+ "reconciliation_backoff_interval": 0,
+ "reconciliation_backoff_lookback": 0,
+ "reconciliation_interval": 0
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-----------------------------------|---------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `reconciliation_backoff_interval` | integer | false | | Reconciliation backoff interval specifies the amount of time to increase the backoff interval when errors occur during reconciliation. |
+| `reconciliation_backoff_lookback` | integer | false | | Reconciliation backoff lookback determines the time window to look back when calculating the number of failed prebuilds, which influences the backoff strategy. |
+| `reconciliation_interval` | integer | false | | Reconciliation interval defines how often the workspace prebuilds state should be reconciled. |
+
## codersdk.Preset
```json
diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md
index 5d09c46a01d30..cb1fe3a71642d 100644
--- a/docs/reference/api/workspaces.md
+++ b/docs/reference/api/workspaces.md
@@ -25,6 +25,7 @@ of the template will be used.
{
"automatic_updates": "always",
"autostart_schedule": "string",
+ "claim_prebuild_if_available": true,
"enable_dynamic_parameters": true,
"name": "string",
"rich_parameter_values": [
@@ -606,6 +607,7 @@ of the template will be used.
{
"automatic_updates": "always",
"autostart_schedule": "string",
+ "claim_prebuild_if_available": true,
"enable_dynamic_parameters": true,
"name": "string",
"rich_parameter_values": [
diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md
index 1b4052e335e66..c01baa45a3f69 100644
--- a/docs/reference/cli/server.md
+++ b/docs/reference/cli/server.md
@@ -1603,3 +1603,14 @@ Enable Coder Inbox.
| Default | 5
|
The upper limit of attempts to send a notification.
+
+### --workspace-prebuilds-reconciliation-interval
+
+| | |
+|-------------|-----------------------------------------------------------------|
+| Type | duration
|
+| Environment | $CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL
|
+| YAML | workspace_prebuilds.reconciliation_interval
|
+| Default | 15s
|
+
+How often to reconcile workspace prebuilds state.
diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden
index d11304742d974..344df781c3b7e 100644
--- a/enterprise/cli/testdata/coder_server_--help.golden
+++ b/enterprise/cli/testdata/coder_server_--help.golden
@@ -671,6 +671,12 @@ workspaces stopping during the day due to template scheduling.
must be *. Only one hour and minute can be specified (ranges or comma
separated values are not supported).
+WORKSPACE PREBUILDS OPTIONS:
+Configure how workspace prebuilds behave.
+
+ --workspace-prebuilds-reconciliation-interval duration, $CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL (default: 15s)
+ How often to reconcile workspace prebuilds state.
+
⚠️ DANGEROUS OPTIONS:
--dangerous-allow-path-app-sharing bool, $CODER_DANGEROUS_ALLOW_PATH_APP_SHARING
Allow workspace apps that are not served from subdomains to be shared.
diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go
index 6b45bc65e2c3f..c291e6690b32e 100644
--- a/enterprise/coderd/coderd.go
+++ b/enterprise/coderd/coderd.go
@@ -12,12 +12,15 @@ import (
"sync"
"time"
+ "github.com/coder/quartz"
+
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/appearance"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/idpsync"
agplportsharing "github.com/coder/coder/v2/coderd/portsharing"
+ agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/enterprise/coderd/enidpsync"
"github.com/coder/coder/v2/enterprise/coderd/portsharing"
@@ -43,6 +46,7 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/dbauthz"
"github.com/coder/coder/v2/enterprise/coderd/license"
+ "github.com/coder/coder/v2/enterprise/coderd/prebuilds"
"github.com/coder/coder/v2/enterprise/coderd/proxyhealth"
"github.com/coder/coder/v2/enterprise/coderd/schedule"
"github.com/coder/coder/v2/enterprise/dbcrypt"
@@ -628,6 +632,9 @@ type API struct {
licenseMetricsCollector *license.MetricsCollector
tailnetService *tailnet.ClientService
+
+ PrebuildsReconciler agplprebuilds.ReconciliationOrchestrator
+ prebuildsMetricsCollector *prebuilds.MetricsCollector
}
// writeEntitlementWarningsHeader writes the entitlement warnings to the response header
@@ -658,6 +665,13 @@ func (api *API) Close() error {
if api.Options.CheckInactiveUsersCancelFunc != nil {
api.Options.CheckInactiveUsersCancelFunc()
}
+
+ if api.PrebuildsReconciler != nil {
+ ctx, giveUp := context.WithTimeoutCause(context.Background(), time.Second*30, xerrors.New("gave up waiting for reconciler to stop"))
+ defer giveUp()
+ api.PrebuildsReconciler.Stop(ctx, xerrors.New("api closed")) // TODO: determine root cause (requires changes up the stack, though).
+ }
+
return api.AGPL.Close()
}
@@ -860,6 +874,25 @@ func (api *API) updateEntitlements(ctx context.Context) error {
api.AGPL.PortSharer.Store(&ps)
}
+ if initial, changed, enabled := featureChanged(codersdk.FeatureWorkspacePrebuilds); shouldUpdate(initial, changed, enabled) || api.PrebuildsReconciler == nil {
+ reconciler, claimer, metrics := api.setupPrebuilds(enabled)
+ if api.PrebuildsReconciler != nil {
+ stopCtx, giveUp := context.WithTimeoutCause(context.Background(), time.Second*30, xerrors.New("gave up waiting for reconciler to stop"))
+ defer giveUp()
+ api.PrebuildsReconciler.Stop(stopCtx, xerrors.New("entitlements change"))
+ }
+
+ // Only register metrics once.
+ if api.prebuildsMetricsCollector != nil {
+ api.prebuildsMetricsCollector = metrics
+ }
+
+ api.PrebuildsReconciler = reconciler
+ go reconciler.RunLoop(context.Background())
+
+ api.AGPL.PrebuildsClaimer.Store(&claimer)
+ }
+
// External token encryption is soft-enforced
featureExternalTokenEncryption := reloadedEntitlements.Features[codersdk.FeatureExternalTokenEncryption]
featureExternalTokenEncryption.Enabled = len(api.ExternalTokenEncryption) > 0
@@ -1128,3 +1161,28 @@ func (api *API) runEntitlementsLoop(ctx context.Context) {
func (api *API) Authorize(r *http.Request, action policy.Action, object rbac.Objecter) bool {
return api.AGPL.HTTPAuth.Authorize(r, action, object)
}
+
+func (api *API) setupPrebuilds(entitled bool) (agplprebuilds.ReconciliationOrchestrator, agplprebuilds.Claimer, *prebuilds.MetricsCollector) {
+ enabled := api.AGPL.Experiments.Enabled(codersdk.ExperimentWorkspacePrebuilds)
+ if !enabled || !entitled {
+ api.Logger.Debug(context.Background(), "prebuilds not enabled",
+ slog.F("experiment_enabled", enabled), slog.F("entitled", entitled))
+
+ return agplprebuilds.NewNoopReconciler(), agplprebuilds.DefaultClaimer, nil
+ }
+
+ reconciler := prebuilds.NewStoreReconciler(api.Database, api.Pubsub, api.DeploymentValues.Prebuilds,
+ api.Logger.Named("prebuilds"), quartz.NewReal())
+
+ logger := api.Logger.Named("prebuilds.metrics")
+ collector := prebuilds.NewMetricsCollector(api.Database, logger, reconciler)
+ err := api.PrometheusRegistry.Register(collector)
+ if err != nil {
+ logger.Error(context.Background(), "failed to register prebuilds metrics collector", slog.F("error", err))
+ collector = nil
+ }
+
+ return reconciler,
+ prebuilds.NewEnterpriseClaimer(api.Database),
+ collector
+}
diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go
index 028aa3328535f..1d2c6442a7fa7 100644
--- a/enterprise/coderd/groups_test.go
+++ b/enterprise/coderd/groups_test.go
@@ -821,6 +821,7 @@ func TestGroup(t *testing.T) {
})
t.Run("everyoneGroupReturnsEmpty", func(t *testing.T) {
+ // TODO (sasswart): this test seems to have drifted from its original intention. evaluate and remove/fix
t.Parallel()
client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
diff --git a/enterprise/coderd/prebuilds/claim.go b/enterprise/coderd/prebuilds/claim.go
index f040ee756e678..c5eec750d1195 100644
--- a/enterprise/coderd/prebuilds/claim.go
+++ b/enterprise/coderd/prebuilds/claim.go
@@ -5,11 +5,12 @@ import (
"database/sql"
"errors"
+ "github.com/coder/coder/v2/coderd/prebuilds"
+
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
- "github.com/coder/coder/v2/coderd/prebuilds"
)
type EnterpriseClaimer struct {
diff --git a/enterprise/coderd/prebuilds/claim_test.go b/enterprise/coderd/prebuilds/claim_test.go
index 4f398724b8265..41c44df7b4e04 100644
--- a/enterprise/coderd/prebuilds/claim_test.go
+++ b/enterprise/coderd/prebuilds/claim_test.go
@@ -3,25 +3,28 @@ package prebuilds_test
import (
"context"
"database/sql"
- "slices"
+ agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds"
+ "github.com/coder/quartz"
+ "golang.org/x/xerrors"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "golang.org/x/xerrors"
- "github.com/coder/quartz"
+ "github.com/coder/serpent"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
- agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds"
"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/enterprise/coderd/prebuilds"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
@@ -106,16 +109,40 @@ func TestClaimPrebuild(t *testing.T) {
)
cases := map[string]struct {
+ entitlementEnabled bool
+ experimentEnabled bool
+ attemptPrebuildClaim bool
expectPrebuildClaimed bool
markPrebuildsClaimable bool
+ expectedPrebuildsCount int
}{
- "no eligible prebuilds to claim": {
+ "without the experiment enabled, prebuilds will not provisioned": {
+ experimentEnabled: false,
+ entitlementEnabled: true,
+ attemptPrebuildClaim: false,
+ expectedPrebuildsCount: 0,
+ },
+ "without the entitlement, prebuilds will not provisioned": {
+ experimentEnabled: true,
+ entitlementEnabled: false,
+ attemptPrebuildClaim: false,
+ expectedPrebuildsCount: 0,
+ },
+ "with everything enabled, but no eligible prebuilds to claim": {
+ entitlementEnabled: true,
+ experimentEnabled: true,
+ attemptPrebuildClaim: true,
expectPrebuildClaimed: false,
markPrebuildsClaimable: false,
+ expectedPrebuildsCount: desiredInstances * presetCount,
},
- "claiming an eligible prebuild should succeed": {
+ "with everything enabled, claiming an eligible prebuild should succeed": {
+ entitlementEnabled: true,
+ experimentEnabled: true,
+ attemptPrebuildClaim: true,
expectPrebuildClaimed: true,
markPrebuildsClaimable: true,
+ expectedPrebuildsCount: desiredInstances * presetCount,
},
}
@@ -125,26 +152,48 @@ func TestClaimPrebuild(t *testing.T) {
t.Run(name, func(t *testing.T) {
t.Parallel()
- // Setup.
- ctx := testutil.Context(t, testutil.WaitSuperLong)
+ // Setup. // TODO: abstract?
+
+ ctx := testutil.Context(t, testutil.WaitMedium)
db, pubsub := dbtestutil.NewDB(t)
spy := newStoreSpy(db)
- expectedPrebuildsCount := desiredInstances * presetCount
- logger := testutil.Logger(t)
+ var prebuildsEntitled int64
+ if tc.entitlementEnabled {
+ prebuildsEntitled = 1
+ }
+
client, _, api, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
Database: spy,
Pubsub: pubsub,
+ DeploymentValues: coderdtest.DeploymentValues(t, func(values *codersdk.DeploymentValues) {
+ values.Prebuilds.ReconciliationInterval = serpent.Duration(time.Hour) // We will kick off a reconciliation manually.
+
+ if tc.experimentEnabled {
+ values.Experiments = serpent.StringArray{string(codersdk.ExperimentWorkspacePrebuilds)}
+ }
+ }),
},
EntitlementsUpdateInterval: time.Second,
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspacePrebuilds: prebuildsEntitled,
+ },
+ },
})
+ reconciler := api.PrebuildsReconciler
- reconciler := prebuilds.NewStoreReconciler(spy, pubsub, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t))
- var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(spy)
- api.AGPL.PrebuildsClaimer.Store(&claimer)
+ // The entitlements will need to refresh before the reconciler is set.
+ require.Eventually(t, func() bool {
+ if tc.entitlementEnabled && tc.experimentEnabled {
+ assert.IsType(t, &prebuilds.StoreReconciler{}, reconciler)
+ }
+
+ return reconciler != nil
+ }, testutil.WaitSuperLong, testutil.IntervalFast)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(desiredInstances))
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
@@ -155,11 +204,19 @@ func TestClaimPrebuild(t *testing.T) {
userClient, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember())
+ ctx = dbauthz.AsPrebuildsOrchestrator(ctx)
+
// Given: the reconciliation state is snapshot.
state, err := reconciler.SnapshotState(ctx, spy)
require.NoError(t, err)
- require.Len(t, state.Presets, presetCount)
+ // When: the experiment or entitlement is not preset, there should be nothing to reconcile.
+ if !tc.entitlementEnabled || !tc.experimentEnabled {
+ require.Len(t, state.Presets, 0)
+ return
+ }
+
+ require.Len(t, state.Presets, presetCount)
// When: a reconciliation is setup for each preset.
for _, preset := range presets {
ps, err := state.FilterByPreset(preset.ID)
@@ -176,9 +233,7 @@ func TestClaimPrebuild(t *testing.T) {
runningPrebuilds := make(map[uuid.UUID]database.GetRunningPrebuiltWorkspacesRow, desiredInstances*presetCount)
require.Eventually(t, func() bool {
rows, err := spy.GetRunningPrebuiltWorkspaces(ctx)
- if err != nil {
- return false
- }
+ require.NoError(t, err)
for _, row := range rows {
runningPrebuilds[row.CurrentPresetID.UUID] = row
@@ -188,27 +243,21 @@ func TestClaimPrebuild(t *testing.T) {
}
agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, row.ID)
- if err != nil {
- return false
- }
+ require.NoError(t, err)
- // Workspaces are eligible once its agent is marked "ready".
for _, agent := range agents {
- err = db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
+ require.NoError(t, db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
ID: agent.ID,
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
StartedAt: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
ReadyAt: sql.NullTime{Time: time.Now().Add(-1 * time.Hour), Valid: true},
- })
- if err != nil {
- return false
- }
+ }))
}
}
- t.Logf("found %d running prebuilds so far, want %d", len(runningPrebuilds), expectedPrebuildsCount)
+ t.Logf("found %d running prebuilds so far, want %d", len(runningPrebuilds), tc.expectedPrebuildsCount)
- return len(runningPrebuilds) == expectedPrebuildsCount
+ return len(runningPrebuilds) == tc.expectedPrebuildsCount
}, testutil.WaitSuperLong, testutil.IntervalSlow)
// When: a user creates a new workspace with a preset for which prebuilds are configured.
@@ -219,16 +268,29 @@ func TestClaimPrebuild(t *testing.T) {
PresetID: presets[0].ID,
}
userWorkspace, err := userClient.CreateUserWorkspace(ctx, user.Username, codersdk.CreateWorkspaceRequest{
- TemplateVersionID: version.ID,
- Name: workspaceName,
- TemplateVersionPresetID: presets[0].ID,
+ TemplateVersionID: version.ID,
+ Name: workspaceName,
+ TemplateVersionPresetID: presets[0].ID,
+ ClaimPrebuildIfAvailable: true, // TODO: doesn't do anything yet; it probably should though.
})
-
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, userWorkspace.LatestBuild.ID)
+ // Then: if we're not expecting any prebuild claims to succeed, handle this specifically.
+ if !tc.attemptPrebuildClaim {
+ require.EqualValues(t, spy.claims.Load(), 0)
+ require.Nil(t, spy.claimedWorkspace.Load())
+
+ currentPrebuilds, err := spy.GetRunningPrebuiltWorkspaces(ctx)
+ require.NoError(t, err)
+ // The number of prebuilds should NOT change.
+ require.Equal(t, len(currentPrebuilds), len(runningPrebuilds))
+ return
+ }
+
// Then: a prebuild should have been claimed.
require.EqualValues(t, spy.claims.Load(), 1)
+ require.NotNil(t, spy.claims.Load())
require.EqualValues(t, *spy.claimParams.Load(), params)
if !tc.expectPrebuildClaimed {
@@ -238,7 +300,7 @@ func TestClaimPrebuild(t *testing.T) {
require.NotNil(t, spy.claimedWorkspace.Load())
claimed := *spy.claimedWorkspace.Load()
- require.NotEqual(t, claimed.ID, uuid.Nil)
+ require.NotEqual(t, claimed, uuid.Nil)
// Then: the claimed prebuild must now be owned by the requester.
workspace, err := spy.GetWorkspaceByID(ctx, claimed.ID)
@@ -248,12 +310,19 @@ func TestClaimPrebuild(t *testing.T) {
// Then: the number of running prebuilds has changed since one was claimed.
currentPrebuilds, err := spy.GetRunningPrebuiltWorkspaces(ctx)
require.NoError(t, err)
- require.Equal(t, expectedPrebuildsCount-1, len(currentPrebuilds))
+ require.NotEqual(t, len(currentPrebuilds), len(runningPrebuilds))
// Then: the claimed prebuild is now missing from the running prebuilds set.
- found := slices.ContainsFunc(currentPrebuilds, func(prebuild database.GetRunningPrebuiltWorkspacesRow) bool {
- return prebuild.ID == claimed.ID
- })
+ current, err := spy.GetRunningPrebuiltWorkspaces(ctx)
+ require.NoError(t, err)
+
+ var found bool
+ for _, prebuild := range current {
+ if prebuild.ID == claimed.ID {
+ found = true
+ break
+ }
+ }
require.False(t, found, "claimed prebuild should not still be considered a running prebuild")
// Then: reconciling at this point will provision a new prebuild to replace the claimed one.
@@ -274,13 +343,11 @@ func TestClaimPrebuild(t *testing.T) {
require.Eventually(t, func() bool {
rows, err := spy.GetRunningPrebuiltWorkspaces(ctx)
- if err != nil {
- return false
- }
+ require.NoError(t, err)
- t.Logf("found %d running prebuilds so far, want %d", len(rows), expectedPrebuildsCount)
+ t.Logf("found %d running prebuilds so far, want %d", len(rows), tc.expectedPrebuildsCount)
- return len(runningPrebuilds) == expectedPrebuildsCount
+ return len(runningPrebuilds) == tc.expectedPrebuildsCount
}, testutil.WaitSuperLong, testutil.IntervalSlow)
// Then: when restarting the created workspace (which claimed a prebuild), it should not try and claim a new prebuild.
diff --git a/enterprise/coderd/prebuilds/metricscollector.go b/enterprise/coderd/prebuilds/metricscollector.go
new file mode 100644
index 0000000000000..07b34315e12dd
--- /dev/null
+++ b/enterprise/coderd/prebuilds/metricscollector.go
@@ -0,0 +1,126 @@
+package prebuilds
+
+import (
+ "context"
+ "time"
+
+ "cdr.dev/slog"
+
+ "github.com/prometheus/client_golang/prometheus"
+
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbauthz"
+ "github.com/coder/coder/v2/coderd/prebuilds"
+)
+
+var (
+ labels = []string{"template_name", "preset_name", "organization_name"}
+ createdPrebuildsDesc = prometheus.NewDesc(
+ "coderd_prebuilds_created_total",
+ "The number of prebuilds that have been created to meet the desired count set by presets.",
+ labels,
+ nil,
+ )
+ failedPrebuildsDesc = prometheus.NewDesc(
+ "coderd_prebuilds_failed_total",
+ "The number of prebuilds that failed to build during creation.",
+ labels,
+ nil,
+ )
+ claimedPrebuildsDesc = prometheus.NewDesc(
+ "coderd_prebuilds_claimed_total",
+ "The number of prebuilds that were claimed by a user. Each count means that a user created a workspace using a preset and was assigned a prebuild instead of a brand new workspace.",
+ labels,
+ nil,
+ )
+ usedPresetsDesc = prometheus.NewDesc(
+ "coderd_prebuilds_used_presets",
+ "The number of times a preset was used to build a prebuild.",
+ labels,
+ nil,
+ )
+ desiredPrebuildsDesc = prometheus.NewDesc(
+ "coderd_prebuilds_desired",
+ "The number of prebuilds desired by each preset of each template.",
+ labels,
+ nil,
+ )
+ runningPrebuildsDesc = prometheus.NewDesc(
+ "coderd_prebuilds_running",
+ "The number of prebuilds that are currently running. Running prebuilds have successfully started, but they may not be ready to be claimed by a user yet.",
+ labels,
+ nil,
+ )
+ eligiblePrebuildsDesc = prometheus.NewDesc(
+ "coderd_prebuilds_eligible",
+ "The number of eligible prebuilds. Eligible prebuilds are prebuilds that are ready to be claimed by a user.",
+ labels,
+ nil,
+ )
+)
+
+type MetricsCollector struct {
+ database database.Store
+ logger slog.Logger
+ reconciler prebuilds.Reconciler
+}
+
+var _ prometheus.Collector = new(MetricsCollector)
+
+func NewMetricsCollector(db database.Store, logger slog.Logger, reconciler prebuilds.Reconciler) *MetricsCollector {
+ return &MetricsCollector{
+ database: db,
+ logger: logger.Named("prebuilds_metrics_collector"),
+ reconciler: reconciler,
+ }
+}
+
+func (*MetricsCollector) Describe(descCh chan<- *prometheus.Desc) {
+ descCh <- createdPrebuildsDesc
+ descCh <- failedPrebuildsDesc
+ descCh <- claimedPrebuildsDesc
+ descCh <- usedPresetsDesc
+ descCh <- desiredPrebuildsDesc
+ descCh <- runningPrebuildsDesc
+ descCh <- eligiblePrebuildsDesc
+}
+
+func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) {
+ ctx, cancel := context.WithTimeout(dbauthz.AsPrebuildsOrchestrator(context.Background()), 10*time.Second)
+ defer cancel()
+ // nolint:gocritic // just until we get back to this
+ prebuildMetrics, err := mc.database.GetPrebuildMetrics(ctx)
+ if err != nil {
+ mc.logger.Error(ctx, "failed to get prebuild metrics", slog.Error(err))
+ return
+ }
+
+ for _, metric := range prebuildMetrics {
+ metricsCh <- prometheus.MustNewConstMetric(createdPrebuildsDesc, prometheus.CounterValue, float64(metric.CreatedCount), metric.TemplateName, metric.PresetName, metric.OrganizationName)
+ metricsCh <- prometheus.MustNewConstMetric(failedPrebuildsDesc, prometheus.CounterValue, float64(metric.FailedCount), metric.TemplateName, metric.PresetName, metric.OrganizationName)
+ metricsCh <- prometheus.MustNewConstMetric(claimedPrebuildsDesc, prometheus.CounterValue, float64(metric.ClaimedCount), metric.TemplateName, metric.PresetName, metric.OrganizationName)
+ }
+
+ snapshot, err := mc.reconciler.SnapshotState(ctx, mc.database)
+ if err != nil {
+ mc.logger.Error(ctx, "failed to get latest prebuild state", slog.Error(err))
+ return
+ }
+
+ for _, preset := range snapshot.Presets {
+ if !preset.UsingActiveVersion {
+ continue
+ }
+
+ presetSnapshot, err := snapshot.FilterByPreset(preset.ID)
+ if err != nil {
+ mc.logger.Error(ctx, "failed to filter by preset", slog.Error(err))
+ continue
+ }
+ state := presetSnapshot.CalculateState()
+
+ metricsCh <- prometheus.MustNewConstMetric(desiredPrebuildsDesc, prometheus.GaugeValue, float64(state.Desired), preset.TemplateName, preset.Name, preset.OrganizationName)
+ metricsCh <- prometheus.MustNewConstMetric(runningPrebuildsDesc, prometheus.GaugeValue, float64(state.Actual), preset.TemplateName, preset.Name, preset.OrganizationName)
+ metricsCh <- prometheus.MustNewConstMetric(eligiblePrebuildsDesc, prometheus.GaugeValue, float64(state.Eligible), preset.TemplateName, preset.Name, preset.OrganizationName)
+ }
+}
diff --git a/enterprise/coderd/prebuilds/metricscollector_test.go b/enterprise/coderd/prebuilds/metricscollector_test.go
new file mode 100644
index 0000000000000..0b321112df514
--- /dev/null
+++ b/enterprise/coderd/prebuilds/metricscollector_test.go
@@ -0,0 +1,284 @@
+package prebuilds_test
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+ "tailscale.com/types/ptr"
+
+ "github.com/prometheus/client_golang/prometheus"
+ prometheus_client "github.com/prometheus/client_model/go"
+
+ "cdr.dev/slog/sloggers/slogtest"
+ "github.com/coder/quartz"
+
+ "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/codersdk"
+ "github.com/coder/coder/v2/enterprise/coderd/prebuilds"
+ "github.com/coder/coder/v2/testutil"
+)
+
+func TestMetricsCollector(t *testing.T) {
+ t.Parallel()
+
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("this test requires postgres")
+ }
+
+ type metricCheck struct {
+ name string
+ value *float64
+ isCounter bool
+ }
+
+ type testCase struct {
+ name string
+ transitions []database.WorkspaceTransition
+ jobStatuses []database.ProvisionerJobStatus
+ initiatorIDs []uuid.UUID
+ ownerIDs []uuid.UUID
+ metrics []metricCheck
+ templateDeleted []bool
+ }
+
+ tests := []testCase{
+ {
+ name: "prebuild created",
+ transitions: allTransitions,
+ jobStatuses: allJobStatuses,
+ initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ // TODO: reexamine and refactor the test cases and assertions:
+ // * a running prebuild that is not elibible to be claimed currently seems to be eligible.
+ // * a prebuild that was claimed should not be deemed running, not eligible.
+ ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID, uuid.New()},
+ metrics: []metricCheck{
+ {"coderd_prebuilds_created", ptr.To(1.0), true},
+ {"coderd_prebuilds_desired", ptr.To(1.0), false},
+ // {"coderd_prebuilds_running", ptr.To(0.0), false},
+ // {"coderd_prebuilds_eligible", ptr.To(0.0), false},
+ },
+ templateDeleted: []bool{false},
+ },
+ {
+ name: "prebuild running",
+ transitions: []database.WorkspaceTransition{database.WorkspaceTransitionStart},
+ jobStatuses: []database.ProvisionerJobStatus{database.ProvisionerJobStatusSucceeded},
+ initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ metrics: []metricCheck{
+ {"coderd_prebuilds_created", ptr.To(1.0), true},
+ {"coderd_prebuilds_desired", ptr.To(1.0), false},
+ {"coderd_prebuilds_running", ptr.To(1.0), false},
+ {"coderd_prebuilds_eligible", ptr.To(0.0), false},
+ },
+ templateDeleted: []bool{false},
+ },
+ {
+ name: "prebuild failed",
+ transitions: allTransitions,
+ jobStatuses: []database.ProvisionerJobStatus{database.ProvisionerJobStatusFailed},
+ initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID, uuid.New()},
+ metrics: []metricCheck{
+ {"coderd_prebuilds_created", ptr.To(1.0), true},
+ {"coderd_prebuilds_failed", ptr.To(1.0), true},
+ {"coderd_prebuilds_desired", ptr.To(1.0), false},
+ {"coderd_prebuilds_running", ptr.To(0.0), false},
+ {"coderd_prebuilds_eligible", ptr.To(0.0), false},
+ },
+ templateDeleted: []bool{false},
+ },
+ {
+ name: "prebuild assigned",
+ transitions: allTransitions,
+ jobStatuses: allJobStatuses,
+ initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ ownerIDs: []uuid.UUID{uuid.New()},
+ metrics: []metricCheck{
+ {"coderd_prebuilds_created", ptr.To(1.0), true},
+ {"coderd_prebuilds_claimed", ptr.To(1.0), true},
+ {"coderd_prebuilds_desired", ptr.To(1.0), false},
+ {"coderd_prebuilds_running", ptr.To(0.0), false},
+ {"coderd_prebuilds_eligible", ptr.To(0.0), false},
+ },
+ templateDeleted: []bool{false},
+ },
+ {
+ name: "workspaces that were not created by the prebuilds user are not counted",
+ transitions: allTransitions,
+ jobStatuses: allJobStatuses,
+ initiatorIDs: []uuid.UUID{uuid.New()},
+ ownerIDs: []uuid.UUID{uuid.New()},
+ metrics: []metricCheck{
+ {"coderd_prebuilds_desired", ptr.To(1.0), false},
+ {"coderd_prebuilds_running", ptr.To(0.0), false},
+ {"coderd_prebuilds_eligible", ptr.To(0.0), false},
+ },
+ templateDeleted: []bool{false},
+ },
+ {
+ name: "deleted templates never desire prebuilds",
+ transitions: allTransitions,
+ jobStatuses: allJobStatuses,
+ initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID, uuid.New()},
+ metrics: []metricCheck{
+ {"coderd_prebuilds_desired", ptr.To(0.0), false},
+ },
+ templateDeleted: []bool{true},
+ },
+ {
+ name: "running prebuilds for deleted templates are still counted, so that they can be deleted",
+ transitions: []database.WorkspaceTransition{database.WorkspaceTransitionStart},
+ jobStatuses: []database.ProvisionerJobStatus{database.ProvisionerJobStatusSucceeded},
+ initiatorIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ ownerIDs: []uuid.UUID{agplprebuilds.SystemUserID},
+ metrics: []metricCheck{
+ {"coderd_prebuilds_running", ptr.To(1.0), false},
+ {"coderd_prebuilds_eligible", ptr.To(0.0), false},
+ },
+ templateDeleted: []bool{true},
+ },
+ }
+ 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
+ t.Run(fmt.Sprintf("%v/transition:%s/jobStatus:%s", test.name, transition, jobStatus), func(t *testing.T) {
+ t.Parallel()
+
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
+ t.Cleanup(func() {
+ if t.Failed() {
+ t.Logf("failed to run test: %s", test.name)
+ t.Logf("transition: %s", transition)
+ t.Logf("jobStatus: %s", jobStatus)
+ t.Logf("initiatorID: %s", initiatorID)
+ t.Logf("ownerID: %s", ownerID)
+ t.Logf("templateDeleted: %t", templateDeleted)
+ }
+ })
+ clock := quartz.NewMock(t)
+ db, pubsub := dbtestutil.NewDB(t)
+ reconciler := prebuilds.NewStoreReconciler(db, pubsub, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t))
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ createdUsers := []uuid.UUID{agplprebuilds.SystemUserID}
+ for _, user := range slices.Concat(test.ownerIDs, test.initiatorIDs) {
+ if !slices.Contains(createdUsers, user) {
+ dbgen.User(t, db, database.User{
+ ID: user,
+ })
+ createdUsers = append(createdUsers, user)
+ }
+ }
+
+ collector := prebuilds.NewMetricsCollector(db, logger, reconciler)
+ registry := prometheus.NewPedanticRegistry()
+ registry.Register(collector)
+
+ numTemplates := 2
+ for i := 0; i < numTemplates; i++ {
+ org, template := setupTestDBTemplate(t, db, ownerID, templateDeleted)
+ templateVersionID := setupTestDBTemplateVersion(ctx, t, clock, db, pubsub, org.ID, ownerID, template.ID)
+ preset := setupTestDBPreset(t, db, templateVersionID, 1, uuid.New().String())
+ setupTestDBWorkspace(
+ t, clock, db, pubsub,
+ transition, jobStatus, org.ID, preset, template.ID, templateVersionID, initiatorID, ownerID,
+ )
+ }
+
+ metricsFamilies, err := registry.Gather()
+ require.NoError(t, err)
+
+ templates, err := db.GetTemplates(ctx)
+ require.NoError(t, err)
+ require.Equal(t, numTemplates, len(templates))
+
+ for _, template := range templates {
+ org, err := db.GetOrganizationByID(ctx, template.OrganizationID)
+ require.NoError(t, err)
+ templateVersions, err := db.GetTemplateVersionsByTemplateID(ctx, database.GetTemplateVersionsByTemplateIDParams{
+ TemplateID: template.ID,
+ })
+ require.NoError(t, err)
+ require.Equal(t, 1, len(templateVersions))
+
+ presets, err := db.GetPresetsByTemplateVersionID(ctx, templateVersions[0].ID)
+ require.NoError(t, err)
+ 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,
+ "organization_name": org.Name,
+ }
+
+ for _, check := range test.metrics {
+ metric := findMetric(metricsFamilies, check.name, labels)
+ if check.value == nil {
+ continue
+ }
+
+ require.NotNil(t, metric, "metric %s should exist", check.name)
+
+ if check.isCounter {
+ require.Equal(t, *check.value, metric.GetCounter().GetValue(), "counter %s value mismatch", check.name)
+ } else {
+ require.Equal(t, *check.value, metric.GetGauge().GetValue(), "gauge %s value mismatch", check.name)
+ }
+ }
+ }
+ }
+ })
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+func findMetric(metricsFamilies []*prometheus_client.MetricFamily, name string, labels map[string]string) *prometheus_client.Metric {
+ for _, metricFamily := range metricsFamilies {
+ if metricFamily.GetName() != name {
+ continue
+ }
+
+ for _, metric := range metricFamily.GetMetric() {
+ labelPairs := metric.GetLabel()
+
+ // Convert label pairs to map for easier lookup
+ metricLabels := make(map[string]string, len(labelPairs))
+ for _, label := range labelPairs {
+ metricLabels[label.GetName()] = label.GetValue()
+ }
+
+ // Check if all requested labels match
+ for wantName, wantValue := range labels {
+ if metricLabels[wantName] != wantValue {
+ continue
+ }
+ }
+
+ return metric
+ }
+ }
+ return nil
+}
diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go
index 57b66a368248c..b2d7da47a608d 100644
--- a/enterprise/coderd/roles_test.go
+++ b/enterprise/coderd/roles_test.go
@@ -7,6 +7,8 @@ import (
"slices"
"testing"
+ "github.com/coder/coder/v2/coderd/database/dbtestutil"
+
"github.com/google/uuid"
"github.com/stretchr/testify/require"
@@ -333,6 +335,12 @@ func TestCustomOrganizationRole(t *testing.T) {
// Verify deleting a custom role cascades to all members
t.Run("DeleteRoleCascadeMembers", func(t *testing.T) {
t.Parallel()
+
+ // TODO: we should not be returning the prebuilds user in OrganizationMembers, and this is not returned in dbmem.
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("This test requires postgres")
+ }
+
owner, first := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
diff --git a/examples/parameters/main.tf b/examples/parameters/main.tf
index 07e77d3170d2c..de32add77e8cd 100644
--- a/examples/parameters/main.tf
+++ b/examples/parameters/main.tf
@@ -281,3 +281,19 @@ data "coder_parameter" "force-rebuild" {
order = 4
}
+
+# data "coder_workspace_preset" "coder" {
+# name = "coder"
+# parameters = {
+# project_id = "coder"
+# apps_dir = "/var/apps/coder"
+# }
+# }
+
+# data "coder_workspace_preset" "envbuilder" {
+# name = "envbuilder"
+# parameters = {
+# project_id = "envbuilder"
+# apps_dir = "/var/apps/envbuilder"
+# }
+# }
diff --git a/go.mod b/go.mod
index 230c911779b2f..76547127e205d 100644
--- a/go.mod
+++ b/go.mod
@@ -529,3 +529,6 @@ require (
google.golang.org/genai v0.7.0 // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
)
+
+// TODO: remove this once code merged upstream
+replace github.com/coder/terraform-provider-coder/v2 => github.com/coder/terraform-provider-coder/v2 v2.4.0-pre1.0.20250415114329-82d12ec030e9
diff --git a/go.sum b/go.sum
index acdc4d34c8286..4bf78b2f21e04 100644
--- a/go.sum
+++ b/go.sum
@@ -921,8 +921,8 @@ github.com/coder/tailscale v1.1.1-0.20250422090654-5090e715905e h1:nope/SZfoLB9M
github.com/coder/tailscale v1.1.1-0.20250422090654-5090e715905e/go.mod h1:1ggFFdHTRjPRu9Yc1yA7nVHBYB50w9Ce7VIXNqcW6Ko=
github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e h1:JNLPDi2P73laR1oAclY6jWzAbucf70ASAvf5mh2cME0=
github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI=
-github.com/coder/terraform-provider-coder/v2 v2.4.0-pre1.0.20250417100258-c86bb5c3ddcd h1:FsIG6Fd0YOEK7D0Hl/CJywRA+Y6Gd5RQbSIa2L+/BmE=
-github.com/coder/terraform-provider-coder/v2 v2.4.0-pre1.0.20250417100258-c86bb5c3ddcd/go.mod h1:56/KdGYaA+VbwXJbTI8CA57XPfnuTxN8rjxbR34PbZw=
+github.com/coder/terraform-provider-coder/v2 v2.4.0-pre1.0.20250415114329-82d12ec030e9 h1:NjMJLgT0UUTOzTAOsJipEVRv5s81a8O7LWjswUgfCGo=
+github.com/coder/terraform-provider-coder/v2 v2.4.0-pre1.0.20250415114329-82d12ec030e9/go.mod h1:qKLEgjP/F5CPNEdvXJI+2ajaKBpK9lb/1HnH21wlCG0=
github.com/coder/trivy v0.0.0-20250409153844-e6b004bc465a h1:yryP7e+IQUAArlycH4hQrjXQ64eRNbxsV5/wuVXHgME=
github.com/coder/trivy v0.0.0-20250409153844-e6b004bc465a/go.mod h1:dDvq9axp3kZsT63gY2Znd1iwzfqDq3kXbQnccIrjRYY=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
diff --git a/provisioner/terraform/executor.go b/provisioner/terraform/executor.go
index 150f51e6dd10d..e52b869122349 100644
--- a/provisioner/terraform/executor.go
+++ b/provisioner/terraform/executor.go
@@ -15,6 +15,8 @@ import (
"sync"
"time"
+ "github.com/coder/terraform-provider-coder/v2/provider"
+
"github.com/hashicorp/go-version"
tfjson "github.com/hashicorp/terraform-json"
"go.opentelemetry.io/otel/attribute"
@@ -261,6 +263,16 @@ func (e *executor) plan(ctx, killCtx context.Context, env, vars []string, logr l
e.mut.Lock()
defer e.mut.Unlock()
+ var isPrebuild bool
+ for _, v := range env {
+ if envName(v) == provider.IsPrebuildEnvironmentVariable() && envVar(v) == "true" {
+ isPrebuild = true
+ break
+ }
+ }
+
+ _ = isPrebuild
+
planfilePath := getPlanFilePath(e.workdir)
args := []string{
"plan",
@@ -330,6 +342,68 @@ func onlyDataResources(sm tfjson.StateModule) tfjson.StateModule {
return filtered
}
+func (e *executor) logResourceReplacements(ctx context.Context, plan *tfjson.Plan) {
+ if plan == nil {
+ return
+ }
+
+ if len(plan.ResourceChanges) == 0 {
+ return
+ }
+ var (
+ count int
+ replacements = make(map[string][]string, len(plan.ResourceChanges))
+ )
+
+ for _, ch := range plan.ResourceChanges {
+ // No change, no problem!
+ if ch.Change == nil {
+ continue
+ }
+
+ // No-op change, no problem!
+ if ch.Change.Actions.NoOp() {
+ continue
+ }
+
+ // No replacements, no problem!
+ if len(ch.Change.ReplacePaths) == 0 {
+ continue
+ }
+
+ // Replacing our resources, no problem!
+ if strings.Index(ch.Type, "coder_") == 0 {
+ continue
+ }
+
+ for _, p := range ch.Change.ReplacePaths {
+ var path string
+ switch p.(type) {
+ case []interface{}:
+ segs := p.([]interface{})
+ list := make([]string, 0, len(segs))
+ for _, s := range segs {
+ list = append(list, fmt.Sprintf("%v", s))
+ }
+ path = strings.Join(list, ".")
+ default:
+ path = fmt.Sprintf("%v", p)
+ }
+
+ replacements[ch.Address] = append(replacements[ch.Address], path)
+ }
+
+ count++
+ }
+
+ if count > 0 {
+ e.server.logger.Warn(ctx, "plan introduces resource changes", slog.F("count", count))
+ for n, p := range replacements {
+ e.server.logger.Warn(ctx, "resource will be replaced!", slog.F("name", n), slog.F("replacement_paths", strings.Join(p, ",")))
+ }
+ }
+}
+
// planResources must only be called while the lock is held.
func (e *executor) planResources(ctx, killCtx context.Context, planfilePath string) (*State, json.RawMessage, error) {
ctx, span := e.server.startTrace(ctx, tracing.FuncName())
@@ -340,6 +414,8 @@ func (e *executor) planResources(ctx, killCtx context.Context, planfilePath stri
return nil, nil, xerrors.Errorf("show terraform plan file: %w", err)
}
+ e.logResourceReplacements(ctx, plan)
+
rawGraph, err := e.graph(ctx, killCtx)
if err != nil {
return nil, nil, xerrors.Errorf("graph: %w", err)
diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go
index f8f82bbad7b9a..54655956ab21b 100644
--- a/provisioner/terraform/provision.go
+++ b/provisioner/terraform/provision.go
@@ -272,8 +272,9 @@ func provisionEnv(
)
if metadata.GetIsPrebuild() {
env = append(env, provider.IsPrebuildEnvironmentVariable()+"=true")
+ } else {
+ env = append(env, provider.RunningAgentTokenEnvironmentVariable()+"="+metadata.GetRunningWorkspaceAgentToken())
}
-
for key, value := range provisionersdk.AgentScriptEnv() {
env = append(env, key+"="+value)
}
diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go
index da86ab2f3d48e..0446760592c23 100644
--- a/provisioner/terraform/resources.go
+++ b/provisioner/terraform/resources.go
@@ -901,6 +901,12 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s
Instances: prebuildInstances,
},
}
+ // TODO: more than 1 allowable?
+ if len(preset.Prebuilds) == 1 {
+ protoPreset.Prebuild = &proto.Prebuild{
+ Instances: int32(preset.Prebuilds[0].Instances),
+ }
+ }
if slice.Contains(duplicatedPresetNames, preset.Name) {
duplicatedPresetNames = append(duplicatedPresetNames, preset.Name)
diff --git a/provisioner/terraform/safeenv.go b/provisioner/terraform/safeenv.go
index 4da2fc32cd996..8a1bfe15df939 100644
--- a/provisioner/terraform/safeenv.go
+++ b/provisioner/terraform/safeenv.go
@@ -30,6 +30,14 @@ func envName(env string) string {
return ""
}
+func envVar(env string) string {
+ parts := strings.SplitN(env, "=", 1)
+ if len(parts) > 0 {
+ return parts[1]
+ }
+ return ""
+}
+
func isCanarySet(env []string) bool {
for _, e := range env {
if envName(e) == unsafeEnvCanary {
diff --git a/site/e2e/constants.ts b/site/e2e/constants.ts
index 98757064c6f3f..92cf3ec3fe153 100644
--- a/site/e2e/constants.ts
+++ b/site/e2e/constants.ts
@@ -25,6 +25,12 @@ export const users = {
password: defaultPassword,
email: "owner@coder.com",
},
+ admin: {
+ username: "admin",
+ password: defaultPassword,
+ email: "admin@coder.com",
+ roles: ["Owner"],
+ },
templateAdmin: {
username: "template-admin",
password: defaultPassword,
diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts
index f4ad6485b2681..ea9af9baae009 100644
--- a/site/e2e/helpers.ts
+++ b/site/e2e/helpers.ts
@@ -1,6 +1,8 @@
import { type ChildProcess, exec, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
+import fs from "node:fs";
import net from "node:net";
+import * as os from "node:os";
import path from "node:path";
import { Duplex } from "node:stream";
import { type BrowserContext, type Page, expect, test } from "@playwright/test";
@@ -10,6 +12,7 @@ import type {
WorkspaceBuildParameter,
} from "api/typesGenerated";
import express from "express";
+import JSZip from "jszip";
import capitalize from "lodash/capitalize";
import * as ssh from "ssh2";
import { TarWriter } from "utils/tar";
@@ -1178,3 +1181,84 @@ export async function addUserToOrganization(
}
await page.mouse.click(10, 10); // close the popover by clicking outside of it
}
+
+// TODO: convert to test fixture and dispose after each test.
+export async function importTemplate(
+ page: Page,
+ templateName: string,
+ files: string[],
+ orgName = defaultOrganizationName,
+): Promise {
+ // Create a ZIP from the given input files.
+ const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), templateName));
+ const templatePath = path.join(tmpdir, `${templateName}.zip`);
+ await createZIP(templatePath, files);
+
+ // Create new template.
+ await page.goto("/templates/new", { waitUntil: "domcontentloaded" });
+ await page.getByTestId("drop-zone").click();
+
+ // Select the template file.
+ const [fileChooser] = await Promise.all([
+ page.waitForEvent("filechooser"),
+ page.getByTestId("drop-zone").click(),
+ ]);
+ await fileChooser.setFiles(templatePath);
+
+ // Set name and submit.
+ await page.locator("input[name=name]").fill(templateName);
+
+ // If the organization picker is present on the page, select the default
+ // organization.
+ const orgPicker = page.getByLabel("Belongs to *");
+ const organizationsEnabled = await orgPicker.isVisible();
+ if (organizationsEnabled && await orgPicker.isEnabled()) { // The org picker is disabled if there is only one org.
+ if (orgName !== defaultOrganizationName) {
+ throw new Error(
+ `No provisioners registered for ${orgName}, creating this template will fail`,
+ );
+ }
+
+ await orgPicker.click();
+ await page.getByText(orgName, { exact: true }).click();
+ }
+
+ await page.getByRole("button", { name: "Save" }).click();
+
+ await page.waitForURL(`/templates/${orgName}/${templateName}/files`, {
+ timeout: 120_000,
+ });
+ return templateName;
+}
+
+async function createZIP(
+ outpath: string,
+ inputFiles: string[],
+): Promise<{ path: string; length: number }> {
+ const zip = new JSZip();
+
+ let found = false;
+ for (const file of inputFiles) {
+ if (!fs.existsSync(file)) {
+ console.warn(`${file} not found, not including in zip`);
+ continue;
+ }
+ found = true;
+
+ const contents = fs.readFileSync(file);
+ zip.file(path.basename(file), contents);
+ }
+
+ if (!found) {
+ throw new Error(`no files found to zip into ${outpath}`);
+ }
+
+ zip
+ .generateNodeStream({ type: "nodebuffer", streamFiles: true })
+ .pipe(fs.createWriteStream(outpath));
+
+ return {
+ path: outpath,
+ length: zip.length,
+ };
+}
diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts
index 762b7f0158dba..f6ea421cb4af0 100644
--- a/site/e2e/playwright.config.ts
+++ b/site/e2e/playwright.config.ts
@@ -31,7 +31,7 @@ export default defineConfig({
],
reporter: [["./reporter.ts"]],
use: {
- actionTimeout: 5000,
+ actionTimeout: 60_000,
baseURL: `http://localhost:${coderPort}`,
video: "retain-on-failure",
...(wsEndpoint
@@ -111,7 +111,7 @@ export default defineConfig({
gitAuth.validatePath,
),
CODER_PPROF_ADDRESS: `127.0.0.1:${coderdPProfPort}`,
- CODER_EXPERIMENTS: `${e2eFakeExperiment1},${e2eFakeExperiment2}`,
+ CODER_EXPERIMENTS: `${e2eFakeExperiment1},${e2eFakeExperiment2},${process.env.CODER_EXPERIMENTS}`,
// Tests for Deployment / User Authentication / OIDC
CODER_OIDC_ISSUER_URL: "https://accounts.google.com",
@@ -122,6 +122,8 @@ export default defineConfig({
CODER_OIDC_SIGN_IN_TEXT: "Hello",
CODER_OIDC_ICON_URL: "/icon/google.svg",
},
- reuseExistingServer: false,
+ reuseExistingServer: process.env.CODER_E2E_REUSE_EXISTING_SERVER
+ ? Boolean(process.env.CODER_E2E_REUSE_EXISTING_SERVER)
+ : false,
},
});
diff --git a/site/e2e/setup/preflight.ts b/site/e2e/setup/preflight.ts
index dedcc195db480..0a5eefc68c7d5 100644
--- a/site/e2e/setup/preflight.ts
+++ b/site/e2e/setup/preflight.ts
@@ -36,7 +36,7 @@ export default function () {
throw new Error(msg);
}
- if (!process.env.CI) {
+ if (!process.env.CI && !process.env.CODER_E2E_REUSE_EXISTING_SERVER) {
console.info("==> make site/e2e/bin/coder");
execSync("make site/e2e/bin/coder", {
cwd: path.join(__dirname, "../../../"),
diff --git a/site/e2e/tests/presets/basic-presets-with-prebuild/main.tf b/site/e2e/tests/presets/basic-presets-with-prebuild/main.tf
new file mode 100644
index 0000000000000..a7519fcd0c7aa
--- /dev/null
+++ b/site/e2e/tests/presets/basic-presets-with-prebuild/main.tf
@@ -0,0 +1,152 @@
+terraform {
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = "2.3.0-pre2"
+ }
+ docker = {
+ source = "kreuzwerker/docker"
+ version = "3.0.2"
+ }
+ }
+}
+
+variable "docker_socket" {
+ default = ""
+ description = "(Optional) Docker socket URI"
+ type = string
+}
+
+provider "docker" {
+ # Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default
+ host = var.docker_socket != "" ? var.docker_socket : null
+}
+
+data "coder_provisioner" "me" {}
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+
+data "coder_workspace_preset" "goland" {
+ name = "I Like GoLand"
+ parameters = {
+ "jetbrains_ide" = "GO"
+ }
+ prebuilds {
+ instances = 2
+ }
+}
+
+data "coder_workspace_preset" "python" {
+ name = "Some Like PyCharm"
+ parameters = {
+ "jetbrains_ide" = "PY"
+ }
+}
+
+resource "coder_agent" "main" {
+ arch = data.coder_provisioner.me.arch
+ os = "linux"
+ startup_script = <<-EOT
+ set -e
+
+ # Prepare user home with default files on first start!
+ if [ ! -f ~/.init_done ]; then
+ cp -rT /etc/skel ~
+ touch ~/.init_done
+ fi
+
+ if [[ "${data.coder_workspace.me.prebuild_count}" -eq 1 ]]; then
+ touch ~/.prebuild_note
+ fi
+ EOT
+
+ env = {
+ OWNER_EMAIL = data.coder_workspace_owner.me.email
+ }
+
+ # The following metadata blocks are optional. They are used to display
+ # information about your workspace in the dashboard. You can remove them
+ # if you don't want to display any information.
+ # For basic resources, you can use the `coder stat` command.
+ # If you need more control, you can write your own script.
+ metadata {
+ display_name = "Was Prebuild"
+ key = "prebuild"
+ script = "[[ -e ~/.prebuild_note ]] && echo 'Yes' || echo 'No'"
+ interval = 10
+ timeout = 1
+ }
+
+ metadata {
+ display_name = "Owner"
+ key = "owner"
+ script = "echo $OWNER_EMAIL"
+ interval = 10
+ timeout = 1
+ }
+
+ metadata {
+ display_name = "Hostname"
+ key = "hostname"
+ script = "hostname"
+ interval = 10
+ timeout = 1
+ }
+}
+
+# See https://registry.coder.com/modules/jetbrains-gateway
+module "jetbrains_gateway" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/jetbrains-gateway/coder"
+
+ # JetBrains IDEs to make available for the user to select
+ jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"]
+ default = "IU"
+
+ # Default folder to open when starting a JetBrains IDE
+ folder = "/home/coder"
+
+ # This ensures that the latest version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production.
+ version = ">= 1.0.0"
+
+ agent_id = coder_agent.main.id
+ agent_name = "main"
+ order = 2
+}
+
+resource "docker_volume" "home_volume" {
+ name = "coder-${data.coder_workspace.me.id}-home"
+ # Protect the volume from being deleted due to changes in attributes.
+ lifecycle {
+ ignore_changes = all
+ }
+}
+
+resource "docker_container" "workspace" {
+ lifecycle {
+ ignore_changes = all
+ }
+
+ network_mode = "host"
+
+ count = data.coder_workspace.me.start_count
+ image = "codercom/enterprise-base:ubuntu"
+ # Uses lower() to avoid Docker restriction on container names.
+ name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
+ # Hostname makes the shell more user friendly: coder@my-workspace:~$
+ hostname = data.coder_workspace.me.name
+ # Use the docker gateway if the access URL is 127.0.0.1
+ entrypoint = [
+ "sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")
+ ]
+ env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
+ host {
+ host = "host.docker.internal"
+ ip = "host-gateway"
+ }
+ volumes {
+ container_path = "/home/coder"
+ volume_name = docker_volume.home_volume.name
+ read_only = false
+ }
+}
diff --git a/site/e2e/tests/presets/basic-presets/main.tf b/site/e2e/tests/presets/basic-presets/main.tf
new file mode 100644
index 0000000000000..3303f556a9412
--- /dev/null
+++ b/site/e2e/tests/presets/basic-presets/main.tf
@@ -0,0 +1,146 @@
+terraform {
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = "2.3.0-pre2"
+ }
+ docker = {
+ source = "kreuzwerker/docker"
+ version = "3.0.2"
+ }
+ }
+}
+
+variable "docker_socket" {
+ default = ""
+ description = "(Optional) Docker socket URI"
+ type = string
+}
+
+provider "docker" {
+ # Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default
+ host = var.docker_socket != "" ? var.docker_socket : null
+}
+
+data "coder_provisioner" "me" {}
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+
+data "coder_workspace_preset" "goland" {
+ name = "I Like GoLand"
+ parameters = {
+ "jetbrains_ide" = "GO"
+ }
+}
+
+data "coder_workspace_preset" "python" {
+ name = "Some Like PyCharm"
+ parameters = {
+ "jetbrains_ide" = "PY"
+ }
+}
+
+resource "coder_agent" "main" {
+ arch = data.coder_provisioner.me.arch
+ os = "linux"
+ startup_script = <<-EOT
+ set -e
+
+ # Prepare user home with default files on first start!
+ if [ ! -f ~/.init_done ]; then
+ cp -rT /etc/skel ~
+ touch ~/.init_done
+ fi
+
+ # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
+ EOT
+
+ # These environment variables allow you to make Git commits right away after creating a
+ # workspace. Note that they take precedence over configuration defined in ~/.gitconfig!
+ # You can remove this block if you'd prefer to configure Git manually or using
+ # dotfiles. (see docs/dotfiles.md)
+ env = {
+ GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
+ GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
+ GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
+ GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
+ }
+
+ # The following metadata blocks are optional. They are used to display
+ # information about your workspace in the dashboard. You can remove them
+ # if you don't want to display any information.
+ # For basic resources, you can use the `coder stat` command.
+ # If you need more control, you can write your own script.
+ metadata {
+ display_name = "Is Prebuild"
+ key = "prebuild"
+ script = "echo ${data.coder_workspace.me.prebuild_count}"
+ interval = 10
+ timeout = 1
+ }
+
+ metadata {
+ display_name = "Hostname"
+ key = "hostname"
+ script = "hostname"
+ interval = 10
+ timeout = 1
+ }
+}
+
+# See https://registry.coder.com/modules/jetbrains-gateway
+module "jetbrains_gateway" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/jetbrains-gateway/coder"
+
+ # JetBrains IDEs to make available for the user to select
+ jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"]
+ default = "IU"
+
+ # Default folder to open when starting a JetBrains IDE
+ folder = "/home/coder"
+
+ # This ensures that the latest version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production.
+ version = ">= 1.0.0"
+
+ agent_id = coder_agent.main.id
+ agent_name = "main"
+ order = 2
+}
+
+resource "docker_volume" "home_volume" {
+ name = "coder-${data.coder_workspace.me.id}-home"
+ # Protect the volume from being deleted due to changes in attributes.
+ lifecycle {
+ ignore_changes = all
+ }
+}
+
+resource "docker_container" "workspace" {
+ lifecycle {
+ ignore_changes = all
+ }
+
+ network_mode = "host"
+
+ count = data.coder_workspace.me.start_count
+ image = "codercom/enterprise-base:ubuntu"
+ # Uses lower() to avoid Docker restriction on container names.
+ name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
+ # Hostname makes the shell more user friendly: coder@my-workspace:~$
+ hostname = data.coder_workspace.me.name
+ # Use the docker gateway if the access URL is 127.0.0.1
+ entrypoint = [
+ "sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")
+ ]
+ env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
+ host {
+ host = "host.docker.internal"
+ ip = "host-gateway"
+ }
+ volumes {
+ container_path = "/home/coder"
+ volume_name = docker_volume.home_volume.name
+ read_only = false
+ }
+}
diff --git a/site/e2e/tests/presets/prebuilds.spec.ts b/site/e2e/tests/presets/prebuilds.spec.ts
new file mode 100644
index 0000000000000..532b9bcc7b3f5
--- /dev/null
+++ b/site/e2e/tests/presets/prebuilds.spec.ts
@@ -0,0 +1,184 @@
+import path from "node:path";
+import {type Locator, expect, test, Page} from "@playwright/test";
+import { users } from "../../constants";
+import {
+ currentUser,
+ importTemplate,
+ login,
+ randomName,
+ requiresLicense,
+} from "../../helpers";
+import { beforeCoderTest } from "../../hooks";
+
+test.beforeEach(async ({ page }) => {
+ // TODO: we can improve things here by supporting using the standard web server BUT:
+ // 1. we can't use the in-memory db because we didn't implement many dbmem functions
+ // 2. we'd have to require terraform provisioners are setup (see requireTerraformTests)
+ if(!test.info().config.webServer?.reuseExistingServer) {
+ console.warn('test requires existing server with terraform provisioners');
+ test.skip()
+ }
+
+ beforeCoderTest(page);
+ await login(page, users.admin);
+});
+
+const waitForBuildTimeout = 120_000; // Builds can take a while, let's give them at most 2m.
+
+const templateFiles = [
+ path.join(__dirname, "basic-presets-with-prebuild/main.tf"),
+ path.join(__dirname, "basic-presets-with-prebuild/.terraform.lock.hcl"),
+];
+
+const expectedPrebuilds = 2;
+
+// TODO: update provider version in *.tf
+
+// NOTE: requires the `workspace-prebuilds` experiment enabled!
+test("create template with desired prebuilds", async ({ page, baseURL }) => {
+ test.setTimeout(300_000);
+
+ requiresLicense();
+
+ // Create new template.
+ const templateName = randomName();
+ await importTemplate(page, templateName, templateFiles);
+
+ await page.goto(
+ `/workspaces?filter=owner:prebuilds%20template:${templateName}&page=1`,
+ { waitUntil: "domcontentloaded" },
+ );
+
+ // Wait for prebuilds to show up.
+ const prebuilds = page.getByTestId(/^workspace-.+$/);
+ await waitForExpectedCount(prebuilds, expectedPrebuilds);
+
+ // Wait for prebuilds to start.
+ const runningPrebuilds = runningPrebuildsLocator(page);
+ await waitForExpectedCount(runningPrebuilds, expectedPrebuilds);
+});
+
+// NOTE: requires the `workspace-prebuilds` experiment enabled!
+test("claim prebuild matching selected preset", async ({ page, baseURL }) => {
+ test.setTimeout(300_000);
+
+ requiresLicense();
+
+ // Create new template.
+ const templateName = randomName();
+ await importTemplate(page, templateName, templateFiles);
+
+ await page.goto(
+ `/workspaces?filter=owner:prebuilds%20template:${templateName}&page=1`,
+ { waitUntil: "domcontentloaded" },
+ );
+
+ // Wait for prebuilds to show up.
+ const prebuilds = page.getByTestId(/^workspace-.+$/);
+ await waitForExpectedCount(prebuilds, expectedPrebuilds);
+
+ const previousWorkspaceNames = await Promise.all(
+ (await prebuilds.all()).map((value) => {
+ return value.getByText(/prebuild-.+/).textContent();
+ }),
+ );
+
+ // Wait for prebuilds to start.
+ let runningPrebuilds = runningPrebuildsLocator(page);
+ await waitForExpectedCount(runningPrebuilds, expectedPrebuilds);
+
+ // Open the first prebuild.
+ await runningPrebuilds.first().click();
+ await page.waitForURL(/\/@prebuilds\/prebuild-.+/);
+
+ // Wait for the prebuild to become ready so it's eligible to be claimed.
+ await page.getByTestId("agent-status-ready").waitFor({ timeout: 120_000 });
+
+ // Logout as admin, and login as an unprivileged user.
+ await login(page, users.member);
+
+ // Create a new workspace using the same preset as one of the prebuilds.
+ await page.goto(`/templates/coder/${templateName}/workspace`, {
+ waitUntil: "domcontentloaded",
+ });
+
+ // Visit workspace creation page for new template.
+ await page.goto(`/templates/default/${templateName}/workspace`, {
+ waitUntil: "domcontentloaded",
+ });
+
+ // Choose a preset.
+ await page.locator('button[aria-label="Preset"]').click();
+ // Choose the GoLand preset.
+ const preset = page.getByText("I Like GoLand");
+ await expect(preset).toBeVisible();
+ await preset.click();
+
+ // Create a workspace.
+ const workspaceName = randomName();
+ await page.locator("input[name=name]").fill(workspaceName);
+ await page.getByRole("button", { name: "Create workspace" }).click();
+
+ // Wait for the workspace build display to be navigated to.
+ const user = currentUser(page);
+ await page.waitForURL(`/@${user.username}/${workspaceName}`, {
+ timeout: waitForBuildTimeout, // Account for workspace build time.
+ });
+
+ // Validate via the workspace metadata that it was indeed a claimed prebuild.
+ const indicator = page.getByText("Was Prebuild");
+ await indicator.waitFor({ timeout: 60_000 });
+ const text = indicator.locator("xpath=..").getByText("Yes");
+ await text.waitFor({ timeout: 30_000 });
+
+ // Validate via the workspace metadata that terraform was run again, injecting the new owner via agent environment,
+ // and the agent picked this up and reinitialized with a new environment.
+ const owner = page.getByText("Owner");
+ await owner.waitFor({ timeout: 60_000 });
+ const ownerTxt = owner.locator("xpath=..").getByText(users.member.email);
+ await ownerTxt.waitFor({ timeout: 30_000 });
+
+ // Logout as unprivileged user, and login as admin.
+ await login(page, users.admin);
+
+ // Navigate back to prebuilds page to see that a new prebuild replaced the claimed one.
+ await page.goto(
+ `/workspaces?filter=owner:prebuilds%20template:${templateName}&page=1`,
+ { waitUntil: "domcontentloaded" },
+ );
+
+ // Wait for prebuilds to show up.
+ const newPrebuilds = page.getByTestId(/^workspace-.+$/);
+ await waitForExpectedCount(newPrebuilds, expectedPrebuilds);
+
+ const currentWorkspaceNames = await Promise.all(
+ (await newPrebuilds.all()).map((value) => {
+ return value.getByText(/prebuild-.+/).textContent();
+ }),
+ );
+
+ // Ensure the prebuilds have changed.
+ expect(currentWorkspaceNames).not.toEqual(previousWorkspaceNames);
+
+ // Wait for prebuilds to start.
+ runningPrebuilds = runningPrebuildsLocator(page);
+ await waitForExpectedCount(runningPrebuilds, expectedPrebuilds);
+});
+
+function runningPrebuildsLocator(page: Page): Locator {
+ return page.locator(".build-status").getByText("Running");
+}
+
+function waitForExpectedCount(prebuilds: Locator, expectedCount: number) {
+ return expect
+ .poll(
+ async () => {
+ return (await prebuilds.all()).length === expectedCount;
+ },
+ {
+ intervals: [100],
+ timeout: waitForBuildTimeout,
+ },
+ )
+ .toBe(true);
+}
diff --git a/site/e2e/tests/presets/presets.spec.ts b/site/e2e/tests/presets/presets.spec.ts
new file mode 100644
index 0000000000000..85266d281d101
--- /dev/null
+++ b/site/e2e/tests/presets/presets.spec.ts
@@ -0,0 +1,59 @@
+import path from "node:path";
+import { expect, test } from "@playwright/test";
+import { currentUser, importTemplate, login, randomName } from "../../helpers";
+import { beforeCoderTest } from "../../hooks";
+
+test.beforeEach(async ({ page }) => {
+ beforeCoderTest(page);
+ await login(page);
+});
+
+test("create template with preset and use in workspace", async ({
+ page,
+ baseURL,
+}) => {
+ test.setTimeout(300_000);
+
+ // Create new template.
+ const templateName = randomName();
+ await importTemplate(page, templateName, [
+ path.join(__dirname, "basic-presets/main.tf"),
+ path.join(__dirname, "basic-presets/.terraform.lock.hcl"),
+ ]);
+
+ // Visit workspace creation page for new template.
+ await page.goto(`/templates/default/${templateName}/workspace`, {
+ waitUntil: "domcontentloaded",
+ });
+
+ await page.locator('button[aria-label="Preset"]').click();
+
+ const preset1 = page.getByText("I Like GoLand");
+ const preset2 = page.getByText("Some Like PyCharm");
+
+ await expect(preset1).toBeVisible();
+ await expect(preset2).toBeVisible();
+
+ // Choose the GoLand preset.
+ await preset1.click();
+
+ // Validate the preset was applied correctly.
+ await expect(page.locator('input[value="GO"]')).toBeChecked();
+
+ // Create a workspace.
+ const workspaceName = randomName();
+ await page.locator("input[name=name]").fill(workspaceName);
+ await page.getByRole("button", { name: "Create workspace" }).click();
+
+ // Wait for the workspace build display to be navigated to.
+ const user = currentUser(page);
+ await page.waitForURL(`/@${user.username}/${workspaceName}`, {
+ timeout: 120_000, // Account for workspace build time.
+ });
+
+ // Visit workspace settings page.
+ await page.goto(`/@${user.username}/${workspaceName}/settings/parameters`);
+
+ // Validate the preset was applied correctly.
+ await expect(page.locator('input[value="GO"]')).toBeChecked();
+});
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 025ed9f1933cf..0903bfbe83d43 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -467,6 +467,7 @@ export interface CreateWorkspaceRequest {
readonly rich_parameter_values?: readonly WorkspaceBuildParameter[];
readonly automatic_updates?: AutomaticUpdates;
readonly template_version_preset_id?: string;
+ readonly claim_prebuild_if_available?: boolean;
readonly enable_dynamic_parameters?: boolean;
}
@@ -690,6 +691,7 @@ export interface DeploymentValues {
readonly terms_of_service_url?: string;
readonly notifications?: NotificationsConfig;
readonly additional_csp_policy?: string;
+ readonly workspace_prebuilds?: PrebuildsConfig;
readonly workspace_hostname_suffix?: string;
readonly config?: string;
readonly write_config?: boolean;
@@ -773,6 +775,7 @@ export type Experiment =
| "example"
| "notifications"
| "web-push"
+ | "workspace-prebuilds"
| "workspace-usage";
// From codersdk/deployment.go
@@ -887,6 +890,7 @@ export type FeatureName =
| "user_limit"
| "user_role_management"
| "workspace_batch_actions"
+ | "workspace_prebuilds"
| "workspace_proxy";
export const FeatureNames: FeatureName[] = [
@@ -907,6 +911,7 @@ export const FeatureNames: FeatureName[] = [
"user_limit",
"user_role_management",
"workspace_batch_actions",
+ "workspace_prebuilds",
"workspace_proxy",
];
diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx
index a9d585fccf58c..a3a64c3e090ef 100644
--- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx
+++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx
@@ -376,7 +376,7 @@ const WorkspaceStatusCell: FC = ({ workspace }) => {
return (
-