From 1375abadfa044225ea842fee3b9ac24e2a7d90b8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 10 Dec 2024 10:58:12 +0000 Subject: [PATCH 1/9] fix(provisioner/terraform/tfparse): evaluate coder_parameter defaults with variables (#15800) - adds support for dynamic default values in coder_parameter data source (cherry picked from commit 7dc3ad9f21580e398bb0d005869c0b4d53499fcd) --- provisioner/terraform/tfparse/tfparse.go | 20 +++++- provisioner/terraform/tfparse/tfparse_test.go | 67 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) 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{ From 129cc751db0fe97c8e296b76266363c5bc903b81 Mon Sep 17 00:00:00 2001 From: Eric Paulsen Date: Wed, 11 Dec 2024 11:38:03 +0000 Subject: [PATCH 2/9] fix: docs reference in create headless user flow (#15826) when creating a headless user, the linked documentation sent users to the `Disable password auth` page, instead of the headless user documentation. this PR corrects the typescript. (cherry picked from commit 104898ae87fd98a81f9b0674ac2e05ed64c54d4f) --- site/src/pages/CreateUserPage/CreateUserForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 {" "} From 0a71d2d7d5488bebbd3c1913268659f97a0f29d6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 28 Nov 2024 12:57:43 +0000 Subject: [PATCH 3/9] fix(coderd): extract provisionerdserver.StaleInterval to 90 seconds (#15643) Follow-up from https://github.com/coder/coder/pull/15578 Extracts `provisionerdserver.StaleInterval` and sets it to 90 seconds by default (cherry picked from commit ef09b5191231f8bf5f71494e4735ac37893a5392) --- coderd/coderd.go | 3 +- coderd/healthcheck/provisioner.go | 2 +- coderd/healthcheck/provisioner_test.go | 106 +++++++++++++----- .../provisionerdserver/provisionerdserver.go | 4 + coderd/templateversions.go | 8 +- 5 files changed, 91 insertions(+), 32 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index d64727567720d..4c7ff4fe081ff 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 }, }) } 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..3e42879171a45 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -1515,7 +1515,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht // 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()) + matchedProvisioners, err = checkProvisioners(ctx, tx, organization.ID, tags) if err != nil { api.Logger.Error(ctx, "failed to check eligible provisioner daemons for job", slog.Error(err)) } else if matchedProvisioners.Count == 0 { @@ -1823,7 +1823,7 @@ func (api *API) publishTemplateUpdate(ctx context.Context, templateID uuid.UUID) } } -func checkProvisioners(ctx context.Context, store database.Store, orgID uuid.UUID, wantTags map[string]string, pollInterval time.Duration) (codersdk.MatchedProvisioners, error) { +func checkProvisioners(ctx context.Context, store database.Store, orgID uuid.UUID, wantTags map[string]string) (codersdk.MatchedProvisioners, error) { // Check for eligible provisioners. This allows us to return a warning to the user if they // submit a job for which no provisioner is available. eligibleProvisioners, err := store.GetProvisionerDaemonsByOrganization(ctx, database.GetProvisionerDaemonsByOrganizationParams{ @@ -1835,7 +1835,7 @@ func checkProvisioners(ctx context.Context, store database.Store, orgID uuid.UUI return codersdk.MatchedProvisioners{}, xerrors.Errorf("provisioner daemons by organization: %w", err) } - threePollsAgo := time.Now().Add(-3 * pollInterval) + staleInterval := time.Now().Add(-provisionerdserver.StaleInterval) mostRecentlySeen := codersdk.NullTime{} var matched codersdk.MatchedProvisioners for _, provisioner := range eligibleProvisioners { @@ -1843,7 +1843,7 @@ func checkProvisioners(ctx context.Context, store database.Store, orgID uuid.UUI continue } matched.Count++ - if provisioner.LastSeenAt.Time.After(threePollsAgo) { + if provisioner.LastSeenAt.Time.After(staleInterval) { matched.Available++ } if provisioner.LastSeenAt.Time.After(mostRecentlySeen.Time) { From 6da176031284bb24947a9e721dbfb4ba57fc364a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 Nov 2024 19:45:58 +0000 Subject: [PATCH 4/9] fix(cli): handle version mismatch re MatchedProvisioners response (#15682) * Modifies `MatchedProvisioners` response of `codersdk.TemplateVersion` to be a pointer * CLI now checks for absence of `*MatchedProvisioners` before showing warning regarding provisioners * Extracts logic for warning about provisioners to a function * Improves test coverage for CLI template push with `coder_workspace_tags`. (cherry picked from commit 3014713c470af7ae3fb5ecb6c4286dad55eb80e2) --- cli/templatepush.go | 60 +++++---- cli/templatepush_test.go | 232 +++++++++++++++++++++----------- coderd/templateversions.go | 16 +-- coderd/templateversions_test.go | 9 +- codersdk/provisionerdaemons.go | 1 + codersdk/templateversions.go | 2 +- 6 files changed, 206 insertions(+), 114 deletions(-) diff --git a/cli/templatepush.go b/cli/templatepush.go index 8516d7f9c1310..2576e878edddb 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -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) - } - + WarnMatchedProvisioners(inv, version) err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { version, err := client.TemplateVersion(inv.Context(), version.ID) @@ -505,6 +482,41 @@ func ParseProvisionerTags(rawTags []string) (map[string]string, error) { return tags, nil } +var ( + warnNoMatchedProvisioners = `Your build has been enqueued, but there are no provisioners that accept the required tags. Once a compatible provisioner becomes available, your build will continue. Please contact your administrator. +Details: + Provisioner job ID : %s + Requested tags : %s +` + warnNoAvailableProvisioners = `Provisioners that accept the required tags have not responded for longer than expected. This may delay your build. Please contact your administrator if your build does not complete. +Details: + Provisioner job ID : %s + Requested tags : %s + Most recently seen : %s +` +) + +func WarnMatchedProvisioners(inv *serpent.Invocation, tv codersdk.TemplateVersion) { + if tv.MatchedProvisioners == nil { + // Nothing in the response, nothing to do here! + return + } + var tagsJSON strings.Builder + if err := json.NewEncoder(&tagsJSON).Encode(tv.Job.Tags); err != nil { + // Fall back to the less-pretty string representation. + tagsJSON.Reset() + _, _ = tagsJSON.WriteString(fmt.Sprintf("%v", tv.Job.Tags)) + } + if tv.MatchedProvisioners.Count == 0 { + cliui.Warnf(inv.Stderr, warnNoMatchedProvisioners, tv.Job.ID, tagsJSON.String()) + return + } + if tv.MatchedProvisioners.Available == 0 { + cliui.Warnf(inv.Stderr, warnNoAvailableProvisioners, tv.Job.ID, strings.TrimSpace(tagsJSON.String()), tv.MatchedProvisioners.MostRecentlySeen.Time) + return + } +} + // prettyDirectoryPath returns a prettified path when inside the users // home directory. Falls back to dir if the users home directory cannot // discerned. This function calls filepath.Clean on the result. 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/coderd/templateversions.go b/coderd/templateversions.go index 3e42879171a45..004dc6e5a4d6d 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -77,7 +77,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]), nil, warnings)) } // @Summary Patch template version by ID @@ -173,7 +173,7 @@ 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)) + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(updatedTemplateVersion, convertProvisionerJob(jobs[0]), nil, nil)) } // @Summary Cancel template version by ID @@ -814,7 +814,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 @@ -869,7 +869,7 @@ func (api *API) templateVersionByName(rw http.ResponseWriter, r *http.Request) { return } - 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]), nil, nil)) } // @Summary Get template version by organization, template, and name @@ -934,7 +934,7 @@ func (api *API) templateVersionByOrganizationTemplateAndName(rw http.ResponseWri return } - 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]), nil, nil)) } // @Summary Get previous template version by organization, template, and name @@ -1020,7 +1020,7 @@ func (api *API) previousTemplateVersionByOrganizationTemplateAndName(rw http.Res return } - httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(previousTemplateVersion, convertProvisionerJob(jobs[0]), codersdk.MatchedProvisioners{}, nil)) + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(previousTemplateVersion, convertProvisionerJob(jobs[0]), nil, nil)) } // @Summary Archive template unused versions by template id @@ -1633,7 +1633,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht ProvisionerJob: provisionerJob, QueuePosition: 0, }), - matchedProvisioners, + &matchedProvisioners, warnings)) } @@ -1701,7 +1701,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, diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 5e96de10d5058..2a13af0bc410a 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -471,14 +471,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) }) } }) 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..4f07f84074ec4 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 { From 4578e6b265bb163458554bfd4b258658515e505d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 2 Dec 2024 20:54:32 +0000 Subject: [PATCH 5/9] feat(coderd): add matched provisioner daemons information to more places (#15688) - Refactors `checkProvisioners` into `db2sdk.MatchedProvisioners` - Adds a separate RBAC subject just for reading provisioner daemons - Adds matched provisioners information to additional endpoints relating to workspace builds and templates -Updates existing unit tests for above endpoints -Adds API endpoint for matched provisioners of template dry-run job -Updates CLI to show warning when creating/starting/stopping/deleting workspaces for which no provisoners are available --------- Co-authored-by: Danny Kopping (cherry picked from commit 2b57dcc68c76ca5aa7dc9564ec49bf57cecc1436) --- 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 | 39 +--- coderd/apidoc/docs.go | 46 +++++ coderd/apidoc/swagger.json | 42 +++++ coderd/autobuild/lifecycle_executor.go | 2 +- coderd/coderd.go | 1 + coderd/database/db2sdk/db2sdk.go | 20 ++ coderd/database/dbauthz/dbauthz.go | 25 ++- coderd/templateversions.go | 212 ++++++++++++++++------ coderd/templateversions_test.go | 79 +++++++- coderd/workspacebuilds.go | 23 ++- coderd/workspacebuilds_test.go | 120 ++++++++++++ coderd/workspaces.go | 18 +- coderd/workspaces_test.go | 88 +++++++++ coderd/wsbuilder/wsbuilder.go | 59 +++--- coderd/wsbuilder/wsbuilder_test.go | 53 ++++-- codersdk/templateversions.go | 16 ++ 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 +- site/src/api/typesGenerated.ts | 1 + 30 files changed, 1058 insertions(+), 166 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 2576e878edddb..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,7 +416,7 @@ func createValidTemplateVersion(inv *serpent.Invocation, args createValidTemplat if err != nil { return nil, err } - WarnMatchedProvisioners(inv, version) + cliutil.WarnMatchedProvisioners(inv.Stderr, version.MatchedProvisioners, version.Job) err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { version, err := client.TemplateVersion(inv.Context(), version.ID) @@ -482,41 +482,6 @@ func ParseProvisionerTags(rawTags []string) (map[string]string, error) { return tags, nil } -var ( - warnNoMatchedProvisioners = `Your build has been enqueued, but there are no provisioners that accept the required tags. Once a compatible provisioner becomes available, your build will continue. Please contact your administrator. -Details: - Provisioner job ID : %s - Requested tags : %s -` - warnNoAvailableProvisioners = `Provisioners that accept the required tags have not responded for longer than expected. This may delay your build. Please contact your administrator if your build does not complete. -Details: - Provisioner job ID : %s - Requested tags : %s - Most recently seen : %s -` -) - -func WarnMatchedProvisioners(inv *serpent.Invocation, tv codersdk.TemplateVersion) { - if tv.MatchedProvisioners == nil { - // Nothing in the response, nothing to do here! - return - } - var tagsJSON strings.Builder - if err := json.NewEncoder(&tagsJSON).Encode(tv.Job.Tags); err != nil { - // Fall back to the less-pretty string representation. - tagsJSON.Reset() - _, _ = tagsJSON.WriteString(fmt.Sprintf("%v", tv.Job.Tags)) - } - if tv.MatchedProvisioners.Count == 0 { - cliui.Warnf(inv.Stderr, warnNoMatchedProvisioners, tv.Job.ID, tagsJSON.String()) - return - } - if tv.MatchedProvisioners.Available == 0 { - cliui.Warnf(inv.Stderr, warnNoAvailableProvisioners, tv.Job.ID, strings.TrimSpace(tagsJSON.String()), tv.MatchedProvisioners.MostRecentlySeen.Time) - return - } -} - // prettyDirectoryPath returns a prettified path when inside the users // home directory. Falls back to dir if the users home directory cannot // discerned. This function calls filepath.Clean on the result. 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 ac2930c9e32c8..484455daa54c5 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -245,7 +245,7 @@ func (e *Executor) runOnce(t time.Time) Stats { } } - nextBuild, job, err = builder.Build(e.ctx, tx, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) + nextBuild, job, _, err = builder.Build(e.ctx, tx, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) if err != nil { return xerrors.Errorf("build workspace with transition %q: %w", nextTransition, err) } diff --git a/coderd/coderd.go b/coderd/coderd.go index 4c7ff4fe081ff..fd8a10a44f140 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1055,6 +1055,7 @@ func New(options *Options) *API { r.Get("/{jobID}", api.templateVersionDryRun) r.Get("/{jobID}/resources", api.templateVersionDryRunResources) r.Get("/{jobID}/logs", api.templateVersionDryRunLogs) + r.Get("/{jobID}/matched-provisioners", api.templateVersionDryRunMatchedProvisioners) r.Patch("/{jobID}/cancel", api.patchTemplateVersionDryRunCancel) }) }) diff --git a/coderd/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..adc2d1e69000f 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", } diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 004dc6e5a4d6d..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]), nil, warnings)) + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(jobs[0]), matchedProvisioners, warnings)) } // @Summary Patch template version by ID @@ -173,7 +191,23 @@ func (api *API) patchTemplateVersion(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(updatedTemplateVersion, convertProvisionerJob(jobs[0]), nil, nil)) + var matchedProvisioners *codersdk.MatchedProvisioners + if jobs[0].ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending { + // nolint: gocritic // The user hitting this endpoint may not have + // permission to read provisioner daemons, but we want to show them + // information about the provisioner daemons that are available. + provisioners, err := api.Database.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(ctx), database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: jobs[0].ProvisionerJob.OrganizationID, + WantTags: jobs[0].ProvisionerJob.Tags, + }) + if err != nil { + api.Logger.Error(ctx, "failed to fetch provisioners for job id", slog.F("job_id", jobs[0].ProvisionerJob.ID), slog.Error(err)) + } else { + matchedProvisioners = ptr.Ref(db2sdk.MatchedProvisioners(provisioners, dbtime.Now(), provisionerdserver.StaleInterval)) + } + } + + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(updatedTemplateVersion, convertProvisionerJob(jobs[0]), matchedProvisioners, nil)) } // @Summary Cancel template version by ID @@ -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 @@ -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]), nil, nil)) + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(jobs[0]), matchedProvisioners, nil)) } // @Summary Get template version by organization, template, and name @@ -934,7 +1020,23 @@ func (api *API) templateVersionByOrganizationTemplateAndName(rw http.ResponseWri return } - httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(jobs[0]), nil, nil)) + var matchedProvisioners *codersdk.MatchedProvisioners + if jobs[0].ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending { + // nolint: gocritic // The user hitting this endpoint may not have + // permission to read provisioner daemons, but we want to show them + // information about the provisioner daemons that are available. + provisioners, err := api.Database.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(ctx), database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: jobs[0].ProvisionerJob.OrganizationID, + WantTags: jobs[0].ProvisionerJob.Tags, + }) + if err != nil { + api.Logger.Error(ctx, "failed to fetch provisioners for job id", slog.F("job_id", jobs[0].ProvisionerJob.ID), slog.Error(err)) + } else { + matchedProvisioners = ptr.Ref(db2sdk.MatchedProvisioners(provisioners, dbtime.Now(), provisionerdserver.StaleInterval)) + } + } + + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(jobs[0]), matchedProvisioners, nil)) } // @Summary Get previous template version by organization, template, and name @@ -1020,7 +1122,23 @@ func (api *API) previousTemplateVersionByOrganizationTemplateAndName(rw http.Res return } - httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(previousTemplateVersion, convertProvisionerJob(jobs[0]), nil, nil)) + var matchedProvisioners *codersdk.MatchedProvisioners + if jobs[0].ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending { + // nolint: gocritic // The user hitting this endpoint may not have + // permission to read provisioner daemons, but we want to show them + // information about the provisioner daemons that are available. + provisioners, err := api.Database.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(ctx), database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: jobs[0].ProvisionerJob.OrganizationID, + WantTags: jobs[0].ProvisionerJob.Tags, + }) + if err != nil { + api.Logger.Error(ctx, "failed to fetch provisioners for job id", slog.F("job_id", jobs[0].ProvisionerJob.ID), slog.Error(err)) + } else { + matchedProvisioners = ptr.Ref(db2sdk.MatchedProvisioners(provisioners, dbtime.Now(), provisionerdserver.StaleInterval)) + } + } + + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(previousTemplateVersion, convertProvisionerJob(jobs[0]), matchedProvisioners, nil)) } // @Summary Archive template unused versions by template id @@ -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) - 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{ @@ -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) (codersdk.MatchedProvisioners, error) { - // Check for eligible provisioners. This allows us to return a warning to the user if they - // submit a job for which no provisioner is available. - eligibleProvisioners, err := store.GetProvisionerDaemonsByOrganization(ctx, database.GetProvisionerDaemonsByOrganizationParams{ - OrganizationID: orgID, - WantTags: wantTags, - }) - if err != nil { - // Log the error but do not return any warnings. This is purely advisory and we should not block. - return codersdk.MatchedProvisioners{}, xerrors.Errorf("provisioner daemons by organization: %w", err) - } - - staleInterval := time.Now().Add(-provisionerdserver.StaleInterval) - mostRecentlySeen := codersdk.NullTime{} - var matched codersdk.MatchedProvisioners - for _, provisioner := range eligibleProvisioners { - if !provisioner.LastSeenAt.Valid { - continue - } - matched.Count++ - if provisioner.LastSeenAt.Time.After(staleInterval) { - matched.Available++ - } - if provisioner.LastSeenAt.Time.After(mostRecentlySeen.Time) { - matched.MostRecentlySeen.Valid = true - matched.MostRecentlySeen.Time = provisioner.LastSeenAt.Time - } - } - return matched, nil -} diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 2a13af0bc410a..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) @@ -790,8 +808,15 @@ func TestTemplateVersionByName(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, err := client.TemplateVersionByName(ctx, template.ID, version.Name) + tv, err := client.TemplateVersionByName(ctx, template.ID, version.Name) require.NoError(t, err) + + if assert.Equal(t, tv.Job.Status, codersdk.ProvisionerJobPending) { + assert.NotNil(t, tv.MatchedProvisioners) + assert.Zero(t, tv.MatchedProvisioners.Available) + assert.Zero(t, tv.MatchedProvisioners.Count) + assert.False(t, tv.MatchedProvisioners.MostRecentlySeen.Valid) + } }) } @@ -979,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) @@ -1151,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..f041734a7d195 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/database/provisionerjobs" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/wsbuilder" @@ -85,6 +86,7 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) { data.scripts, data.logSources, data.templateVersions[0], + nil, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -289,6 +291,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ data.scripts, data.logSources, data.templateVersions[0], + nil, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -352,7 +355,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { builder = builder.State(createBuild.ProvisionerState) } - workspaceBuild, provisionerJob, err := builder.Build( + workspaceBuild, provisionerJob, provisionerDaemons, err := builder.Build( ctx, api.Database, func(action policy.Action, object rbac.Objecter) bool { @@ -384,12 +387,18 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { }) return } - err = provisionerjobs.PostJob(api.Pubsub, *provisionerJob) - if err != nil { - // Client probably doesn't care about this error, so just log it. - api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) + + if provisionerJob != nil { + if err := provisionerjobs.PostJob(api.Pubsub, *provisionerJob); err != nil { + // Client probably doesn't care about this error, so just log it. + api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) + } } + var matchedProvisioners codersdk.MatchedProvisioners + if provisionerJob != nil { + matchedProvisioners = db2sdk.MatchedProvisioners(provisionerDaemons, provisionerJob.CreatedAt, provisionerdserver.StaleInterval) + } apiBuild, err := api.convertWorkspaceBuild( *workspaceBuild, workspace, @@ -404,6 +413,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, + &matchedProvisioners, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -804,6 +814,7 @@ func (api *API) convertWorkspaceBuilds( agentScripts, agentLogSources, templateVersion, + nil, ) if err != nil { return nil, xerrors.Errorf("converting workspace build: %w", err) @@ -826,6 +837,7 @@ func (api *API) convertWorkspaceBuild( agentScripts []database.WorkspaceAgentScript, agentLogSources []database.WorkspaceAgentLogSource, templateVersion database.TemplateVersion, + matchedProvisioners *codersdk.MatchedProvisioners, ) (codersdk.WorkspaceBuild, error) { resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{} for _, resource := range workspaceResources { @@ -918,6 +930,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..6b62a7305d9d5 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/schedule/cron" @@ -593,8 +594,7 @@ func createWorkspace( }}, }) return - } - if err != nil && !errors.Is(err, sql.ErrNoRows) { + } else if !errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: fmt.Sprintf("Internal error fetching workspace by name %q.", req.Name), Detail: err.Error(), @@ -603,8 +603,10 @@ func createWorkspace( } var ( - provisionerJob *database.ProvisionerJob - workspaceBuild *database.WorkspaceBuild + provisionerJob *database.ProvisionerJob + workspaceBuild *database.WorkspaceBuild + provisionerDaemons []database.ProvisionerDaemon + matchedProvisioners codersdk.MatchedProvisioners ) err = api.Database.InTx(func(db database.Store) error { now := dbtime.Now() @@ -645,7 +647,7 @@ func createWorkspace( builder = builder.VersionID(req.TemplateVersionID) } - workspaceBuild, provisionerJob, err = builder.Build( + workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build( ctx, db, func(action policy.Action, object rbac.Objecter) bool { @@ -655,6 +657,7 @@ func createWorkspace( ) return err }, nil) + var bldErr wsbuilder.BuildError if xerrors.As(err, &bldErr) { httpapi.Write(ctx, rw, bldErr.Status, codersdk.Response{ @@ -675,6 +678,10 @@ func createWorkspace( // Client probably doesn't care about this error, so just log it. api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) } + if provisionerJob != nil { + matchedProvisioners = db2sdk.MatchedProvisioners(provisionerDaemons, provisionerJob.CreatedAt, provisionerdserver.StaleInterval) + } + auditReq.New = workspace.WorkspaceTable() api.Telemetry.Report(&telemetry.Snapshot{ @@ -696,6 +703,7 @@ func createWorkspace( []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, + &matchedProvisioners, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/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..46b65e70d0f80 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/provisionerdserver" @@ -213,12 +214,12 @@ func (b *Builder) Build( authFunc func(action policy.Action, object rbac.Objecter) bool, auditBaggage audit.WorkspaceBuildBaggage, ) ( - *database.WorkspaceBuild, *database.ProvisionerJob, error, + *database.WorkspaceBuild, *database.ProvisionerJob, []database.ProvisionerDaemon, error, ) { var err error b.ctx, err = audit.BaggageToContext(ctx, auditBaggage) if err != nil { - return nil, nil, xerrors.Errorf("create audit baggage: %w", err) + return nil, nil, nil, xerrors.Errorf("create audit baggage: %w", err) } // Run the build in a transaction with RepeatableRead isolation, and retries. @@ -227,16 +228,17 @@ func (b *Builder) Build( // later reads are consistent with earlier ones. var workspaceBuild *database.WorkspaceBuild var provisionerJob *database.ProvisionerJob + var provisionerDaemons []database.ProvisionerDaemon err = database.ReadModifyUpdate(store, func(tx database.Store) error { var err error b.store = tx - workspaceBuild, provisionerJob, err = b.buildTx(authFunc) + workspaceBuild, provisionerJob, provisionerDaemons, err = b.buildTx(authFunc) return err }) if err != nil { - return nil, nil, xerrors.Errorf("build tx: %w", err) + return nil, nil, nil, xerrors.Errorf("build tx: %w", err) } - return workspaceBuild, provisionerJob, nil + return workspaceBuild, provisionerJob, provisionerDaemons, nil } // buildTx contains the business logic of computing a new build. Attributes of the new database objects are computed @@ -246,35 +248,35 @@ func (b *Builder) Build( // // In order to utilize this cache, the functions that compute build attributes use a pointer receiver type. func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Objecter) bool) ( - *database.WorkspaceBuild, *database.ProvisionerJob, error, + *database.WorkspaceBuild, *database.ProvisionerJob, []database.ProvisionerDaemon, error, ) { if authFunc != nil { err := b.authorize(authFunc) if err != nil { - return nil, nil, err + return nil, nil, nil, err } } err := b.checkTemplateVersionMatchesTemplate() if err != nil { - return nil, nil, err + return nil, nil, nil, err } err = b.checkTemplateJobStatus() if err != nil { - return nil, nil, err + return nil, nil, nil, err } err = b.checkRunningBuild() if err != nil { - return nil, nil, err + return nil, nil, nil, err } template, err := b.getTemplate() if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch template", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch template", err} } templateVersionJob, err := b.getTemplateVersionJob() if err != nil { - return nil, nil, BuildError{ + return nil, nil, nil, BuildError{ http.StatusInternalServerError, "failed to fetch template version job", err, } } @@ -294,7 +296,7 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object LogLevel: b.logLevel, }) if err != nil { - return nil, nil, BuildError{ + return nil, nil, nil, BuildError{ http.StatusInternalServerError, "marshal provision job", err, @@ -302,12 +304,12 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object } traceMetadataRaw, err := json.Marshal(tracing.MetadataFromContext(b.ctx)) if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "marshal metadata", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "marshal metadata", err} } tags, err := b.getProvisionerTags() if err != nil { - return nil, nil, err // already wrapped BuildError + return nil, nil, nil, err // already wrapped BuildError } now := dbtime.Now() @@ -329,20 +331,35 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object }, }) if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "insert provisioner job", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "insert provisioner job", err} + } + + // nolint:gocritic // The user performing this request may not have permission + // to read all provisioner daemons. We need to retrieve the eligible + // provisioner daemons for this job to show in the UI if there is no + // matching provisioner daemon. + provisionerDaemons, err := b.store.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(b.ctx), database.GetProvisionerDaemonsByOrganizationParams{ + OrganizationID: template.OrganizationID, + WantTags: provisionerJob.Tags, + }) + if err != nil { + // NOTE: we do **not** want to fail a workspace build if we fail to + // retrieve provisioner daemons. This is just to show in the UI if there + // is no matching provisioner daemon for the job. + provisionerDaemons = []database.ProvisionerDaemon{} } templateVersionID, err := b.getTemplateVersionID() if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "compute template version ID", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "compute template version ID", err} } buildNum, err := b.getBuildNumber() if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "compute build number", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "compute build number", err} } state, err := b.getState() if err != nil { - return nil, nil, BuildError{http.StatusInternalServerError, "compute build state", err} + return nil, nil, nil, BuildError{http.StatusInternalServerError, "compute build state", err} } var workspaceBuild database.WorkspaceBuild @@ -393,10 +410,10 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object return nil }, nil) if err != nil { - return nil, nil, err + return nil, nil, nil, err } - return &workspaceBuild, &provisionerJob, nil + return &workspaceBuild, &provisionerJob, provisionerDaemons, nil } func (b *Builder) getTemplate() (*database.Template, error) { diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index dd532467bbc92..ad0df2816ffac 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -61,6 +61,7 @@ func TestBuilder_NoOptions(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -94,7 +95,8 @@ func TestBuilder_NoOptions(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -114,6 +116,7 @@ func TestBuilder_Initiator(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -130,7 +133,8 @@ func TestBuilder_Initiator(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Initiator(otherUserID) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -157,6 +161,7 @@ func TestBuilder_Baggage(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -172,7 +177,8 @@ func TestBuilder_Baggage(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Initiator(otherUserID) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) req.NoError(err) } @@ -192,6 +198,7 @@ func TestBuilder_Reason(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(_ database.InsertProvisionerJobParams) { @@ -207,7 +214,8 @@ func TestBuilder_Reason(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Reason(database.BuildReasonAutostart) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -226,6 +234,7 @@ func TestBuilder_ActiveVersion(t *testing.T) { withLastBuildNotFound, withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // previous rich parameters are not queried because there is no previous build. // Outputs @@ -247,7 +256,8 @@ func TestBuilder_ActiveVersion(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).ActiveVersion() - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -314,6 +324,7 @@ func TestWorkspaceBuildWithTags(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, workspaceTags), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -343,7 +354,8 @@ func TestWorkspaceBuildWithTags(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(buildParameters) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -404,6 +416,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -422,7 +435,8 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) t.Run("UsePreviousParameterValues", func(t *testing.T) { @@ -448,6 +462,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -466,7 +481,8 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -502,7 +518,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) bldErr := wsbuilder.BuildError{} req.ErrorAs(err, &bldErr) asrt.Equal(http.StatusBadRequest, bldErr.Status) @@ -536,7 +552,8 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) bldErr := wsbuilder.BuildError{} req.ErrorAs(err, &bldErr) asrt.Equal(http.StatusBadRequest, bldErr.Status) @@ -579,6 +596,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -599,7 +617,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -640,6 +658,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -660,7 +679,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -699,6 +718,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), + withProvisionerDaemons([]database.ProvisionerDaemon{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -719,7 +739,8 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) } @@ -987,3 +1008,9 @@ func expectBuildParameters( ) } } + +func withProvisionerDaemons(provisionerDaemons []database.ProvisionerDaemon) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().GetProvisionerDaemonsByOrganization(gomock.Any(), gomock.Any()).Return(provisionerDaemons, nil) + } +} diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index 4f07f84074ec4..de8bb7b970957 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -224,6 +224,22 @@ func (c *Client) TemplateVersionDryRun(ctx context.Context, version, job uuid.UU return j, json.NewDecoder(res.Body).Decode(&j) } +// TemplateVersionDryRunMatchedProvisioners returns the matched provisioners for a +// template version dry-run job. +func (c *Client) TemplateVersionDryRunMatchedProvisioners(ctx context.Context, version, job uuid.UUID) (MatchedProvisioners, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templateversions/%s/dry-run/%s/matched-provisioners", version, job), nil) + if err != nil { + return MatchedProvisioners{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return MatchedProvisioners{}, ReadBodyAsError(res) + } + + var matched MatchedProvisioners + return matched, json.NewDecoder(res.Body).Decode(&matched) +} + // TemplateVersionDryRunResources returns the resources of a finished template // version dry-run job. func (c *Client) TemplateVersionDryRunResources(ctx context.Context, version, job uuid.UUID) ([]WorkspaceResource, error) { diff --git a/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/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 From ac75ebc7951f78896afa9ebe391fa85e67a07bcd Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Wed, 11 Dec 2024 13:38:13 +0200 Subject: [PATCH 6/9] feat(site): add a provisioner warning to workspace builds (#15686) This PR adds warnings about provisioner health to workspace build pages. It closes https://github.com/coder/coder/issues/15048 ![image](https://github.com/user-attachments/assets/fa54d0e8-c51f-427a-8f66-7e5dbbc9baca) ![image](https://github.com/user-attachments/assets/b5169669-ab05-43d5-8553-315a3099b4fd) (cherry picked from commit b39becba66f1c6ad9c8d73694b0d8779cf7467a2) --- cli/testdata/coder_list_--output_json.golden | 7 +- coderd/database/dbauthz/dbauthz.go | 4 + 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/workspacebuilds.go | 83 ++++--- coderd/workspaces.go | 14 +- coderd/wsbuilder/wsbuilder.go | 13 +- coderd/wsbuilder/wsbuilder_test.go | 26 +-- enterprise/coderd/workspaces_test.go | 212 ++++++++++++++++++ .../provisioners/ProvisionerAlert.stories.tsx | 22 +- .../modules/provisioners/ProvisionerAlert.tsx | 42 +++- .../ProvisionerStatusAlert.stories.tsx | 16 ++ .../provisioners/ProvisionerStatusAlert.tsx | 5 +- .../CreateTemplatePage/BuildLogsDrawer.tsx | 2 + .../TemplateVersionEditor.tsx | 3 + .../pages/WorkspacePage/Workspace.stories.tsx | 49 +++- site/src/pages/WorkspacePage/Workspace.tsx | 28 ++- .../WorkspacePage/WorkspaceReadyPage.tsx | 11 +- site/src/testHelpers/entities.ts | 4 + 27 files changed, 827 insertions(+), 99 deletions(-) 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/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index adc2d1e69000f..bc79fbaaa2ae6 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1561,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/workspacebuilds.go b/coderd/workspacebuilds.go index f041734a7d195..7eb598a7d4564 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -202,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{ @@ -291,7 +292,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ data.scripts, data.logSources, data.templateVersions[0], - nil, + data.provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -395,10 +396,6 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { } } - var matchedProvisioners codersdk.MatchedProvisioners - if provisionerJob != nil { - matchedProvisioners = db2sdk.MatchedProvisioners(provisionerDaemons, provisionerJob.CreatedAt, provisionerdserver.StaleInterval) - } apiBuild, err := api.convertWorkspaceBuild( *workspaceBuild, workspace, @@ -413,7 +410,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, - &matchedProvisioners, + provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -648,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) { @@ -667,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 { @@ -687,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 } @@ -711,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 } @@ -751,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 } @@ -773,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 { @@ -814,7 +827,7 @@ func (api *API) convertWorkspaceBuilds( agentScripts, agentLogSources, templateVersion, - nil, + provisionerDaemons, ) if err != nil { return nil, xerrors.Errorf("converting workspace build: %w", err) @@ -837,7 +850,7 @@ func (api *API) convertWorkspaceBuild( agentScripts []database.WorkspaceAgentScript, agentLogSources []database.WorkspaceAgentLogSource, templateVersion database.TemplateVersion, - matchedProvisioners *codersdk.MatchedProvisioners, + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, ) (codersdk.WorkspaceBuild, error) { resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{} for _, resource := range workspaceResources { @@ -863,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) @@ -930,7 +951,7 @@ func (api *API) convertWorkspaceBuild( Resources: apiResources, Status: codersdk.ConvertWorkspaceStatus(apiJob.Status, transition), DailyCost: build.DailyCost, - MatchedProvisioners: matchedProvisioners, + MatchedProvisioners: &matchedProvisioners, }, nil } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 6b62a7305d9d5..be23cc3215a32 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -27,7 +27,6 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" - "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/schedule/cron" @@ -603,10 +602,9 @@ func createWorkspace( } var ( - provisionerJob *database.ProvisionerJob - workspaceBuild *database.WorkspaceBuild - provisionerDaemons []database.ProvisionerDaemon - matchedProvisioners codersdk.MatchedProvisioners + provisionerJob *database.ProvisionerJob + workspaceBuild *database.WorkspaceBuild + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow ) err = api.Database.InTx(func(db database.Store) error { now := dbtime.Now() @@ -678,9 +676,6 @@ func createWorkspace( // Client probably doesn't care about this error, so just log it. api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) } - if provisionerJob != nil { - matchedProvisioners = db2sdk.MatchedProvisioners(provisionerDaemons, provisionerJob.CreatedAt, provisionerdserver.StaleInterval) - } auditReq.New = workspace.WorkspaceTable() @@ -703,7 +698,7 @@ func createWorkspace( []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, - &matchedProvisioners, + provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -1824,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/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 46b65e70d0f80..d59af8cdc1b32 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -214,7 +214,7 @@ func (b *Builder) Build( authFunc func(action policy.Action, object rbac.Objecter) bool, auditBaggage audit.WorkspaceBuildBaggage, ) ( - *database.WorkspaceBuild, *database.ProvisionerJob, []database.ProvisionerDaemon, error, + *database.WorkspaceBuild, *database.ProvisionerJob, []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error, ) { var err error b.ctx, err = audit.BaggageToContext(ctx, auditBaggage) @@ -228,7 +228,7 @@ func (b *Builder) Build( // later reads are consistent with earlier ones. var workspaceBuild *database.WorkspaceBuild var provisionerJob *database.ProvisionerJob - var provisionerDaemons []database.ProvisionerDaemon + var provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow err = database.ReadModifyUpdate(store, func(tx database.Store) error { var err error b.store = tx @@ -248,7 +248,7 @@ 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, []database.ProvisionerDaemon, error, + *database.WorkspaceBuild, *database.ProvisionerJob, []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, error, ) { if authFunc != nil { err := b.authorize(authFunc) @@ -338,15 +338,12 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object // to read all provisioner daemons. We need to retrieve the eligible // provisioner daemons for this job to show in the UI if there is no // matching provisioner daemon. - provisionerDaemons, err := b.store.GetProvisionerDaemonsByOrganization(dbauthz.AsSystemReadProvisionerDaemons(b.ctx), database.GetProvisionerDaemonsByOrganizationParams{ - OrganizationID: template.OrganizationID, - WantTags: provisionerJob.Tags, - }) + 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.ProvisionerDaemon{} + provisionerDaemons = []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{} } templateVersionID, err := b.getTemplateVersionID() diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index ad0df2816ffac..3f373efd3bfdb 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -61,7 +61,7 @@ func TestBuilder_NoOptions(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -116,7 +116,7 @@ func TestBuilder_Initiator(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -161,7 +161,7 @@ func TestBuilder_Baggage(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -198,7 +198,7 @@ func TestBuilder_Reason(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(_ database.InsertProvisionerJobParams) { @@ -234,7 +234,7 @@ func TestBuilder_ActiveVersion(t *testing.T) { withLastBuildNotFound, withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // previous rich parameters are not queried because there is no previous build. // Outputs @@ -324,7 +324,7 @@ func TestWorkspaceBuildWithTags(t *testing.T) { withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, workspaceTags), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -416,7 +416,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -462,7 +462,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -596,7 +596,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -658,7 +658,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -718,7 +718,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), - withProvisionerDaemons([]database.ProvisionerDaemon{}), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -1009,8 +1009,8 @@ func expectBuildParameters( } } -func withProvisionerDaemons(provisionerDaemons []database.ProvisionerDaemon) func(mTx *dbmock.MockStore) { +func withProvisionerDaemons(provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow) func(mTx *dbmock.MockStore) { return func(mTx *dbmock.MockStore) { - mTx.EXPECT().GetProvisionerDaemonsByOrganization(gomock.Any(), gomock.Any()).Return(provisionerDaemons, nil) + mTx.EXPECT().GetEligibleProvisionerDaemonsByProvisionerJobIDs(gomock.Any(), gomock.Any()).Return(provisionerDaemons, nil) } } 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/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/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..3dd05a398cf2e 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -95,6 +95,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 +175,7 @@ export const FailedWithLogs: Story = { }, }, }, - buildLogs: , + buildLogs: makeFailedBuildLogs(), }, }; @@ -148,7 +193,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..1d89e63d8914a 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,14 @@ export const Workspace: FC = ({ (r) => resourceOptionValue(r) === resourcesNav.value, ); + const shouldDisplayBuildLogs = + (buildLogs ?? []).length > 0 && workspace.latest_build.status !== "running"; + + const provisionersHealthy = + (workspace.latest_build.matched_provisioners?.available ?? 0) > 0; + const shouldShowProvisionerAlert = + !provisionersHealthy && (!buildLogs || buildLogs.length === 0); + return (
= ({ /> )} + {shouldShowProvisionerAlert && ( + + )} + {workspace.latest_build.job.error && ( Workspace build failed @@ -222,7 +244,9 @@ export const Workspace: FC = ({ /> )} - {buildLogs} + {shouldDisplayBuildLogs && ( + + )} {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 = { From 9a8d2f1c3293068fba54e66fefcef1b07b9e97d2 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 12 Dec 2024 17:25:10 +0200 Subject: [PATCH 7/9] fix(site): remove a misplaced warning banner in the frontend (#15837) This PR fixes some faulty frontend logic that was introduced in #15686 (cherry picked from commit 0e98c0e4560b8c898e7b666f190b4ad161529989) --- site/src/pages/WorkspacePage/Workspace.stories.tsx | 11 ++++++++++- site/src/pages/WorkspacePage/Workspace.tsx | 8 ++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index 3dd05a398cf2e..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, diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 1d89e63d8914a..af4883e73740a 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -110,13 +110,13 @@ export const Workspace: FC = ({ (r) => resourceOptionValue(r) === resourcesNav.value, ); - const shouldDisplayBuildLogs = - (buildLogs ?? []).length > 0 && workspace.latest_build.status !== "running"; - + const workspaceRunning = workspace.latest_build.status === "running"; + const haveBuildLogs = (buildLogs ?? []).length > 0; const provisionersHealthy = (workspace.latest_build.matched_provisioners?.available ?? 0) > 0; + const shouldDisplayBuildLogs = haveBuildLogs && !workspaceRunning; const shouldShowProvisionerAlert = - !provisionersHealthy && (!buildLogs || buildLogs.length === 0); + !workspaceRunning && !haveBuildLogs && !provisionersHealthy; return (
Date: Fri, 13 Dec 2024 11:58:19 +0200 Subject: [PATCH 8/9] fix(site): only show provisioner warnings for pending workspaces (#15858) When creating, starting, stopping or otherwise mutating a workspace, we used to erroneously and briefly display a provisioner health warning alert. This PR updates the component to only display this warning if the build is pending, not "starting" or any other state that means a provisioner has already acquired the job. (cherry picked from commit b5ba3e3da8c97b048f497011d641a01f64f40f0c) --- site/src/pages/WorkspacePage/Workspace.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index af4883e73740a..f28cb775bdd6f 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -111,12 +111,13 @@ export const Workspace: FC = ({ ); 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 ?? 0) > 0; - const shouldDisplayBuildLogs = haveBuildLogs && !workspaceRunning; + (workspace.latest_build.matched_provisioners?.available ?? 1) > 0; const shouldShowProvisionerAlert = - !workspaceRunning && !haveBuildLogs && !provisionersHealthy; + workspacePending && !haveBuildLogs && !provisionersHealthy && !isRestarting; return (
= ({ /> )} - {shouldDisplayBuildLogs && ( + {shouldShowBuildLogs && ( )} From 4c452afae0cf6a229cd893038128eb7e93015da1 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 16 Dec 2024 13:42:53 +0000 Subject: [PATCH 9/9] chore(go.mod): update x/crypto to 0.31.0 (#15869) (#15870) (cherry picked from commit 14ce3aa018dfcfdc702aab3cff23efbcf67971ed) --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) 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=