mirror of
https://github.com/coder/coder.git
synced 2026-06-06 14:38:23 +00:00
ca234f346d
## Description - Updates `wsbuilder` to return a `BuildError` with `http.StatusBadRequest` to signify a "validation error" on missing or invalid parameters - Adds a short-circuit in `prebuilds.StoreReconciler` to mark presets for which creating a build returns a "validation error" as "validation failed" and skip further attempts to reconcile. - Adds a test to verify the above - Introduces a new Prometheus metric `coderd_prebuilt_workspaces_preset_validation_failed` to track the above Closes: https://github.com/coder/coder/issues/21237 --------- Co-authored-by: Cian Johnston <cian@coder.com>
1322 lines
49 KiB
Go
1322 lines
49 KiB
Go
package prebuilds
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/hashicorp/go-multierror"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
|
|
"github.com/coder/coder/v2/coderd/database/pubsub"
|
|
"github.com/coder/coder/v2/coderd/files"
|
|
"github.com/coder/coder/v2/coderd/notifications"
|
|
"github.com/coder/coder/v2/coderd/prebuilds"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/coderd/tracing"
|
|
"github.com/coder/coder/v2/coderd/wsbuilder"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
type StoreReconciler struct {
|
|
store database.Store
|
|
cfg codersdk.PrebuildsConfig
|
|
pubsub pubsub.Pubsub
|
|
fileCache *files.Cache
|
|
logger slog.Logger
|
|
clock quartz.Clock
|
|
registerer prometheus.Registerer
|
|
notifEnq notifications.Enqueuer
|
|
buildUsageChecker *atomic.Pointer[wsbuilder.UsageChecker]
|
|
tracer trace.Tracer
|
|
|
|
// mu protects the reconciler's lifecycle state.
|
|
mu sync.Mutex
|
|
running bool
|
|
stopped bool
|
|
cancelFn context.CancelCauseFunc
|
|
|
|
done chan struct{}
|
|
provisionNotifyCh chan database.ProvisionerJob
|
|
|
|
reconciliationConcurrency int
|
|
|
|
// Prebuild state metrics
|
|
metrics *MetricsCollector
|
|
// Operational metrics
|
|
reconciliationDuration prometheus.Histogram
|
|
workspaceBuilderMetrics *wsbuilder.Metrics
|
|
}
|
|
|
|
var _ prebuilds.ReconciliationOrchestrator = &StoreReconciler{}
|
|
|
|
type DeprovisionMode int
|
|
|
|
const (
|
|
DeprovisionModeNormal DeprovisionMode = iota
|
|
DeprovisionModeOrphan
|
|
)
|
|
|
|
func (d DeprovisionMode) String() string {
|
|
switch d {
|
|
case DeprovisionModeOrphan:
|
|
return "orphan"
|
|
case DeprovisionModeNormal:
|
|
return "normal"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func NewStoreReconciler(store database.Store,
|
|
ps pubsub.Pubsub,
|
|
fileCache *files.Cache,
|
|
cfg codersdk.PrebuildsConfig,
|
|
logger slog.Logger,
|
|
clock quartz.Clock,
|
|
registerer prometheus.Registerer,
|
|
notifEnq notifications.Enqueuer,
|
|
buildUsageChecker *atomic.Pointer[wsbuilder.UsageChecker],
|
|
tracerProvider trace.TracerProvider,
|
|
maxDBConnections int,
|
|
workspaceBuilderMetrics *wsbuilder.Metrics,
|
|
) *StoreReconciler {
|
|
reconciliationConcurrency := calculateReconciliationConcurrency(maxDBConnections)
|
|
|
|
logger.Debug(context.Background(), "reconciler initialized",
|
|
slog.F("reconciliation_concurrency", reconciliationConcurrency),
|
|
slog.F("max_db_connections", maxDBConnections))
|
|
|
|
reconciler := &StoreReconciler{
|
|
store: store,
|
|
pubsub: ps,
|
|
fileCache: fileCache,
|
|
logger: logger,
|
|
cfg: cfg,
|
|
clock: clock,
|
|
registerer: registerer,
|
|
notifEnq: notifEnq,
|
|
buildUsageChecker: buildUsageChecker,
|
|
tracer: tracerProvider.Tracer(tracing.TracerName),
|
|
done: make(chan struct{}, 1),
|
|
provisionNotifyCh: make(chan database.ProvisionerJob, 10),
|
|
reconciliationConcurrency: reconciliationConcurrency,
|
|
workspaceBuilderMetrics: workspaceBuilderMetrics,
|
|
}
|
|
|
|
if registerer != nil {
|
|
reconciler.metrics = NewMetricsCollector(store, logger, reconciler)
|
|
if err := registerer.Register(reconciler.metrics); err != nil {
|
|
// If the registerer fails to register the metrics collector, it's not fatal.
|
|
logger.Error(context.Background(), "failed to register prometheus metrics", slog.Error(err))
|
|
}
|
|
|
|
factory := promauto.With(registerer)
|
|
reconciler.reconciliationDuration = factory.NewHistogram(prometheus.HistogramOpts{
|
|
Namespace: "coderd",
|
|
Subsystem: "prebuilds",
|
|
Name: "reconciliation_duration_seconds",
|
|
Help: "Duration of each prebuilds reconciliation cycle.",
|
|
Buckets: prometheus.DefBuckets,
|
|
})
|
|
}
|
|
|
|
return reconciler
|
|
}
|
|
|
|
// calculateReconciliationConcurrency determines the number of concurrent
|
|
// goroutines for preset reconciliation. Each preset may perform multiple
|
|
// database operations (creates/deletes), so we limit concurrency to avoid
|
|
// exhausting the connection pool while maintaining reasonable parallelism.
|
|
//
|
|
// Uses half the pool size, with a minimum of 1 and a maximum of 5.
|
|
// TODO(ssncferreira): If this becomes a bottleneck, consider adding a configuration option.
|
|
func calculateReconciliationConcurrency(maxDBConnections int) int {
|
|
if maxDBConnections <= 0 {
|
|
return 1
|
|
}
|
|
|
|
concurrency := maxDBConnections / 2
|
|
if concurrency < 1 {
|
|
return 1
|
|
}
|
|
if concurrency > 5 {
|
|
return 5
|
|
}
|
|
|
|
return concurrency
|
|
}
|
|
|
|
func (c *StoreReconciler) Run(ctx context.Context) {
|
|
reconciliationInterval := c.cfg.ReconciliationInterval.Value()
|
|
if reconciliationInterval <= 0 { // avoids a panic
|
|
reconciliationInterval = 5 * time.Minute
|
|
}
|
|
|
|
c.logger.Info(ctx, "starting reconciler",
|
|
slog.F("interval", reconciliationInterval),
|
|
slog.F("backoff_interval", c.cfg.ReconciliationBackoffInterval.String()),
|
|
slog.F("backoff_lookback", c.cfg.ReconciliationBackoffLookback.String()),
|
|
slog.F("preset_concurrency", c.reconciliationConcurrency))
|
|
|
|
// Create a child context that will be canceled when:
|
|
// 1. The parent context is canceled, OR
|
|
// 2. c.cancelFn() is called to trigger shutdown
|
|
// nolint:gocritic // Reconciliation Loop needs Prebuilds Orchestrator permissions.
|
|
ctx, cancel := context.WithCancelCause(dbauthz.AsPrebuildsOrchestrator(ctx))
|
|
|
|
// If the reconciler was already stopped, exit early and release the context.
|
|
// Otherwise, mark it as running and store the cancel function for shutdown.
|
|
c.mu.Lock()
|
|
if c.stopped || c.running {
|
|
c.mu.Unlock()
|
|
cancel(nil)
|
|
return
|
|
}
|
|
c.running = true
|
|
c.cancelFn = cancel
|
|
c.mu.Unlock()
|
|
|
|
ticker := c.clock.NewTicker(reconciliationInterval)
|
|
defer ticker.Stop()
|
|
// Wait for all background goroutines to exit before signaling completion.
|
|
var wg sync.WaitGroup
|
|
defer func() {
|
|
wg.Wait()
|
|
c.done <- struct{}{}
|
|
}()
|
|
|
|
// Start updating metrics in the background.
|
|
if c.metrics != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
c.metrics.BackgroundFetch(ctx, metricsUpdateInterval, metricsUpdateTimeout)
|
|
}()
|
|
}
|
|
|
|
// Publish provisioning jobs outside of database transactions.
|
|
// A connection is held while a database transaction is active; PGPubsub also tries to acquire a new connection on
|
|
// Publish, so we can exhaust available connections.
|
|
//
|
|
// A single worker dequeues from the channel, which should be sufficient.
|
|
// If any messages are missed due to congestion or errors, provisionerdserver has a backup polling mechanism which
|
|
// will periodically pick up any queued jobs (see poll(time.Duration) in coderd/provisionerdserver/acquirer.go).
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case job := <-c.provisionNotifyCh:
|
|
err := provisionerjobs.PostJob(c.pubsub, job)
|
|
if err != nil {
|
|
c.logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err))
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
// TODO: implement pubsub listener to allow reconciling a specific template imperatively once it has been changed,
|
|
// instead of waiting for the next reconciliation interval
|
|
case <-ticker.C:
|
|
// Trigger a new iteration on each tick.
|
|
stats, err := c.ReconcileAll(ctx)
|
|
if err != nil {
|
|
c.logger.Error(context.Background(), "reconciliation failed", slog.Error(err))
|
|
}
|
|
|
|
if c.reconciliationDuration != nil {
|
|
c.reconciliationDuration.Observe(stats.Elapsed.Seconds())
|
|
}
|
|
c.logger.Info(ctx, "reconciliation stats",
|
|
slog.F("elapsed", stats.Elapsed),
|
|
slog.F("presets_total", stats.PresetsTotal),
|
|
slog.F("presets_reconciled", stats.PresetsReconciled),
|
|
)
|
|
case <-ctx.Done():
|
|
// nolint:gocritic // it's okay to use slog.F() for an error in this case
|
|
// because we want to differentiate two different types of errors: ctx.Err() and context.Cause()
|
|
c.logger.Warn(
|
|
context.Background(),
|
|
"reconciliation loop exited",
|
|
slog.Error(ctx.Err()),
|
|
slog.F("cause", context.Cause(ctx)),
|
|
)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop triggers reconciler shutdown and waits for it to complete.
|
|
// The ctx parameter provides a timeout, if cleanup doesn't finish within
|
|
// this timeout, Stop() logs an error and returns.
|
|
func (c *StoreReconciler) Stop(ctx context.Context, cause error) {
|
|
if cause != nil {
|
|
c.logger.Info(context.Background(), "stopping reconciler", slog.F("cause", cause.Error()))
|
|
} else {
|
|
c.logger.Info(context.Background(), "stopping reconciler")
|
|
}
|
|
|
|
// Mark the reconciler as stopped. If it was already stopped, return early.
|
|
// If the reconciler is running, we'll proceed to shut it down.
|
|
//
|
|
// NOTE: we need to *prospectively* mark this as stopped to prevent the
|
|
// reconciler from being stopped multiple times and causing problems.
|
|
c.mu.Lock()
|
|
if c.stopped {
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
c.stopped = true
|
|
running := c.running
|
|
c.mu.Unlock()
|
|
|
|
// Unregister prebuilds state and operational metrics.
|
|
if c.metrics != nil && c.registerer != nil {
|
|
if !c.registerer.Unregister(c.metrics) {
|
|
// The API doesn't allow us to know why the de-registration failed, but it's not very consequential.
|
|
// The only time this would be an issue is if the premium license is removed, leading to the feature being
|
|
// disabled (and consequently this Stop method being called), and then adding a new license which enables the
|
|
// feature again. If the metrics cannot be registered, it'll log an error from NewStoreReconciler.
|
|
c.logger.Warn(context.Background(), "failed to unregister metrics collector")
|
|
}
|
|
if c.reconciliationDuration != nil {
|
|
if !c.registerer.Unregister(c.reconciliationDuration) {
|
|
c.logger.Warn(context.Background(), "failed to unregister reconciliation duration histogram")
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the reconciler is not running, there's nothing else to do.
|
|
if !running {
|
|
return
|
|
}
|
|
|
|
// Trigger reconciler shutdown by canceling its internal context.
|
|
if c.cancelFn != nil {
|
|
c.cancelFn(cause)
|
|
}
|
|
|
|
// Wait for the reconciler to signal that it has fully exited and cleaned up.
|
|
select {
|
|
// Timeout: reconciler didn't finish cleanup within the timeout period.
|
|
case <-ctx.Done():
|
|
// nolint:gocritic // it's okay to use slog.F() for an error in this case
|
|
// because we want to differentiate two different types of errors: ctx.Err() and context.Cause()
|
|
c.logger.Error(
|
|
context.Background(),
|
|
"reconciler stop exited prematurely",
|
|
slog.Error(ctx.Err()),
|
|
slog.F("cause", context.Cause(ctx)),
|
|
)
|
|
// Happy path: reconciler has successfully exited.
|
|
case <-c.done:
|
|
c.logger.Info(context.Background(), "reconciler stopped")
|
|
}
|
|
}
|
|
|
|
// ReconcileAll attempts to reconcile the desired vs actual state of all prebuilds for each
|
|
// (organization, template, template version, preset) tuple.
|
|
//
|
|
// The result is a set of provisioning actions for each preset. These actions are fire-and-forget:
|
|
// the reconciliation loop does not wait for prebuilt workspaces to complete provisioning.
|
|
//
|
|
// An outer read-only transaction holds an advisory lock ensuring only one replica reconciles at a time.
|
|
// This transaction remains open throughout the entire reconciliation cycle. Goroutines responsible for
|
|
// preset reconciliation use separate, independent write transactions (via c.store). In the rare case
|
|
// of the lock transaction failing mid-reconciliation, goroutines may continue while another replica
|
|
// acquires the lock, potentially causing temporary under/over-provisioning. Since the reconciliation
|
|
// loop is eventually consistent, subsequent cycles will converge to the desired state.
|
|
//
|
|
// NOTE: Read operations must use db (the lock transaction) while write operations must use c.store.
|
|
func (c *StoreReconciler) ReconcileAll(ctx context.Context) (stats prebuilds.ReconcileStats, err error) {
|
|
ctx, span := c.tracer.Start(ctx, "prebuilds.ReconcileAll")
|
|
defer span.End()
|
|
|
|
start := c.clock.Now()
|
|
defer func() {
|
|
stats.Elapsed = c.clock.Since(start)
|
|
}()
|
|
|
|
logger := c.logger.With(slog.F("reconcile_context", "all"))
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
logger.Warn(context.Background(), "reconcile exiting prematurely; context done", slog.Error(ctx.Err()))
|
|
return stats, nil
|
|
default:
|
|
}
|
|
|
|
logger.Debug(ctx, "starting reconciliation")
|
|
|
|
err = c.WithReconciliationLock(ctx, logger, func(ctx context.Context, db database.Store) error {
|
|
// Check if prebuilds reconciliation is paused
|
|
// Use db (lock tx) for read-only operations
|
|
settingsJSON, err := db.GetPrebuildsSettings(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("get prebuilds settings: %w", err)
|
|
}
|
|
|
|
var settings codersdk.PrebuildsSettings
|
|
if len(settingsJSON) > 0 {
|
|
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
|
|
return xerrors.Errorf("unmarshal prebuilds settings: %w", err)
|
|
}
|
|
}
|
|
|
|
if c.metrics != nil {
|
|
c.metrics.setReconciliationPaused(settings.ReconciliationPaused)
|
|
}
|
|
|
|
if settings.ReconciliationPaused {
|
|
logger.Info(ctx, "prebuilds reconciliation is paused, skipping reconciliation")
|
|
return nil
|
|
}
|
|
|
|
// MembershipReconciler performs write operations, therefore it needs to use c.store
|
|
// directly, since the lock transaction db is read-only.
|
|
membershipReconciler := NewStoreMembershipReconciler(c.store, c.clock, logger)
|
|
err = membershipReconciler.ReconcileAll(ctx, database.PrebuildsSystemUserID, PrebuiltWorkspacesGroupName)
|
|
if err != nil {
|
|
return xerrors.Errorf("reconcile prebuild membership: %w", err)
|
|
}
|
|
|
|
// Use db (lock tx) for read-only operations
|
|
snapshot, err := c.SnapshotState(ctx, db)
|
|
if err != nil {
|
|
return xerrors.Errorf("determine current snapshot: %w", err)
|
|
}
|
|
|
|
c.reportHardLimitedPresets(snapshot)
|
|
c.reportValidationFailedPresets(snapshot)
|
|
|
|
if len(snapshot.Presets) == 0 {
|
|
logger.Debug(ctx, "no templates found with prebuilds configured")
|
|
return nil
|
|
}
|
|
|
|
var eg errgroup.Group
|
|
// Limit concurrency to avoid exhausting the coderd database connection pool.
|
|
eg.SetLimit(c.reconciliationConcurrency)
|
|
|
|
presetsReconciled := 0
|
|
|
|
// Reconcile presets in parallel. Each preset in its own goroutine.
|
|
for _, preset := range snapshot.Presets {
|
|
ps, err := snapshot.FilterByPreset(preset.ID)
|
|
if err != nil {
|
|
logger.Warn(ctx, "failed to find preset snapshot", slog.Error(err), slog.F("preset_id", preset.ID.String()))
|
|
continue
|
|
}
|
|
|
|
// Performance optimization: Skip presets that won't need any database operations.
|
|
// This avoids holding a slot in the errgroup limiter, reserving capacity for
|
|
// presets that actually need database connections.
|
|
if ps.CanSkipReconciliation() {
|
|
continue
|
|
}
|
|
|
|
presetsReconciled++
|
|
|
|
eg.Go(func() error {
|
|
// Pass outer context.
|
|
err = c.ReconcilePreset(ctx, *ps)
|
|
if err != nil {
|
|
logger.Error(
|
|
ctx,
|
|
"failed to reconcile prebuilds for preset",
|
|
slog.Error(err),
|
|
slog.F("preset_id", preset.ID),
|
|
)
|
|
}
|
|
// DO NOT return error otherwise the tx will end.
|
|
return nil
|
|
})
|
|
}
|
|
|
|
stats.PresetsTotal = len(snapshot.Presets)
|
|
stats.PresetsReconciled = presetsReconciled
|
|
|
|
// Release lock only when all preset reconciliation goroutines are finished.
|
|
return eg.Wait()
|
|
})
|
|
if err != nil {
|
|
logger.Error(ctx, "failed to reconcile", slog.Error(err))
|
|
}
|
|
|
|
return stats, err
|
|
}
|
|
|
|
func (c *StoreReconciler) reportHardLimitedPresets(snapshot *prebuilds.GlobalSnapshot) {
|
|
// presetsMap is a map from key (orgName:templateName:presetName) to list of corresponding presets.
|
|
// Multiple versions of a preset can exist with the same orgName, templateName, and presetName,
|
|
// because templates can have multiple versions — or deleted templates can share the same name.
|
|
presetsMap := make(map[hardLimitedPresetKey][]database.GetTemplatePresetsWithPrebuildsRow)
|
|
for _, preset := range snapshot.Presets {
|
|
key := hardLimitedPresetKey{
|
|
orgName: preset.OrganizationName,
|
|
templateName: preset.TemplateName,
|
|
presetName: preset.Name,
|
|
}
|
|
|
|
presetsMap[key] = append(presetsMap[key], preset)
|
|
}
|
|
|
|
// Report a preset as hard-limited only if all the following conditions are met:
|
|
// - The preset is marked as hard-limited
|
|
// - The preset is using the active version of its template, and the template has not been deleted
|
|
//
|
|
// The second condition is important because a hard-limited preset that has become outdated is no longer relevant.
|
|
// Its associated prebuilt workspaces were likely deleted, and it's not meaningful to continue reporting it
|
|
// as hard-limited to the admin.
|
|
//
|
|
// This approach accounts for all relevant scenarios:
|
|
// Scenario #1: The admin created a new template version with the same preset names.
|
|
// Scenario #2: The admin created a new template version and renamed the presets.
|
|
// Scenario #3: The admin deleted a template version that contained hard-limited presets.
|
|
//
|
|
// In all of these cases, only the latest and non-deleted presets will be reported.
|
|
// All other presets will be ignored and eventually removed from Prometheus.
|
|
isPresetHardLimited := make(map[hardLimitedPresetKey]bool)
|
|
for key, presets := range presetsMap {
|
|
for _, preset := range presets {
|
|
if preset.UsingActiveVersion && !preset.Deleted && snapshot.IsHardLimited(preset.ID) {
|
|
isPresetHardLimited[key] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
c.metrics.registerHardLimitedPresets(isPresetHardLimited)
|
|
}
|
|
|
|
func (c *StoreReconciler) reportValidationFailedPresets(snapshot *prebuilds.GlobalSnapshot) {
|
|
// presetsMap is a map from key (orgName:templateName:presetName) to list of corresponding presets.
|
|
// Multiple versions of a preset can exist with the same orgName, templateName, and presetName,
|
|
// because templates can have multiple versions - or deleted templates can share the same name.
|
|
presetsMap := make(map[hardLimitedPresetKey][]database.GetTemplatePresetsWithPrebuildsRow)
|
|
for _, preset := range snapshot.Presets {
|
|
key := hardLimitedPresetKey{
|
|
orgName: preset.OrganizationName,
|
|
templateName: preset.TemplateName,
|
|
presetName: preset.Name,
|
|
}
|
|
|
|
presetsMap[key] = append(presetsMap[key], preset)
|
|
}
|
|
|
|
// Report a preset as validation-failed only if all the following conditions are met:
|
|
// - The preset has PrebuildStatus == PrebuildStatusValidationFailed
|
|
// - The preset is using the active version of its template, and the template has not been deleted
|
|
//
|
|
// The second condition is important because a validation-failed preset that has become outdated is no longer relevant.
|
|
// Its associated prebuilt workspaces were likely deleted, and it's not meaningful to continue reporting it
|
|
// as validation-failed to the admin.
|
|
isPresetValidationFailed := make(map[hardLimitedPresetKey]bool)
|
|
for key, presets := range presetsMap {
|
|
for _, preset := range presets {
|
|
if preset.UsingActiveVersion && !preset.Deleted &&
|
|
preset.PrebuildStatus == database.PrebuildStatusValidationFailed {
|
|
isPresetValidationFailed[key] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
c.metrics.registerValidationFailedPresets(isPresetValidationFailed)
|
|
}
|
|
|
|
// SnapshotState captures the current state of all prebuilds across templates.
|
|
func (c *StoreReconciler) SnapshotState(ctx context.Context, store database.Store) (*prebuilds.GlobalSnapshot, error) {
|
|
ctx, span := c.tracer.Start(ctx, "prebuilds.SnapshotState")
|
|
defer span.End()
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var state prebuilds.GlobalSnapshot
|
|
|
|
// If called with a store that is already in a transaction,
|
|
// InTx will reuse that transaction rather than creating a new one.
|
|
err := store.InTx(func(db database.Store) error {
|
|
// TODO: implement template-specific reconciliations later
|
|
presetsWithPrebuilds, err := db.GetTemplatePresetsWithPrebuilds(ctx, uuid.NullUUID{})
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to get template presets with prebuilds: %w", err)
|
|
}
|
|
if len(presetsWithPrebuilds) == 0 {
|
|
return nil
|
|
}
|
|
|
|
presetPrebuildSchedules, err := db.GetActivePresetPrebuildSchedules(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to get preset prebuild schedules: %w", err)
|
|
}
|
|
|
|
// Get results from both original and optimized queries for comparison
|
|
allRunningPrebuilds, err := db.GetRunningPrebuiltWorkspaces(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to get running prebuilds: %w", err)
|
|
}
|
|
|
|
allPrebuildsInProgress, err := db.CountInProgressPrebuilds(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to get prebuilds in progress: %w", err)
|
|
}
|
|
|
|
allPendingPrebuilds, err := db.CountPendingNonActivePrebuilds(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to get pending prebuilds: %w", err)
|
|
}
|
|
|
|
presetsBackoff, err := db.GetPresetsBackoff(ctx, c.clock.Now().Add(-c.cfg.ReconciliationBackoffLookback.Value()))
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to get backoffs for presets: %w", err)
|
|
}
|
|
|
|
hardLimitedPresets, err := db.GetPresetsAtFailureLimit(ctx, c.cfg.FailureHardLimit.Value())
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to get hard limited presets: %w", err)
|
|
}
|
|
|
|
state = prebuilds.NewGlobalSnapshot(
|
|
presetsWithPrebuilds,
|
|
presetPrebuildSchedules,
|
|
allRunningPrebuilds,
|
|
allPrebuildsInProgress,
|
|
allPendingPrebuilds,
|
|
presetsBackoff,
|
|
hardLimitedPresets,
|
|
c.clock,
|
|
c.logger,
|
|
)
|
|
return nil
|
|
}, &database.TxOptions{
|
|
Isolation: sql.LevelRepeatableRead, // This mirrors the MVCC snapshotting Postgres does when using CTEs
|
|
ReadOnly: true,
|
|
TxIdentifier: "prebuilds.SnapshotState",
|
|
})
|
|
|
|
return &state, err
|
|
}
|
|
|
|
func (c *StoreReconciler) ReconcilePreset(ctx context.Context, ps prebuilds.PresetSnapshot) error {
|
|
ctx, span := c.tracer.Start(ctx, "prebuilds.ReconcilePreset", trace.WithAttributes(
|
|
attribute.String("preset_id", ps.Preset.ID.String()),
|
|
attribute.String("preset_name", ps.Preset.Name),
|
|
attribute.String("template_id", ps.Preset.TemplateID.String()),
|
|
attribute.String("template_name", ps.Preset.TemplateName),
|
|
))
|
|
defer span.End()
|
|
|
|
logger := c.logger.With(
|
|
slog.F("template_id", ps.Preset.TemplateID.String()),
|
|
slog.F("template_name", ps.Preset.TemplateName),
|
|
slog.F("template_version_id", ps.Preset.TemplateVersionID),
|
|
slog.F("template_version_name", ps.Preset.TemplateVersionName),
|
|
slog.F("preset_id", ps.Preset.ID),
|
|
slog.F("preset_name", ps.Preset.Name),
|
|
)
|
|
|
|
// If the preset reached the hard failure limit for the first time during this iteration:
|
|
// - Mark it as hard-limited in the database
|
|
// - Continue execution, we disallow only creation operation for hard-limited presets. Deletion is allowed.
|
|
if ps.Preset.PrebuildStatus != database.PrebuildStatusHardLimited && ps.IsHardLimited {
|
|
logger.Warn(ctx, "preset is hard limited, notifying template admins")
|
|
|
|
err := c.store.UpdatePresetPrebuildStatus(ctx, database.UpdatePresetPrebuildStatusParams{
|
|
Status: database.PrebuildStatusHardLimited,
|
|
PresetID: ps.Preset.ID,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to update preset prebuild status: %w", err)
|
|
}
|
|
}
|
|
|
|
state := ps.CalculateState()
|
|
actions, err := c.CalculateActions(ctx, ps)
|
|
if err != nil {
|
|
logger.Error(ctx, "failed to calculate actions for preset", slog.Error(err))
|
|
return err
|
|
}
|
|
|
|
fields := []slog.Field{
|
|
slog.F("desired", state.Desired), slog.F("actual", state.Actual),
|
|
slog.F("extraneous", state.Extraneous), slog.F("starting", state.Starting),
|
|
slog.F("stopping", state.Stopping), slog.F("deleting", state.Deleting),
|
|
slog.F("eligible", state.Eligible),
|
|
}
|
|
|
|
levelFn := logger.Debug
|
|
levelFn(ctx, "calculated reconciliation state for preset", fields...)
|
|
|
|
var multiErr multierror.Error
|
|
for _, action := range actions {
|
|
err = c.executeReconciliationAction(ctx, logger, ps, action)
|
|
if err != nil {
|
|
logger.Error(ctx, "failed to execute action", slog.F("type", action.ActionType), slog.Error(err))
|
|
multiErr.Errors = append(multiErr.Errors, err)
|
|
}
|
|
}
|
|
return multiErr.ErrorOrNil()
|
|
}
|
|
|
|
func (c *StoreReconciler) CalculateActions(ctx context.Context, snapshot prebuilds.PresetSnapshot) ([]*prebuilds.ReconciliationActions, error) {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
return snapshot.CalculateActions(c.cfg.ReconciliationBackoffInterval.Value())
|
|
}
|
|
|
|
func (c *StoreReconciler) WithReconciliationLock(
|
|
ctx context.Context,
|
|
logger slog.Logger,
|
|
fn func(ctx context.Context, db database.Store) error,
|
|
) error {
|
|
ctx, span := c.tracer.Start(ctx, "prebuilds.WithReconciliationLock")
|
|
defer span.End()
|
|
|
|
// This tx holds a global lock, which prevents any other coderd replica from starting a reconciliation and
|
|
// possibly getting an inconsistent view of the state.
|
|
//
|
|
// The lock MUST be held until ALL modifications have been effected.
|
|
//
|
|
// It is run with RepeatableRead isolation, so it's effectively snapshotting the data at the start of the tx.
|
|
//
|
|
// This is a read-only tx, so returning an error (i.e. causing a rollback) has no impact.
|
|
return c.store.InTx(func(db database.Store) error {
|
|
start := c.clock.Now()
|
|
|
|
// Try to acquire the lock. If we can't get it, another replica is handling reconciliation.
|
|
acquired, err := db.TryAcquireLock(ctx, database.LockIDReconcilePrebuilds)
|
|
if err != nil {
|
|
// This is a real database error, not just lock contention
|
|
logger.Error(ctx, "failed to acquire reconciliation lock due to database error", slog.Error(err))
|
|
return err
|
|
}
|
|
if !acquired {
|
|
// Normal case: another replica has the lock
|
|
span.SetAttributes(attribute.Bool("lock_acquired", false))
|
|
return nil
|
|
}
|
|
span.SetAttributes(attribute.Bool("lock_acquired", true))
|
|
|
|
logger.Debug(ctx,
|
|
"acquired top-level reconciliation lock",
|
|
slog.F("acquire_wait_secs", fmt.Sprintf("%.4f", c.clock.Since(start).Seconds())),
|
|
)
|
|
|
|
return fn(ctx, db)
|
|
}, &database.TxOptions{
|
|
Isolation: sql.LevelRepeatableRead,
|
|
ReadOnly: true,
|
|
TxIdentifier: "prebuilds.WithReconciliationLock",
|
|
})
|
|
}
|
|
|
|
// executeReconciliationAction executes a reconciliation action on the given preset snapshot.
|
|
//
|
|
// The action can be of different types (create, delete, backoff), and may internally include
|
|
// multiple items to process, for example, a delete action can contain multiple prebuild IDs to delete,
|
|
// and a create action includes a count of prebuilds to create.
|
|
//
|
|
// This method handles logging at appropriate levels and performs the necessary operations
|
|
// according to the action type. It returns an error if any part of the action fails.
|
|
func (c *StoreReconciler) executeReconciliationAction(ctx context.Context, logger slog.Logger, ps prebuilds.PresetSnapshot, action *prebuilds.ReconciliationActions) error {
|
|
ctx, span := c.tracer.Start(ctx, "prebuilds.executeReconciliationAction", trace.WithAttributes(
|
|
attribute.Int("action_type", int(action.ActionType)),
|
|
attribute.Int("create_count", int(action.Create)),
|
|
attribute.Int("delete_count", len(action.DeleteIDs)),
|
|
))
|
|
defer span.End()
|
|
|
|
levelFn := logger.Debug
|
|
|
|
// Nothing has to be done.
|
|
if !ps.Preset.UsingActiveVersion && action.IsNoop() {
|
|
logger.Debug(ctx, "skipping reconciliation for preset - nothing has to be done",
|
|
slog.F("template_id", ps.Preset.TemplateID.String()), slog.F("template_name", ps.Preset.TemplateName),
|
|
slog.F("template_version_id", ps.Preset.TemplateVersionID.String()), slog.F("template_version_name", ps.Preset.TemplateVersionName),
|
|
slog.F("preset_id", ps.Preset.ID.String()), slog.F("preset_name", ps.Preset.Name))
|
|
return nil
|
|
}
|
|
|
|
// nolint:gocritic // ReconcilePreset needs Prebuilds Orchestrator permissions.
|
|
prebuildsCtx := dbauthz.AsPrebuildsOrchestrator(ctx)
|
|
|
|
fields := []slog.Field{
|
|
slog.F("action_type", action.ActionType), slog.F("create_count", action.Create),
|
|
slog.F("delete_count", len(action.DeleteIDs)), slog.F("to_delete", action.DeleteIDs),
|
|
}
|
|
levelFn(ctx, "calculated reconciliation action for preset", fields...)
|
|
|
|
switch {
|
|
case action.ActionType == prebuilds.ActionTypeBackoff:
|
|
levelFn = logger.Warn
|
|
// Log at info level when there's a change to be effected.
|
|
case action.ActionType == prebuilds.ActionTypeCreate && action.Create > 0:
|
|
levelFn = logger.Info
|
|
case action.ActionType == prebuilds.ActionTypeDelete && len(action.DeleteIDs) > 0:
|
|
levelFn = logger.Info
|
|
case action.ActionType == prebuilds.ActionTypeCancelPending:
|
|
levelFn = logger.Info
|
|
}
|
|
|
|
switch action.ActionType {
|
|
case prebuilds.ActionTypeBackoff:
|
|
// If there is anything to backoff for (usually a cycle of failed prebuilds), then log and bail out.
|
|
levelFn(ctx, "template prebuild state retrieved, backing off",
|
|
append(fields,
|
|
slog.F("backoff_until", action.BackoffUntil.Format(time.RFC3339)),
|
|
slog.F("backoff_secs", math.Round(action.BackoffUntil.Sub(c.clock.Now()).Seconds())),
|
|
)...)
|
|
|
|
return nil
|
|
|
|
case prebuilds.ActionTypeCreate:
|
|
// Unexpected things happen (i.e. bugs or bitflips); let's defend against disastrous outcomes.
|
|
// See https://blog.robertelder.org/causes-of-bit-flips-in-computer-memory/.
|
|
// This is obviously not comprehensive protection against this sort of problem, but this is one essential check.
|
|
desired := ps.CalculateDesiredInstances(c.clock.Now())
|
|
|
|
if action.Create > desired {
|
|
logger.Critical(ctx, "determined excessive count of prebuilds to create; clamping to desired count",
|
|
slog.F("create_count", action.Create), slog.F("desired_count", desired))
|
|
|
|
action.Create = desired
|
|
}
|
|
|
|
// If preset is hard-limited, and it's a create operation, log it and exit early.
|
|
// Creation operation is disallowed for hard-limited preset.
|
|
if ps.IsHardLimited && action.Create > 0 {
|
|
logger.Warn(ctx, "skipping hard limited preset for create operation")
|
|
return nil
|
|
}
|
|
|
|
// If preset previously failed validation (e.g. missing required parameter,
|
|
// invalid workspace tags), skip creation until the template version is updated.
|
|
// The status resets naturally when a new template version is promoted, since
|
|
// new presets are created with the default 'healthy' status.
|
|
if ps.Preset.PrebuildStatus == database.PrebuildStatusValidationFailed && action.Create > 0 {
|
|
logger.Warn(ctx, "skipping preset with validation failure for create operation")
|
|
return nil
|
|
}
|
|
|
|
var multiErr multierror.Error
|
|
for range action.Create {
|
|
if err := c.createPrebuiltWorkspace(prebuildsCtx, uuid.New(), ps.Preset.TemplateID, ps.Preset.ID); err != nil {
|
|
logger.Error(ctx, "failed to create prebuild", slog.Error(err))
|
|
multiErr.Errors = append(multiErr.Errors, err)
|
|
|
|
// A 400 BuildError means the build failed due to a validation error
|
|
// (e.g. missing parameter, invalid workspace tags). These errors are
|
|
// deterministic and will persist until the template is updated, so we
|
|
// mark the preset to prevent endless retries on every reconciliation loop.
|
|
var buildErr wsbuilder.BuildError
|
|
if xerrors.As(err, &buildErr) && buildErr.Status == http.StatusBadRequest {
|
|
logger.Warn(ctx, "marking preset as failed validation")
|
|
if dbErr := c.store.UpdatePresetPrebuildStatus(ctx, database.UpdatePresetPrebuildStatusParams{
|
|
Status: database.PrebuildStatusValidationFailed,
|
|
PresetID: ps.Preset.ID,
|
|
}); dbErr != nil {
|
|
logger.Error(ctx, "failed to update preset prebuild status", slog.Error(dbErr))
|
|
}
|
|
// All prebuilds for this preset will fail the same way, so stop trying.
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
|
|
case prebuilds.ActionTypeDelete:
|
|
var multiErr multierror.Error
|
|
for _, id := range action.DeleteIDs {
|
|
if err := c.deletePrebuiltWorkspace(prebuildsCtx, id, ps.Preset.TemplateID, ps.Preset.ID); err != nil {
|
|
logger.Error(ctx, "failed to delete prebuild", slog.Error(err))
|
|
multiErr.Errors = append(multiErr.Errors, err)
|
|
}
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
|
|
case prebuilds.ActionTypeCancelPending:
|
|
return c.cancelAndOrphanDeletePendingPrebuilds(ctx, ps.Preset.TemplateID, ps.Preset.TemplateVersionID, ps.Preset.ID)
|
|
|
|
default:
|
|
return xerrors.Errorf("unknown action type: %v", action.ActionType)
|
|
}
|
|
}
|
|
|
|
func (c *StoreReconciler) createPrebuiltWorkspace(ctx context.Context, prebuiltWorkspaceID uuid.UUID, templateID uuid.UUID, presetID uuid.UUID) error {
|
|
ctx, span := c.tracer.Start(ctx, "prebuilds.createPrebuiltWorkspace", trace.WithAttributes(
|
|
attribute.String("prebuild_id", prebuiltWorkspaceID.String()),
|
|
attribute.String("template_id", templateID.String()),
|
|
attribute.String("preset_id", presetID.String()),
|
|
))
|
|
defer span.End()
|
|
|
|
name, err := prebuilds.GenerateName()
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to generate unique prebuild ID: %w", err)
|
|
}
|
|
|
|
var provisionerJob *database.ProvisionerJob
|
|
err = c.store.InTx(func(db database.Store) error {
|
|
template, err := db.GetTemplateByID(ctx, templateID)
|
|
if err != nil {
|
|
return xerrors.Errorf("failed to get template: %w", err)
|
|
}
|
|
|
|
now := c.clock.Now()
|
|
|
|
minimumWorkspace, err := db.InsertWorkspace(ctx, database.InsertWorkspaceParams{
|
|
ID: prebuiltWorkspaceID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
OwnerID: database.PrebuildsSystemUserID,
|
|
OrganizationID: template.OrganizationID,
|
|
TemplateID: template.ID,
|
|
Name: name,
|
|
LastUsedAt: c.clock.Now(),
|
|
AutomaticUpdates: database.AutomaticUpdatesNever,
|
|
AutostartSchedule: sql.NullString{},
|
|
Ttl: sql.NullInt64{},
|
|
NextStartAt: sql.NullTime{},
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert workspace: %w", err)
|
|
}
|
|
|
|
// We have to refetch the workspace for the joined in fields.
|
|
workspace, err := db.GetWorkspaceByID(ctx, minimumWorkspace.ID)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace by ID: %w", err)
|
|
}
|
|
|
|
c.logger.Info(ctx, "attempting to create prebuild", slog.F("name", name),
|
|
slog.F("workspace_id", prebuiltWorkspaceID.String()), slog.F("preset_id", presetID.String()))
|
|
|
|
provisionerJob, err = c.provision(ctx, db, prebuiltWorkspaceID, template, presetID, database.WorkspaceTransitionStart, workspace, DeprovisionModeNormal)
|
|
return err
|
|
}, &database.TxOptions{
|
|
Isolation: sql.LevelRepeatableRead,
|
|
ReadOnly: false,
|
|
TxIdentifier: "prebuilds.createPrebuiltWorkspace",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Publish provisioner job event to notify the acquirer that a new job was posted
|
|
c.publishProvisionerJob(ctx, provisionerJob, prebuiltWorkspaceID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// provisionDelete provisions a delete transition for a prebuilt workspace.
|
|
//
|
|
// If mode is DeprovisionModeOrphan, the builder will not send Terraform state to the provisioner.
|
|
// This allows the workspace to be deleted even when no provisioners are available, and is safe
|
|
// when no Terraform resources were actually created (e.g., for pending prebuilds that were canceled
|
|
// before provisioning started).
|
|
//
|
|
// IMPORTANT: This function must be called within a database transaction. It does not create its own transaction.
|
|
// The caller is responsible for managing the transaction boundary via db.InTx().
|
|
func (c *StoreReconciler) provisionDelete(ctx context.Context, db database.Store, workspaceID uuid.UUID, templateID uuid.UUID, presetID uuid.UUID, mode DeprovisionMode) (*database.ProvisionerJob, error) {
|
|
workspace, err := db.GetWorkspaceByID(ctx, workspaceID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("get workspace by ID: %w", err)
|
|
}
|
|
|
|
template, err := db.GetTemplateByID(ctx, templateID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("failed to get template: %w", err)
|
|
}
|
|
|
|
if workspace.OwnerID != database.PrebuildsSystemUserID {
|
|
return nil, xerrors.Errorf("prebuilt workspace is not owned by prebuild user anymore, probably it was claimed")
|
|
}
|
|
|
|
c.logger.Info(ctx, "attempting to delete prebuild", slog.F("orphan", mode.String()),
|
|
slog.F("name", workspace.Name), slog.F("workspace_id", workspaceID.String()), slog.F("preset_id", presetID.String()))
|
|
|
|
return c.provision(ctx, db, workspaceID, template, presetID, database.WorkspaceTransitionDelete, workspace, mode)
|
|
}
|
|
|
|
// cancelAndOrphanDeletePendingPrebuilds cancels pending prebuild jobs from inactive template versions
|
|
// and orphan-deletes their associated workspaces.
|
|
//
|
|
// The cancel operation uses a criteria-based update to ensure only jobs that are still pending at
|
|
// execution time are canceled, avoiding race conditions where jobs may have transitioned to running.
|
|
//
|
|
// Since these jobs were never processed by a provisioner, no Terraform resources were created,
|
|
// making it safe to orphan-delete the workspaces (skipping Terraform destroy).
|
|
func (c *StoreReconciler) cancelAndOrphanDeletePendingPrebuilds(ctx context.Context, templateID uuid.UUID, templateVersionID uuid.UUID, presetID uuid.UUID) error {
|
|
ctx, span := c.tracer.Start(ctx, "prebuilds.cancelAndOrphanDeletePendingPrebuilds", trace.WithAttributes(
|
|
attribute.String("template_id", templateID.String()),
|
|
attribute.String("template_version_id", templateVersionID.String()),
|
|
attribute.String("preset_id", presetID.String()),
|
|
))
|
|
defer span.End()
|
|
|
|
var canceledProvisionerJob *database.ProvisionerJob
|
|
var canceledWorkspaceID uuid.UUID
|
|
err := c.store.InTx(func(db database.Store) error {
|
|
canceledJobs, err := db.UpdatePrebuildProvisionerJobWithCancel(
|
|
ctx,
|
|
database.UpdatePrebuildProvisionerJobWithCancelParams{
|
|
Now: c.clock.Now(),
|
|
PresetID: uuid.NullUUID{
|
|
UUID: presetID,
|
|
Valid: true,
|
|
},
|
|
})
|
|
if err != nil {
|
|
c.logger.Error(ctx, "failed to cancel pending prebuild jobs",
|
|
slog.F("template_id", templateID.String()),
|
|
slog.F("template_version_id", templateVersionID.String()),
|
|
slog.F("preset_id", presetID.String()),
|
|
slog.Error(err))
|
|
return err
|
|
}
|
|
|
|
if len(canceledJobs) > 0 {
|
|
c.logger.Info(ctx, "canceled pending prebuild jobs for inactive version",
|
|
slog.F("template_id", templateID.String()),
|
|
slog.F("template_version_id", templateVersionID.String()),
|
|
slog.F("preset_id", presetID.String()),
|
|
slog.F("count", len(canceledJobs)))
|
|
}
|
|
|
|
var multiErr multierror.Error
|
|
for _, job := range canceledJobs {
|
|
provisionerJob, err := c.provisionDelete(ctx, db, job.WorkspaceID, job.TemplateID, presetID, DeprovisionModeOrphan)
|
|
if err != nil {
|
|
c.logger.Error(ctx, "failed to orphan delete canceled prebuild",
|
|
slog.F("workspace_id", job.WorkspaceID.String()), slog.Error(err))
|
|
multiErr.Errors = append(multiErr.Errors, err)
|
|
} else if canceledProvisionerJob == nil {
|
|
canceledProvisionerJob = provisionerJob
|
|
canceledWorkspaceID = job.WorkspaceID
|
|
}
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
}, &database.TxOptions{
|
|
Isolation: sql.LevelRepeatableRead,
|
|
ReadOnly: false,
|
|
TxIdentifier: "prebuilds.cancelAndOrphanDeletePendingPrebuilds",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Job event notifications contain organization, provisioner type, and tags.
|
|
// Since all canceled jobs have the same values, we only send one notification
|
|
// for the first successfully canceled job, which is sufficient to trigger the
|
|
// provisioner chain that processes all remaining jobs.
|
|
if canceledProvisionerJob != nil {
|
|
c.publishProvisionerJob(ctx, canceledProvisionerJob, canceledWorkspaceID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *StoreReconciler) deletePrebuiltWorkspace(ctx context.Context, prebuiltWorkspaceID uuid.UUID, templateID uuid.UUID, presetID uuid.UUID) error {
|
|
ctx, span := c.tracer.Start(ctx, "prebuilds.deletePrebuiltWorkspace", trace.WithAttributes(
|
|
attribute.String("prebuild_id", prebuiltWorkspaceID.String()),
|
|
attribute.String("template_id", templateID.String()),
|
|
attribute.String("preset_id", presetID.String()),
|
|
))
|
|
defer span.End()
|
|
|
|
var provisionerJob *database.ProvisionerJob
|
|
err := c.store.InTx(func(db database.Store) (err error) {
|
|
provisionerJob, err = c.provisionDelete(ctx, db, prebuiltWorkspaceID, templateID, presetID, DeprovisionModeNormal)
|
|
return err
|
|
}, &database.TxOptions{
|
|
Isolation: sql.LevelRepeatableRead,
|
|
ReadOnly: false,
|
|
TxIdentifier: "prebuilds.deletePrebuiltWorkspace",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Publish provisioner job event to notify the acquirer that a new job was posted
|
|
c.publishProvisionerJob(ctx, provisionerJob, prebuiltWorkspaceID)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *StoreReconciler) provision(
|
|
ctx context.Context,
|
|
db database.Store,
|
|
prebuildID uuid.UUID,
|
|
template database.Template,
|
|
presetID uuid.UUID,
|
|
transition database.WorkspaceTransition,
|
|
workspace database.Workspace,
|
|
mode DeprovisionMode,
|
|
) (*database.ProvisionerJob, error) {
|
|
ctx, span := c.tracer.Start(ctx, "prebuilds.provision", trace.WithAttributes(
|
|
attribute.String("prebuild_id", prebuildID.String()),
|
|
attribute.String("template_id", template.ID.String()),
|
|
attribute.String("preset_id", presetID.String()),
|
|
attribute.String("transition", string(transition)),
|
|
attribute.String("workspace_id", workspace.ID.String()),
|
|
attribute.String("mode", mode.String()),
|
|
))
|
|
defer span.End()
|
|
|
|
tvp, err := db.GetPresetParametersByTemplateVersionID(ctx, template.ActiveVersionID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("fetch preset details: %w", err)
|
|
}
|
|
|
|
var params []codersdk.WorkspaceBuildParameter
|
|
for _, param := range tvp {
|
|
// TODO: don't fetch in the first place.
|
|
if param.TemplateVersionPresetID != presetID {
|
|
continue
|
|
}
|
|
|
|
params = append(params, codersdk.WorkspaceBuildParameter{
|
|
Name: param.Name,
|
|
Value: param.Value,
|
|
})
|
|
}
|
|
|
|
builder := wsbuilder.New(workspace, transition, *c.buildUsageChecker.Load()).
|
|
Reason(database.BuildReasonInitiator).
|
|
Initiator(database.PrebuildsSystemUserID).
|
|
MarkPrebuild().
|
|
BuildMetrics(c.workspaceBuilderMetrics)
|
|
|
|
if transition != database.WorkspaceTransitionDelete {
|
|
// We don't specify the version for a delete transition,
|
|
// because the prebuilt workspace may have been created using an older template version.
|
|
// If the version isn't explicitly set, the builder will automatically use the version
|
|
// from the last workspace build — which is the desired behavior.
|
|
builder = builder.VersionID(template.ActiveVersionID)
|
|
|
|
// We only inject the required params when the prebuild is being created.
|
|
// This mirrors the behavior of regular workspace deletion (see cli/delete.go).
|
|
builder = builder.TemplateVersionPresetID(presetID)
|
|
builder = builder.RichParameterValues(params)
|
|
}
|
|
|
|
// Use orphan mode for deletes when no Terraform resources exist
|
|
if transition == database.WorkspaceTransitionDelete && mode == DeprovisionModeOrphan {
|
|
builder = builder.Orphan()
|
|
}
|
|
|
|
// Strip trace context - provisionerd is a separate service and should
|
|
// start its own trace rather than continuing the prebuilds trace.
|
|
buildCtx := trace.ContextWithSpan(ctx, tracing.NoopSpan)
|
|
|
|
_, provisionerJob, _, err := builder.Build(
|
|
buildCtx,
|
|
db,
|
|
c.fileCache,
|
|
func(_ policy.Action, _ rbac.Objecter) bool {
|
|
return true // TODO: harden?
|
|
},
|
|
audit.WorkspaceBuildBaggage{},
|
|
)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("provision workspace: %w", err)
|
|
}
|
|
if provisionerJob == nil {
|
|
// This should not happen, builder.Build() should either return a job or an error.
|
|
// Returning an error to fail fast if we hit this unexpected case.
|
|
return nil, xerrors.Errorf("provision succeeded but returned no job")
|
|
}
|
|
|
|
c.logger.Info(ctx, "prebuild job scheduled", slog.F("transition", transition),
|
|
slog.F("prebuild_id", prebuildID.String()), slog.F("preset_id", presetID.String()),
|
|
slog.F("job_id", provisionerJob.ID))
|
|
|
|
return provisionerJob, nil
|
|
}
|
|
|
|
// publishProvisionerJob publishes a provisioner job event to notify the acquirer that a new job has been created.
|
|
// This must be called after the database transaction that creates the job has committed to ensure
|
|
// the job is visible to provisioners when they query the database.
|
|
func (c *StoreReconciler) publishProvisionerJob(ctx context.Context, provisionerJob *database.ProvisionerJob, workspaceID uuid.UUID) {
|
|
if provisionerJob == nil {
|
|
return
|
|
}
|
|
select {
|
|
case c.provisionNotifyCh <- *provisionerJob:
|
|
default: // channel full, drop the message; provisioner will pick this job up later with its periodic check
|
|
c.logger.Warn(ctx, "provisioner job notification queue full, dropping",
|
|
slog.F("job_id", provisionerJob.ID), slog.F("prebuild_id", workspaceID.String()))
|
|
}
|
|
}
|
|
|
|
// ForceMetricsUpdate forces the metrics collector, if defined, to update its state (we cache the metrics state to
|
|
// reduce load on the database).
|
|
func (c *StoreReconciler) ForceMetricsUpdate(ctx context.Context) error {
|
|
if c.metrics == nil {
|
|
return nil
|
|
}
|
|
|
|
return c.metrics.UpdateState(ctx, time.Second*10)
|
|
}
|
|
|
|
func (c *StoreReconciler) TrackResourceReplacement(ctx context.Context, workspaceID, buildID uuid.UUID, replacements []*sdkproto.ResourceReplacement) {
|
|
// nolint:gocritic // Necessary to query all the required data.
|
|
ctx = dbauthz.AsSystemRestricted(ctx)
|
|
// Since this may be called in a fire-and-forget fashion, we need to give up at some point.
|
|
trackCtx, trackCancel := context.WithTimeout(ctx, time.Minute)
|
|
defer trackCancel()
|
|
|
|
if err := c.trackResourceReplacement(trackCtx, workspaceID, buildID, replacements); err != nil {
|
|
c.logger.Error(ctx, "failed to track resource replacement", slog.Error(err))
|
|
}
|
|
}
|
|
|
|
// nolint:revive // Shut up it's fine.
|
|
func (c *StoreReconciler) trackResourceReplacement(ctx context.Context, workspaceID, buildID uuid.UUID, replacements []*sdkproto.ResourceReplacement) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
workspace, err := c.store.GetWorkspaceByID(ctx, workspaceID)
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch workspace %q: %w", workspaceID.String(), err)
|
|
}
|
|
|
|
build, err := c.store.GetWorkspaceBuildByID(ctx, buildID)
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch workspace build %q: %w", buildID.String(), err)
|
|
}
|
|
|
|
// The first build will always be the prebuild.
|
|
prebuild, err := c.store.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{
|
|
WorkspaceID: workspaceID, BuildNumber: 1,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch prebuild: %w", err)
|
|
}
|
|
|
|
// This should not be possible, but defend against it.
|
|
if !prebuild.TemplateVersionPresetID.Valid || prebuild.TemplateVersionPresetID.UUID == uuid.Nil {
|
|
return xerrors.Errorf("no preset used in prebuild for workspace %q", workspaceID.String())
|
|
}
|
|
|
|
prebuildPreset, err := c.store.GetPresetByID(ctx, prebuild.TemplateVersionPresetID.UUID)
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch template preset for template version ID %q: %w", prebuild.TemplateVersionID.String(), err)
|
|
}
|
|
|
|
claimant, err := c.store.GetUserByID(ctx, workspace.OwnerID) // At this point, the workspace is owned by the new owner.
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch claimant %q: %w", workspace.OwnerID.String(), err)
|
|
}
|
|
|
|
// Use the claiming build here (not prebuild) because both should be equivalent, and we might as well spot inconsistencies now.
|
|
templateVersion, err := c.store.GetTemplateVersionByID(ctx, build.TemplateVersionID)
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch template version %q: %w", build.TemplateVersionID.String(), err)
|
|
}
|
|
|
|
org, err := c.store.GetOrganizationByID(ctx, workspace.OrganizationID)
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch org %q: %w", workspace.OrganizationID.String(), err)
|
|
}
|
|
|
|
// Track resource replacement in Prometheus metric.
|
|
if c.metrics != nil {
|
|
c.metrics.trackResourceReplacement(org.Name, workspace.TemplateName, prebuildPreset.Name)
|
|
}
|
|
|
|
// Send notification to template admins.
|
|
if c.notifEnq == nil {
|
|
c.logger.Warn(ctx, "notification enqueuer not set, cannot send resource replacement notification(s)")
|
|
return nil
|
|
}
|
|
|
|
repls := make(map[string]string, len(replacements))
|
|
for _, repl := range replacements {
|
|
repls[repl.GetResource()] = strings.Join(repl.GetPaths(), ", ")
|
|
}
|
|
|
|
templateAdmins, err := c.store.GetUsers(ctx, database.GetUsersParams{
|
|
RbacRole: []string{codersdk.RoleTemplateAdmin},
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch template admins: %w", err)
|
|
}
|
|
|
|
var notifErr error
|
|
for _, templateAdmin := range templateAdmins {
|
|
if _, err := c.notifEnq.EnqueueWithData(ctx, templateAdmin.ID, notifications.TemplateWorkspaceResourceReplaced,
|
|
map[string]string{
|
|
"org": org.Name,
|
|
"workspace": workspace.Name,
|
|
"template": workspace.TemplateName,
|
|
"template_version": templateVersion.Name,
|
|
"preset": prebuildPreset.Name,
|
|
"workspace_build_num": fmt.Sprintf("%d", build.BuildNumber),
|
|
"claimant": claimant.Username,
|
|
},
|
|
map[string]any{
|
|
"replacements": repls,
|
|
}, "prebuilds_reconciler",
|
|
// Associate this notification with all the related entities.
|
|
workspace.ID, workspace.OwnerID, workspace.TemplateID, templateVersion.ID, prebuildPreset.ID, workspace.OrganizationID,
|
|
); err != nil {
|
|
notifErr = errors.Join(xerrors.Errorf("send notification to %q: %w", templateAdmin.ID.String(), err))
|
|
continue
|
|
}
|
|
}
|
|
|
|
return notifErr
|
|
}
|
|
|
|
type Settings struct {
|
|
ReconciliationPaused bool `json:"reconciliation_paused"`
|
|
}
|
|
|
|
func SetPrebuildsReconciliationPaused(ctx context.Context, db database.Store, paused bool) error {
|
|
settings := Settings{
|
|
ReconciliationPaused: paused,
|
|
}
|
|
settingsJSON, err := json.Marshal(settings)
|
|
if err != nil {
|
|
return xerrors.Errorf("marshal settings: %w", err)
|
|
}
|
|
return db.UpsertPrebuildsSettings(ctx, string(settingsJSON))
|
|
}
|