From 4097ec3a6dde514b3f5a4c568f24e12230ece54e Mon Sep 17 00:00:00 2001 From: Phorcys <57866459+phorcys420@users.noreply.github.com> Date: Mon, 9 Dec 2024 15:48:59 +0100 Subject: [PATCH 1/3] fix(examples/templates/aws-linux): (backport) add hashicorp/cloudinit provider (#15796) Backport #15794 --- examples/templates/aws-linux/main.tf | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/templates/aws-linux/main.tf b/examples/templates/aws-linux/main.tf index ae48d2be18d8b..56682ebc1950e 100644 --- a/examples/templates/aws-linux/main.tf +++ b/examples/templates/aws-linux/main.tf @@ -3,6 +3,9 @@ terraform { coder = { source = "coder/coder" } + cloudinit = { + source = "hashicorp/cloudinit" + } aws = { source = "hashicorp/aws" } From c5e87690be8b769d11503b95a8e67cfc70f16f95 Mon Sep 17 00:00:00 2001 From: Stephen Kirby <58410745+stirby@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:01:04 -0600 Subject: [PATCH 2/3] chore: acquire lock for individual workspace transition (#15883) When Coder is ran in High Availability mode, each Coder instance has a lifecycle executor. These lifecycle executors are all trying to do the same work, and whilst transactions saves us from this causing an issue, we are still doing extra work that could be prevented. This PR adds a `TryAcquireLock` call for each attempted workspace transition, meaning two Coder instances shouldn't duplicate effort. (cherry picked from commit 50ff06cc3c1845f5aecd260d743be5cb0a0f4431) Co-authored-by: Danielle Maywood --- coderd/autobuild/lifecycle_executor.go | 12 +++- coderd/autobuild/lifecycle_executor_test.go | 71 +++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index ac2930c9e32c8..8bd266362e7dc 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -3,6 +3,7 @@ package autobuild import ( "context" "database/sql" + "fmt" "net/http" "sync" "sync/atomic" @@ -177,6 +178,15 @@ func (e *Executor) runOnce(t time.Time) Stats { err := e.db.InTx(func(tx database.Store) error { var err error + ok, err := tx.TryAcquireLock(e.ctx, database.GenLockID(fmt.Sprintf("lifecycle-executor:%s", wsID))) + if err != nil { + return xerrors.Errorf("try acquire lifecycle executor lock: %w", err) + } + if !ok { + log.Debug(e.ctx, "unable to acquire lock for workspace, skipping") + return nil + } + // Re-check eligibility since the first check was outside the // transaction and the workspace settings may have changed. ws, err = tx.GetWorkspaceByID(e.ctx, wsID) @@ -372,7 +382,7 @@ func (e *Executor) runOnce(t time.Time) Stats { } return nil }() - if err != nil { + if err != nil && !xerrors.Is(err, context.Canceled) { log.Error(e.ctx, "failed to transition workspace", slog.Error(err)) statsMu.Lock() stats.Errors[wsID] = err diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 667b20dd9fd4f..7bcd1ccd5836f 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/schedule" @@ -72,6 +73,76 @@ func TestExecutorAutostartOK(t *testing.T) { require.Equal(t, template.AutostartRequirement.DaysOfWeek, []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}) } +func TestMultipleLifecycleExecutors(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + + var ( + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") + // Create our first client + tickCh = make(chan time.Time, 2) + statsChA = make(chan autobuild.Stats) + clientA = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + AutobuildTicker: tickCh, + AutobuildStats: statsChA, + Database: db, + Pubsub: ps, + }) + // ... And then our second client + statsChB = make(chan autobuild.Stats) + _ = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + AutobuildTicker: tickCh, + AutobuildStats: statsChB, + Database: db, + Pubsub: ps, + }) + // Now create a workspace (we can use either client, it doesn't matter) + workspace = mustProvisionWorkspace(t, clientA, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) + ) + + // Have the workspace stopped so we can perform an autostart + workspace = coderdtest.MustTransitionWorkspace(t, clientA, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + + // Get both clients to perform a lifecycle execution tick + next := sched.Next(workspace.LatestBuild.CreatedAt) + + startCh := make(chan struct{}) + go func() { + <-startCh + tickCh <- next + }() + go func() { + <-startCh + tickCh <- next + }() + close(startCh) + + // Now we want to check the stats for both clients + statsA := <-statsChA + statsB := <-statsChB + + // We expect there to be no errors + assert.Len(t, statsA.Errors, 0) + assert.Len(t, statsB.Errors, 0) + + // We also expect there to have been only one transition + require.Equal(t, 1, len(statsA.Transitions)+len(statsB.Transitions)) + + stats := statsA + if len(statsB.Transitions) == 1 { + stats = statsB + } + + // And we expect this transition to have been a start transition + assert.Contains(t, stats.Transitions, workspace.ID) + assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID]) +} + func TestExecutorAutostartTemplateUpdated(t *testing.T) { t.Parallel() From 765d99caa31bbd0108286846c594cb18031fe962 Mon Sep 17 00:00:00 2001 From: Stephen Kirby <58410745+stirby@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:49:21 -0600 Subject: [PATCH 3/3] chore: cherry-pick commits for 2.18.1 (#15885) Co-authored-by: Cian Johnston Co-authored-by: Eric Paulsen Co-authored-by: Sas Swart --- cli/cliutil/provisionerwarn.go | 53 ++++ cli/cliutil/provisionerwarn_test.go | 74 ++++++ cli/create.go | 11 +- cli/delete.go | 2 + cli/delete_test.go | 44 ++++ cli/start.go | 19 ++ cli/stop.go | 17 ++ cli/templatepush.go | 27 +- cli/templatepush_test.go | 232 ++++++++++++------ cli/testdata/coder_list_--output_json.golden | 7 +- coderd/apidoc/docs.go | 46 ++++ coderd/apidoc/swagger.json | 42 ++++ coderd/autobuild/lifecycle_executor.go | 2 +- coderd/coderd.go | 4 +- coderd/database/db2sdk/db2sdk.go | 20 ++ coderd/database/dbauthz/dbauthz.go | 29 ++- coderd/database/dbauthz/dbauthz_test.go | 23 ++ coderd/database/dbgen/dbgen.go | 40 +++ coderd/database/dbmem/dbmem.go | 89 ++++++- coderd/database/dbmetrics/querymetrics.go | 7 + coderd/database/dbmock/dbmock.go | 15 ++ coderd/database/modelmethods.go | 4 + coderd/database/querier.go | 1 + coderd/database/querier_test.go | 140 +++++++++++ coderd/database/queries.sql.go | 54 ++++ .../database/queries/provisionerdaemons.sql | 12 + coderd/healthcheck/provisioner.go | 2 +- coderd/healthcheck/provisioner_test.go | 106 ++++++-- .../provisionerdserver/provisionerdserver.go | 4 + coderd/templateversions.go | 218 +++++++++++----- coderd/templateversions_test.go | 88 ++++++- coderd/workspacebuilds.go | 88 +++++-- coderd/workspacebuilds_test.go | 120 +++++++++ coderd/workspaces.go | 14 +- coderd/workspaces_test.go | 88 +++++++ coderd/wsbuilder/wsbuilder.go | 56 +++-- coderd/wsbuilder/wsbuilder_test.go | 53 +++- codersdk/provisionerdaemons.go | 1 + codersdk/templateversions.go | 18 +- codersdk/workspacebuilds.go | 43 ++-- docs/reference/api/builds.md | 29 +++ docs/reference/api/schemas.md | 16 ++ docs/reference/api/templates.md | 40 +++ docs/reference/api/workspaces.md | 30 +++ enterprise/coderd/workspacebuilds_test.go | 2 +- enterprise/coderd/workspaces_test.go | 212 ++++++++++++++++ go.mod | 10 +- go.sum | 20 +- provisioner/terraform/tfparse/tfparse.go | 20 +- provisioner/terraform/tfparse/tfparse_test.go | 67 +++++ site/src/api/typesGenerated.ts | 1 + .../provisioners/ProvisionerAlert.stories.tsx | 22 +- .../modules/provisioners/ProvisionerAlert.tsx | 42 +++- .../ProvisionerStatusAlert.stories.tsx | 16 ++ .../provisioners/ProvisionerStatusAlert.tsx | 5 +- .../CreateTemplatePage/BuildLogsDrawer.tsx | 2 + .../pages/CreateUserPage/CreateUserForm.tsx | 2 +- .../TemplateVersionEditor.tsx | 3 + .../pages/WorkspacePage/Workspace.stories.tsx | 60 ++++- site/src/pages/WorkspacePage/Workspace.tsx | 29 ++- .../WorkspacePage/WorkspaceReadyPage.tsx | 11 +- site/src/testHelpers/entities.ts | 4 + 62 files changed, 2209 insertions(+), 347 deletions(-) create mode 100644 cli/cliutil/provisionerwarn.go create mode 100644 cli/cliutil/provisionerwarn_test.go diff --git a/cli/cliutil/provisionerwarn.go b/cli/cliutil/provisionerwarn.go new file mode 100644 index 0000000000000..861add25f7d31 --- /dev/null +++ b/cli/cliutil/provisionerwarn.go @@ -0,0 +1,53 @@ +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 +` +) + +// 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(job.Tags); err != nil { + // Fall back to the less-pretty string representation. + tagsJSON.Reset() + _, _ = tagsJSON.WriteString(fmt.Sprintf("%v", job.Tags)) + } + if mp.Count == 0 { + cliui.Warnf(w, warnNoMatchedProvisioners, job.ID, tagsJSON.String()) + return + } + 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()) + } + }) + } +} diff --git a/cli/create.go b/cli/create.go index 81a65772c26b3..9b5e1a0c4cd09 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) @@ -433,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/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 { 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 + }) } 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 8516d7f9c1310..7b3cec06a7353 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,30 +416,7 @@ func createValidTemplateVersion(inv *serpent.Invocation, args createValidTemplat if err != nil { return nil, err } - var tagsJSON strings.Builder - if err := json.NewEncoder(&tagsJSON).Encode(version.Job.Tags); err != nil { - // Fall back to the less-pretty string representation. - tagsJSON.Reset() - _, _ = tagsJSON.WriteString(fmt.Sprintf("%v", version.Job.Tags)) - } - if version.MatchedProvisioners.Count == 0 { - cliui.Warnf(inv.Stderr, `No provisioners are available to handle the job! -Please contact your deployment administrator for assistance. -Details: - Provisioner job ID : %s - Requested tags : %s -`, version.Job.ID, tagsJSON.String()) - } else if version.MatchedProvisioners.Available == 0 { - cliui.Warnf(inv.Stderr, `All available provisioner daemons have been silent for a while. -Your build will proceed once they become available. -If this persists, please contact your deployment administrator for assistance. -Details: - Provisioner job ID : %s - Requested tags : %s - Most recently seen : %s -`, version.Job.ID, strings.TrimSpace(tagsJSON.String()), version.MatchedProvisioners.MostRecentlySeen.Time) - } - + 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) diff --git a/cli/templatepush_test.go b/cli/templatepush_test.go index a20e3070740a8..ae8f60bd9c551 100644 --- a/cli/templatepush_test.go +++ b/cli/templatepush_test.go @@ -3,6 +3,7 @@ package cli_test import ( "bytes" "context" + "database/sql" "os" "path/filepath" "runtime" @@ -18,6 +19,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" @@ -412,84 +414,162 @@ func TestTemplatePush(t *testing.T) { t.Run("WorkspaceTagsTerraform", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitShort) - // Start an instance **without** a built-in provisioner. - // We're not actually testing that the Terraform applies. - // What we test is that a provisioner job is created with the expected - // tags based on the __content__ of the Terraform. - store, ps := dbtestutil.NewDB(t) - client := coderdtest.New(t, &coderdtest.Options{ - Database: store, - Pubsub: ps, - }) - - owner := coderdtest.CreateFirstUser(t, client) - templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - - // Create a tar file with some pre-defined content - tarFile := testutil.CreateTar(t, map[string]string{ - "main.tf": ` -variable "a" { - type = string - default = "1" -} -data "coder_parameter" "b" { - type = string - default = "2" -} -resource "null_resource" "test" {} -data "coder_workspace_tags" "tags" { - tags = { - "foo": "bar", - "a": var.a, - "b": data.coder_parameter.b.value, - } -}`, - }) - - // Write the tar file to disk. - tempDir := t.TempDir() - err := tfparse.WriteArchive(tarFile, "application/x-tar", tempDir) - require.NoError(t, err) - - // Run `coder templates push` - templateName := strings.ReplaceAll(testutil.GetRandomName(t), "_", "-") - var stdout, stderr strings.Builder - inv, root := clitest.New(t, "templates", "push", templateName, "-d", tempDir, "--yes") - inv.Stdout = &stdout - inv.Stderr = &stderr - clitest.SetupConfig(t, templateAdmin, root) - - // Don't forget to clean up! - cancelCtx, cancel := context.WithCancel(ctx) - t.Cleanup(cancel) - done := make(chan error) - go func() { - done <- inv.WithContext(cancelCtx).Run() - }() - - // Assert that a provisioner job was created with the desired tags. - wantTags := database.StringMap(provisionersdk.MutateTags(uuid.Nil, map[string]string{ - "foo": "bar", - "a": "1", - "b": "2", - })) - require.Eventually(t, func() bool { - jobs, err := store.GetProvisionerJobsCreatedAfter(ctx, time.Time{}) - if !assert.NoError(t, err) { - return false - } - if len(jobs) == 0 { - return false - } - return assert.EqualValues(t, wantTags, jobs[0].Tags) - }, testutil.WaitShort, testutil.IntervalSlow) - - cancel() - <-done + tests := []struct { + name string + setupDaemon func(ctx context.Context, store database.Store, owner codersdk.CreateFirstUserResponse, tags database.StringMap, now time.Time) error + expectOutput string + }{ + { + name: "no provisioners available", + setupDaemon: func(_ context.Context, _ database.Store, _ codersdk.CreateFirstUserResponse, _ database.StringMap, _ time.Time) error { + return nil + }, + expectOutput: "there are no provisioners that accept the required tags", + }, + { + name: "provisioner stale", + setupDaemon: func(ctx context.Context, store database.Store, owner codersdk.CreateFirstUserResponse, tags database.StringMap, now time.Time) error { + pk, err := store.InsertProvisionerKey(ctx, database.InsertProvisionerKeyParams{ + ID: uuid.New(), + CreatedAt: now, + OrganizationID: owner.OrganizationID, + Name: "test", + Tags: tags, + HashedSecret: []byte("secret"), + }) + if err != nil { + return err + } + oneHourAgo := now.Add(-time.Hour) + _, err = store.UpsertProvisionerDaemon(ctx, database.UpsertProvisionerDaemonParams{ + Provisioners: []database.ProvisionerType{database.ProvisionerTypeTerraform}, + LastSeenAt: sql.NullTime{Time: oneHourAgo, Valid: true}, + CreatedAt: oneHourAgo, + Name: "test", + Tags: tags, + OrganizationID: owner.OrganizationID, + KeyID: pk.ID, + }) + return err + }, + expectOutput: "Provisioners that accept the required tags have not responded for longer than expected", + }, + { + name: "active provisioner", + setupDaemon: func(ctx context.Context, store database.Store, owner codersdk.CreateFirstUserResponse, tags database.StringMap, now time.Time) error { + pk, err := store.InsertProvisionerKey(ctx, database.InsertProvisionerKeyParams{ + ID: uuid.New(), + CreatedAt: now, + OrganizationID: owner.OrganizationID, + Name: "test", + Tags: tags, + HashedSecret: []byte("secret"), + }) + if err != nil { + return err + } + _, err = store.UpsertProvisionerDaemon(ctx, database.UpsertProvisionerDaemonParams{ + Provisioners: []database.ProvisionerType{database.ProvisionerTypeTerraform}, + LastSeenAt: sql.NullTime{Time: now, Valid: true}, + CreatedAt: now, + Name: "test-active", + Tags: tags, + OrganizationID: owner.OrganizationID, + KeyID: pk.ID, + }) + return err + }, + expectOutput: "", + }, + } - require.Contains(t, stderr.String(), "No provisioners are available to handle the job!") + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Start an instance **without** a built-in provisioner. + // We're not actually testing that the Terraform applies. + // What we test is that a provisioner job is created with the expected + // tags based on the __content__ of the Terraform. + store, ps := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: store, + Pubsub: ps, + }) + + owner := coderdtest.CreateFirstUser(t, client) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + + // Create a tar file with some pre-defined content + tarFile := testutil.CreateTar(t, map[string]string{ + "main.tf": ` + variable "a" { + type = string + default = "1" + } + data "coder_parameter" "b" { + type = string + default = "2" + } + resource "null_resource" "test" {} + data "coder_workspace_tags" "tags" { + tags = { + "a": var.a, + "b": data.coder_parameter.b.value, + "test_name": "` + tt.name + `" + } + }`, + }) + + // Write the tar file to disk. + tempDir := t.TempDir() + err := tfparse.WriteArchive(tarFile, "application/x-tar", tempDir) + require.NoError(t, err) + + wantTags := database.StringMap(provisionersdk.MutateTags(uuid.Nil, map[string]string{ + "a": "1", + "b": "2", + "test_name": tt.name, + })) + + templateName := strings.ReplaceAll(testutil.GetRandomName(t), "_", "-") + + inv, root := clitest.New(t, "templates", "push", templateName, "-d", tempDir, "--yes") + clitest.SetupConfig(t, templateAdmin, root) + pty := ptytest.New(t).Attach(inv) + + ctx := testutil.Context(t, testutil.WaitShort) + now := dbtime.Now() + require.NoError(t, tt.setupDaemon(ctx, store, owner, wantTags, now)) + + cancelCtx, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + done := make(chan error) + go func() { + done <- inv.WithContext(cancelCtx).Run() + }() + + require.Eventually(t, func() bool { + jobs, err := store.GetProvisionerJobsCreatedAfter(ctx, time.Time{}) + if !assert.NoError(t, err) { + return false + } + if len(jobs) == 0 { + return false + } + return assert.EqualValues(t, wantTags, jobs[0].Tags) + }, testutil.WaitShort, testutil.IntervalFast) + + if tt.expectOutput != "" { + pty.ExpectMatch(tt.expectOutput) + } + + cancel() + <-done + }) + } }) t.Run("ChangeTags", func(t *testing.T) { diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 8f45fd79cfd5a..f728fc2cb28b3 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -50,7 +50,12 @@ "deadline": "[timestamp]", "max_deadline": null, "status": "running", - "daily_cost": 0 + "daily_cost": 0, + "matched_provisioners": { + "count": 0, + "available": 0, + "most_recently_seen": null + } }, "outdated": false, "name": "test-workspace", diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fe5d7c6384c2e..93fe5067e8565 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": [ @@ -15068,6 +15111,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 04af1b4015600..806a56be2956a 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": [ @@ -13712,6 +13751,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 8bd266362e7dc..c9fa54dcf136a 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -255,7 +255,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/coderd.go b/coderd/coderd.go index d64727567720d..fd8a10a44f140 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -628,7 +628,8 @@ func New(options *Options) *API { CurrentVersion: buildinfo.Version(), CurrentAPIMajorVersion: proto.CurrentMajor, Store: options.Database, - // TimeNow and StaleInterval set to defaults, see healthcheck/provisioner.go + StaleInterval: provisionerdserver.StaleInterval, + // TimeNow set to default, see healthcheck/provisioner.go }, }) } @@ -1054,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/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 c8e8880b79fed..bc79fbaaa2ae6 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}, @@ -317,6 +317,23 @@ var ( }), Scope: rbac.ScopeAll, }.WithCachedASTValue() + + subjectSystemReadProvisionerDaemons = rbac.Subject{ + FriendlyName: "Provisioner Daemons Reader", + 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", } @@ -1538,6 +1561,10 @@ func (q *querier) GetDeploymentWorkspaceStats(ctx context.Context) (database.Get return q.db.GetDeploymentWorkspaceStats(ctx) } +func (q *querier) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.Context, provisionerJobIds []uuid.UUID) ([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetEligibleProvisionerDaemonsByProvisionerJobIDs)(ctx, provisionerJobIds) +} + func (q *querier) GetExternalAuthLink(ctx context.Context, arg database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetExternalAuthLink)(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 1c60018e87062..95ff224867dbf 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2100,6 +2100,29 @@ func (s *MethodTestSuite) TestExtraMethods() { s.NoError(err, "get provisioner daemon by org") check.Args(database.GetProvisionerDaemonsByOrganizationParams{OrganizationID: org.ID}).Asserts(d, policy.ActionRead).Returns(ds) })) + s.Run("GetEligibleProvisionerDaemonsByProvisionerJobIDs", s.Subtest(func(db database.Store, check *expects) { + org := dbgen.Organization(s.T(), db, database.Organization{}) + tags := database.StringMap(map[string]string{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }) + j, err := db.InsertProvisionerJob(context.Background(), database.InsertProvisionerJobParams{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Tags: tags, + Provisioner: database.ProvisionerTypeEcho, + StorageMethod: database.ProvisionerStorageMethodFile, + }) + s.NoError(err, "insert provisioner job") + d, err := db.UpsertProvisionerDaemon(context.Background(), database.UpsertProvisionerDaemonParams{ + OrganizationID: org.ID, + Tags: tags, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }) + s.NoError(err, "insert provisioner daemon") + ds, err := db.GetEligibleProvisionerDaemonsByProvisionerJobIDs(context.Background(), []uuid.UUID{j.ID}) + s.NoError(err, "get provisioner daemon by org") + check.Args(uuid.UUIDs{j.ID}).Asserts(d, policy.ActionRead).Returns(ds) + })) s.Run("DeleteOldProvisionerDaemons", s.Subtest(func(db database.Store, check *expects) { _, err := db.UpsertProvisionerDaemon(context.Background(), database.UpsertProvisionerDaemonParams{ Tags: database.StringMap(map[string]string{ diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 9c8696112dea8..17d3d199639cc 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -502,6 +502,46 @@ func GroupMember(t testing.TB, db database.Store, member database.GroupMemberTab return groupMember } +// ProvisionerDaemon creates a provisioner daemon as far as the database is concerned. It does not run a provisioner daemon. +// If no key is provided, it will create one. +func ProvisionerDaemon(t testing.TB, db database.Store, daemon database.ProvisionerDaemon) database.ProvisionerDaemon { + t.Helper() + + if daemon.KeyID == uuid.Nil { + key, err := db.InsertProvisionerKey(genCtx, database.InsertProvisionerKeyParams{ + ID: uuid.New(), + Name: daemon.Name + "-key", + OrganizationID: daemon.OrganizationID, + HashedSecret: []byte("secret"), + CreatedAt: dbtime.Now(), + Tags: daemon.Tags, + }) + require.NoError(t, err) + daemon.KeyID = key.ID + } + + if daemon.CreatedAt.IsZero() { + daemon.CreatedAt = dbtime.Now() + } + if daemon.Name == "" { + daemon.Name = "test-daemon" + } + + d, err := db.UpsertProvisionerDaemon(genCtx, database.UpsertProvisionerDaemonParams{ + Name: daemon.Name, + OrganizationID: daemon.OrganizationID, + CreatedAt: daemon.CreatedAt, + Provisioners: daemon.Provisioners, + Tags: daemon.Tags, + KeyID: daemon.KeyID, + LastSeenAt: daemon.LastSeenAt, + Version: daemon.Version, + APIVersion: daemon.APIVersion, + }) + require.NoError(t, err) + return d +} + // ProvisionerJob is a bit more involved to get the values such as "completedAt", "startedAt", "cancelledAt" set. ps // can be set to nil if you are SURE that you don't require a provisionerdaemon to acquire the job in your test. func ProvisionerJob(t testing.TB, db database.Store, ps pubsub.Pubsub, orig database.ProvisionerJob) database.ProvisionerJob { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 385cdcfde5709..507f040abbd9b 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1119,6 +1119,14 @@ func (q *FakeQuerier) getWorkspaceAgentScriptsByAgentIDsNoLock(ids []uuid.UUID) return scripts, nil } +// getOwnerFromTags returns the lowercase owner from tags, matching SQL's COALESCE(tags ->> 'owner', ”) +func getOwnerFromTags(tags map[string]string) string { + if owner, ok := tags["owner"]; ok { + return strings.ToLower(owner) + } + return "" +} + func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } @@ -2743,6 +2751,63 @@ func (q *FakeQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (database return stat, nil } +func (q *FakeQuerier) GetEligibleProvisionerDaemonsByProvisionerJobIDs(_ context.Context, provisionerJobIds []uuid.UUID) ([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + results := make([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, 0) + seen := make(map[string]struct{}) // Track unique combinations + + for _, jobID := range provisionerJobIds { + var job database.ProvisionerJob + found := false + for _, j := range q.provisionerJobs { + if j.ID == jobID { + job = j + found = true + break + } + } + if !found { + continue + } + + for _, daemon := range q.provisionerDaemons { + if daemon.OrganizationID != job.OrganizationID { + continue + } + + if !tagsSubset(job.Tags, daemon.Tags) { + continue + } + + provisionerMatches := false + for _, p := range daemon.Provisioners { + if p == job.Provisioner { + provisionerMatches = true + break + } + } + if !provisionerMatches { + continue + } + + key := jobID.String() + "-" + daemon.ID.String() + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + + results = append(results, database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{ + JobID: jobID, + ProvisionerDaemon: daemon, + }) + } + } + + return results, nil +} + func (q *FakeQuerier) GetExternalAuthLink(_ context.Context, arg database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { if err := validateDatabaseType(arg); err != nil { return database.ExternalAuthLink{}, err @@ -10249,25 +10314,26 @@ func (q *FakeQuerier) UpsertOAuthSigningKey(_ context.Context, value string) err } func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.UpsertProvisionerDaemonParams) (database.ProvisionerDaemon, error) { - err := validateDatabaseType(arg) - if err != nil { + if err := validateDatabaseType(arg); err != nil { return database.ProvisionerDaemon{}, err } q.mutex.Lock() defer q.mutex.Unlock() - for _, d := range q.provisionerDaemons { - if d.Name == arg.Name { - if d.Tags[provisionersdk.TagScope] == provisionersdk.ScopeOrganization && arg.Tags[provisionersdk.TagOwner] != "" { - continue - } - if d.Tags[provisionersdk.TagScope] == provisionersdk.ScopeUser && arg.Tags[provisionersdk.TagOwner] != d.Tags[provisionersdk.TagOwner] { - continue - } + + // Look for existing daemon using the same composite key as SQL + for i, d := range q.provisionerDaemons { + if d.OrganizationID == arg.OrganizationID && + d.Name == arg.Name && + getOwnerFromTags(d.Tags) == getOwnerFromTags(arg.Tags) { d.Provisioners = arg.Provisioners d.Tags = maps.Clone(arg.Tags) - d.Version = arg.Version d.LastSeenAt = arg.LastSeenAt + d.Version = arg.Version + d.APIVersion = arg.APIVersion + d.OrganizationID = arg.OrganizationID + d.KeyID = arg.KeyID + q.provisionerDaemons[i] = d return d, nil } } @@ -10277,7 +10343,6 @@ func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.Up Name: arg.Name, Provisioners: arg.Provisioners, Tags: maps.Clone(arg.Tags), - ReplicaID: uuid.NullUUID{}, LastSeenAt: arg.LastSeenAt, Version: arg.Version, APIVersion: arg.APIVersion, diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 54dd723ae1395..64b935dff22d4 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -630,6 +630,13 @@ func (m queryMetricsStore) GetDeploymentWorkspaceStats(ctx context.Context) (dat return row, err } +func (m queryMetricsStore) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.Context, provisionerJobIds []uuid.UUID) ([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) { + start := time.Now() + r0, r1 := m.s.GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx, provisionerJobIds) + m.queryLatencies.WithLabelValues("GetEligibleProvisionerDaemonsByProvisionerJobIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetExternalAuthLink(ctx context.Context, arg database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { start := time.Now() link, err := m.s.GetExternalAuthLink(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 064d0dfd926c8..09b0a6f2e37f8 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1267,6 +1267,21 @@ func (mr *MockStoreMockRecorder) GetDeploymentWorkspaceStats(arg0 any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeploymentWorkspaceStats", reflect.TypeOf((*MockStore)(nil).GetDeploymentWorkspaceStats), arg0) } +// GetEligibleProvisionerDaemonsByProvisionerJobIDs mocks base method. +func (m *MockStore) GetEligibleProvisionerDaemonsByProvisionerJobIDs(arg0 context.Context, arg1 []uuid.UUID) ([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEligibleProvisionerDaemonsByProvisionerJobIDs", arg0, arg1) + ret0, _ := ret[0].([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEligibleProvisionerDaemonsByProvisionerJobIDs indicates an expected call of GetEligibleProvisionerDaemonsByProvisionerJobIDs. +func (mr *MockStoreMockRecorder) GetEligibleProvisionerDaemonsByProvisionerJobIDs(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEligibleProvisionerDaemonsByProvisionerJobIDs", reflect.TypeOf((*MockStore)(nil).GetEligibleProvisionerDaemonsByProvisionerJobIDs), arg0, arg1) +} + // GetExternalAuthLink mocks base method. func (m *MockStore) GetExternalAuthLink(arg0 context.Context, arg1 database.GetExternalAuthLinkParams) (database.ExternalAuthLink, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index a74ddf29bfcf9..b71919778cd8f 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -268,6 +268,10 @@ func (p ProvisionerDaemon) RBACObject() rbac.Object { InOrg(p.OrganizationID) } +func (p GetEligibleProvisionerDaemonsByProvisionerJobIDsRow) RBACObject() rbac.Object { + return p.ProvisionerDaemon.RBACObject() +} + func (p ProvisionerKey) RBACObject() rbac.Object { return rbac.ResourceProvisionerKeys. WithID(p.ID). diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 07b8056e1a5c4..86f7eb81fc9a1 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -144,6 +144,7 @@ type sqlcQuerier interface { GetDeploymentWorkspaceAgentStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentStatsRow, error) GetDeploymentWorkspaceAgentUsageStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentUsageStatsRow, error) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploymentWorkspaceStatsRow, error) + GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.Context, provisionerJobIds []uuid.UUID) ([]GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) GetExternalAuthLink(ctx context.Context, arg GetExternalAuthLinkParams) (ExternalAuthLink, error) GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]ExternalAuthLink, error) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, arg GetFailedWorkspaceBuildsByTemplateIDParams) ([]GetFailedWorkspaceBuildsByTemplateIDRow, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 619e9868b612f..8fb12a5acf923 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/testutil" ) @@ -211,6 +212,145 @@ func TestGetDeploymentWorkspaceAgentUsageStats(t *testing.T) { }) } +func TestGetEligibleProvisionerDaemonsByProvisionerJobIDs(t *testing.T) { + t.Parallel() + + t.Run("NoJobsReturnsEmpty", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + daemons, err := db.GetEligibleProvisionerDaemonsByProvisionerJobIDs(context.Background(), []uuid.UUID{}) + require.NoError(t, err) + require.Empty(t, daemons) + }) + + t.Run("MatchesProvisionerType", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Provisioner: database.ProvisionerTypeEcho, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + matchingDaemon := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "matching-daemon", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "non-matching-daemon", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeTerraform}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + daemons, err := db.GetEligibleProvisionerDaemonsByProvisionerJobIDs(context.Background(), []uuid.UUID{job.ID}) + require.NoError(t, err) + require.Len(t, daemons, 1) + require.Equal(t, matchingDaemon.ID, daemons[0].ProvisionerDaemon.ID) + }) + + t.Run("MatchesOrganizationScope", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Provisioner: database.ProvisionerTypeEcho, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + provisionersdk.TagOwner: "", + }, + }) + + orgDaemon := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "org-daemon", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + provisionersdk.TagOwner: "", + }, + }) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "user-daemon", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeUser, + }, + }) + + daemons, err := db.GetEligibleProvisionerDaemonsByProvisionerJobIDs(context.Background(), []uuid.UUID{job.ID}) + require.NoError(t, err) + require.Len(t, daemons, 1) + require.Equal(t, orgDaemon.ID, daemons[0].ProvisionerDaemon.ID) + }) + + t.Run("MatchesMultipleProvisioners", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Provisioner: database.ProvisionerTypeEcho, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + daemon1 := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "daemon-1", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + daemon2 := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "daemon-2", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "daemon-3", + OrganizationID: org.ID, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeTerraform}, + Tags: database.StringMap{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }, + }) + + daemons, err := db.GetEligibleProvisionerDaemonsByProvisionerJobIDs(context.Background(), []uuid.UUID{job.ID}) + require.NoError(t, err) + require.Len(t, daemons, 2) + + daemonIDs := []uuid.UUID{daemons[0].ProvisionerDaemon.ID, daemons[1].ProvisionerDaemon.ID} + require.ElementsMatch(t, []uuid.UUID{daemon1.ID, daemon2.ID}, daemonIDs) + }) +} + func TestGetWorkspaceAgentUsageStats(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e9fe766f31e53..ea2b7be288adb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5255,6 +5255,60 @@ func (q *sqlQuerier) DeleteOldProvisionerDaemons(ctx context.Context) error { return err } +const getEligibleProvisionerDaemonsByProvisionerJobIDs = `-- name: GetEligibleProvisionerDaemonsByProvisionerJobIDs :many +SELECT DISTINCT + provisioner_jobs.id as job_id, provisioner_daemons.id, provisioner_daemons.created_at, provisioner_daemons.name, provisioner_daemons.provisioners, provisioner_daemons.replica_id, provisioner_daemons.tags, provisioner_daemons.last_seen_at, provisioner_daemons.version, provisioner_daemons.api_version, provisioner_daemons.organization_id, provisioner_daemons.key_id +FROM + provisioner_jobs +JOIN + provisioner_daemons ON provisioner_daemons.organization_id = provisioner_jobs.organization_id + AND provisioner_tagset_contains(provisioner_daemons.tags::tagset, provisioner_jobs.tags::tagset) + AND provisioner_jobs.provisioner = ANY(provisioner_daemons.provisioners) +WHERE + provisioner_jobs.id = ANY($1 :: uuid[]) +` + +type GetEligibleProvisionerDaemonsByProvisionerJobIDsRow struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + ProvisionerDaemon ProvisionerDaemon `db:"provisioner_daemon" json:"provisioner_daemon"` +} + +func (q *sqlQuerier) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.Context, provisionerJobIds []uuid.UUID) ([]GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error) { + rows, err := q.db.QueryContext(ctx, getEligibleProvisionerDaemonsByProvisionerJobIDs, pq.Array(provisionerJobIds)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetEligibleProvisionerDaemonsByProvisionerJobIDsRow + for rows.Next() { + var i GetEligibleProvisionerDaemonsByProvisionerJobIDsRow + if err := rows.Scan( + &i.JobID, + &i.ProvisionerDaemon.ID, + &i.ProvisionerDaemon.CreatedAt, + &i.ProvisionerDaemon.Name, + pq.Array(&i.ProvisionerDaemon.Provisioners), + &i.ProvisionerDaemon.ReplicaID, + &i.ProvisionerDaemon.Tags, + &i.ProvisionerDaemon.LastSeenAt, + &i.ProvisionerDaemon.Version, + &i.ProvisionerDaemon.APIVersion, + &i.ProvisionerDaemon.OrganizationID, + &i.ProvisionerDaemon.KeyID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getProvisionerDaemons = `-- name: GetProvisionerDaemons :many SELECT id, created_at, name, provisioners, replica_id, tags, last_seen_at, version, api_version, organization_id, key_id diff --git a/coderd/database/queries/provisionerdaemons.sql b/coderd/database/queries/provisionerdaemons.sql index a6633c91158a9..f76f71f5015bf 100644 --- a/coderd/database/queries/provisionerdaemons.sql +++ b/coderd/database/queries/provisionerdaemons.sql @@ -16,6 +16,18 @@ WHERE -- adding support for searching by tags: (@want_tags :: tagset = 'null' :: tagset OR provisioner_tagset_contains(provisioner_daemons.tags::tagset, @want_tags::tagset)); +-- name: GetEligibleProvisionerDaemonsByProvisionerJobIDs :many +SELECT DISTINCT + provisioner_jobs.id as job_id, sqlc.embed(provisioner_daemons) +FROM + provisioner_jobs +JOIN + provisioner_daemons ON provisioner_daemons.organization_id = provisioner_jobs.organization_id + AND provisioner_tagset_contains(provisioner_daemons.tags::tagset, provisioner_jobs.tags::tagset) + AND provisioner_jobs.provisioner = ANY(provisioner_daemons.provisioners) +WHERE + provisioner_jobs.id = ANY(@provisioner_job_ids :: uuid[]); + -- name: DeleteOldProvisionerDaemons :exec -- Delete provisioner daemons that have been created at least a week ago -- and have not connected to coderd since a week. diff --git a/coderd/healthcheck/provisioner.go b/coderd/healthcheck/provisioner.go index 370a5ad04de86..ae3220170dd69 100644 --- a/coderd/healthcheck/provisioner.go +++ b/coderd/healthcheck/provisioner.go @@ -50,7 +50,7 @@ func (r *ProvisionerDaemonsReport) Run(ctx context.Context, opts *ProvisionerDae now := opts.TimeNow() if opts.StaleInterval == 0 { - opts.StaleInterval = provisionerdserver.DefaultHeartbeatInterval * 3 + opts.StaleInterval = provisionerdserver.StaleInterval } if opts.CurrentVersion == "" { diff --git a/coderd/healthcheck/provisioner_test.go b/coderd/healthcheck/provisioner_test.go index 37530f9f8c747..93871f4a709ad 100644 --- a/coderd/healthcheck/provisioner_test.go +++ b/coderd/healthcheck/provisioner_test.go @@ -15,15 +15,21 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/healthcheck" "github.com/coder/coder/v2/coderd/healthcheck/health" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/healthsdk" "github.com/coder/coder/v2/provisionerd/proto" + "github.com/coder/coder/v2/testutil" ) func TestProvisionerDaemonReport(t *testing.T) { t.Parallel() - now := dbtime.Now() + var ( + now = dbtime.Now() + oneHourAgo = now.Add(-time.Hour) + staleThreshold = now.Add(-provisionerdserver.StaleInterval).Add(-time.Second) + ) for _, tt := range []struct { name string @@ -65,7 +71,9 @@ func TestProvisionerDaemonReport(t *testing.T) { currentVersion: "v1.2.3", currentAPIMajorVersion: proto.CurrentMajor, expectedSeverity: health.SeverityOK, - provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-ok", "v1.2.3", "1.0", now)}, + provisionerDaemons: []database.ProvisionerDaemon{ + fakeProvisionerDaemon(t, withName("pd-ok"), withVersion("v1.2.3"), withAPIVersion("1.0"), withCreatedAt(now), withLastSeenAt(now)), + }, expectedItems: []healthsdk.ProvisionerDaemonsReportItem{ { ProvisionerDaemon: codersdk.ProvisionerDaemon{ @@ -88,7 +96,9 @@ func TestProvisionerDaemonReport(t *testing.T) { currentAPIMajorVersion: proto.CurrentMajor, expectedSeverity: health.SeverityWarning, expectedWarningCode: health.CodeProvisionerDaemonVersionMismatch, - provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-old", "v1.1.2", "1.0", now)}, + provisionerDaemons: []database.ProvisionerDaemon{ + fakeProvisionerDaemon(t, withName("pd-old"), withVersion("v1.1.2"), withAPIVersion("1.0"), withCreatedAt(now), withLastSeenAt(now)), + }, expectedItems: []healthsdk.ProvisionerDaemonsReportItem{ { ProvisionerDaemon: codersdk.ProvisionerDaemon{ @@ -116,7 +126,9 @@ func TestProvisionerDaemonReport(t *testing.T) { currentAPIMajorVersion: proto.CurrentMajor, expectedSeverity: health.SeverityError, expectedWarningCode: health.CodeUnknown, - provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-invalid-version", "invalid", "1.0", now)}, + provisionerDaemons: []database.ProvisionerDaemon{ + fakeProvisionerDaemon(t, withName("pd-invalid-version"), withVersion("invalid"), withAPIVersion("1.0"), withCreatedAt(now), withLastSeenAt(now)), + }, expectedItems: []healthsdk.ProvisionerDaemonsReportItem{ { ProvisionerDaemon: codersdk.ProvisionerDaemon{ @@ -144,7 +156,9 @@ func TestProvisionerDaemonReport(t *testing.T) { currentAPIMajorVersion: proto.CurrentMajor, expectedSeverity: health.SeverityError, expectedWarningCode: health.CodeUnknown, - provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-invalid-api", "v1.2.3", "invalid", now)}, + provisionerDaemons: []database.ProvisionerDaemon{ + fakeProvisionerDaemon(t, withName("pd-invalid-api"), withVersion("v1.2.3"), withAPIVersion("invalid"), withCreatedAt(now), withLastSeenAt(now)), + }, expectedItems: []healthsdk.ProvisionerDaemonsReportItem{ { ProvisionerDaemon: codersdk.ProvisionerDaemon{ @@ -172,7 +186,9 @@ func TestProvisionerDaemonReport(t *testing.T) { currentAPIMajorVersion: 2, expectedSeverity: health.SeverityWarning, expectedWarningCode: health.CodeProvisionerDaemonAPIMajorVersionDeprecated, - provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-old-api", "v2.3.4", "1.0", now)}, + provisionerDaemons: []database.ProvisionerDaemon{ + fakeProvisionerDaemon(t, withName("pd-old-api"), withVersion("v2.3.4"), withAPIVersion("1.0"), withCreatedAt(now), withLastSeenAt(now)), + }, expectedItems: []healthsdk.ProvisionerDaemonsReportItem{ { ProvisionerDaemon: codersdk.ProvisionerDaemon{ @@ -200,7 +216,10 @@ func TestProvisionerDaemonReport(t *testing.T) { currentAPIMajorVersion: proto.CurrentMajor, expectedSeverity: health.SeverityWarning, expectedWarningCode: health.CodeProvisionerDaemonVersionMismatch, - provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-ok", "v1.2.3", "1.0", now), fakeProvisionerDaemon(t, "pd-old", "v1.1.2", "1.0", now)}, + provisionerDaemons: []database.ProvisionerDaemon{ + fakeProvisionerDaemon(t, withName("pd-ok"), withVersion("v1.2.3"), withAPIVersion("1.0"), withCreatedAt(now), withLastSeenAt(now)), + fakeProvisionerDaemon(t, withName("pd-old"), withVersion("v1.1.2"), withAPIVersion("1.0"), withCreatedAt(now), withLastSeenAt(now)), + }, expectedItems: []healthsdk.ProvisionerDaemonsReportItem{ { ProvisionerDaemon: codersdk.ProvisionerDaemon{ @@ -241,7 +260,10 @@ func TestProvisionerDaemonReport(t *testing.T) { currentAPIMajorVersion: proto.CurrentMajor, expectedSeverity: health.SeverityWarning, expectedWarningCode: health.CodeProvisionerDaemonVersionMismatch, - provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemon(t, "pd-ok", "v1.2.3", "1.0", now), fakeProvisionerDaemon(t, "pd-new", "v2.3.4", "1.0", now)}, + provisionerDaemons: []database.ProvisionerDaemon{ + fakeProvisionerDaemon(t, withName("pd-ok"), withVersion("v1.2.3"), withAPIVersion("1.0"), withCreatedAt(now), withLastSeenAt(now)), + fakeProvisionerDaemon(t, withName("pd-new"), withVersion("v2.3.4"), withAPIVersion("1.0"), withCreatedAt(now), withLastSeenAt(now)), + }, expectedItems: []healthsdk.ProvisionerDaemonsReportItem{ { ProvisionerDaemon: codersdk.ProvisionerDaemon{ @@ -281,7 +303,10 @@ func TestProvisionerDaemonReport(t *testing.T) { currentVersion: "v2.3.4", currentAPIMajorVersion: proto.CurrentMajor, expectedSeverity: health.SeverityOK, - provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemonStale(t, "pd-stale", "v1.2.3", "0.9", now.Add(-5*time.Minute), now), fakeProvisionerDaemon(t, "pd-ok", "v2.3.4", "1.0", now)}, + provisionerDaemons: []database.ProvisionerDaemon{ + fakeProvisionerDaemon(t, withName("pd-stale"), withVersion("v1.2.3"), withAPIVersion("0.9"), withCreatedAt(oneHourAgo), withLastSeenAt(staleThreshold)), + fakeProvisionerDaemon(t, withName("pd-ok"), withVersion("v2.3.4"), withAPIVersion("1.0"), withCreatedAt(now), withLastSeenAt(now)), + }, expectedItems: []healthsdk.ProvisionerDaemonsReportItem{ { ProvisionerDaemon: codersdk.ProvisionerDaemon{ @@ -304,8 +329,10 @@ func TestProvisionerDaemonReport(t *testing.T) { currentAPIMajorVersion: proto.CurrentMajor, expectedSeverity: health.SeverityError, expectedWarningCode: health.CodeProvisionerDaemonsNoProvisionerDaemons, - provisionerDaemons: []database.ProvisionerDaemon{fakeProvisionerDaemonStale(t, "pd-ok", "v1.2.3", "0.9", now.Add(-5*time.Minute), now)}, - expectedItems: []healthsdk.ProvisionerDaemonsReportItem{}, + provisionerDaemons: []database.ProvisionerDaemon{ + fakeProvisionerDaemon(t, withName("pd-stale"), withVersion("v1.2.3"), withAPIVersion("0.9"), withCreatedAt(oneHourAgo), withLastSeenAt(staleThreshold)), + }, + expectedItems: []healthsdk.ProvisionerDaemonsReportItem{}, }, } { tt := tt @@ -353,25 +380,52 @@ func TestProvisionerDaemonReport(t *testing.T) { } } -func fakeProvisionerDaemon(t *testing.T, name, version, apiVersion string, now time.Time) database.ProvisionerDaemon { +func withName(s string) func(*database.ProvisionerDaemon) { + return func(pd *database.ProvisionerDaemon) { + pd.Name = s + } +} + +func withCreatedAt(at time.Time) func(*database.ProvisionerDaemon) { + return func(pd *database.ProvisionerDaemon) { + pd.CreatedAt = at + } +} + +func withLastSeenAt(at time.Time) func(*database.ProvisionerDaemon) { + return func(pd *database.ProvisionerDaemon) { + pd.LastSeenAt.Valid = true + pd.LastSeenAt.Time = at + } +} + +func withVersion(v string) func(*database.ProvisionerDaemon) { + return func(pd *database.ProvisionerDaemon) { + pd.Version = v + } +} + +func withAPIVersion(v string) func(*database.ProvisionerDaemon) { + return func(pd *database.ProvisionerDaemon) { + pd.APIVersion = v + } +} + +func fakeProvisionerDaemon(t *testing.T, opts ...func(*database.ProvisionerDaemon)) database.ProvisionerDaemon { t.Helper() - return database.ProvisionerDaemon{ + pd := database.ProvisionerDaemon{ ID: uuid.Nil, - Name: name, - CreatedAt: now, - LastSeenAt: sql.NullTime{Time: now, Valid: true}, + Name: testutil.GetRandomName(t), + CreatedAt: time.Time{}, + LastSeenAt: sql.NullTime{}, Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho, database.ProvisionerTypeTerraform}, ReplicaID: uuid.NullUUID{}, Tags: map[string]string{}, - Version: version, - APIVersion: apiVersion, + Version: "", + APIVersion: "", } -} - -func fakeProvisionerDaemonStale(t *testing.T, name, version, apiVersion string, lastSeenAt, now time.Time) database.ProvisionerDaemon { - t.Helper() - d := fakeProvisionerDaemon(t, name, version, apiVersion, now) - d.LastSeenAt.Valid = true - d.LastSeenAt.Time = lastSeenAt - return d + for _, o := range opts { + o(&pd) + } + return pd } diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 71847b0562d0b..0e9892b892172 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -57,6 +57,10 @@ const ( // DefaultHeartbeatInterval is the interval at which the provisioner daemon // will update its last seen at timestamp in the database. DefaultHeartbeatInterval = time.Minute + + // StaleInterval is the amount of time after the last heartbeat for which + // the provisioner will be reported as 'stale'. + StaleInterval = 90 * time.Second ) type Options struct { diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 12def3e5d681b..e9297d02e2a55 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,8 @@ 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]), codersdk.MatchedProvisioners{}, 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]), codersdk.MatchedProvisioners{}, 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 @@ -546,6 +580,43 @@ 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 + } + 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 @@ -814,7 +885,7 @@ func (api *API) templateVersionsByTemplate(rw http.ResponseWriter, r *http.Reque return err } - apiVersions = append(apiVersions, convertTemplateVersion(version, convertProvisionerJob(job), codersdk.MatchedProvisioners{}, nil)) + apiVersions = append(apiVersions, convertTemplateVersion(version, convertProvisionerJob(job), nil, nil)) } return nil @@ -868,8 +939,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]), codersdk.MatchedProvisioners{}, 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 +1020,23 @@ func (api *API) templateVersionByOrganizationTemplateAndName(rw http.ResponseWri return } - httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(jobs[0]), codersdk.MatchedProvisioners{}, 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 +1122,23 @@ func (api *API) previousTemplateVersionByOrganizationTemplateAndName(rw http.Res return } - httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(previousTemplateVersion, convertProvisionerJob(jobs[0]), codersdk.MatchedProvisioners{}, 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 @@ -1513,27 +1631,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, api.DeploymentValues.Provisioner.DaemonPollInterval.Value()) - 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 +1656,36 @@ 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. + // 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, + }) + 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{ @@ -1633,7 +1760,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht ProvisionerJob: provisionerJob, QueuePosition: 0, }), - matchedProvisioners, + &matchedProvisioners, warnings)) } @@ -1701,7 +1828,7 @@ func (api *API) templateVersionLogs(rw http.ResponseWriter, r *http.Request) { api.provisionerJobLogs(rw, r, job) } -func convertTemplateVersion(version database.TemplateVersion, job codersdk.ProvisionerJob, matchedProvisioners codersdk.MatchedProvisioners, warnings []codersdk.TemplateVersionWarning) codersdk.TemplateVersion { +func convertTemplateVersion(version database.TemplateVersion, job codersdk.ProvisionerJob, matchedProvisioners *codersdk.MatchedProvisioners, warnings []codersdk.TemplateVersionWarning) codersdk.TemplateVersion { return codersdk.TemplateVersion{ ID: version.ID, TemplateID: &version.TemplateID.UUID, @@ -1822,34 +1949,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, pollInterval time.Duration) (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) - } - - threePollsAgo := time.Now().Add(-3 * pollInterval) - mostRecentlySeen := codersdk.NullTime{} - var matched codersdk.MatchedProvisioners - for _, provisioner := range eligibleProvisioners { - if !provisioner.LastSeenAt.Valid { - continue - } - matched.Count++ - if provisioner.LastSeenAt.Time.After(threePollsAgo) { - 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/templateversions_test.go b/coderd/templateversions_test.go index 5e96de10d5058..d8377821245bf 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) @@ -471,14 +489,13 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { pj, err := store.GetProvisionerJobByID(ctx, tv.Job.ID) require.NoError(t, err) require.EqualValues(t, tt.wantTags, pj.Tags) + // Also assert that we get the expected information back from the API endpoint + require.Zero(t, tv.MatchedProvisioners.Count) + require.Zero(t, tv.MatchedProvisioners.Available) + require.Zero(t, tv.MatchedProvisioners.MostRecentlySeen.Time) } else { require.ErrorContains(t, err, tt.expectError) } - - // Also assert that we get the expected information back from the API endpoint - require.Zero(t, tv.MatchedProvisioners.Count) - require.Zero(t, tv.MatchedProvisioners.Available) - require.Zero(t, tv.MatchedProvisioners.MostRecentlySeen.Time) }) } }) @@ -791,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) + } }) } @@ -980,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) @@ -1152,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/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index fa88a72cf0702..7eb598a7d4564 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{ @@ -200,6 +202,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { data.scripts, data.logSources, data.templateVersions, + data.provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -289,6 +292,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ data.scripts, data.logSources, data.templateVersions[0], + data.provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -352,7 +356,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 { @@ -384,10 +388,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)) + } } apiBuild, err := api.convertWorkspaceBuild( @@ -404,6 +410,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, + provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -638,14 +645,15 @@ func (api *API) workspaceBuildTimings(rw http.ResponseWriter, r *http.Request) { } type workspaceBuildsData struct { - jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow - templateVersions []database.TemplateVersion - resources []database.WorkspaceResource - metadata []database.WorkspaceResourceMetadatum - agents []database.WorkspaceAgent - apps []database.WorkspaceApp - scripts []database.WorkspaceAgentScript - logSources []database.WorkspaceAgentLogSource + jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow + templateVersions []database.TemplateVersion + resources []database.WorkspaceResource + metadata []database.WorkspaceResourceMetadatum + agents []database.WorkspaceAgent + apps []database.WorkspaceApp + scripts []database.WorkspaceAgentScript + logSources []database.WorkspaceAgentLogSource + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow } func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []database.WorkspaceBuild) (workspaceBuildsData, error) { @@ -657,6 +665,17 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab if err != nil && !errors.Is(err, sql.ErrNoRows) { return workspaceBuildsData{}, xerrors.Errorf("get provisioner jobs: %w", err) } + pendingJobIDs := []uuid.UUID{} + for _, job := range jobs { + if job.ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending { + pendingJobIDs = append(pendingJobIDs, job.ProvisionerJob.ID) + } + } + + pendingJobProvisioners, err := api.Database.GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx, pendingJobIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return workspaceBuildsData{}, xerrors.Errorf("get provisioner daemons: %w", err) + } templateVersionIDs := make([]uuid.UUID, 0, len(workspaceBuilds)) for _, build := range workspaceBuilds { @@ -677,8 +696,9 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab if len(resources) == 0 { return workspaceBuildsData{ - jobs: jobs, - templateVersions: templateVersions, + jobs: jobs, + templateVersions: templateVersions, + provisionerDaemons: pendingJobProvisioners, }, nil } @@ -701,10 +721,11 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab if len(resources) == 0 { return workspaceBuildsData{ - jobs: jobs, - templateVersions: templateVersions, - resources: resources, - metadata: metadata, + jobs: jobs, + templateVersions: templateVersions, + resources: resources, + metadata: metadata, + provisionerDaemons: pendingJobProvisioners, }, nil } @@ -741,14 +762,15 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab } return workspaceBuildsData{ - jobs: jobs, - templateVersions: templateVersions, - resources: resources, - metadata: metadata, - agents: agents, - apps: apps, - scripts: scripts, - logSources: logSources, + jobs: jobs, + templateVersions: templateVersions, + resources: resources, + metadata: metadata, + agents: agents, + apps: apps, + scripts: scripts, + logSources: logSources, + provisionerDaemons: pendingJobProvisioners, }, nil } @@ -763,6 +785,7 @@ func (api *API) convertWorkspaceBuilds( agentScripts []database.WorkspaceAgentScript, agentLogSources []database.WorkspaceAgentLogSource, templateVersions []database.TemplateVersion, + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, ) ([]codersdk.WorkspaceBuild, error) { workspaceByID := map[uuid.UUID]database.Workspace{} for _, workspace := range workspaces { @@ -804,6 +827,7 @@ func (api *API) convertWorkspaceBuilds( agentScripts, agentLogSources, templateVersion, + provisionerDaemons, ) if err != nil { return nil, xerrors.Errorf("converting workspace build: %w", err) @@ -826,6 +850,7 @@ func (api *API) convertWorkspaceBuild( agentScripts []database.WorkspaceAgentScript, agentLogSources []database.WorkspaceAgentLogSource, templateVersion database.TemplateVersion, + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, ) (codersdk.WorkspaceBuild, error) { resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{} for _, resource := range workspaceResources { @@ -851,6 +876,14 @@ func (api *API) convertWorkspaceBuild( for _, logSource := range agentLogSources { logSourcesByAgentID[logSource.WorkspaceAgentID] = append(logSourcesByAgentID[logSource.WorkspaceAgentID], logSource) } + provisionerDaemonsForThisWorkspaceBuild := []database.ProvisionerDaemon{} + for _, provisionerDaemon := range provisionerDaemons { + if provisionerDaemon.JobID != job.ProvisionerJob.ID { + continue + } + provisionerDaemonsForThisWorkspaceBuild = append(provisionerDaemonsForThisWorkspaceBuild, provisionerDaemon.ProvisionerDaemon) + } + matchedProvisioners := db2sdk.MatchedProvisioners(provisionerDaemonsForThisWorkspaceBuild, job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval) resources := resourcesByJobID[job.ProvisionerJob.ID] apiResources := make([]codersdk.WorkspaceResource, 0) @@ -918,6 +951,7 @@ func (api *API) convertWorkspaceBuild( Resources: apiResources, Status: codersdk.ConvertWorkspaceStatus(apiJob.Status, transition), DailyCost: build.DailyCost, + MatchedProvisioners: &matchedProvisioners, }, nil } diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 29642e5ae2dd4..feb748ad29250 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,102 @@ func TestPostWorkspaceBuild(t *testing.T) { require.NoError(t, err) require.Len(t, res.Workspaces, 0) }) + + 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) + 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) { + 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) + } + }) + + 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) + 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) { + 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) + } + }) } func TestWorkspaceBuildTimings(t *testing.T) { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index ff8a55ded775a..be23cc3215a32 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -593,8 +593,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 +602,9 @@ func createWorkspace( } var ( - provisionerJob *database.ProvisionerJob - workspaceBuild *database.WorkspaceBuild + provisionerJob *database.ProvisionerJob + workspaceBuild *database.WorkspaceBuild + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow ) err = api.Database.InTx(func(db database.Store) error { now := dbtime.Now() @@ -645,7 +645,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 +655,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 +676,7 @@ 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)) } + auditReq.New = workspace.WorkspaceTable() api.Telemetry.Report(&telemetry.Snapshot{ @@ -696,6 +698,7 @@ func createWorkspace( []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, + provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -1816,6 +1819,7 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa data.scripts, data.logSources, data.templateVersions, + data.provisionerDaemons, ) if err != nil { return workspaceData{}, xerrors.Errorf("convert workspace builds: %w", err) 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) { diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 9e8de1d688768..d59af8cdc1b32 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, 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,32 @@ 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.GetEligibleProvisionerDaemonsByProvisionerJobIDs(dbauthz.AsSystemReadProvisionerDaemons(b.ctx), []uuid.UUID{provisionerJob.ID}) + 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{} } 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 +407,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..3f373efd3bfdb 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // 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.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().GetEligibleProvisionerDaemonsByProvisionerJobIDs(gomock.Any(), gomock.Any()).Return(provisionerDaemons, nil) + } +} diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index c8bd4354df153..fb588ef8ba468 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -54,6 +54,7 @@ type ProvisionerDaemon struct { // MatchedProvisioners represents the number of provisioner daemons // available to take a job at a specific point in time. +// Introduced in Coder version 2.18.0. type MatchedProvisioners struct { // Count is the number of provisioner daemons that matched the given // tags. If the count is 0, it means no provisioner daemons matched the diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index 5bda52daf3dfe..de8bb7b970957 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -32,7 +32,7 @@ type TemplateVersion struct { Archived bool `json:"archived"` Warnings []TemplateVersionWarning `json:"warnings,omitempty" enums:"DEPRECATED_PARAMETERS"` - MatchedProvisioners MatchedProvisioners `json:"matched_provisioners,omitempty"` + MatchedProvisioners *MatchedProvisioners `json:"matched_provisioners,omitempty"` } type TemplateVersionExternalAuth struct { @@ -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/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 211dc9297f0fc..2aadd1f95ccef 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/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 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/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 239c7ae377102..e20bfba9c189c 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "context" + "database/sql" "net/http" "sync/atomic" "testing" @@ -17,8 +18,10 @@ import ( "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac" agplschedule "github.com/coder/coder/v2/coderd/schedule" @@ -31,6 +34,7 @@ import ( "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/enterprise/coderd/schedule" "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/testutil" ) @@ -1522,6 +1526,214 @@ func TestAdminViewAllWorkspaces(t *testing.T) { require.Equal(t, 0, len(memberViewWorkspaces.Workspaces), "member in other org should see 0 workspaces") } +func TestWorkspaceByOwnerAndName(t *testing.T) { + t.Parallel() + + t.Run("Matching Provisioner", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client, db, userResponse := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, rbac.ExpandableScope(rbac.ScopeAll)) + require.NoError(t, err) + user, err := client.User(ctx, userSubject.ID) + require.NoError(t, err) + username := user.Username + + _ = coderdenttest.NewExternalProvisionerDaemon(t, client, userResponse.OrganizationID, map[string]string{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }) + + version := coderdtest.CreateTemplateVersion(t, client, userResponse.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, userResponse.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + + // Pending builds should show matching provisioners + require.Equal(t, workspace.LatestBuild.Status, codersdk.WorkspaceStatusPending) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Count, 1) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Available, 1) + + // Completed builds should not show matching provisioners, because no provisioner daemon can + // be eligible to process a job that is already completed. + completedBuild := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + require.Equal(t, completedBuild.Status, codersdk.WorkspaceStatusRunning) + require.Equal(t, completedBuild.MatchedProvisioners.Count, 0) + require.Equal(t, completedBuild.MatchedProvisioners.Available, 0) + + ws, err := client.WorkspaceByOwnerAndName(ctx, username, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + + // Verify the workspace details + require.Equal(t, workspace.ID, ws.ID) + require.Equal(t, workspace.Name, ws.Name) + require.Equal(t, workspace.TemplateID, ws.TemplateID) + require.Equal(t, completedBuild.Status, ws.LatestBuild.Status) + require.Equal(t, ws.LatestBuild.MatchedProvisioners.Count, 0) + require.Equal(t, ws.LatestBuild.MatchedProvisioners.Available, 0) + + // Verify that the provisioner daemon is registered in the database + //nolint:gocritic // unit testing + daemons, err := db.GetProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) + require.NoError(t, err) + require.Equal(t, 1, len(daemons)) + require.Equal(t, provisionersdk.ScopeOrganization, daemons[0].Tags[provisionersdk.TagScope]) + }) + + t.Run("No Matching Provisioner", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client, db, userResponse := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, rbac.ExpandableScope(rbac.ScopeAll)) + require.NoError(t, err) + user, err := client.User(ctx, userSubject.ID) + require.NoError(t, err) + username := user.Username + + closer := coderdenttest.NewExternalProvisionerDaemon(t, client, userResponse.OrganizationID, map[string]string{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }) + + version := coderdtest.CreateTemplateVersion(t, client, userResponse.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, userResponse.OrganizationID, version.ID) + + // nolint:gocritic // unit testing + daemons, err := db.GetProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) + require.NoError(t, err) + require.Equal(t, len(daemons), 1) + + // Simulate a provisioner daemon failure: + err = closer.Close() + require.NoError(t, err) + + // Simulate it's subsequent deletion from the database: + + // nolint:gocritic // unit testing + _, err = db.UpsertProvisionerDaemon(dbauthz.AsSystemRestricted(ctx), database.UpsertProvisionerDaemonParams{ + Name: daemons[0].Name, + OrganizationID: daemons[0].OrganizationID, + Tags: daemons[0].Tags, + Provisioners: daemons[0].Provisioners, + Version: daemons[0].Version, + APIVersion: daemons[0].APIVersion, + KeyID: daemons[0].KeyID, + // Simulate the passing of time such that the provisioner daemon is considered stale + // and will be deleted: + CreatedAt: time.Now().Add(-time.Hour * 24 * 8), + LastSeenAt: sql.NullTime{ + Time: time.Now().Add(-time.Hour * 24 * 8), + Valid: true, + }, + }) + require.NoError(t, err) + // nolint:gocritic // unit testing + err = db.DeleteOldProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) + require.NoError(t, err) + + // Create a workspace that will not be able to provision due to a lack of provisioner daemons: + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + + require.Equal(t, workspace.LatestBuild.Status, codersdk.WorkspaceStatusPending) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Count, 0) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Available, 0) + + // nolint:gocritic // unit testing + _, err = client.WorkspaceByOwnerAndName(dbauthz.As(ctx, userSubject), username, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + require.Equal(t, workspace.LatestBuild.Status, codersdk.WorkspaceStatusPending) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Count, 0) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Available, 0) + }) + + t.Run("Unavailable Provisioner", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client, db, userResponse := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, rbac.ExpandableScope(rbac.ScopeAll)) + require.NoError(t, err) + user, err := client.User(ctx, userSubject.ID) + require.NoError(t, err) + username := user.Username + + closer := coderdenttest.NewExternalProvisionerDaemon(t, client, userResponse.OrganizationID, map[string]string{ + provisionersdk.TagScope: provisionersdk.ScopeOrganization, + }) + + version := coderdtest.CreateTemplateVersion(t, client, userResponse.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, userResponse.OrganizationID, version.ID) + + // nolint:gocritic // unit testing + daemons, err := db.GetProvisionerDaemons(dbauthz.AsSystemRestricted(ctx)) + require.NoError(t, err) + require.Equal(t, len(daemons), 1) + + // Simulate a provisioner daemon failure: + err = closer.Close() + require.NoError(t, err) + + // nolint:gocritic // unit testing + _, err = db.UpsertProvisionerDaemon(dbauthz.AsSystemRestricted(ctx), database.UpsertProvisionerDaemonParams{ + Name: daemons[0].Name, + OrganizationID: daemons[0].OrganizationID, + Tags: daemons[0].Tags, + Provisioners: daemons[0].Provisioners, + Version: daemons[0].Version, + APIVersion: daemons[0].APIVersion, + KeyID: daemons[0].KeyID, + // Simulate the passing of time such that the provisioner daemon, though not stale, has been + // has been inactive for a while: + CreatedAt: time.Now().Add(-time.Hour * 24 * 2), + LastSeenAt: sql.NullTime{ + Time: time.Now().Add(-time.Hour * 24 * 2), + Valid: true, + }, + }) + require.NoError(t, err) + + // Create a workspace that will not be able to provision due to a lack of provisioner daemons: + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + + require.Equal(t, workspace.LatestBuild.Status, codersdk.WorkspaceStatusPending) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Count, 1) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Available, 0) + + // nolint:gocritic // unit testing + _, err = client.WorkspaceByOwnerAndName(dbauthz.As(ctx, userSubject), username, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + require.Equal(t, workspace.LatestBuild.Status, codersdk.WorkspaceStatusPending) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Count, 1) + require.Equal(t, workspace.LatestBuild.MatchedProvisioners.Available, 0) + }) +} + func must[T any](value T, err error) T { if err != nil { panic(err) diff --git a/go.mod b/go.mod index 54d577072c554..6023eb8ec7b0e 100644 --- a/go.mod +++ b/go.mod @@ -174,15 +174,15 @@ require ( go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.3.1-0.20240429205332-517bace7cc29 go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 - golang.org/x/crypto v0.29.0 + golang.org/x/crypto v0.31.0 golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa golang.org/x/mod v0.22.0 golang.org/x/net v0.31.0 golang.org/x/oauth2 v0.24.0 - golang.org/x/sync v0.9.0 - golang.org/x/sys v0.27.0 - golang.org/x/term v0.26.0 - golang.org/x/text v0.20.0 + golang.org/x/sync v0.10.0 + golang.org/x/sys v0.28.0 + golang.org/x/term v0.27.0 + golang.org/x/text v0.21.0 golang.org/x/tools v0.27.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da google.golang.org/api v0.209.0 diff --git a/go.sum b/go.sum index d5cdae95ebb46..15be98cb88a72 100644 --- a/go.sum +++ b/go.sum @@ -1058,8 +1058,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= @@ -1106,8 +1106,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1149,8 +1149,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1158,8 +1158,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= -golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -1170,8 +1170,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/provisioner/terraform/tfparse/tfparse.go b/provisioner/terraform/tfparse/tfparse.go index 3807c518cbb73..0eb6a0094e505 100644 --- a/provisioner/terraform/tfparse/tfparse.go +++ b/provisioner/terraform/tfparse/tfparse.go @@ -172,7 +172,7 @@ func (p *Parser) WorkspaceTagDefaults(ctx context.Context) (map[string]string, e if err != nil { return nil, xerrors.Errorf("load variable defaults: %w", err) } - paramsDefaults, err := p.CoderParameterDefaults(ctx) + paramsDefaults, err := p.CoderParameterDefaults(ctx, varsDefaults) if err != nil { return nil, xerrors.Errorf("load parameter defaults: %w", err) } @@ -268,7 +268,7 @@ func (p *Parser) VariableDefaults(ctx context.Context) (map[string]string, error // CoderParameterDefaults returns the default values of all coder_parameter data sources // in the parsed module. -func (p *Parser) CoderParameterDefaults(ctx context.Context) (map[string]string, error) { +func (p *Parser) CoderParameterDefaults(ctx context.Context, varsDefaults map[string]string) (map[string]string, error) { defaultsM := make(map[string]string) var ( skipped []string @@ -316,6 +316,7 @@ func (p *Parser) CoderParameterDefaults(ctx context.Context) (map[string]string, } if _, ok := resContent.Attributes["default"]; !ok { + p.logger.Warn(ctx, "coder_parameter data source does not have a default value", slog.F("name", dataResource.Name)) defaultsM[dataResource.Name] = "" } else { expr := resContent.Attributes["default"].Expr @@ -323,7 +324,20 @@ func (p *Parser) CoderParameterDefaults(ctx context.Context) (map[string]string, if err != nil { return nil, xerrors.Errorf("can't preview the resource file: %v", err) } - defaultsM[dataResource.Name] = strings.Trim(value, `"`) + // Issue #15795: the "default" value could also be an expression we need + // to evaluate. + // TODO: should we support coder_parameter default values that reference other coder_parameter data sources? + evalCtx := buildEvalContext(varsDefaults, nil) + val, diags := expr.Value(evalCtx) + if diags.HasErrors() { + return nil, xerrors.Errorf("failed to evaluate coder_parameter %q default value %q: %s", dataResource.Name, value, diags.Error()) + } + // Do not use "val.AsString()" as it can panic + strVal, err := ctyValueString(val) + if err != nil { + return nil, xerrors.Errorf("failed to marshal coder_parameter %q default value %q as string: %s", dataResource.Name, value, err) + } + defaultsM[dataResource.Name] = strings.Trim(strVal, `"`) } } } diff --git a/provisioner/terraform/tfparse/tfparse_test.go b/provisioner/terraform/tfparse/tfparse_test.go index 8436d99e67d03..9d9bcc4526584 100644 --- a/provisioner/terraform/tfparse/tfparse_test.go +++ b/provisioner/terraform/tfparse/tfparse_test.go @@ -114,6 +114,73 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { expectTags: map[string]string{"platform": "kubernetes", "cluster": "developers", "region": "us", "az": "a"}, expectError: "", }, + { + name: "main.tf with parameter that has default value from dynamic value", + files: map[string]string{ + "main.tf": ` + provider "foo" {} + resource "foo_bar" "baz" {} + variable "region" { + type = string + default = "us" + } + variable "az" { + type = string + default = "${""}${"a"}" + } + data "base" "ours" { + all = true + } + data "coder_parameter" "az" { + name = "az" + type = "string" + default = var.az + } + data "coder_workspace_tags" "tags" { + tags = { + "platform" = "kubernetes", + "cluster" = "${"devel"}${"opers"}" + "region" = var.region + "az" = data.coder_parameter.az.value + } + }`, + }, + expectTags: map[string]string{"platform": "kubernetes", "cluster": "developers", "region": "us", "az": "a"}, + expectError: "", + }, + { + name: "main.tf with parameter that has default value from another parameter", + files: map[string]string{ + "main.tf": ` + provider "foo" {} + resource "foo_bar" "baz" {} + variable "region" { + type = string + default = "us" + } + data "base" "ours" { + all = true + } + data "coder_parameter" "az" { + type = string + default = "${""}${"a"}" + } + data "coder_parameter" "az2" { + name = "az" + type = "string" + default = data.coder_parameter.az.value + } + data "coder_workspace_tags" "tags" { + tags = { + "platform" = "kubernetes", + "cluster" = "${"devel"}${"opers"}" + "region" = var.region + "az" = data.coder_parameter.az2.value + } + }`, + }, + expectError: "Unknown variable; There is no variable named \"data\".", + }, { name: "main.tf with multiple valid workspace tags", files: map[string]string{ 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/modules/provisioners/ProvisionerAlert.stories.tsx b/site/src/modules/provisioners/ProvisionerAlert.stories.tsx index d9ca1501d6611..496934bf2275e 100644 --- a/site/src/modules/provisioners/ProvisionerAlert.stories.tsx +++ b/site/src/modules/provisioners/ProvisionerAlert.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { chromatic } from "testHelpers/chromatic"; -import { ProvisionerAlert } from "./ProvisionerAlert"; +import { AlertVariant, ProvisionerAlert } from "./ProvisionerAlert"; const meta: Meta = { title: "modules/provisioners/ProvisionerAlert", @@ -21,6 +21,26 @@ export default meta; type Story = StoryObj; export const Info: Story = {}; + +export const InfoInline: Story = { + args: { + variant: AlertVariant.Inline, + }, +}; + +export const Warning: Story = { + args: { + severity: "warning", + }, +}; + +export const WarningInline: Story = { + args: { + severity: "warning", + variant: AlertVariant.Inline, + }, +}; + export const NullTags: Story = { args: { tags: undefined, diff --git a/site/src/modules/provisioners/ProvisionerAlert.tsx b/site/src/modules/provisioners/ProvisionerAlert.tsx index 54d9ab8473e87..86d69796cd4b9 100644 --- a/site/src/modules/provisioners/ProvisionerAlert.tsx +++ b/site/src/modules/provisioners/ProvisionerAlert.tsx @@ -1,34 +1,54 @@ +import type { Theme } from "@emotion/react"; import AlertTitle from "@mui/material/AlertTitle"; import { Alert, type AlertColor } from "components/Alert/Alert"; import { AlertDetail } from "components/Alert/Alert"; import { Stack } from "components/Stack/Stack"; import { ProvisionerTag } from "modules/provisioners/ProvisionerTag"; import type { FC } from "react"; + +export enum AlertVariant { + // Alerts are usually styled with a full rounded border and meant to use as a visually distinct element of the page. + // The Standalone variant conforms to this styling. + Standalone = "Standalone", + // We show these same alerts in environments such as log drawers where we stream the logs from builds. + // In this case the full border is incongruent with the surroundings of the component. + // The Inline variant replaces the full rounded border with a left border and a divider so that it complements the surroundings. + Inline = "Inline", +} + interface ProvisionerAlertProps { title: string; detail: string; severity: AlertColor; tags: Record; + variant?: AlertVariant; } +const getAlertStyles = (variant: AlertVariant, severity: AlertColor) => { + switch (variant) { + case AlertVariant.Inline: + return { + css: (theme: Theme) => ({ + borderRadius: 0, + border: 0, + borderBottom: `1px solid ${theme.palette.divider}`, + borderLeft: `2px solid ${theme.palette[severity].main}`, + }), + }; + default: + return {}; + } +}; + export const ProvisionerAlert: FC = ({ title, detail, severity, tags, + variant = AlertVariant.Standalone, }) => { return ( - { - return { - borderRadius: 0, - border: 0, - borderBottom: `1px solid ${theme.palette.divider}`, - borderLeft: `2px solid ${theme.palette[severity].main}`, - }; - }} - > + {title}
{detail}
diff --git a/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx b/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx index d4f746e99c417..ec3e7ed20f953 100644 --- a/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx +++ b/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { chromatic } from "testHelpers/chromatic"; import { MockTemplateVersion } from "testHelpers/entities"; +import { AlertVariant } from "./ProvisionerAlert"; import { ProvisionerStatusAlert } from "./ProvisionerStatusAlert"; const meta: Meta = { @@ -47,9 +48,24 @@ export const NoMatchingProvisioners: Story = { }, }; +export const NoMatchingProvisionersInLogs: Story = { + args: { + matchingProvisioners: 0, + variant: AlertVariant.Inline, + }, +}; + export const NoAvailableProvisioners: Story = { args: { matchingProvisioners: 1, availableProvisioners: 0, }, }; + +export const NoAvailableProvisionersInLogs: Story = { + args: { + matchingProvisioners: 1, + availableProvisioners: 0, + variant: AlertVariant.Inline, + }, +}; diff --git a/site/src/modules/provisioners/ProvisionerStatusAlert.tsx b/site/src/modules/provisioners/ProvisionerStatusAlert.tsx index 54a2b56704877..e75887f1d97a7 100644 --- a/site/src/modules/provisioners/ProvisionerStatusAlert.tsx +++ b/site/src/modules/provisioners/ProvisionerStatusAlert.tsx @@ -1,17 +1,19 @@ import type { AlertColor } from "components/Alert/Alert"; import type { FC } from "react"; -import { ProvisionerAlert } from "./ProvisionerAlert"; +import { AlertVariant, ProvisionerAlert } from "./ProvisionerAlert"; interface ProvisionerStatusAlertProps { matchingProvisioners: number | undefined; availableProvisioners: number | undefined; tags: Record; + variant?: AlertVariant; } export const ProvisionerStatusAlert: FC = ({ matchingProvisioners, availableProvisioners, tags, + variant = AlertVariant.Standalone, }) => { let title: string; let detail: string; @@ -42,6 +44,7 @@ export const ProvisionerStatusAlert: FC = ({ detail={detail} severity={severity} tags={tags} + variant={variant} /> ); }; diff --git a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx index 4eb1805b60e36..7f6b5f45aef04 100644 --- a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx +++ b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx @@ -8,6 +8,7 @@ import { visuallyHidden } from "@mui/utils"; import { JobError } from "api/queries/templates"; import type { TemplateVersion } from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; +import { AlertVariant } from "modules/provisioners/ProvisionerAlert"; import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs"; import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; @@ -94,6 +95,7 @@ export const BuildLogsDrawer: FC = ({ matchingProvisioners={matchingProvisioners} availableProvisioners={availableProvisioners} tags={templateVersion?.job.tags ?? {}} + variant={AlertVariant.Inline} /> diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index 51dae50df26fa..4ba432b7b0765 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -51,7 +51,7 @@ export const authMethodLanguage = { documentation {" "} diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index 858f57dd59493..bb9bbb7c72732 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -29,6 +29,7 @@ import { import { Loader } from "components/Loader/Loader"; import { linkToTemplate, useLinks } from "modules/navigation"; import { ProvisionerAlert } from "modules/provisioners/ProvisionerAlert"; +import { AlertVariant } from "modules/provisioners/ProvisionerAlert"; import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { TemplateFileTree } from "modules/templates/TemplateFiles/TemplateFileTree"; import { isBinaryData } from "modules/templates/TemplateFiles/isBinaryData"; @@ -593,6 +594,7 @@ export const TemplateVersionEditor: FC = ({ detail={templateVersion.job.error} severity="error" tags={templateVersion.job.tags} + variant={AlertVariant.Inline} /> ) : ( @@ -602,6 +604,7 @@ export const TemplateVersionEditor: FC = ({ matchingProvisioners={matchingProvisioners} availableProvisioners={availableProvisioners} tags={templateVersion.job.tags} + variant={AlertVariant.Inline} /> diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index 2e3745c2f65bf..6efbeef76ee25 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -63,7 +63,16 @@ type Story = StoryObj; export const Running: Story = { args: { - workspace: Mocks.MockWorkspace, + workspace: { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspace.latest_build, + matched_provisioners: { + count: 0, + available: 0, + }, + }, + }, handleStart: action("start"), handleStop: action("stop"), buildInfo: Mocks.MockBuildInfo, @@ -95,6 +104,51 @@ export const PendingInQueue: Story = { }, }; +export const PendingWithNoProvisioners: Story = { + args: { + ...Running.args, + workspace: { + ...Mocks.MockPendingWorkspace, + latest_build: { + ...Mocks.MockPendingWorkspace.latest_build, + matched_provisioners: { + count: 0, + available: 0, + }, + }, + }, + }, +}; + +export const PendingWithNoAvailableProvisioners: Story = { + args: { + ...Running.args, + workspace: { + ...Mocks.MockPendingWorkspace, + latest_build: { + ...Mocks.MockPendingWorkspace.latest_build, + matched_provisioners: { + count: 1, + available: 0, + }, + }, + }, + }, +}; + +export const PendingWithUndefinedProvisioners: Story = { + args: { + ...Running.args, + workspace: { + ...Mocks.MockPendingWorkspace, + latest_build: { + ...Mocks.MockPendingWorkspace.latest_build, + matched_provisioners: undefined, + }, + }, + }, +}; + export const Starting: Story = { args: { ...Running.args, @@ -130,7 +184,7 @@ export const FailedWithLogs: Story = { }, }, }, - buildLogs: , + buildLogs: makeFailedBuildLogs(), }, }; @@ -148,7 +202,7 @@ export const FailedWithRetry: Story = { }, }, }, - buildLogs: , + buildLogs: makeFailedBuildLogs(), }, }; diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 5b9919474a620..f28cb775bdd6f 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -7,6 +7,7 @@ import type * as TypesGen from "api/typesGenerated"; import { Alert, AlertDetail } from "components/Alert/Alert"; import { SidebarIconButton } from "components/FullPageLayout/Sidebar"; import { useSearchParamsKey } from "hooks/useSearchParamsKey"; +import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { AgentRow } from "modules/resources/AgentRow"; import { WorkspaceTimings } from "modules/workspaces/WorkspaceTiming/WorkspaceTimings"; import type { FC } from "react"; @@ -14,6 +15,7 @@ import { useNavigate } from "react-router-dom"; import { HistorySidebar } from "./HistorySidebar"; import { ResourceMetadata } from "./ResourceMetadata"; import { ResourcesSidebar } from "./ResourcesSidebar"; +import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection"; import { ActiveTransition, WorkspaceBuildProgress, @@ -46,7 +48,7 @@ export interface WorkspaceProps { canDebugMode: boolean; handleRetry: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; handleDebug: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; - buildLogs?: React.ReactNode; + buildLogs?: TypesGen.ProvisionerJobLog[]; latestVersion?: TypesGen.TemplateVersion; permissions: WorkspacePermissions; isOwner: boolean; @@ -108,6 +110,15 @@ export const Workspace: FC = ({ (r) => resourceOptionValue(r) === resourcesNav.value, ); + const workspaceRunning = workspace.latest_build.status === "running"; + const workspacePending = workspace.latest_build.status === "pending"; + const haveBuildLogs = (buildLogs ?? []).length > 0; + const shouldShowBuildLogs = haveBuildLogs && !workspaceRunning; + const provisionersHealthy = + (workspace.latest_build.matched_provisioners?.available ?? 1) > 0; + const shouldShowProvisionerAlert = + workspacePending && !haveBuildLogs && !provisionersHealthy && !isRestarting; + return (
= ({ /> )} + {shouldShowProvisionerAlert && ( + + )} + {workspace.latest_build.job.error && ( Workspace build failed @@ -222,7 +245,9 @@ export const Workspace: FC = ({ /> )} - {buildLogs} + {shouldShowBuildLogs && ( + + )} {selectedResource && (
= ({ }); // Build logs - const shouldDisplayBuildLogs = workspace.latest_build.status !== "running"; + const shouldStreamBuildLogs = workspace.latest_build.status !== "running"; const buildLogs = useWorkspaceBuildLogs( workspace.latest_build.id, - shouldDisplayBuildLogs, + shouldStreamBuildLogs, ); // Restart @@ -264,11 +263,7 @@ export const WorkspaceReadyPage: FC = ({ buildInfo={buildInfoQuery.data} sshPrefix={sshPrefixQuery.data?.hostname_prefix} template={template} - buildLogs={ - shouldDisplayBuildLogs && ( - - ) - } + buildLogs={buildLogs} isOwner={isOwner} timings={timingsQuery.data} /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 1593790e9792d..a06392c458e78 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1199,6 +1199,10 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { resources: [MockWorkspaceResource], status: "running", daily_cost: 20, + matched_provisioners: { + count: 1, + available: 1, + }, }; export const MockWorkspaceBuildAutostart: TypesGen.WorkspaceBuild = {