mirror of
https://github.com/coder/coder.git
synced 2026-09-23 05:43:53 +08:00
## Problem #23108 made prebuild claim delivery durable: when an agent connects to `/api/v2/workspaceagents/me/reinit?wait=true`, the handler checks whether the workspace's first build was created by the prebuilds system user and whether its latest build succeeded, and if so pre-seeds a `prebuild_claimed` reinitialization event in case the original pubsub event was missed. The check does not verify that the latest build is the claim build, so it keeps firing for the rest of the workspace's life. Any workspace that was claimed from a prebuild receives a spurious "prebuild claimed" reinit every time its agent (re)opens the `/reinit` connection: after every agent restart, every coderd deploy or replica restart, and every dropped SSE connection. Each one shuts the agent down and reinitializes it, killing SSH/IDE sessions and re-running startup scripts. In our deployment, where most workspaces are claimed from prebuilds, this caused fleet-wide "agent disconnected" blips whenever a coderd replica restarted, and a few workspaces whose container exits when the agent restarts went into a restart loop every 15-60 minutes. The agent-side dedup (`lastOwnerID` in `cli/agent.go`) only suppresses the second event within one agent process, so every new agent process takes at least one spurious restart. ## Fix Only seed the reinitialization event while the latest build is the claim build itself, determined from the build job's input (`prebuilt_workspace_stage`), the same signal `provisionerdserver` uses when publishing the claim event: - Latest build is the claim build: behavior unchanged (seed when the job succeeded, 409 when it failed permanently, wait on pubsub while it is in progress). - Latest build is still a prebuilds-initiated build (claim build not created yet): fall through to the pubsub subscription, which delivers the claim event when the claim build completes. - Latest build is any later user-initiated build: the claim was already handled, so return 409 and the agent stops polling, the same as a regular workspace. `dbfake` gains a `MarkPrebuiltWorkspaceClaim()` builder option so tests can model claim builds' job input, and the existing `TestReinit` claim subtests now use it. A new subtest covers the long-claimed workspace case. One deliberate behavior change worth calling out: if a claim build fails and the owner retries with another start build, the handler now returns 409 for that retry build rather than seeding a reinit. This matches the existing treatment of failed claim builds as terminal for the reinit poller. ## Verification - `go test ./coderd/ -run TestReinit` against Postgres 17: all subtests pass, including the new `workspace claimed in the past gets 409` case. - `gofmt`, `go vet`, and `golangci-lint` (v1.64.8) are clean on the touched packages. - The fix mirrors behavior validated by hand against an affected deployment: for a long-claimed workspace, `/reinit?wait=true` returned the seeded `prebuild_claimed` event on every connection before the change and a 409 afterwards. Note: this branch was prepared in an environment without the full local toolchain, so the repo's pre-commit hook (`make pre-commit`) was not run locally; relying on CI for the full gen/fmt/lint suite. Opening as a draft mainly to report the issue and propose a fix; happy to rework it to the maintainers' preferred approach. --------- Co-authored-by: Sas Swart <sas.swart.cdk@gmail.com>
939 lines
31 KiB
Go
939 lines
31 KiB
Go
package dbfake
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/sqlc-dev/pqtype"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/database/pubsub"
|
|
"github.com/coder/coder/v2/coderd/provisionerdserver"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/telemetry"
|
|
"github.com/coder/coder/v2/coderd/wspubsub"
|
|
"github.com/coder/coder/v2/provisionersdk"
|
|
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
|
|
)
|
|
|
|
var ownerCtx = dbauthz.As(context.Background(), rbac.Subject{
|
|
ID: "owner",
|
|
Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleOwner()}.Expand())),
|
|
Groups: []string{},
|
|
Scope: rbac.ExpandableScope(rbac.ScopeAll),
|
|
})
|
|
|
|
type WorkspaceResponse struct {
|
|
Workspace database.WorkspaceTable
|
|
Build database.WorkspaceBuild
|
|
Agents []database.WorkspaceAgent
|
|
AgentToken string
|
|
TemplateVersionResponse
|
|
Task database.Task
|
|
}
|
|
|
|
// WorkspaceBuildBuilder generates workspace builds and associated
|
|
// resources.
|
|
type WorkspaceBuildBuilder struct {
|
|
t testing.TB
|
|
logger slog.Logger
|
|
db database.Store
|
|
ps pubsub.Pubsub
|
|
ws database.WorkspaceTable
|
|
seed database.WorkspaceBuild
|
|
resources []*sdkproto.Resource
|
|
params []database.WorkspaceBuildParameter
|
|
agentToken string
|
|
jobStatus database.ProvisionerJobStatus
|
|
taskAppID uuid.UUID
|
|
taskSeed database.TaskTable
|
|
|
|
// Individual timestamp fields for job customization.
|
|
jobCreatedAt time.Time
|
|
jobStartedAt time.Time
|
|
jobUpdatedAt time.Time
|
|
jobCompletedAt time.Time
|
|
|
|
jobError string // Error message for failed jobs
|
|
jobErrorCode string // Error code for failed jobs
|
|
|
|
provisionerState []byte
|
|
|
|
prebuiltWorkspaceBuildStage sdkproto.PrebuiltWorkspaceBuildStage
|
|
}
|
|
|
|
// BuilderOption is a functional option for customizing job timestamps
|
|
// on status methods.
|
|
type BuilderOption func(*WorkspaceBuildBuilder)
|
|
|
|
// WithJobCreatedAt sets the CreatedAt timestamp for the provisioner job.
|
|
func WithJobCreatedAt(t time.Time) BuilderOption {
|
|
return func(b *WorkspaceBuildBuilder) {
|
|
b.jobCreatedAt = t
|
|
}
|
|
}
|
|
|
|
// WithJobStartedAt sets the StartedAt timestamp for the provisioner job.
|
|
func WithJobStartedAt(t time.Time) BuilderOption {
|
|
return func(b *WorkspaceBuildBuilder) {
|
|
b.jobStartedAt = t
|
|
}
|
|
}
|
|
|
|
// WithJobUpdatedAt sets the UpdatedAt timestamp for the provisioner job.
|
|
func WithJobUpdatedAt(t time.Time) BuilderOption {
|
|
return func(b *WorkspaceBuildBuilder) {
|
|
b.jobUpdatedAt = t
|
|
}
|
|
}
|
|
|
|
// WithJobCompletedAt sets the CompletedAt timestamp for the provisioner job.
|
|
func WithJobCompletedAt(t time.Time) BuilderOption {
|
|
return func(b *WorkspaceBuildBuilder) {
|
|
b.jobCompletedAt = t
|
|
}
|
|
}
|
|
|
|
// WithJobError sets the error message for the provisioner job.
|
|
func WithJobError(msg string) BuilderOption {
|
|
return func(b *WorkspaceBuildBuilder) {
|
|
b.jobError = msg
|
|
}
|
|
}
|
|
|
|
// WithJobErrorCode sets the error code for the provisioner job.
|
|
func WithJobErrorCode(code string) BuilderOption {
|
|
return func(b *WorkspaceBuildBuilder) {
|
|
b.jobErrorCode = code
|
|
}
|
|
}
|
|
|
|
// WorkspaceBuild generates a workspace build for the provided workspace.
|
|
// Pass a database.Workspace{} with a nil ID to also generate a new workspace.
|
|
// Omitting the template ID on a workspace will also generate a new template
|
|
// with a template version.
|
|
func WorkspaceBuild(t testing.TB, db database.Store, ws database.WorkspaceTable) WorkspaceBuildBuilder {
|
|
return WorkspaceBuildBuilder{
|
|
t: t, db: db, ws: ws,
|
|
logger: slogtest.Make(t, &slogtest.Options{}).Named("dbfake").Leveled(slog.LevelDebug),
|
|
}
|
|
}
|
|
|
|
func (b WorkspaceBuildBuilder) Pubsub(ps pubsub.Pubsub) WorkspaceBuildBuilder {
|
|
// nolint: revive // returns modified struct
|
|
b.ps = ps
|
|
return b
|
|
}
|
|
|
|
func (b WorkspaceBuildBuilder) Seed(seed database.WorkspaceBuild) WorkspaceBuildBuilder {
|
|
//nolint: revive // returns modified struct
|
|
b.seed = seed
|
|
return b
|
|
}
|
|
|
|
// ProvisionerState sets the provisioner state for the workspace build.
|
|
// This is stored separately from the seed because ProvisionerState is
|
|
// not part of the WorkspaceBuild view struct.
|
|
func (b WorkspaceBuildBuilder) ProvisionerState(state []byte) WorkspaceBuildBuilder {
|
|
//nolint: revive // returns modified struct
|
|
b.provisionerState = state
|
|
return b
|
|
}
|
|
|
|
// MarkPrebuiltWorkspaceClaim marks the build's provisioner job as the claim
|
|
// of a prebuilt workspace, mirroring wsbuilder.MarkPrebuiltWorkspaceClaim.
|
|
func (b WorkspaceBuildBuilder) MarkPrebuiltWorkspaceClaim() WorkspaceBuildBuilder {
|
|
//nolint: revive // returns modified struct
|
|
b.prebuiltWorkspaceBuildStage = sdkproto.PrebuiltWorkspaceBuildStage_CLAIM
|
|
return b
|
|
}
|
|
|
|
func (b WorkspaceBuildBuilder) Resource(resource ...*sdkproto.Resource) WorkspaceBuildBuilder {
|
|
//nolint: revive // returns modified struct
|
|
b.resources = append(b.resources, resource...)
|
|
return b
|
|
}
|
|
|
|
func (b WorkspaceBuildBuilder) Params(params ...database.WorkspaceBuildParameter) WorkspaceBuildBuilder {
|
|
b.params = params
|
|
return b
|
|
}
|
|
|
|
func (b WorkspaceBuildBuilder) WithAgent(mutations ...func([]*sdkproto.Agent) []*sdkproto.Agent) WorkspaceBuildBuilder {
|
|
//nolint: revive // returns modified struct
|
|
b.agentToken = uuid.NewString()
|
|
agents := []*sdkproto.Agent{{
|
|
Id: uuid.NewString(),
|
|
Name: "dev",
|
|
Auth: &sdkproto.Agent_Token{
|
|
Token: b.agentToken,
|
|
},
|
|
Env: map[string]string{},
|
|
}}
|
|
for _, m := range mutations {
|
|
agents = m(agents)
|
|
}
|
|
b.resources = append(b.resources, &sdkproto.Resource{
|
|
Name: "example",
|
|
Type: "aws_instance",
|
|
Agents: agents,
|
|
})
|
|
return b
|
|
}
|
|
|
|
func (b WorkspaceBuildBuilder) WithTask(taskSeed database.TaskTable, appSeed *sdkproto.App) WorkspaceBuildBuilder {
|
|
//nolint:revive // returns modified struct
|
|
b.taskSeed = taskSeed
|
|
|
|
if appSeed == nil {
|
|
appSeed = &sdkproto.App{}
|
|
}
|
|
|
|
var err error
|
|
//nolint: revive // returns modified struct
|
|
b.taskAppID, err = uuid.Parse(takeFirst(appSeed.Id, uuid.NewString()))
|
|
require.NoError(b.t, err)
|
|
|
|
return b.WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent {
|
|
a[0].Apps = []*sdkproto.App{
|
|
{
|
|
Id: b.taskAppID.String(),
|
|
Slug: takeFirst(appSeed.Slug, "task-app"),
|
|
Url: takeFirst(appSeed.Url, ""),
|
|
},
|
|
}
|
|
return a
|
|
})
|
|
}
|
|
|
|
// Starting sets the job to running status.
|
|
func (b WorkspaceBuildBuilder) Starting(opts ...BuilderOption) WorkspaceBuildBuilder {
|
|
//nolint: revive // returns modified struct
|
|
b.jobStatus = database.ProvisionerJobStatusRunning
|
|
for _, opt := range opts {
|
|
opt(&b)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Pending sets the job to pending status.
|
|
func (b WorkspaceBuildBuilder) Pending(opts ...BuilderOption) WorkspaceBuildBuilder {
|
|
//nolint: revive // returns modified struct
|
|
b.jobStatus = database.ProvisionerJobStatusPending
|
|
for _, opt := range opts {
|
|
opt(&b)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Canceled sets the job to canceled status.
|
|
func (b WorkspaceBuildBuilder) Canceled(opts ...BuilderOption) WorkspaceBuildBuilder {
|
|
//nolint: revive // returns modified struct
|
|
b.jobStatus = database.ProvisionerJobStatusCanceled
|
|
for _, opt := range opts {
|
|
opt(&b)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Succeeded sets the job to succeeded status.
|
|
// This is the default status.
|
|
func (b WorkspaceBuildBuilder) Succeeded(opts ...BuilderOption) WorkspaceBuildBuilder {
|
|
//nolint: revive // returns modified struct
|
|
b.jobStatus = database.ProvisionerJobStatusSucceeded
|
|
for _, opt := range opts {
|
|
opt(&b)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Failed sets the provisioner job to a failed state. Use WithJobError and
|
|
// WithJobErrorCode options to set the error message and code. If no error
|
|
// message is provided, "failed" is used as the default.
|
|
func (b WorkspaceBuildBuilder) Failed(opts ...BuilderOption) WorkspaceBuildBuilder {
|
|
//nolint: revive // returns modified struct
|
|
b.jobStatus = database.ProvisionerJobStatusFailed
|
|
for _, opt := range opts {
|
|
opt(&b)
|
|
}
|
|
if b.jobError == "" {
|
|
b.jobError = "failed"
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Do generates all the resources associated with a workspace build.
|
|
// Template and TemplateVersion will be optionally populated if no
|
|
// TemplateID is set on the provided workspace.
|
|
// Workspace will be optionally populated if no ID is set on the provided
|
|
// workspace.
|
|
func (b WorkspaceBuildBuilder) Do() WorkspaceResponse {
|
|
var resp WorkspaceResponse
|
|
// Use transaction, like real wsbuilder.
|
|
err := b.db.InTx(func(tx database.Store) error {
|
|
//nolint:revive // calls do on modified struct
|
|
b.db = tx
|
|
resp = b.doInTX() // intxcheck:ignore // b.db is reassigned to tx on the line above
|
|
return nil
|
|
}, nil)
|
|
require.NoError(b.t, err)
|
|
return resp
|
|
}
|
|
|
|
func (b WorkspaceBuildBuilder) doInTX() WorkspaceResponse {
|
|
b.t.Helper()
|
|
jobID := uuid.New()
|
|
b.seed.ID = uuid.New()
|
|
b.seed.JobID = jobID
|
|
|
|
if b.taskAppID != uuid.Nil {
|
|
b.seed.HasAITask = sql.NullBool{
|
|
Bool: true,
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
resp := WorkspaceResponse{
|
|
AgentToken: b.agentToken,
|
|
Agents: make([]database.WorkspaceAgent, 0),
|
|
}
|
|
if b.ws.TemplateID == uuid.Nil {
|
|
b.logger.Debug(context.Background(), "creating template and version")
|
|
resp.TemplateVersionResponse = TemplateVersion(b.t, b.db).
|
|
Resources(b.resources...).
|
|
Pubsub(b.ps).
|
|
Seed(database.TemplateVersion{
|
|
OrganizationID: b.ws.OrganizationID,
|
|
CreatedBy: b.ws.OwnerID,
|
|
}).
|
|
Do()
|
|
b.ws.TemplateID = resp.Template.ID
|
|
b.seed.TemplateVersionID = resp.TemplateVersion.ID
|
|
}
|
|
|
|
// If no template version is set assume the active version.
|
|
if b.seed.TemplateVersionID == uuid.Nil {
|
|
b.logger.Debug(context.Background(), "assuming active template version")
|
|
template, err := b.db.GetTemplateByID(ownerCtx, b.ws.TemplateID)
|
|
require.NoError(b.t, err)
|
|
require.NotNil(b.t, template.ActiveVersionID, "active version ID unexpectedly nil")
|
|
b.seed.TemplateVersionID = template.ActiveVersionID
|
|
}
|
|
|
|
// No ID on the workspace implies we should generate an entry.
|
|
if b.ws.ID == uuid.Nil {
|
|
// nolint: revive
|
|
b.ws = dbgen.Workspace(b.t, b.db, b.ws)
|
|
b.logger.Debug(context.Background(), "created workspace",
|
|
slog.F("name", b.ws.Name),
|
|
slog.F("workspace_id", b.ws.ID))
|
|
}
|
|
resp.Workspace = b.ws
|
|
b.seed.WorkspaceID = b.ws.ID
|
|
b.seed.InitiatorID = takeFirst(b.seed.InitiatorID, b.ws.OwnerID)
|
|
|
|
// If a task was requested, ensure it exists and is associated with this
|
|
// workspace.
|
|
if b.taskAppID != uuid.Nil {
|
|
b.logger.Debug(context.Background(), "creating or updating task", slog.F("task_id", b.taskSeed.ID))
|
|
b.taskSeed.OrganizationID = takeFirst(b.taskSeed.OrganizationID, b.ws.OrganizationID)
|
|
b.taskSeed.OwnerID = takeFirst(b.taskSeed.OwnerID, b.ws.OwnerID)
|
|
b.taskSeed.Name = takeFirst(b.taskSeed.Name, b.ws.Name)
|
|
b.taskSeed.WorkspaceID = uuid.NullUUID{UUID: takeFirst(b.taskSeed.WorkspaceID.UUID, b.ws.ID), Valid: true}
|
|
b.taskSeed.TemplateVersionID = takeFirst(b.taskSeed.TemplateVersionID, b.seed.TemplateVersionID)
|
|
|
|
// Try to fetch existing task and update its workspace ID.
|
|
if task, err := b.db.GetTaskByID(ownerCtx, b.taskSeed.ID); err == nil {
|
|
if !task.WorkspaceID.Valid {
|
|
b.logger.Info(context.Background(), "updating task workspace id",
|
|
slog.F("task_id", b.taskSeed.ID),
|
|
slog.F("workspace_id", b.ws.ID))
|
|
_, err = b.db.UpdateTaskWorkspaceID(ownerCtx, database.UpdateTaskWorkspaceIDParams{
|
|
ID: b.taskSeed.ID,
|
|
WorkspaceID: uuid.NullUUID{UUID: b.ws.ID, Valid: true},
|
|
})
|
|
require.NoError(b.t, err, "update task workspace id")
|
|
} else if task.WorkspaceID.UUID != b.ws.ID {
|
|
require.Fail(b.t, "task already has a workspace id, mismatch", task.WorkspaceID.UUID, b.ws.ID)
|
|
}
|
|
} else if errors.Is(err, sql.ErrNoRows) {
|
|
task := dbgen.Task(b.t, b.db, b.taskSeed)
|
|
b.taskSeed.ID = task.ID
|
|
b.logger.Info(context.Background(), "created new task", slog.F("task_id", b.taskSeed.ID))
|
|
} else {
|
|
require.NoError(b.t, err, "get task by id")
|
|
}
|
|
}
|
|
|
|
// Create a provisioner job for the build!
|
|
payload, err := json.Marshal(provisionerdserver.WorkspaceProvisionJob{
|
|
WorkspaceBuildID: b.seed.ID,
|
|
PrebuiltWorkspaceBuildStage: b.prebuiltWorkspaceBuildStage,
|
|
})
|
|
require.NoError(b.t, err)
|
|
|
|
// Tag the job so AcquireProvisionerJob only matches this
|
|
// builder's job, preventing cross-test interference when
|
|
// parallel tests share a database. Same pattern as
|
|
// dbgen.ProvisionerJob.
|
|
tags := database.StringMap{jobID.String(): "true", "scope": "organization"}
|
|
|
|
job, err := b.db.InsertProvisionerJob(ownerCtx, database.InsertProvisionerJobParams{
|
|
ID: jobID,
|
|
CreatedAt: takeFirstTime(b.jobCreatedAt, b.ws.CreatedAt, dbtime.Now()),
|
|
UpdatedAt: takeFirstTime(b.jobCreatedAt, b.ws.CreatedAt, dbtime.Now()),
|
|
OrganizationID: b.ws.OrganizationID,
|
|
InitiatorID: b.ws.OwnerID,
|
|
Provisioner: database.ProvisionerTypeEcho,
|
|
StorageMethod: database.ProvisionerStorageMethodFile,
|
|
FileID: uuid.New(),
|
|
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
|
Input: payload,
|
|
Tags: tags,
|
|
TraceMetadata: pqtype.NullRawMessage{},
|
|
LogsOverflowed: false,
|
|
})
|
|
require.NoError(b.t, err, "insert job")
|
|
b.logger.Debug(context.Background(), "inserted provisioner job", slog.F("job_id", job.ID))
|
|
|
|
switch b.jobStatus {
|
|
case database.ProvisionerJobStatusPending:
|
|
// Provisioner jobs are created in 'pending' status
|
|
b.logger.Debug(context.Background(), "pending the provisioner job")
|
|
case database.ProvisionerJobStatusRunning:
|
|
b.logger.Debug(context.Background(), "acquiring the provisioner job")
|
|
startedAt := takeFirstTime(b.jobStartedAt, dbtime.Now())
|
|
j, err := b.db.AcquireProvisionerJob(ownerCtx, database.AcquireProvisionerJobParams{
|
|
OrganizationID: job.OrganizationID,
|
|
StartedAt: sql.NullTime{
|
|
Time: startedAt,
|
|
Valid: true,
|
|
},
|
|
WorkerID: uuid.NullUUID{
|
|
UUID: uuid.New(),
|
|
Valid: true,
|
|
},
|
|
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
|
|
ProvisionerTags: must(json.Marshal(tags)),
|
|
})
|
|
require.NoError(b.t, err, "acquire the provisioner job")
|
|
require.Equal(b.t, job.ID, j.ID, "acquired wrong provisioner job")
|
|
b.logger.Debug(context.Background(), "acquired provisioner job", slog.F("job_id", job.ID))
|
|
if !b.jobUpdatedAt.IsZero() {
|
|
err = b.db.UpdateProvisionerJobByID(ownerCtx, database.UpdateProvisionerJobByIDParams{
|
|
ID: job.ID,
|
|
UpdatedAt: b.jobUpdatedAt,
|
|
})
|
|
require.NoError(b.t, err, "update job updated_at")
|
|
}
|
|
case database.ProvisionerJobStatusCanceled:
|
|
// Set provisioner job status to 'canceled'
|
|
b.logger.Debug(context.Background(), "canceling the provisioner job")
|
|
completedAt := takeFirstTime(b.jobCompletedAt, dbtime.Now())
|
|
err = b.db.UpdateProvisionerJobWithCancelByID(ownerCtx, database.UpdateProvisionerJobWithCancelByIDParams{
|
|
ID: jobID,
|
|
CanceledAt: sql.NullTime{
|
|
Time: completedAt,
|
|
Valid: true,
|
|
},
|
|
CompletedAt: sql.NullTime{
|
|
Time: completedAt,
|
|
Valid: true,
|
|
},
|
|
})
|
|
require.NoError(b.t, err, "cancel job")
|
|
case database.ProvisionerJobStatusFailed:
|
|
b.logger.Debug(context.Background(), "failing the provisioner job")
|
|
completedAt := takeFirstTime(b.jobCompletedAt, dbtime.Now())
|
|
err = b.db.UpdateProvisionerJobWithCompleteByID(ownerCtx, database.UpdateProvisionerJobWithCompleteByIDParams{
|
|
ID: job.ID,
|
|
UpdatedAt: completedAt,
|
|
Error: sql.NullString{String: b.jobError, Valid: b.jobError != ""},
|
|
ErrorCode: sql.NullString{String: b.jobErrorCode, Valid: b.jobErrorCode != ""},
|
|
CompletedAt: sql.NullTime{
|
|
Time: completedAt,
|
|
Valid: true,
|
|
},
|
|
})
|
|
require.NoError(b.t, err, "fail job")
|
|
default:
|
|
// By default, consider jobs in 'succeeded' status
|
|
b.logger.Debug(context.Background(), "completing the provisioner job")
|
|
completedAt := takeFirstTime(b.jobCompletedAt, dbtime.Now())
|
|
err = b.db.UpdateProvisionerJobWithCompleteByID(ownerCtx, database.UpdateProvisionerJobWithCompleteByIDParams{
|
|
ID: job.ID,
|
|
UpdatedAt: completedAt,
|
|
Error: sql.NullString{},
|
|
ErrorCode: sql.NullString{},
|
|
CompletedAt: sql.NullTime{
|
|
Time: completedAt,
|
|
Valid: true,
|
|
},
|
|
})
|
|
require.NoError(b.t, err, "complete job")
|
|
ProvisionerJobResources(b.t, b.db, job.ID, b.seed.Transition, b.resources...).Do()
|
|
}
|
|
|
|
resp.Build = dbgen.WorkspaceBuild(b.t, b.db, b.seed)
|
|
if len(b.provisionerState) > 0 {
|
|
err = b.db.UpdateWorkspaceBuildProvisionerStateByID(ownerCtx, database.UpdateWorkspaceBuildProvisionerStateByIDParams{
|
|
ID: resp.Build.ID,
|
|
UpdatedAt: dbtime.Now(),
|
|
ProvisionerState: b.provisionerState,
|
|
})
|
|
require.NoError(b.t, err, "update provisioner state")
|
|
}
|
|
b.logger.Debug(context.Background(), "created workspace build",
|
|
slog.F("build_id", resp.Build.ID),
|
|
slog.F("workspace_id", resp.Workspace.ID),
|
|
slog.F("build_number", resp.Build.BuildNumber))
|
|
|
|
// If this is a task workspace, link it to the workspace build.
|
|
task, err := b.db.GetTaskByWorkspaceID(ownerCtx, resp.Workspace.ID)
|
|
if err != nil {
|
|
if b.taskAppID != uuid.Nil {
|
|
require.Fail(b.t, "task app configured but failed to get task by workspace id", err)
|
|
}
|
|
} else {
|
|
if b.taskAppID == uuid.Nil {
|
|
require.Fail(b.t, "task app not configured but workspace is a task workspace")
|
|
}
|
|
|
|
workspaceAgentID := uuid.NullUUID{}
|
|
workspaceAppID := uuid.NullUUID{}
|
|
// Workspace agent and app are only properly set upon job completion
|
|
if b.jobStatus != database.ProvisionerJobStatusPending && b.jobStatus != database.ProvisionerJobStatusRunning {
|
|
app := mustWorkspaceAppByWorkspaceAndBuildAndAppID(ownerCtx, b.t, b.db, resp.Workspace.ID, resp.Build.BuildNumber, b.taskAppID)
|
|
workspaceAgentID = uuid.NullUUID{UUID: app.AgentID, Valid: true}
|
|
workspaceAppID = uuid.NullUUID{UUID: app.ID, Valid: true}
|
|
}
|
|
|
|
_, err = b.db.UpsertTaskWorkspaceApp(ownerCtx, database.UpsertTaskWorkspaceAppParams{
|
|
TaskID: task.ID,
|
|
WorkspaceBuildNumber: resp.Build.BuildNumber,
|
|
WorkspaceAgentID: workspaceAgentID,
|
|
WorkspaceAppID: workspaceAppID,
|
|
})
|
|
require.NoError(b.t, err, "upsert task workspace app")
|
|
b.logger.Debug(context.Background(), "linked task to workspace build",
|
|
slog.F("task_id", task.ID),
|
|
slog.F("build_number", resp.Build.BuildNumber))
|
|
|
|
// Update task after linking.
|
|
task, err = b.db.GetTaskByID(ownerCtx, task.ID)
|
|
require.NoError(b.t, err, "get task by id")
|
|
resp.Task = task
|
|
}
|
|
|
|
for i := range b.params {
|
|
b.params[i].WorkspaceBuildID = resp.Build.ID
|
|
}
|
|
params := dbgen.WorkspaceBuildParameters(b.t, b.db, b.params)
|
|
b.logger.Debug(context.Background(), "created workspace build parameters", slog.F("count", len(params)))
|
|
|
|
if b.ws.Deleted {
|
|
err = b.db.UpdateWorkspaceDeletedByID(ownerCtx, database.UpdateWorkspaceDeletedByIDParams{
|
|
ID: b.ws.ID,
|
|
Deleted: true,
|
|
})
|
|
require.NoError(b.t, err)
|
|
b.logger.Debug(context.Background(), "deleted workspace", slog.F("workspace_id", resp.Workspace.ID))
|
|
}
|
|
|
|
if b.ps != nil {
|
|
msg, err := json.Marshal(wspubsub.WorkspaceEvent{
|
|
Kind: wspubsub.WorkspaceEventKindStateChange,
|
|
WorkspaceID: resp.Workspace.ID,
|
|
})
|
|
require.NoError(b.t, err)
|
|
err = b.ps.Publish(wspubsub.WorkspaceEventChannel(resp.Workspace.OwnerID), msg)
|
|
require.NoError(b.t, err)
|
|
b.logger.Debug(context.Background(), "published workspace event",
|
|
slog.F("owner_id", resp.Workspace.ID),
|
|
slog.F("owner_id", resp.Workspace.OwnerID))
|
|
}
|
|
|
|
agents, err := b.db.GetWorkspaceAgentsByWorkspaceAndBuildNumber(ownerCtx, database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{
|
|
WorkspaceID: resp.Workspace.ID,
|
|
BuildNumber: resp.Build.BuildNumber,
|
|
})
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
require.NoError(b.t, err, "get workspace agents")
|
|
// Insert deleted subagent test antagonists for the workspace build.
|
|
// See also `dbgen.WorkspaceAgent()`.
|
|
for _, agent := range agents {
|
|
resp.Agents = append(resp.Agents, agent)
|
|
subAgent := dbgen.WorkspaceSubAgent(b.t, b.db, agent, database.WorkspaceAgent{
|
|
TroubleshootingURL: "I AM A TEST ANTAGONIST AND I AM HERE TO MESS UP YOUR TESTS. IF YOU SEE ME, SOMETHING IS WRONG AND SUB AGENT DELETION MAY NOT BE HANDLED CORRECTLY IN A QUERY.",
|
|
})
|
|
err = b.db.DeleteWorkspaceSubAgentByID(ownerCtx, subAgent.ID)
|
|
require.NoError(b.t, err, "delete workspace agent subagent antagonist")
|
|
|
|
b.logger.Debug(context.Background(), "inserted deleted subagent antagonist",
|
|
slog.F("subagent_name", subAgent.Name),
|
|
slog.F("subagent_id", subAgent.ID),
|
|
slog.F("agent_name", agent.Name),
|
|
slog.F("agent_id", agent.ID),
|
|
)
|
|
}
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
type ProvisionerJobResourcesBuilder struct {
|
|
t testing.TB
|
|
logger slog.Logger
|
|
db database.Store
|
|
jobID uuid.UUID
|
|
transition database.WorkspaceTransition
|
|
resources []*sdkproto.Resource
|
|
}
|
|
|
|
// ProvisionerJobResources inserts a series of resources into a provisioner job.
|
|
func ProvisionerJobResources(
|
|
t testing.TB, db database.Store, jobID uuid.UUID, transition database.WorkspaceTransition, resources ...*sdkproto.Resource,
|
|
) ProvisionerJobResourcesBuilder {
|
|
return ProvisionerJobResourcesBuilder{
|
|
t: t,
|
|
logger: slogtest.Make(t, &slogtest.Options{}).Named("dbfake").Leveled(slog.LevelDebug).With(slog.F("job_id", jobID)),
|
|
db: db,
|
|
jobID: jobID,
|
|
transition: transition,
|
|
resources: resources,
|
|
}
|
|
}
|
|
|
|
func (b ProvisionerJobResourcesBuilder) Do() {
|
|
b.t.Helper()
|
|
transition := b.transition
|
|
if transition == "" {
|
|
b.logger.Debug(context.Background(), "setting default transition to start")
|
|
transition = database.WorkspaceTransitionStart
|
|
}
|
|
for _, resource := range b.resources {
|
|
//nolint:gocritic // This is only used by tests.
|
|
err := provisionerdserver.InsertWorkspaceResource(ownerCtx, b.db, b.jobID, transition, resource, &telemetry.Snapshot{})
|
|
require.NoError(b.t, err)
|
|
b.logger.Debug(context.Background(), "created workspace resource",
|
|
slog.F("resource_name", resource.Name),
|
|
slog.F("agent_count", len(resource.Agents)),
|
|
)
|
|
}
|
|
}
|
|
|
|
type TemplateVersionResponse struct {
|
|
Template database.Template
|
|
TemplateVersion database.TemplateVersion
|
|
}
|
|
|
|
type TemplateVersionBuilder struct {
|
|
t testing.TB
|
|
logger slog.Logger
|
|
db database.Store
|
|
seed database.TemplateVersion
|
|
fileID uuid.UUID
|
|
ps pubsub.Pubsub
|
|
resources []*sdkproto.Resource
|
|
params []database.TemplateVersionParameter
|
|
presets []database.TemplateVersionPreset
|
|
presetParams []database.TemplateVersionPresetParameter
|
|
promote bool
|
|
autoCreateTemplate bool
|
|
}
|
|
|
|
// TemplateVersion generates a template version and optionally a parent
|
|
// template if no template ID is set on the seed.
|
|
func TemplateVersion(t testing.TB, db database.Store) TemplateVersionBuilder {
|
|
return TemplateVersionBuilder{
|
|
t: t,
|
|
logger: slogtest.Make(t, &slogtest.Options{}).Named("dbfake").Leveled(slog.LevelDebug),
|
|
db: db,
|
|
promote: true,
|
|
autoCreateTemplate: true,
|
|
}
|
|
}
|
|
|
|
func (t TemplateVersionBuilder) Seed(v database.TemplateVersion) TemplateVersionBuilder {
|
|
// nolint: revive // returns modified struct
|
|
t.seed = v
|
|
return t
|
|
}
|
|
|
|
func (t TemplateVersionBuilder) FileID(fid uuid.UUID) TemplateVersionBuilder {
|
|
// nolint: revive // returns modified struct
|
|
t.fileID = fid
|
|
return t
|
|
}
|
|
|
|
func (t TemplateVersionBuilder) Pubsub(ps pubsub.Pubsub) TemplateVersionBuilder {
|
|
// nolint: revive // returns modified struct
|
|
t.ps = ps
|
|
return t
|
|
}
|
|
|
|
func (t TemplateVersionBuilder) Resources(rs ...*sdkproto.Resource) TemplateVersionBuilder {
|
|
// nolint: revive // returns modified struct
|
|
t.resources = rs
|
|
return t
|
|
}
|
|
|
|
func (t TemplateVersionBuilder) Params(ps ...database.TemplateVersionParameter) TemplateVersionBuilder {
|
|
// nolint: revive // returns modified struct
|
|
t.params = ps
|
|
return t
|
|
}
|
|
|
|
func (t TemplateVersionBuilder) Preset(preset database.TemplateVersionPreset, params ...database.TemplateVersionPresetParameter) TemplateVersionBuilder {
|
|
// nolint: revive // returns modified struct
|
|
t.presets = append(t.presets, preset)
|
|
t.presetParams = append(t.presetParams, params...)
|
|
return t
|
|
}
|
|
|
|
func (t TemplateVersionBuilder) SkipCreateTemplate() TemplateVersionBuilder {
|
|
// nolint: revive // returns modified struct
|
|
t.autoCreateTemplate = false
|
|
t.promote = false
|
|
return t
|
|
}
|
|
|
|
func (t TemplateVersionBuilder) Do() TemplateVersionResponse {
|
|
t.t.Helper()
|
|
|
|
t.seed.OrganizationID = takeFirst(t.seed.OrganizationID, uuid.New())
|
|
t.seed.ID = takeFirst(t.seed.ID, uuid.New())
|
|
t.seed.CreatedBy = takeFirst(t.seed.CreatedBy, uuid.New())
|
|
// nolint: revive
|
|
t.fileID = takeFirst(t.fileID, uuid.New())
|
|
|
|
var resp TemplateVersionResponse
|
|
if t.seed.TemplateID.UUID == uuid.Nil && t.autoCreateTemplate {
|
|
resp.Template = dbgen.Template(t.t, t.db, database.Template{
|
|
ActiveVersionID: t.seed.ID,
|
|
OrganizationID: t.seed.OrganizationID,
|
|
CreatedBy: t.seed.CreatedBy,
|
|
})
|
|
t.seed.TemplateID = uuid.NullUUID{
|
|
Valid: true,
|
|
UUID: resp.Template.ID,
|
|
}
|
|
t.logger.Debug(context.Background(), "created template",
|
|
slog.F("organization_id", resp.Template.OrganizationID),
|
|
slog.F("template_id", resp.Template.CreatedBy),
|
|
)
|
|
}
|
|
|
|
version := dbgen.TemplateVersion(t.t, t.db, t.seed)
|
|
t.logger.Debug(context.Background(), "created template version",
|
|
slog.F("template_version_id", version.ID),
|
|
)
|
|
if t.promote {
|
|
err := t.db.UpdateTemplateActiveVersionByID(ownerCtx, database.UpdateTemplateActiveVersionByIDParams{
|
|
ID: t.seed.TemplateID.UUID,
|
|
ActiveVersionID: t.seed.ID,
|
|
UpdatedAt: dbtime.Now(),
|
|
})
|
|
require.NoError(t.t, err)
|
|
t.logger.Debug(context.Background(), "promoted template version",
|
|
slog.F("template_version_id", t.seed.ID),
|
|
)
|
|
}
|
|
|
|
for _, preset := range t.presets {
|
|
prst := dbgen.Preset(t.t, t.db, database.InsertPresetParams{
|
|
ID: preset.ID,
|
|
TemplateVersionID: version.ID,
|
|
Name: preset.Name,
|
|
CreatedAt: version.CreatedAt,
|
|
DesiredInstances: preset.DesiredInstances,
|
|
InvalidateAfterSecs: preset.InvalidateAfterSecs,
|
|
SchedulingTimezone: preset.SchedulingTimezone,
|
|
IsDefault: false,
|
|
Description: preset.Description,
|
|
Icon: preset.Icon,
|
|
LastInvalidatedAt: preset.LastInvalidatedAt,
|
|
})
|
|
t.logger.Debug(context.Background(), "added preset",
|
|
slog.F("preset_id", prst.ID),
|
|
slog.F("preset_name", prst.Name),
|
|
)
|
|
}
|
|
|
|
for _, presetParam := range t.presetParams {
|
|
prm := dbgen.PresetParameter(t.t, t.db, database.InsertPresetParametersParams{
|
|
TemplateVersionPresetID: presetParam.TemplateVersionPresetID,
|
|
Names: []string{presetParam.Name},
|
|
Values: []string{presetParam.Value},
|
|
})
|
|
t.logger.Debug(context.Background(), "added preset parameter", slog.F("param_name", prm[0].Name))
|
|
}
|
|
|
|
payload, err := json.Marshal(provisionerdserver.TemplateVersionImportJob{
|
|
TemplateID: t.seed.TemplateID,
|
|
TemplateVersionID: t.seed.ID,
|
|
})
|
|
require.NoError(t.t, err)
|
|
|
|
job := dbgen.ProvisionerJob(t.t, t.db, t.ps, database.ProvisionerJob{
|
|
ID: version.JobID,
|
|
OrganizationID: t.seed.OrganizationID,
|
|
InitiatorID: t.seed.CreatedBy,
|
|
Type: database.ProvisionerJobTypeTemplateVersionImport,
|
|
Input: payload,
|
|
CompletedAt: sql.NullTime{
|
|
Time: dbtime.Now(),
|
|
Valid: true,
|
|
},
|
|
FileID: t.fileID,
|
|
})
|
|
t.logger.Debug(context.Background(), "added template version import job", slog.F("job_id", job.ID))
|
|
|
|
t.seed.JobID = job.ID
|
|
|
|
ProvisionerJobResources(t.t, t.db, job.ID, "", t.resources...).Do()
|
|
|
|
for i, param := range t.params {
|
|
param.TemplateVersionID = version.ID
|
|
t.params[i] = dbgen.TemplateVersionParameter(t.t, t.db, param)
|
|
}
|
|
|
|
// Update response with template and version
|
|
if resp.Template.ID == uuid.Nil && version.TemplateID.Valid {
|
|
template, err := t.db.GetTemplateByID(ownerCtx, version.TemplateID.UUID)
|
|
require.NoError(t.t, err)
|
|
resp.Template = template
|
|
}
|
|
resp.TemplateVersion = version
|
|
return resp
|
|
}
|
|
|
|
type JobCompleteBuilder struct {
|
|
t testing.TB
|
|
db database.Store
|
|
jobID uuid.UUID
|
|
ps pubsub.Pubsub
|
|
}
|
|
|
|
type JobCompleteResponse struct {
|
|
CompletedAt time.Time
|
|
}
|
|
|
|
func JobComplete(t testing.TB, db database.Store, jobID uuid.UUID) JobCompleteBuilder {
|
|
return JobCompleteBuilder{
|
|
t: t,
|
|
db: db,
|
|
jobID: jobID,
|
|
}
|
|
}
|
|
|
|
func (b JobCompleteBuilder) Pubsub(ps pubsub.Pubsub) JobCompleteBuilder {
|
|
// nolint: revive // returns modified struct
|
|
b.ps = ps
|
|
return b
|
|
}
|
|
|
|
func (b JobCompleteBuilder) Do() JobCompleteResponse {
|
|
r := JobCompleteResponse{CompletedAt: dbtime.Now()}
|
|
err := b.db.UpdateProvisionerJobWithCompleteWithStartedAtByID(ownerCtx, database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams{
|
|
ID: b.jobID,
|
|
UpdatedAt: r.CompletedAt,
|
|
Error: sql.NullString{},
|
|
ErrorCode: sql.NullString{},
|
|
CompletedAt: sql.NullTime{
|
|
Time: r.CompletedAt,
|
|
Valid: true,
|
|
},
|
|
StartedAt: sql.NullTime{
|
|
Time: r.CompletedAt,
|
|
Valid: true,
|
|
},
|
|
})
|
|
require.NoError(b.t, err, "complete job")
|
|
if b.ps != nil {
|
|
data, err := json.Marshal(provisionersdk.ProvisionerJobLogsNotifyMessage{EndOfLogs: true})
|
|
require.NoError(b.t, err)
|
|
err = b.ps.Publish(provisionersdk.ProvisionerJobLogsNotifyChannel(b.jobID), data)
|
|
require.NoError(b.t, err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func must[V any](v V, err error) V {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
// takeFirstF takes the first value that returns true
|
|
func takeFirstF[Value any](values []Value, take func(v Value) bool) Value {
|
|
for _, v := range values {
|
|
if take(v) {
|
|
return v
|
|
}
|
|
}
|
|
// If all empty, return the last element
|
|
if len(values) > 0 {
|
|
return values[len(values)-1]
|
|
}
|
|
var empty Value
|
|
return empty
|
|
}
|
|
|
|
// takeFirst will take the first non-empty value.
|
|
func takeFirst[Value comparable](values ...Value) Value {
|
|
var empty Value
|
|
return takeFirstF(values, func(v Value) bool {
|
|
return v != empty
|
|
})
|
|
}
|
|
|
|
// takeFirstTime returns the first non-zero time.Time.
|
|
func takeFirstTime(values ...time.Time) time.Time {
|
|
for _, v := range values {
|
|
if !v.IsZero() {
|
|
return v
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// mustWorkspaceAppByWorkspaceAndBuildAndAppID finds a workspace app by
|
|
// workspace ID, build number, and app ID. It returns the workspace app
|
|
// if found, otherwise fails the test.
|
|
func mustWorkspaceAppByWorkspaceAndBuildAndAppID(ctx context.Context, t testing.TB, db database.Store, workspaceID uuid.UUID, buildNumber int32, appID uuid.UUID) database.WorkspaceApp {
|
|
t.Helper()
|
|
|
|
agents, err := db.GetWorkspaceAgentsByWorkspaceAndBuildNumber(ctx, database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{
|
|
WorkspaceID: workspaceID,
|
|
BuildNumber: buildNumber,
|
|
})
|
|
require.NoError(t, err, "get workspace agents")
|
|
require.NotEmpty(t, agents, "no agents found for workspace")
|
|
|
|
for _, agent := range agents {
|
|
apps, err := db.GetWorkspaceAppsByAgentID(ctx, agent.ID)
|
|
require.NoError(t, err, "get workspace apps")
|
|
for _, app := range apps {
|
|
if app.ID == appID {
|
|
return app
|
|
}
|
|
}
|
|
}
|
|
|
|
require.FailNow(t, "could not find workspace app", "workspaceID=%s buildNumber=%d appID=%s", workspaceID, buildNumber, appID)
|
|
return database.WorkspaceApp{} // Unreachable.
|
|
}
|