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 acd0c6955ab7f..27d2766a7cd13 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -53,6 +53,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 {