From ba944abb8621b33de123d9e138234c2310bac6e2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 Nov 2024 17:09:42 +0000 Subject: [PATCH 01/16] feat(coderd/wsbuilder): return provisioners available at time of insert --- coderd/apidoc/docs.go | 3 + coderd/apidoc/swagger.json | 3 + coderd/autobuild/lifecycle_executor.go | 2 +- coderd/database/db2sdk/db2sdk.go | 20 +++++ coderd/database/dbauthz/dbauthz.go | 2 +- coderd/templateversions.go | 81 +++++++------------ coderd/workspacebuilds.go | 13 ++- coderd/workspaces.go | 18 +++-- coderd/wsbuilder/wsbuilder.go | 59 +++++++++----- coderd/wsbuilder/wsbuilder_test.go | 53 +++++++++--- codersdk/workspacebuilds.go | 43 +++++----- docs/reference/api/builds.md | 29 +++++++ docs/reference/api/schemas.md | 16 ++++ docs/reference/api/workspaces.md | 30 +++++++ enterprise/coderd/workspacebuilds_test.go | 2 +- site/src/api/typesGenerated.ts | 1 + .../WorkspaceBuildLogsSection.tsx | 6 +- 17 files changed, 263 insertions(+), 118 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f814b25d99337..02a44689cb8ed 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15068,6 +15068,9 @@ const docTemplate = `{ "job": { "$ref": "#/definitions/codersdk.ProvisionerJob" }, + "matched_provisioners": { + "$ref": "#/definitions/codersdk.MatchedProvisioners" + }, "max_deadline": { "type": "string", "format": "date-time" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4f439e472fa7b..3ccb94bd1a581 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13712,6 +13712,9 @@ "job": { "$ref": "#/definitions/codersdk.ProvisionerJob" }, + "matched_provisioners": { + "$ref": "#/definitions/codersdk.MatchedProvisioners" + }, "max_deadline": { "type": "string", "format": "date-time" diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index ac2930c9e32c8..484455daa54c5 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -245,7 +245,7 @@ func (e *Executor) runOnce(t time.Time) Stats { } } - nextBuild, job, err = builder.Build(e.ctx, tx, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) + nextBuild, job, _, err = builder.Build(e.ctx, tx, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) if err != nil { return xerrors.Errorf("build workspace with transition %q: %w", nextTransition, err) } diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index a0e8977ff8879..19f0d7201106d 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -673,3 +673,23 @@ func CryptoKey(key database.CryptoKey) codersdk.CryptoKey { Secret: key.Secret.String, } } + +func MatchedProvisioners(provisionerDaemons []database.ProvisionerDaemon, now time.Time, staleInterval time.Duration) codersdk.MatchedProvisioners { + minLastSeenAt := now.Add(-staleInterval) + mostRecentlySeen := codersdk.NullTime{} + var matched codersdk.MatchedProvisioners + for _, provisioner := range provisionerDaemons { + if !provisioner.LastSeenAt.Valid { + continue + } + matched.Count++ + if provisioner.LastSeenAt.Time.After(minLastSeenAt) { + matched.Available++ + } + if provisioner.LastSeenAt.Time.After(mostRecentlySeen.Time) { + matched.MostRecentlySeen.Valid = true + matched.MostRecentlySeen.Time = provisioner.LastSeenAt.Time + } + } + return matched +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 58c9179da5e4b..a4717bc108265 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -299,7 +299,7 @@ var ( rbac.ResourceSystem.Type: {policy.WildcardSymbol}, rbac.ResourceOrganization.Type: {policy.ActionCreate, policy.ActionRead}, rbac.ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionDelete, policy.ActionRead}, - rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionUpdate}, + rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, rbac.ResourceProvisionerKeys.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, rbac.ResourceUser.Type: rbac.ResourceUser.AvailableActions(), rbac.ResourceWorkspaceDormant.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStop}, diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 004dc6e5a4d6d..673bb1b235ef1 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -10,7 +10,6 @@ import ( "fmt" "net/http" "os" - "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -22,6 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/provisionerjobs" "github.com/coder/coder/v2/coderd/externalauth" @@ -1513,27 +1513,6 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht return err } - // Check for eligible provisioners. This allows us to log a message warning deployment administrators - // of users submitting jobs for which no provisioners are available. - matchedProvisioners, err = checkProvisioners(ctx, tx, organization.ID, tags) - if err != nil { - api.Logger.Error(ctx, "failed to check eligible provisioner daemons for job", slog.Error(err)) - } else if matchedProvisioners.Count == 0 { - api.Logger.Warn(ctx, "no matching provisioners found for job", - slog.F("user_id", apiKey.UserID), - slog.F("job_id", jobID), - slog.F("job_type", database.ProvisionerJobTypeTemplateVersionImport), - slog.F("tags", tags), - ) - } else if matchedProvisioners.Available == 0 { - api.Logger.Warn(ctx, "no active provisioners found for job", - slog.F("user_id", apiKey.UserID), - slog.F("job_id", jobID), - slog.F("job_type", database.ProvisionerJobTypeTemplateVersionImport), - slog.F("tags", tags), - ) - } - provisionerJob, err = tx.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ ID: jobID, CreatedAt: dbtime.Now(), @@ -1559,6 +1538,33 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht return err } + // Check for eligible provisioners. This allows us to return a warning to the user if they + // submit a job for which no provisioner is available. + eligibleProvisioners, err := tx.GetProvisionerDaemonsByOrganization(ctx, database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: organization.ID, + WantTags: provisionerJob.Tags, + }) + if err != nil { + // Log the error but do not return any warnings. This is purely advisory and we should not block. + api.Logger.Error(ctx, "failed to check eligible provisioner daemons for job", slog.Error(err)) + } + matchedProvisioners = db2sdk.MatchedProvisioners(eligibleProvisioners, provisionerJob.CreatedAt, provisionerdserver.StaleInterval) + if matchedProvisioners.Count == 0 { + api.Logger.Warn(ctx, "no matching provisioners found for job", + slog.F("user_id", apiKey.UserID), + slog.F("job_id", jobID), + slog.F("job_type", database.ProvisionerJobTypeTemplateVersionImport), + slog.F("tags", tags), + ) + } else if matchedProvisioners.Available == 0 { + api.Logger.Warn(ctx, "no active provisioners found for job", + slog.F("user_id", apiKey.UserID), + slog.F("job_id", jobID), + slog.F("job_type", database.ProvisionerJobTypeTemplateVersionImport), + slog.F("tags", tags), + ) + } + var templateID uuid.NullUUID if req.TemplateID != uuid.Nil { templateID = uuid.NullUUID{ @@ -1822,34 +1828,3 @@ func (api *API) publishTemplateUpdate(ctx context.Context, templateID uuid.UUID) slog.F("template_id", templateID), slog.Error(err)) } } - -func checkProvisioners(ctx context.Context, store database.Store, orgID uuid.UUID, wantTags map[string]string) (codersdk.MatchedProvisioners, error) { - // Check for eligible provisioners. This allows us to return a warning to the user if they - // submit a job for which no provisioner is available. - eligibleProvisioners, err := store.GetProvisionerDaemonsByOrganization(ctx, database.GetProvisionerDaemonsByOrganizationParams{ - OrganizationID: orgID, - WantTags: wantTags, - }) - if err != nil { - // Log the error but do not return any warnings. This is purely advisory and we should not block. - return codersdk.MatchedProvisioners{}, xerrors.Errorf("provisioner daemons by organization: %w", err) - } - - staleInterval := time.Now().Add(-provisionerdserver.StaleInterval) - mostRecentlySeen := codersdk.NullTime{} - var matched codersdk.MatchedProvisioners - for _, provisioner := range eligibleProvisioners { - if !provisioner.LastSeenAt.Valid { - continue - } - matched.Count++ - if provisioner.LastSeenAt.Time.After(staleInterval) { - matched.Available++ - } - if provisioner.LastSeenAt.Time.After(mostRecentlySeen.Time) { - matched.MostRecentlySeen.Valid = true - matched.MostRecentlySeen.Time = provisioner.LastSeenAt.Time - } - } - return matched, nil -} diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index fa88a72cf0702..308be194d2e91 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/database/provisionerjobs" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/wsbuilder" @@ -85,6 +86,7 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) { data.scripts, data.logSources, data.templateVersions[0], + nil, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -289,6 +291,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ data.scripts, data.logSources, data.templateVersions[0], + nil, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -352,7 +355,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { builder = builder.State(createBuild.ProvisionerState) } - workspaceBuild, provisionerJob, err := builder.Build( + workspaceBuild, provisionerJob, provisionerDaemons, err := builder.Build( ctx, api.Database, func(action policy.Action, object rbac.Objecter) bool { @@ -390,6 +393,10 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) } + var matchedProvisioners codersdk.MatchedProvisioners + if provisionerJob != nil { + matchedProvisioners = db2sdk.MatchedProvisioners(provisionerDaemons, provisionerJob.CreatedAt, provisionerdserver.StaleInterval) + } apiBuild, err := api.convertWorkspaceBuild( *workspaceBuild, workspace, @@ -404,6 +411,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, + &matchedProvisioners, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -804,6 +812,7 @@ func (api *API) convertWorkspaceBuilds( agentScripts, agentLogSources, templateVersion, + nil, ) if err != nil { return nil, xerrors.Errorf("converting workspace build: %w", err) @@ -826,6 +835,7 @@ func (api *API) convertWorkspaceBuild( agentScripts []database.WorkspaceAgentScript, agentLogSources []database.WorkspaceAgentLogSource, templateVersion database.TemplateVersion, + matchedProvisioners *codersdk.MatchedProvisioners, ) (codersdk.WorkspaceBuild, error) { resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{} for _, resource := range workspaceResources { @@ -918,6 +928,7 @@ func (api *API) convertWorkspaceBuild( Resources: apiResources, Status: codersdk.ConvertWorkspaceStatus(apiJob.Status, transition), DailyCost: build.DailyCost, + MatchedProvisioners: matchedProvisioners, }, nil } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index ff8a55ded775a..6b62a7305d9d5 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/schedule/cron" @@ -593,8 +594,7 @@ func createWorkspace( }}, }) return - } - if err != nil && !errors.Is(err, sql.ErrNoRows) { + } else if !errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: fmt.Sprintf("Internal error fetching workspace by name %q.", req.Name), Detail: err.Error(), @@ -603,8 +603,10 @@ func createWorkspace( } var ( - provisionerJob *database.ProvisionerJob - workspaceBuild *database.WorkspaceBuild + provisionerJob *database.ProvisionerJob + workspaceBuild *database.WorkspaceBuild + provisionerDaemons []database.ProvisionerDaemon + matchedProvisioners codersdk.MatchedProvisioners ) err = api.Database.InTx(func(db database.Store) error { now := dbtime.Now() @@ -645,7 +647,7 @@ func createWorkspace( builder = builder.VersionID(req.TemplateVersionID) } - workspaceBuild, provisionerJob, err = builder.Build( + workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build( ctx, db, func(action policy.Action, object rbac.Objecter) bool { @@ -655,6 +657,7 @@ func createWorkspace( ) return err }, nil) + var bldErr wsbuilder.BuildError if xerrors.As(err, &bldErr) { httpapi.Write(ctx, rw, bldErr.Status, codersdk.Response{ @@ -675,6 +678,10 @@ func createWorkspace( // Client probably doesn't care about this error, so just log it. api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) } + if provisionerJob != nil { + matchedProvisioners = db2sdk.MatchedProvisioners(provisionerDaemons, provisionerJob.CreatedAt, provisionerdserver.StaleInterval) + } + auditReq.New = workspace.WorkspaceTable() api.Telemetry.Report(&telemetry.Snapshot{ @@ -696,6 +703,7 @@ func createWorkspace( []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, + &matchedProvisioners, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 9e8de1d688768..c60a9f17a919c 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/provisionerdserver" @@ -213,12 +214,12 @@ func (b *Builder) Build( authFunc func(action policy.Action, object rbac.Objecter) bool, auditBaggage audit.WorkspaceBuildBaggage, ) ( - *database.WorkspaceBuild, *database.ProvisionerJob, error, + *database.WorkspaceBuild, *database.ProvisionerJob, []database.ProvisionerDaemon, error, ) { var err error b.ctx, err = audit.BaggageToContext(ctx, auditBaggage) if err != nil { - return nil, nil, xerrors.Errorf("create audit baggage: %w", err) + return nil, nil, nil, xerrors.Errorf("create audit baggage: %w", err) } // Run the build in a transaction with RepeatableRead isolation, and retries. @@ -227,16 +228,17 @@ func (b *Builder) Build( // later reads are consistent with earlier ones. var workspaceBuild *database.WorkspaceBuild var provisionerJob *database.ProvisionerJob + var provisionerDaemons []database.ProvisionerDaemon err = database.ReadModifyUpdate(store, func(tx database.Store) error { var err error b.store = tx - workspaceBuild, provisionerJob, err = b.buildTx(authFunc) + workspaceBuild, provisionerJob, provisionerDaemons, err = b.buildTx(authFunc) return err }) if err != nil { - return nil, nil, xerrors.Errorf("build tx: %w", err) + return nil, nil, nil, xerrors.Errorf("build tx: %w", err) } - return workspaceBuild, provisionerJob, nil + return workspaceBuild, provisionerJob, provisionerDaemons, nil } // buildTx contains the business logic of computing a new build. Attributes of the new database objects are computed @@ -246,35 +248,35 @@ func (b *Builder) Build( // // In order to utilize this cache, the functions that compute build attributes use a pointer receiver type. func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Objecter) bool) ( - *database.WorkspaceBuild, *database.ProvisionerJob, error, + *database.WorkspaceBuild, *database.ProvisionerJob, []database.ProvisionerDaemon, error, ) { if authFunc != nil { err := b.authorize(authFunc) if err != nil { - return nil, nil, err + return nil, nil, nil, err } } err := b.checkTemplateVersionMatchesTemplate() if err != nil { - return nil, nil, err + return nil, nil, nil, err } err = b.checkTemplateJobStatus() if err != nil { - return nil, nil, err + return nil, nil, nil, err } err = b.checkRunningBuild() if err != nil { - return nil, nil, err + return nil, nil, nil, err } template, err := b.getTemplate() if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch template", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch template", err} } templateVersionJob, err := b.getTemplateVersionJob() if err != nil { - return nil, nil, BuildError{ + return nil, nil, nil, BuildError{ http.StatusInternalServerError, "failed to fetch template version job", err, } } @@ -294,7 +296,7 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object LogLevel: b.logLevel, }) if err != nil { - return nil, nil, BuildError{ + return nil, nil, nil, BuildError{ http.StatusInternalServerError, "marshal provision job", err, @@ -302,12 +304,12 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object } traceMetadataRaw, err := json.Marshal(tracing.MetadataFromContext(b.ctx)) if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "marshal metadata", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "marshal metadata", err} } tags, err := b.getProvisionerTags() if err != nil { - return nil, nil, err // already wrapped BuildError + return nil, nil, nil, err // already wrapped BuildError } now := dbtime.Now() @@ -329,20 +331,35 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object }, }) if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "insert provisioner job", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "insert provisioner job", err} + } + + // nolint:gocritic // The user performing this request may not have permission + // to read all provisioner daemons. We need to retrieve the eligible + // provisioner daemons for this job to show in the UI if there is no + // matching provisioner daemon. + provisionerDaemons, err := b.store.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemRestricted(b.ctx), database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: template.OrganizationID, + WantTags: provisionerJob.Tags, + }) + if err != nil { + // NOTE: we do **not** want to fail a workspace build if we fail to + // retrieve provisioner daemons. This is just to show in the UI if there + // is no matching provisioner daemon for the job. + provisionerDaemons = []database.ProvisionerDaemon{} } templateVersionID, err := b.getTemplateVersionID() if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "compute template version ID", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "compute template version ID", err} } buildNum, err := b.getBuildNumber() if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "compute build number", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "compute build number", err} } state, err := b.getState() if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "compute build state", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "compute build state", err} } var workspaceBuild database.WorkspaceBuild @@ -393,10 +410,10 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object return nil }, nil) if err != nil { - return nil, nil, err + return nil, nil, nil, err } - return &workspaceBuild, &provisionerJob, nil + return &workspaceBuild, &provisionerJob, provisionerDaemons, nil } func (b *Builder) getTemplate() (*database.Template, error) { diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index dd532467bbc92..ad0df2816ffac 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -61,6 +61,7 @@ func TestBuilder_NoOptions(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -94,7 +95,8 @@ func TestBuilder_NoOptions(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -114,6 +116,7 @@ func TestBuilder_Initiator(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -130,7 +133,8 @@ func TestBuilder_Initiator(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Initiator(otherUserID) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -157,6 +161,7 @@ func TestBuilder_Baggage(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -172,7 +177,8 @@ func TestBuilder_Baggage(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Initiator(otherUserID) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) req.NoError(err) } @@ -192,6 +198,7 @@ func TestBuilder_Reason(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(_ database.InsertProvisionerJobParams) { @@ -207,7 +214,8 @@ func TestBuilder_Reason(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Reason(database.BuildReasonAutostart) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -226,6 +234,7 @@ func TestBuilder_ActiveVersion(t *testing.T) { withLastBuildNotFound, withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // previous rich parameters are not queried because there is no previous build. // Outputs @@ -247,7 +256,8 @@ func TestBuilder_ActiveVersion(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).ActiveVersion() - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -314,6 +324,7 @@ func TestWorkspaceBuildWithTags(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, workspaceTags), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -343,7 +354,8 @@ func TestWorkspaceBuildWithTags(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(buildParameters) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -404,6 +416,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -422,7 +435,8 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) t.Run("UsePreviousParameterValues", func(t *testing.T) { @@ -448,6 +462,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -466,7 +481,8 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -502,7 +518,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) bldErr := wsbuilder.BuildError{} req.ErrorAs(err, &bldErr) asrt.Equal(http.StatusBadRequest, bldErr.Status) @@ -536,7 +552,8 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) bldErr := wsbuilder.BuildError{} req.ErrorAs(err, &bldErr) asrt.Equal(http.StatusBadRequest, bldErr.Status) @@ -579,6 +596,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -599,7 +617,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -640,6 +658,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -660,7 +679,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -699,6 +718,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -719,7 +739,8 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) } @@ -987,3 +1008,9 @@ func expectBuildParameters( ) } } + +func withProvisionerDaemons(provisionerDaemons []database.ProvisionerDaemon) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().GetProvisionerDaemonsByOrganization(gomock.Any(), gomock.Any()).Return(provisionerDaemons, nil) + } +} diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index 761be48a9e488..2718735f01177 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -51,27 +51,28 @@ const ( // WorkspaceBuild is an at-point representation of a workspace state. // BuildNumbers start at 1 and increase by 1 for each subsequent build type WorkspaceBuild struct { - ID uuid.UUID `json:"id" format:"uuid"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` - WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"` - WorkspaceName string `json:"workspace_name"` - WorkspaceOwnerID uuid.UUID `json:"workspace_owner_id" format:"uuid"` - WorkspaceOwnerName string `json:"workspace_owner_name"` - WorkspaceOwnerAvatarURL string `json:"workspace_owner_avatar_url"` - TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid"` - TemplateVersionName string `json:"template_version_name"` - BuildNumber int32 `json:"build_number"` - Transition WorkspaceTransition `json:"transition" enums:"start,stop,delete"` - InitiatorID uuid.UUID `json:"initiator_id" format:"uuid"` - InitiatorUsername string `json:"initiator_name"` - Job ProvisionerJob `json:"job"` - Reason BuildReason `db:"reason" json:"reason" enums:"initiator,autostart,autostop"` - Resources []WorkspaceResource `json:"resources"` - Deadline NullTime `json:"deadline,omitempty" format:"date-time"` - MaxDeadline NullTime `json:"max_deadline,omitempty" format:"date-time"` - Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted"` - DailyCost int32 `json:"daily_cost"` + ID uuid.UUID `json:"id" format:"uuid"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` + WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"` + WorkspaceName string `json:"workspace_name"` + WorkspaceOwnerID uuid.UUID `json:"workspace_owner_id" format:"uuid"` + WorkspaceOwnerName string `json:"workspace_owner_name"` + WorkspaceOwnerAvatarURL string `json:"workspace_owner_avatar_url"` + TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid"` + TemplateVersionName string `json:"template_version_name"` + BuildNumber int32 `json:"build_number"` + Transition WorkspaceTransition `json:"transition" enums:"start,stop,delete"` + InitiatorID uuid.UUID `json:"initiator_id" format:"uuid"` + InitiatorUsername string `json:"initiator_name"` + Job ProvisionerJob `json:"job"` + Reason BuildReason `db:"reason" json:"reason" enums:"initiator,autostart,autostop"` + Resources []WorkspaceResource `json:"resources"` + Deadline NullTime `json:"deadline,omitempty" format:"date-time"` + MaxDeadline NullTime `json:"max_deadline,omitempty" format:"date-time"` + Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted"` + DailyCost int32 `json:"daily_cost"` + MatchedProvisioners *MatchedProvisioners `json:"matched_provisioners,omitempty"` } // WorkspaceResource describes resources used to create a workspace, for instance: diff --git a/docs/reference/api/builds.md b/docs/reference/api/builds.md index 1a03888508e3b..1cbe384df8778 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -52,6 +52,11 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ @@ -237,6 +242,11 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ @@ -856,6 +866,11 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ @@ -1114,6 +1129,11 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ @@ -1277,6 +1297,10 @@ Status Code **200** | `»» tags` | object | false | | | | `»»» [any property]` | string | false | | | | `»» worker_id` | string(uuid) | false | | | +| `» matched_provisioners` | [codersdk.MatchedProvisioners](schemas.md#codersdkmatchedprovisioners) | false | | | +| `»» available` | integer | false | | Available is the number of provisioner daemons that are available to take jobs. This may be less than the count if some provisioners are busy or have been stopped. | +| `»» count` | integer | false | | Count is the number of provisioner daemons that matched the given tags. If the count is 0, it means no provisioner daemons matched the requested tags. | +| `»» most_recently_seen` | string(date-time) | false | | Most recently seen is the most recently seen time of the set of matched provisioners. If no provisioners matched, this field will be null. | | `» max_deadline` | string(date-time) | false | | | | `» reason` | [codersdk.BuildReason](schemas.md#codersdkbuildreason) | false | | | | `» resources` | array | false | | | @@ -1500,6 +1524,11 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 35c677bccdda0..565df855ae597 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6602,6 +6602,11 @@ If the schedule is empty, the user will be updated to use the default schedule.| }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ @@ -7300,6 +7305,11 @@ If the schedule is empty, the user will be updated to use the default schedule.| }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ @@ -7439,6 +7449,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `initiator_id` | string | false | | | | `initiator_name` | string | false | | | | `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | | +| `matched_provisioners` | [codersdk.MatchedProvisioners](#codersdkmatchedprovisioners) | false | | | | `max_deadline` | string | false | | | | `reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | | | `resources` | array of [codersdk.WorkspaceResource](#codersdkworkspaceresource) | false | | | @@ -7926,6 +7937,11 @@ If the schedule is empty, the user will be updated to use the default schedule.| }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 183a59ddd13a3..ca9559a320b72 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -91,6 +91,11 @@ of the template will be used. }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ @@ -309,6 +314,11 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ @@ -551,6 +561,11 @@ of the template will be used. }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ @@ -772,6 +787,11 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ @@ -987,6 +1007,11 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ @@ -1321,6 +1346,11 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "matched_provisioners": { + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" + }, "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ diff --git a/enterprise/coderd/workspacebuilds_test.go b/enterprise/coderd/workspacebuilds_test.go index 12ba9c95f964e..8f9edbb933530 100644 --- a/enterprise/coderd/workspacebuilds_test.go +++ b/enterprise/coderd/workspacebuilds_test.go @@ -109,7 +109,7 @@ func TestWorkspaceBuild(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - _, err = c.Client.CreateWorkspace(ctx, owner.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ + _, err = c.Client.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ TemplateVersionID: oldVersion.ID, Name: "abc123", AutomaticUpdates: codersdk.AutomaticUpdatesNever, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c1b409013b6d7..6a5f6d529438f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2012,6 +2012,7 @@ export interface WorkspaceBuild { readonly max_deadline?: string; readonly status: WorkspaceStatus; readonly daily_cost: number; + readonly matched_provisioners?: MatchedProvisioners; } // From codersdk/workspacebuilds.go diff --git a/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx b/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx index 9c3e6d5479bf6..0ddce4cc80326 100644 --- a/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx @@ -1,6 +1,10 @@ import { useTheme } from "@emotion/react"; -import type { ProvisionerJobLog } from "api/typesGenerated"; +import type { + MatchedProvisioners, + ProvisionerJobLog, +} from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; +import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; import { type FC, useEffect, useRef } from "react"; From 4e51f2069e0b7a61743a259a8b9ac00f8dfa887f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 Nov 2024 22:04:09 +0000 Subject: [PATCH 02/16] feat(coderd): add matched provisioner daemons information to additional template-related endpoints --- coderd/database/dbauthz/dbauthz.go | 23 +++++++ coderd/templateversions.go | 96 ++++++++++++++++++++++++++++-- coderd/templateversions_test.go | 29 ++++++++- 3 files changed, 140 insertions(+), 8 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index a4717bc108265..4adea76c22fbe 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -317,6 +317,23 @@ var ( }), Scope: rbac.ScopeAll, }.WithCachedASTValue() + + subjectSystemReadProvisionerDaemons = rbac.Subject{ + FriendlyName: "System", + ID: uuid.Nil.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "system-read-provisioner-daemons"}, + DisplayName: "Coder", + Site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceProvisionerDaemon.Type: {policy.ActionRead}, + }), + Org: map[string][]rbac.Permission{}, + User: []rbac.Permission{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() ) // AsProvisionerd returns a context with an actor that has permissions required @@ -359,6 +376,12 @@ func AsSystemRestricted(ctx context.Context) context.Context { return context.WithValue(ctx, authContextKey{}, subjectSystemRestricted) } +// AsSystemReadProvisionerDaemons returns a context with an actor that has permissions +// to read provisioner daemons. +func AsSystemReadProvisionerDaemons(ctx context.Context) context.Context { + return context.WithValue(ctx, authContextKey{}, subjectSystemReadProvisionerDaemons) +} + var AsRemoveActor = rbac.Subject{ ID: "remove-actor", } diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 673bb1b235ef1..b6ee225236095 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -22,6 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/provisionerjobs" "github.com/coder/coder/v2/coderd/externalauth" @@ -32,6 +33,7 @@ import ( "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/examples" "github.com/coder/coder/v2/provisioner/terraform/tfparse" @@ -60,6 +62,22 @@ func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) { return } + var matchedProvisioners *codersdk.MatchedProvisioners + if jobs[0].ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending { + // nolint: gocritic // The user hitting this endpoint may not have + // permission to read provisioner daemons, but we want to show them + // information about the provisioner daemons that are available. + provisioners, err := api.Database.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(ctx), database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: jobs[0].ProvisionerJob.OrganizationID, + WantTags: jobs[0].ProvisionerJob.Tags, + }) + if err != nil { + api.Logger.Error(ctx, "failed to fetch provisioners for job id", slog.F("job_id", jobs[0].ProvisionerJob.ID), slog.Error(err)) + } else { + matchedProvisioners = ptr.Ref(db2sdk.MatchedProvisioners(provisioners, dbtime.Now(), provisionerdserver.StaleInterval)) + } + } + schemas, err := api.Database.GetParameterSchemasByJobID(ctx, jobs[0].ProvisionerJob.ID) if errors.Is(err, sql.ErrNoRows) { err = nil @@ -77,7 +95,7 @@ func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) { warnings = append(warnings, codersdk.TemplateVersionWarningUnsupportedWorkspaces) } - httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(jobs[0]), nil, warnings)) + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(jobs[0]), matchedProvisioners, warnings)) } // @Summary Patch template version by ID @@ -173,7 +191,23 @@ func (api *API) patchTemplateVersion(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(updatedTemplateVersion, convertProvisionerJob(jobs[0]), nil, nil)) + var matchedProvisioners *codersdk.MatchedProvisioners + if jobs[0].ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending { + // nolint: gocritic // The user hitting this endpoint may not have + // permission to read provisioner daemons, but we want to show them + // information about the provisioner daemons that are available. + provisioners, err := api.Database.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(ctx), database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: jobs[0].ProvisionerJob.OrganizationID, + WantTags: jobs[0].ProvisionerJob.Tags, + }) + if err != nil { + api.Logger.Error(ctx, "failed to fetch provisioners for job id", slog.F("job_id", jobs[0].ProvisionerJob.ID), slog.Error(err)) + } else { + matchedProvisioners = ptr.Ref(db2sdk.MatchedProvisioners(provisioners, dbtime.Now(), provisionerdserver.StaleInterval)) + } + } + + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(updatedTemplateVersion, convertProvisionerJob(jobs[0]), matchedProvisioners, nil)) } // @Summary Cancel template version by ID @@ -868,8 +902,23 @@ func (api *API) templateVersionByName(rw http.ResponseWriter, r *http.Request) { }) return } + var matchedProvisioners *codersdk.MatchedProvisioners + if jobs[0].ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending { + // nolint: gocritic // The user hitting this endpoint may not have + // permission to read provisioner daemons, but we want to show them + // information about the provisioner daemons that are available. + provisioners, err := api.Database.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(ctx), database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: jobs[0].ProvisionerJob.OrganizationID, + WantTags: jobs[0].ProvisionerJob.Tags, + }) + if err != nil { + api.Logger.Error(ctx, "failed to fetch provisioners for job id", slog.F("job_id", jobs[0].ProvisionerJob.ID), slog.Error(err)) + } else { + matchedProvisioners = ptr.Ref(db2sdk.MatchedProvisioners(provisioners, dbtime.Now(), provisionerdserver.StaleInterval)) + } + } - httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(jobs[0]), nil, nil)) + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(jobs[0]), matchedProvisioners, nil)) } // @Summary Get template version by organization, template, and name @@ -934,7 +983,23 @@ func (api *API) templateVersionByOrganizationTemplateAndName(rw http.ResponseWri return } - httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(jobs[0]), nil, nil)) + var matchedProvisioners *codersdk.MatchedProvisioners + if jobs[0].ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending { + // nolint: gocritic // The user hitting this endpoint may not have + // permission to read provisioner daemons, but we want to show them + // information about the provisioner daemons that are available. + provisioners, err := api.Database.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(ctx), database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: jobs[0].ProvisionerJob.OrganizationID, + WantTags: jobs[0].ProvisionerJob.Tags, + }) + if err != nil { + api.Logger.Error(ctx, "failed to fetch provisioners for job id", slog.F("job_id", jobs[0].ProvisionerJob.ID), slog.Error(err)) + } else { + matchedProvisioners = ptr.Ref(db2sdk.MatchedProvisioners(provisioners, dbtime.Now(), provisionerdserver.StaleInterval)) + } + } + + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(jobs[0]), matchedProvisioners, nil)) } // @Summary Get previous template version by organization, template, and name @@ -1020,7 +1085,23 @@ func (api *API) previousTemplateVersionByOrganizationTemplateAndName(rw http.Res return } - httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(previousTemplateVersion, convertProvisionerJob(jobs[0]), nil, nil)) + var matchedProvisioners *codersdk.MatchedProvisioners + if jobs[0].ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending { + // nolint: gocritic // The user hitting this endpoint may not have + // permission to read provisioner daemons, but we want to show them + // information about the provisioner daemons that are available. + provisioners, err := api.Database.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(ctx), database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: jobs[0].ProvisionerJob.OrganizationID, + WantTags: jobs[0].ProvisionerJob.Tags, + }) + if err != nil { + api.Logger.Error(ctx, "failed to fetch provisioners for job id", slog.F("job_id", jobs[0].ProvisionerJob.ID), slog.Error(err)) + } else { + matchedProvisioners = ptr.Ref(db2sdk.MatchedProvisioners(provisioners, dbtime.Now(), provisionerdserver.StaleInterval)) + } + } + + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(previousTemplateVersion, convertProvisionerJob(jobs[0]), matchedProvisioners, nil)) } // @Summary Archive template unused versions by template id @@ -1540,7 +1621,10 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht // Check for eligible provisioners. This allows us to return a warning to the user if they // submit a job for which no provisioner is available. - eligibleProvisioners, err := tx.GetProvisionerDaemonsByOrganization(ctx, database.GetProvisionerDaemonsByOrganizationParams{ + // nolint: gocritic // The user hitting this endpoint may not have + // permission to read provisioner daemons, but we want to show them + // information about the provisioner daemons that are available. + eligibleProvisioners, err := tx.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(ctx), database.GetProvisionerDaemonsByOrganizationParams{ OrganizationID: organization.ID, WantTags: provisionerJob.Tags, }) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 2a13af0bc410a..c1d1c57878fcc 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -50,6 +50,12 @@ func TestTemplateVersion(t *testing.T) { tv, err := client.TemplateVersion(ctx, version.ID) authz.AssertChecked(t, policy.ActionRead, tv) require.NoError(t, err) + if assert.Equal(t, tv.Job.Status, codersdk.ProvisionerJobPending) { + assert.NotNil(t, tv.MatchedProvisioners) + assert.Zero(t, tv.MatchedProvisioners.Available) + assert.Zero(t, tv.MatchedProvisioners.Count) + assert.False(t, tv.MatchedProvisioners.MostRecentlySeen.Valid) + } assert.Equal(t, "bananas", tv.Name) assert.Equal(t, "first try", tv.Message) @@ -87,8 +93,14 @@ func TestTemplateVersion(t *testing.T) { client1, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) - _, err := client1.TemplateVersion(ctx, version.ID) + tv, err := client1.TemplateVersion(ctx, version.ID) require.NoError(t, err) + if assert.Equal(t, tv.Job.Status, codersdk.ProvisionerJobPending) { + assert.NotNil(t, tv.MatchedProvisioners) + assert.Zero(t, tv.MatchedProvisioners.Available) + assert.Zero(t, tv.MatchedProvisioners.Count) + assert.False(t, tv.MatchedProvisioners.MostRecentlySeen.Valid) + } }) } @@ -158,6 +170,12 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { require.NoError(t, err) require.Equal(t, "bananas", version.Name) require.Equal(t, provisionersdk.ScopeOrganization, version.Job.Tags[provisionersdk.TagScope]) + if assert.Equal(t, version.Job.Status, codersdk.ProvisionerJobPending) { + assert.NotNil(t, version.MatchedProvisioners) + assert.Equal(t, version.MatchedProvisioners.Available, 1) + assert.Equal(t, version.MatchedProvisioners.Count, 1) + assert.True(t, version.MatchedProvisioners.MostRecentlySeen.Valid) + } require.Len(t, auditor.AuditLogs(), 2) assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs()[1].Action) @@ -790,8 +808,15 @@ func TestTemplateVersionByName(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, err := client.TemplateVersionByName(ctx, template.ID, version.Name) + tv, err := client.TemplateVersionByName(ctx, template.ID, version.Name) require.NoError(t, err) + + if assert.Equal(t, tv.Job.Status, codersdk.ProvisionerJobPending) { + assert.NotNil(t, tv.MatchedProvisioners) + assert.Zero(t, tv.MatchedProvisioners.Available) + assert.Zero(t, tv.MatchedProvisioners.Count) + assert.False(t, tv.MatchedProvisioners.MostRecentlySeen.Valid) + } }) } From 47036e8a543550cde3fd74622a384fe1770f95b5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Sun, 1 Dec 2024 14:42:01 +0000 Subject: [PATCH 03/16] fix linter complaint re nil ptr deref --- coderd/workspacebuilds.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 308be194d2e91..f041734a7d195 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -387,10 +387,12 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { }) return } - err = provisionerjobs.PostJob(api.Pubsub, *provisionerJob) - if err != nil { - // Client probably doesn't care about this error, so just log it. - api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) + + if provisionerJob != nil { + if err := provisionerjobs.PostJob(api.Pubsub, *provisionerJob); err != nil { + // Client probably doesn't care about this error, so just log it. + api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) + } } var matchedProvisioners codersdk.MatchedProvisioners From 16be03bc621fdc66d56dba67bbf2a8fc2583a0bb Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Sun, 1 Dec 2024 16:18:54 +0000 Subject: [PATCH 04/16] add test coverage for matched provisioners with workspace build creation --- coderd/workspacebuilds_test.go | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 29642e5ae2dd4..3952c102b8cce 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -1097,6 +1097,12 @@ func TestPostWorkspaceBuild(t *testing.T) { Transition: codersdk.WorkspaceTransitionStart, }) require.NoError(t, err) + if assert.NotNil(t, build.MatchedProvisioners) { + require.Equal(t, 1, build.MatchedProvisioners.Count) + require.Equal(t, 1, build.MatchedProvisioners.Available) + require.NotZero(t, build.MatchedProvisioners.MostRecentlySeen.Time) + } + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) require.Eventually(t, func() bool { @@ -1124,6 +1130,12 @@ func TestPostWorkspaceBuild(t *testing.T) { Transition: codersdk.WorkspaceTransitionStart, }) require.NoError(t, err) + if assert.NotNil(t, build.MatchedProvisioners) { + require.Equal(t, 1, build.MatchedProvisioners.Count) + require.Equal(t, 1, build.MatchedProvisioners.Available) + require.NotZero(t, build.MatchedProvisioners.MostRecentlySeen.Time) + } + require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber) }) @@ -1150,6 +1162,12 @@ func TestPostWorkspaceBuild(t *testing.T) { ProvisionerState: wantState, }) require.NoError(t, err) + if assert.NotNil(t, build.MatchedProvisioners) { + require.Equal(t, 1, build.MatchedProvisioners.Count) + require.Equal(t, 1, build.MatchedProvisioners.Available) + require.NotZero(t, build.MatchedProvisioners.MostRecentlySeen.Time) + } + gotState, err := client.WorkspaceBuildState(ctx, build.ID) require.NoError(t, err) require.Equal(t, wantState, gotState) @@ -1173,6 +1191,12 @@ func TestPostWorkspaceBuild(t *testing.T) { }) require.NoError(t, err) require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber) + if assert.NotNil(t, build.MatchedProvisioners) { + require.Equal(t, 1, build.MatchedProvisioners.Count) + require.Equal(t, 1, build.MatchedProvisioners.Available) + require.NotZero(t, build.MatchedProvisioners.MostRecentlySeen.Time) + } + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ @@ -1181,6 +1205,92 @@ func TestPostWorkspaceBuild(t *testing.T) { require.NoError(t, err) require.Len(t, res.Workspaces, 0) }) + + t.Run("NoProvisionersAvailable", func(t *testing.T) { + t.Parallel() + // Given: a coderd instance with a provisioner daemon + store, ps, db := dbtestutil.NewDBWithSQLDB(t) + client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ + Database: store, + Pubsub: ps, + IncludeProvisionerDaemon: true, + }) + // Given: a user, template, and workspace + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Stop the provisioner daemon. + require.NoError(t, closeDaemon.Close()) + ctx := testutil.Context(t, testutil.WaitLong) + // Given: no provisioner daemons exist. + _, err := db.ExecContext(ctx, `DELETE FROM provisioner_daemons;`) + require.NoError(t, err) + + // When: a new workspace build is created + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: template.ActiveVersionID, + Transition: codersdk.WorkspaceTransitionStart, + }) + // Then: the request should succeed. + require.NoError(t, err) + // Then: the provisioner job should remain pending. + require.Equal(t, codersdk.ProvisionerJobPending, build.Job.Status) + // Then: the response should indicate no provisioners are available. + if assert.NotNil(t, build.MatchedProvisioners) { + require.Zero(t, build.MatchedProvisioners.Count) + require.Zero(t, build.MatchedProvisioners.Available) + require.Zero(t, build.MatchedProvisioners.MostRecentlySeen.Time) + } + }) + + t.Run("AllProvisionersStale", func(t *testing.T) { + t.Parallel() + // Given: a coderd instance with a provisioner daemon + store, ps, db := dbtestutil.NewDBWithSQLDB(t) + client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ + Database: store, + Pubsub: ps, + IncludeProvisionerDaemon: true, + }) + // Given: a user, template, and workspace + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + // Given: all provisioner daemons are stale + // First stop the provisioner + require.NoError(t, closeDaemon.Close()) + newLastSeenAt := dbtime.Now().Add(-time.Hour) + // Update the last seen at for all provisioner daemons. We have to use the + // SQL db directly because store.UpdateProvisionerDaemonLastSeenAt has a + // built-in check to prevent updating the last seen at to a time in the past. + _, err := db.ExecContext(ctx, `UPDATE provisioner_daemons SET last_seen_at = $1;`, newLastSeenAt) + require.NoError(t, err) + + // When: a new workspace build is created + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: template.ActiveVersionID, + Transition: codersdk.WorkspaceTransitionStart, + }) + // Then: the request should succeed + require.NoError(t, err) + // Then: the provisioner job should remain pending + require.Equal(t, codersdk.ProvisionerJobPending, build.Job.Status) + // Then: the response should indicate no provisioners are available + if assert.NotNil(t, build.MatchedProvisioners) { + require.Zero(t, build.MatchedProvisioners.Available) + require.Equal(t, 1, build.MatchedProvisioners.Count) + require.Equal(t, newLastSeenAt.UTC(), build.MatchedProvisioners.MostRecentlySeen.Time.UTC()) + } + }) } func TestWorkspaceBuildTimings(t *testing.T) { From 4304a064293cc55ed5b9d6ad883b2239a6af5734 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Sun, 1 Dec 2024 16:31:25 +0000 Subject: [PATCH 05/16] skip for non-postgres --- coderd/workspacebuilds_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 3952c102b8cce..8b473976b6dfe 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -1208,6 +1208,9 @@ func TestPostWorkspaceBuild(t *testing.T) { t.Run("NoProvisionersAvailable", func(t *testing.T) { t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } // Given: a coderd instance with a provisioner daemon store, ps, db := dbtestutil.NewDBWithSQLDB(t) client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ @@ -1249,6 +1252,9 @@ func TestPostWorkspaceBuild(t *testing.T) { t.Run("AllProvisionersStale", func(t *testing.T) { t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } // Given: a coderd instance with a provisioner daemon store, ps, db := dbtestutil.NewDBWithSQLDB(t) client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ From 9ef68dd61b222875cc4c4d4091cb2fa63e8d36b5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Sun, 1 Dec 2024 16:57:32 +0000 Subject: [PATCH 06/16] add tests for workspace creation --- coderd/workspacebuilds_test.go | 16 ++++--- coderd/workspaces_test.go | 88 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 8b473976b6dfe..feb748ad29250 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -1218,6 +1218,7 @@ func TestPostWorkspaceBuild(t *testing.T) { Pubsub: ps, IncludeProvisionerDaemon: true, }) + defer closeDaemon.Close() // Given: a user, template, and workspace user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -1244,9 +1245,10 @@ func TestPostWorkspaceBuild(t *testing.T) { require.Equal(t, codersdk.ProvisionerJobPending, build.Job.Status) // Then: the response should indicate no provisioners are available. if assert.NotNil(t, build.MatchedProvisioners) { - require.Zero(t, build.MatchedProvisioners.Count) - require.Zero(t, build.MatchedProvisioners.Available) - require.Zero(t, build.MatchedProvisioners.MostRecentlySeen.Time) + assert.Zero(t, build.MatchedProvisioners.Count) + assert.Zero(t, build.MatchedProvisioners.Available) + assert.Zero(t, build.MatchedProvisioners.MostRecentlySeen.Time) + assert.False(t, build.MatchedProvisioners.MostRecentlySeen.Valid) } }) @@ -1262,6 +1264,7 @@ func TestPostWorkspaceBuild(t *testing.T) { Pubsub: ps, IncludeProvisionerDaemon: true, }) + defer closeDaemon.Close() // Given: a user, template, and workspace user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -1292,9 +1295,10 @@ func TestPostWorkspaceBuild(t *testing.T) { require.Equal(t, codersdk.ProvisionerJobPending, build.Job.Status) // Then: the response should indicate no provisioners are available if assert.NotNil(t, build.MatchedProvisioners) { - require.Zero(t, build.MatchedProvisioners.Available) - require.Equal(t, 1, build.MatchedProvisioners.Count) - require.Equal(t, newLastSeenAt.UTC(), build.MatchedProvisioners.MostRecentlySeen.Time.UTC()) + assert.Zero(t, build.MatchedProvisioners.Available) + assert.Equal(t, 1, build.MatchedProvisioners.Count) + assert.Equal(t, newLastSeenAt.UTC(), build.MatchedProvisioners.MostRecentlySeen.Time.UTC()) + assert.True(t, build.MatchedProvisioners.MostRecentlySeen.Valid) } }) } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index aed5fa2723d2a..6a2856dcbbe76 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -766,6 +766,94 @@ func TestPostWorkspacesByOrganization(t *testing.T) { require.NoError(t, err) require.EqualValues(t, exp, *ws.TTLMillis) }) + + t.Run("NoProvisionersAvailable", func(t *testing.T) { + t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } + // Given: a coderd instance with a provisioner daemon + store, ps, db := dbtestutil.NewDBWithSQLDB(t) + client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ + Database: store, + Pubsub: ps, + IncludeProvisionerDaemon: true, + }) + defer closeDaemon.Close() + + // Given: a user, template, and workspace + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + // Given: all the provisioner daemons disappear + ctx := testutil.Context(t, testutil.WaitLong) + _, err := db.ExecContext(ctx, `DELETE FROM provisioner_daemons;`) + require.NoError(t, err) + + // When: a new workspace is created + ws, err := client.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "testing", + }) + // Then: the request succeeds + require.NoError(t, err) + // Then: the workspace build is pending + require.Equal(t, codersdk.ProvisionerJobPending, ws.LatestBuild.Job.Status) + // Then: the workspace build has no matched provisioners + if assert.NotNil(t, ws.LatestBuild.MatchedProvisioners) { + assert.Zero(t, ws.LatestBuild.MatchedProvisioners.Count) + assert.Zero(t, ws.LatestBuild.MatchedProvisioners.Available) + assert.Zero(t, ws.LatestBuild.MatchedProvisioners.MostRecentlySeen.Time) + assert.False(t, ws.LatestBuild.MatchedProvisioners.MostRecentlySeen.Valid) + } + }) + + t.Run("AllProvisionersStale", func(t *testing.T) { + t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } + + // Given: a coderd instance with a provisioner daemon + store, ps, db := dbtestutil.NewDBWithSQLDB(t) + client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ + Database: store, + Pubsub: ps, + IncludeProvisionerDaemon: true, + }) + defer closeDaemon.Close() + + // Given: a user, template, and workspace + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + // Given: all the provisioner daemons have not been seen for a while + ctx := testutil.Context(t, testutil.WaitLong) + newLastSeenAt := dbtime.Now().Add(-time.Hour) + _, err := db.ExecContext(ctx, `UPDATE provisioner_daemons SET last_seen_at = $1;`, newLastSeenAt) + require.NoError(t, err) + + // When: a new workspace is created + ws, err := client.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "testing", + }) + // Then: the request succeeds + require.NoError(t, err) + // Then: the workspace build is pending + require.Equal(t, codersdk.ProvisionerJobPending, ws.LatestBuild.Job.Status) + // Then: we can see that there are some provisioners that are stale + if assert.NotNil(t, ws.LatestBuild.MatchedProvisioners) { + assert.Equal(t, 1, ws.LatestBuild.MatchedProvisioners.Count) + assert.Zero(t, ws.LatestBuild.MatchedProvisioners.Available) + assert.Equal(t, newLastSeenAt.UTC(), ws.LatestBuild.MatchedProvisioners.MostRecentlySeen.Time.UTC()) + assert.True(t, ws.LatestBuild.MatchedProvisioners.MostRecentlySeen.Valid) + } + }) } func TestWorkspaceByOwnerAndName(t *testing.T) { From 38788d5822deceb68560693863c50ba87614219d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Sun, 1 Dec 2024 17:00:25 +0000 Subject: [PATCH 07/16] revert fe changes in this pr --- site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx b/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx index 0ddce4cc80326..9c3e6d5479bf6 100644 --- a/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx @@ -1,10 +1,6 @@ import { useTheme } from "@emotion/react"; -import type { - MatchedProvisioners, - ProvisionerJobLog, -} from "api/typesGenerated"; +import type { ProvisionerJobLog } from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; -import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; import { type FC, useEffect, useRef } from "react"; From 1c95ffe7fc35768d311d022b3da95871de3d707d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Sun, 1 Dec 2024 17:08:35 +0000 Subject: [PATCH 08/16] coderd/wsbuilder: use dbauthz.AsSystemReadProvisionerDaemons instead of dbauthz.AsSystemRestricted --- coderd/wsbuilder/wsbuilder.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index c60a9f17a919c..46b65e70d0f80 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -338,7 +338,7 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object // to read all provisioner daemons. We need to retrieve the eligible // provisioner daemons for this job to show in the UI if there is no // matching provisioner daemon. - provisionerDaemons, err := b.store.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemRestricted(b.ctx), database.GetProvisionerDaemonsByOrganizationParams{ + provisionerDaemons, err := b.store.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(b.ctx), database.GetProvisionerDaemonsByOrganizationParams{ OrganizationID: template.OrganizationID, WantTags: provisionerJob.Tags, }) From 98521bebf8b4b39883380eba56f95402ea85694c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Sun, 1 Dec 2024 21:11:54 +0000 Subject: [PATCH 09/16] refactor: extract WarnMatchedProvisioners to cliutil --- cli/cliutil/provisionerwarn.go | 46 ++++++++++++++++++++++++++++++++++ cli/templatepush.go | 39 ++-------------------------- 2 files changed, 48 insertions(+), 37 deletions(-) create mode 100644 cli/cliutil/provisionerwarn.go diff --git a/cli/cliutil/provisionerwarn.go b/cli/cliutil/provisionerwarn.go new file mode 100644 index 0000000000000..88e863e8b9dad --- /dev/null +++ b/cli/cliutil/provisionerwarn.go @@ -0,0 +1,46 @@ +package cliutil + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" +) + +var ( + warnNoMatchedProvisioners = `Your build has been enqueued, but there are no provisioners that accept the required tags. Once a compatible provisioner becomes available, your build will continue. Please contact your administrator. +Details: + Provisioner job ID : %s + Requested tags : %s +` + warnNoAvailableProvisioners = `Provisioners that accept the required tags have not responded for longer than expected. This may delay your build. Please contact your administrator if your build does not complete. +Details: + Provisioner job ID : %s + Requested tags : %s + Most recently seen : %s +` +) + +func WarnMatchedProvisioners(w io.Writer, tv codersdk.TemplateVersion) { + if tv.MatchedProvisioners == nil { + // Nothing in the response, nothing to do here! + return + } + var tagsJSON strings.Builder + if err := json.NewEncoder(&tagsJSON).Encode(tv.Job.Tags); err != nil { + // Fall back to the less-pretty string representation. + tagsJSON.Reset() + _, _ = tagsJSON.WriteString(fmt.Sprintf("%v", tv.Job.Tags)) + } + if tv.MatchedProvisioners.Count == 0 { + cliui.Warnf(w, warnNoMatchedProvisioners, tv.Job.ID, tagsJSON.String()) + return + } + if tv.MatchedProvisioners.Available == 0 { + cliui.Warnf(w, warnNoAvailableProvisioners, tv.Job.ID, strings.TrimSpace(tagsJSON.String()), tv.MatchedProvisioners.MostRecentlySeen.Time) + return + } +} diff --git a/cli/templatepush.go b/cli/templatepush.go index 2576e878edddb..eacaf4e6dc81d 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -2,7 +2,6 @@ package cli import ( "bufio" - "encoding/json" "errors" "fmt" "io" @@ -17,6 +16,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/pretty" @@ -416,7 +416,7 @@ func createValidTemplateVersion(inv *serpent.Invocation, args createValidTemplat if err != nil { return nil, err } - WarnMatchedProvisioners(inv, version) + cliutil.WarnMatchedProvisioners(inv.Stderr, version) err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { version, err := client.TemplateVersion(inv.Context(), version.ID) @@ -482,41 +482,6 @@ func ParseProvisionerTags(rawTags []string) (map[string]string, error) { return tags, nil } -var ( - warnNoMatchedProvisioners = `Your build has been enqueued, but there are no provisioners that accept the required tags. Once a compatible provisioner becomes available, your build will continue. Please contact your administrator. -Details: - Provisioner job ID : %s - Requested tags : %s -` - warnNoAvailableProvisioners = `Provisioners that accept the required tags have not responded for longer than expected. This may delay your build. Please contact your administrator if your build does not complete. -Details: - Provisioner job ID : %s - Requested tags : %s - Most recently seen : %s -` -) - -func WarnMatchedProvisioners(inv *serpent.Invocation, tv codersdk.TemplateVersion) { - if tv.MatchedProvisioners == nil { - // Nothing in the response, nothing to do here! - return - } - var tagsJSON strings.Builder - if err := json.NewEncoder(&tagsJSON).Encode(tv.Job.Tags); err != nil { - // Fall back to the less-pretty string representation. - tagsJSON.Reset() - _, _ = tagsJSON.WriteString(fmt.Sprintf("%v", tv.Job.Tags)) - } - if tv.MatchedProvisioners.Count == 0 { - cliui.Warnf(inv.Stderr, warnNoMatchedProvisioners, tv.Job.ID, tagsJSON.String()) - return - } - if tv.MatchedProvisioners.Available == 0 { - cliui.Warnf(inv.Stderr, warnNoAvailableProvisioners, tv.Job.ID, strings.TrimSpace(tagsJSON.String()), tv.MatchedProvisioners.MostRecentlySeen.Time) - return - } -} - // prettyDirectoryPath returns a prettified path when inside the users // home directory. Falls back to dir if the users home directory cannot // discerned. This function calls filepath.Clean on the result. From e1423f5c5fadec565eb0b5a91032e23416fed95e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Sun, 1 Dec 2024 21:30:46 +0000 Subject: [PATCH 10/16] fixup! refactor: extract WarnMatchedProvisioners to cliutil --- cli/cliutil/provisionerwarn.go | 23 +++++---- cli/cliutil/provisionerwarn_test.go | 74 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 cli/cliutil/provisionerwarn_test.go diff --git a/cli/cliutil/provisionerwarn.go b/cli/cliutil/provisionerwarn.go index 88e863e8b9dad..861add25f7d31 100644 --- a/cli/cliutil/provisionerwarn.go +++ b/cli/cliutil/provisionerwarn.go @@ -24,23 +24,30 @@ Details: ` ) -func WarnMatchedProvisioners(w io.Writer, tv codersdk.TemplateVersion) { - if tv.MatchedProvisioners == nil { +// WarnMatchedProvisioners warns the user if there are no provisioners that +// match the requested tags for a given provisioner job. +// If the job is not pending, it is ignored. +func WarnMatchedProvisioners(w io.Writer, mp *codersdk.MatchedProvisioners, job codersdk.ProvisionerJob) { + if mp == nil { // Nothing in the response, nothing to do here! return } + if job.Status != codersdk.ProvisionerJobPending { + // Only warn if the job is pending. + return + } var tagsJSON strings.Builder - if err := json.NewEncoder(&tagsJSON).Encode(tv.Job.Tags); err != nil { + if err := json.NewEncoder(&tagsJSON).Encode(job.Tags); err != nil { // Fall back to the less-pretty string representation. tagsJSON.Reset() - _, _ = tagsJSON.WriteString(fmt.Sprintf("%v", tv.Job.Tags)) + _, _ = tagsJSON.WriteString(fmt.Sprintf("%v", job.Tags)) } - if tv.MatchedProvisioners.Count == 0 { - cliui.Warnf(w, warnNoMatchedProvisioners, tv.Job.ID, tagsJSON.String()) + if mp.Count == 0 { + cliui.Warnf(w, warnNoMatchedProvisioners, job.ID, tagsJSON.String()) return } - if tv.MatchedProvisioners.Available == 0 { - cliui.Warnf(w, warnNoAvailableProvisioners, tv.Job.ID, strings.TrimSpace(tagsJSON.String()), tv.MatchedProvisioners.MostRecentlySeen.Time) + if mp.Available == 0 { + cliui.Warnf(w, warnNoAvailableProvisioners, job.ID, strings.TrimSpace(tagsJSON.String()), mp.MostRecentlySeen.Time) return } } diff --git a/cli/cliutil/provisionerwarn_test.go b/cli/cliutil/provisionerwarn_test.go new file mode 100644 index 0000000000000..a737223310d75 --- /dev/null +++ b/cli/cliutil/provisionerwarn_test.go @@ -0,0 +1,74 @@ +package cliutil_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/cliutil" + "github.com/coder/coder/v2/codersdk" +) + +func TestWarnMatchedProvisioners(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + mp *codersdk.MatchedProvisioners + job codersdk.ProvisionerJob + expect string + }{ + { + name: "no_match", + mp: &codersdk.MatchedProvisioners{ + Count: 0, + Available: 0, + }, + job: codersdk.ProvisionerJob{ + Status: codersdk.ProvisionerJobPending, + }, + expect: `there are no provisioners that accept the required tags`, + }, + { + name: "no_available", + mp: &codersdk.MatchedProvisioners{ + Count: 1, + Available: 0, + }, + job: codersdk.ProvisionerJob{ + Status: codersdk.ProvisionerJobPending, + }, + expect: `Provisioners that accept the required tags have not responded for longer than expected`, + }, + { + name: "match", + mp: &codersdk.MatchedProvisioners{ + Count: 1, + Available: 1, + }, + job: codersdk.ProvisionerJob{ + Status: codersdk.ProvisionerJobPending, + }, + }, + { + name: "not_pending", + mp: &codersdk.MatchedProvisioners{}, + job: codersdk.ProvisionerJob{ + Status: codersdk.ProvisionerJobRunning, + }, + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var w strings.Builder + cliutil.WarnMatchedProvisioners(&w, tt.mp, tt.job) + if tt.expect != "" { + require.Contains(t, w.String(), tt.expect) + } else { + require.Empty(t, w.String()) + } + }) + } +} From 517a5050648e154085d67ca515ea6913999cec32 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Sun, 1 Dec 2024 21:59:15 +0000 Subject: [PATCH 11/16] feat(cli): add provisioner warning to create/start/stop commands --- cli/create.go | 5 ++++- cli/start.go | 19 +++++++++++++++++++ cli/stop.go | 17 +++++++++++++++++ cli/templatepush.go | 2 +- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/cli/create.go b/cli/create.go index 81a65772c26b3..dd2b6111a335f 100644 --- a/cli/create.go +++ b/cli/create.go @@ -14,6 +14,7 @@ import ( "github.com/coder/pretty" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" @@ -289,7 +290,7 @@ func (r *RootCmd) create() *serpent.Command { ttlMillis = ptr.Ref(stopAfter.Milliseconds()) } - workspace, err := client.CreateWorkspace(inv.Context(), template.OrganizationID, workspaceOwner, codersdk.CreateWorkspaceRequest{ + workspace, err := client.CreateUserWorkspace(inv.Context(), workspaceOwner, codersdk.CreateWorkspaceRequest{ TemplateVersionID: templateVersionID, Name: workspaceName, AutostartSchedule: schedSpec, @@ -301,6 +302,8 @@ func (r *RootCmd) create() *serpent.Command { return xerrors.Errorf("create workspace: %w", err) } + cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job) + err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, workspace.LatestBuild.ID) if err != nil { return xerrors.Errorf("watch build: %w", err) diff --git a/cli/start.go b/cli/start.go index bca800471f28b..0e8c36da0380d 100644 --- a/cli/start.go +++ b/cli/start.go @@ -8,6 +8,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -35,6 +36,23 @@ func (r *RootCmd) start() *serpent.Command { } var build codersdk.WorkspaceBuild switch workspace.LatestBuild.Status { + case codersdk.WorkspaceStatusPending: + // The above check is technically duplicated in cliutil.WarnmatchedProvisioners + // but we still want to avoid users spamming multiple builds that will + // not be picked up. + _, _ = fmt.Fprintf( + inv.Stdout, + "\nThe %s workspace is waiting to start!\n", + cliui.Keyword(workspace.Name), + ) + cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job) + if _, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Enqueue another start?", + IsConfirm: true, + Default: cliui.ConfirmNo, + }); err != nil { + return err + } case codersdk.WorkspaceStatusRunning: _, _ = fmt.Fprintf( inv.Stdout, "\nThe %s workspace is already running!\n", @@ -159,6 +177,7 @@ func startWorkspace(inv *serpent.Invocation, client *codersdk.Client, workspace if err != nil { return codersdk.WorkspaceBuild{}, xerrors.Errorf("create workspace build: %w", err) } + cliutil.WarnMatchedProvisioners(inv.Stderr, build.MatchedProvisioners, build.Job) return build, nil } diff --git a/cli/stop.go b/cli/stop.go index 9aec5950c292b..218c42061db10 100644 --- a/cli/stop.go +++ b/cli/stop.go @@ -5,6 +5,7 @@ import ( "time" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -36,6 +37,21 @@ func (r *RootCmd) stop() *serpent.Command { if err != nil { return err } + if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobPending { + // cliutil.WarnMatchedProvisioners also checks if the job is pending + // but we still want to avoid users spamming multiple builds that will + // not be picked up. + cliui.Warn(inv.Stderr, "The workspace is already stopping!") + cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job) + if _, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Enqueue another stop?", + IsConfirm: true, + Default: cliui.ConfirmNo, + }); err != nil { + return err + } + } + wbr := codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStop, } @@ -46,6 +62,7 @@ func (r *RootCmd) stop() *serpent.Command { if err != nil { return err } + cliutil.WarnMatchedProvisioners(inv.Stderr, build.MatchedProvisioners, build.Job) err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID) if err != nil { diff --git a/cli/templatepush.go b/cli/templatepush.go index eacaf4e6dc81d..7b3cec06a7353 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -416,7 +416,7 @@ func createValidTemplateVersion(inv *serpent.Invocation, args createValidTemplat if err != nil { return nil, err } - cliutil.WarnMatchedProvisioners(inv.Stderr, version) + cliutil.WarnMatchedProvisioners(inv.Stderr, version.MatchedProvisioners, version.Job) err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { version, err := client.TemplateVersion(inv.Context(), version.ID) From c4295ef58b2db14c399f244b44ff802175e035b1 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 2 Dec 2024 09:21:00 +0000 Subject: [PATCH 12/16] Apply suggestions from code review Co-authored-by: Danny Kopping --- coderd/database/dbauthz/dbauthz.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4adea76c22fbe..56ced41158ad2 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -319,7 +319,7 @@ var ( }.WithCachedASTValue() subjectSystemReadProvisionerDaemons = rbac.Subject{ - FriendlyName: "System", + FriendlyName: "Provisioner Daemons Reader", ID: uuid.Nil.String(), Roles: rbac.Roles([]rbac.Role{ { From c5fb83bfa2473bf6c424e99e4a3d67fe8334e849 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 2 Dec 2024 16:49:39 +0000 Subject: [PATCH 13/16] feat(coderd): add endpoint for matched provisioners of template version dry run, hook up to cli --- cli/create.go | 6 ++++ coderd/apidoc/docs.go | 43 ++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 39 +++++++++++++++++++++++++ coderd/coderd.go | 1 + coderd/templateversions.go | 38 +++++++++++++++++++++++++ coderd/templateversions_test.go | 50 +++++++++++++++++++++++++++++++++ codersdk/templateversions.go | 16 +++++++++++ docs/reference/api/templates.md | 40 ++++++++++++++++++++++++++ 8 files changed, 233 insertions(+) diff --git a/cli/create.go b/cli/create.go index dd2b6111a335f..9b5e1a0c4cd09 100644 --- a/cli/create.go +++ b/cli/create.go @@ -436,6 +436,12 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p if err != nil { return nil, xerrors.Errorf("begin workspace dry-run: %w", err) } + + matchedProvisioners, err := client.TemplateVersionDryRunMatchedProvisioners(inv.Context(), templateVersion.ID, dryRun.ID) + if err != nil { + return nil, xerrors.Errorf("get matched provisioners: %w", err) + } + cliutil.WarnMatchedProvisioners(inv.Stdout, &matchedProvisioners, dryRun) _, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...") err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 02a44689cb8ed..680e683b008f1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4851,6 +4851,49 @@ const docTemplate = `{ } } }, + "/templateversions/{templateversion}/dry-run/{jobID}/matched-provisioners": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Templates" + ], + "summary": "Get template version dry-run matched provisioners", + "operationId": "get-template-version-dry-run-matched-provisioners", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Template version ID", + "name": "templateversion", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Job ID", + "name": "jobID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.MatchedProvisioners" + } + } + } + } + }, "/templateversions/{templateversion}/dry-run/{jobID}/resources": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3ccb94bd1a581..271f57f808da3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4275,6 +4275,45 @@ } } }, + "/templateversions/{templateversion}/dry-run/{jobID}/matched-provisioners": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Templates"], + "summary": "Get template version dry-run matched provisioners", + "operationId": "get-template-version-dry-run-matched-provisioners", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Template version ID", + "name": "templateversion", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Job ID", + "name": "jobID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.MatchedProvisioners" + } + } + } + } + }, "/templateversions/{templateversion}/dry-run/{jobID}/resources": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 4c7ff4fe081ff..fd8a10a44f140 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1055,6 +1055,7 @@ func New(options *Options) *API { r.Get("/{jobID}", api.templateVersionDryRun) r.Get("/{jobID}/resources", api.templateVersionDryRunResources) r.Get("/{jobID}/logs", api.templateVersionDryRunLogs) + r.Get("/{jobID}/matched-provisioners", api.templateVersionDryRunMatchedProvisioners) r.Patch("/{jobID}/cancel", api.patchTemplateVersionDryRunCancel) }) }) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index b6ee225236095..d5d2e79fb8104 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -580,6 +580,44 @@ func (api *API) templateVersionDryRun(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, convertProvisionerJob(job)) } +// @Summary Get template version dry-run matched provisioners +// @ID get-template-version-dry-run-matched-provisioners +// @Security CoderSessionToken +// @Produce json +// @Tags Templates +// @Param templateversion path string true "Template version ID" format(uuid) +// @Param jobID path string true "Job ID" format(uuid) +// @Success 200 {object} codersdk.MatchedProvisioners +// @Router /templateversions/{templateversion}/dry-run/{jobID}/matched-provisioners [get] +func (api *API) templateVersionDryRunMatchedProvisioners(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + job, ok := api.fetchTemplateVersionDryRunJob(rw, r) + if !ok { + return + } + + // nolint:gocritic // The user may not have permissions to read all + // provisioner daemons in the org. + daemons, err := api.Database.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(ctx), database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: job.ProvisionerJob.OrganizationID, + WantTags: job.ProvisionerJob.Tags, + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching provisioner daemons by organization.", + Detail: err.Error(), + }) + return + } else { + daemons = []database.ProvisionerDaemon{} + } + } + + matchedProvisioners := db2sdk.MatchedProvisioners(daemons, dbtime.Now(), provisionerdserver.StaleInterval) + httpapi.Write(ctx, rw, http.StatusOK, matchedProvisioners) +} + // @Summary Get template version dry-run resources by job ID // @ID get-template-version-dry-run-resources-by-job-id // @Security CoderSessionToken diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index c1d1c57878fcc..d8377821245bf 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -1004,6 +1004,13 @@ func TestTemplateVersionDryRun(t *testing.T) { require.NoError(t, err) require.Equal(t, job.ID, newJob.ID) + // Check matched provisioners + matched, err := client.TemplateVersionDryRunMatchedProvisioners(ctx, version.ID, job.ID) + require.NoError(t, err) + require.Equal(t, 1, matched.Count) + require.Equal(t, 1, matched.Available) + require.NotZero(t, matched.MostRecentlySeen.Time) + // Stream logs logs, closer, err := client.TemplateVersionDryRunLogsAfter(ctx, version.ID, job.ID, 0) require.NoError(t, err) @@ -1176,6 +1183,49 @@ func TestTemplateVersionDryRun(t *testing.T) { require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) }) }) + + t.Run("Pending", func(t *testing.T) { + t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } + + store, ps, db := dbtestutil.NewDBWithSQLDB(t) + client, closer := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ + Database: store, + Pubsub: ps, + IncludeProvisionerDaemon: true, + }) + defer closer.Close() + + owner := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + }) + version = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + require.Equal(t, codersdk.ProvisionerJobSucceeded, version.Job.Status) + + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + ctx := testutil.Context(t, testutil.WaitShort) + + _, err := db.Exec("DELETE FROM provisioner_daemons") + require.NoError(t, err) + + job, err := templateAdmin.CreateTemplateVersionDryRun(ctx, version.ID, codersdk.CreateTemplateVersionDryRunRequest{ + WorkspaceName: "test", + RichParameterValues: []codersdk.WorkspaceBuildParameter{}, + UserVariableValues: []codersdk.VariableValue{}, + }) + require.NoError(t, err) + require.Equal(t, codersdk.ProvisionerJobPending, job.Status) + + matched, err := templateAdmin.TemplateVersionDryRunMatchedProvisioners(ctx, version.ID, job.ID) + require.NoError(t, err) + require.Equal(t, 0, matched.Count) + require.Equal(t, 0, matched.Available) + require.Zero(t, matched.MostRecentlySeen.Time) + }) } // TestPaginatedTemplateVersions creates a list of template versions and paginate. diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index 4f07f84074ec4..de8bb7b970957 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -224,6 +224,22 @@ func (c *Client) TemplateVersionDryRun(ctx context.Context, version, job uuid.UU return j, json.NewDecoder(res.Body).Decode(&j) } +// TemplateVersionDryRunMatchedProvisioners returns the matched provisioners for a +// template version dry-run job. +func (c *Client) TemplateVersionDryRunMatchedProvisioners(ctx context.Context, version, job uuid.UUID) (MatchedProvisioners, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/dry-run/%s/matched-provisioners", version, job), nil) + if err != nil { + return MatchedProvisioners{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return MatchedProvisioners{}, ReadBodyAsError(res) + } + + var matched MatchedProvisioners + return matched, json.NewDecoder(res.Body).Decode(&matched) +} + // TemplateVersionDryRunResources returns the resources of a finished template // version dry-run job. func (c *Client) TemplateVersionDryRunResources(ctx context.Context, version, job uuid.UUID) ([]WorkspaceResource, error) { diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index d7da209e94771..b4f642625dcde 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -1944,6 +1944,46 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get template version dry-run matched provisioners + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/dry-run/{jobID}/matched-provisioners \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /templateversions/{templateversion}/dry-run/{jobID}/matched-provisioners` + +### Parameters + +| Name | In | Type | Required | Description | +| ----------------- | ---- | ------------ | -------- | ------------------- | +| `templateversion` | path | string(uuid) | true | Template version ID | +| `jobID` | path | string(uuid) | true | Job ID | + +### Example responses + +> 200 Response + +```json +{ + "available": 0, + "count": 0, + "most_recently_seen": "2019-08-24T14:15:22Z" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.MatchedProvisioners](schemas.md#codersdkmatchedprovisioners) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get template version dry-run resources by job ID ### Code samples From 3bd62fd1928b0684bd0b3b501e35b48aa85ba840 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 2 Dec 2024 17:01:26 +0000 Subject: [PATCH 14/16] feat(cli): delete: warn on no matched provisioners --- cli/delete.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/delete.go b/cli/delete.go index 42abca658623a..04303a706fb8a 100644 --- a/cli/delete.go +++ b/cli/delete.go @@ -5,6 +5,7 @@ import ( "time" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -55,6 +56,7 @@ func (r *RootCmd) deleteWorkspace() *serpent.Command { if err != nil { return err } + cliutil.WarnMatchedProvisioners(inv.Stdout, build.MatchedProvisioners, build.Job) err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID) if err != nil { From 2f625bc52a4a120cdcd2fd855da47a98919da9d6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 2 Dec 2024 17:31:10 +0000 Subject: [PATCH 15/16] address linter complaint --- coderd/templateversions.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index d5d2e79fb8104..e9297d02e2a55 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -609,9 +609,8 @@ func (api *API) templateVersionDryRunMatchedProvisioners(rw http.ResponseWriter, Detail: err.Error(), }) return - } else { - daemons = []database.ProvisionerDaemon{} } + daemons = []database.ProvisionerDaemon{} } matchedProvisioners := db2sdk.MatchedProvisioners(daemons, dbtime.Now(), provisionerdserver.StaleInterval) From 848338e98decf84b46a721697b8f367f2e2985a1 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 2 Dec 2024 17:31:23 +0000 Subject: [PATCH 16/16] add test for cli/delete --- cli/delete_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/cli/delete_test.go b/cli/delete_test.go index e5baee70fe5d9..e3b4532e33cff 100644 --- a/cli/delete_test.go +++ b/cli/delete_test.go @@ -12,6 +12,8 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" @@ -164,4 +166,46 @@ func TestDelete(t *testing.T) { }() <-doneChan }) + + t.Run("WarnNoProvisioners", func(t *testing.T) { + t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } + + store, ps, db := dbtestutil.NewDBWithSQLDB(t) + client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ + Database: store, + Pubsub: ps, + IncludeProvisionerDaemon: true, + }) + + // Given: a user, template, and workspace + user := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, templateAdmin, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, templateAdmin, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, templateAdmin, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, workspace.LatestBuild.ID) + + // When: all provisioner daemons disappear + require.NoError(t, closeDaemon.Close()) + _, err := db.Exec("DELETE FROM provisioner_daemons;") + require.NoError(t, err) + + // Then: the workspace deletion should warn about no provisioners + inv, root := clitest.New(t, "delete", workspace.Name, "-y") + pty := ptytest.New(t).Attach(inv) + clitest.SetupConfig(t, templateAdmin, root) + doneChan := make(chan struct{}) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + go func() { + defer close(doneChan) + _ = inv.WithContext(ctx).Run() + }() + pty.ExpectMatch("there are no provisioners that accept the required tags") + cancel() + <-doneChan + }) } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy