diff --git a/coderd/coderd.go b/coderd/coderd.go index fbc20cbe74de7..fb36deeec42c3 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -349,6 +349,7 @@ func New(options *Options) *API { r.Route("/templateversions", func(r chi.Router) { r.Post("/", api.postTemplateVersionsByOrganization) r.Get("/{templateversionname}", api.templateVersionByOrganizationAndName) + r.Get("/{templateversionname}/previous", api.previousTemplateVersionByOrganizationAndName) }) r.Route("/templates", func(r chi.Router) { r.Post("/", api.postTemplateByOrganization) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 56afc6e103ca3..1d9018c1846ec 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -236,11 +236,12 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { "GET:/api/v2/applications/auth-redirect": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceAPIKey}, // These endpoints need payloads to get to the auth part. Payloads will be required - "PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, - "PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true}, - "POST:/api/v2/workspaces/{workspace}/builds": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, - "POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, - "GET:/api/v2/organizations/{organization}/templateversions/{templateversionname}": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, + "PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, + "PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true}, + "POST:/api/v2/workspaces/{workspace}/builds": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, + "POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, + "GET:/api/v2/organizations/{organization}/templateversions/{templateversionname}": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, + "GET:/api/v2/organizations/{organization}/templateversions/{templateversionname}/previous": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, // Endpoints that use the SQLQuery filter. "GET:/api/v2/workspaces/": { diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 5d489d602c38a..f724f5448d03a 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1718,6 +1718,46 @@ func (q *fakeQuerier) GetTemplateVersionByJobID(_ context.Context, jobID uuid.UU return database.TemplateVersion{}, sql.ErrNoRows } +func (q *fakeQuerier) GetPreviousTemplateVersion(_ context.Context, arg database.GetPreviousTemplateVersionParams) (database.TemplateVersion, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + var currentTemplateVersion database.TemplateVersion + for _, templateVersion := range q.templateVersions { + if templateVersion.TemplateID != arg.TemplateID { + continue + } + if templateVersion.Name != arg.Name { + continue + } + if templateVersion.OrganizationID != arg.OrganizationID { + continue + } + currentTemplateVersion = templateVersion + break + } + + previousTemplateVersions := make([]database.TemplateVersion, 0) + for _, templateVersion := range q.templateVersions { + if templateVersion.ID == currentTemplateVersion.ID { + continue + } + if templateVersion.CreatedAt.Before(currentTemplateVersion.CreatedAt) { + previousTemplateVersions = append(previousTemplateVersions, templateVersion) + } + } + + if len(previousTemplateVersions) == 0 { + return database.TemplateVersion{}, sql.ErrNoRows + } + + sort.Slice(previousTemplateVersions, func(i, j int) bool { + return previousTemplateVersions[i].CreatedAt.After(previousTemplateVersions[j].CreatedAt) + }) + + return previousTemplateVersions[0], nil +} + func (q *fakeQuerier) GetParameterSchemasByJobID(_ context.Context, jobID uuid.UUID) ([]database.ParameterSchema, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 219c21018804b..81093f52eda84 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -66,6 +66,7 @@ type sqlcQuerier interface { GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error) GetParameterSchemasCreatedAfter(ctx context.Context, createdAt time.Time) ([]ParameterSchema, error) GetParameterValueByScopeAndName(ctx context.Context, arg GetParameterValueByScopeAndNameParams) (ParameterValue, error) + GetPreviousTemplateVersion(ctx context.Context, arg GetPreviousTemplateVersionParams) (TemplateVersion, error) GetProvisionerDaemonByID(ctx context.Context, id uuid.UUID) (ProvisionerDaemon, error) GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (ProvisionerJob, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e68b4c0460c72..eb6d3f732ac0c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3500,6 +3500,44 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl return i, err } +const getPreviousTemplateVersion = `-- name: GetPreviousTemplateVersion :one +SELECT + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by +FROM + template_versions +WHERE + created_at < ( + SELECT created_at + FROM template_versions AS tv + WHERE tv.organization_id = $1 AND tv.name = $2 AND tv.template_id = $3 + ) +ORDER BY created_at DESC +LIMIT 1 +` + +type GetPreviousTemplateVersionParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Name string `db:"name" json:"name"` + TemplateID uuid.NullUUID `db:"template_id" json:"template_id"` +} + +func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPreviousTemplateVersionParams) (TemplateVersion, error) { + row := q.db.QueryRowContext(ctx, getPreviousTemplateVersion, arg.OrganizationID, arg.Name, arg.TemplateID) + var i TemplateVersion + err := row.Scan( + &i.ID, + &i.TemplateID, + &i.OrganizationID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.Readme, + &i.JobID, + &i.CreatedBy, + ) + return i, err +} + const getTemplateVersionByID = `-- name: GetTemplateVersionByID :one SELECT id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index aad417b5b3590..721a77f93902a 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -110,3 +110,17 @@ SET updated_at = $3 WHERE job_id = $1; + +-- name: GetPreviousTemplateVersion :one +SELECT + * +FROM + template_versions +WHERE + created_at < ( + SELECT created_at + FROM template_versions AS tv + WHERE tv.organization_id = $1 AND tv.name = $2 AND tv.template_id = $3 + ) +ORDER BY created_at DESC +LIMIT 1; diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 2952baede44e8..4d067756267d3 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -640,6 +640,71 @@ func (api *API) templateVersionByOrganizationAndName(rw http.ResponseWriter, r * httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(job), user)) } +func (api *API) previousTemplateVersionByOrganizationAndName(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + organization := httpmw.OrganizationParam(r) + templateVersionName := chi.URLParam(r, "templateversionname") + templateVersion, err := api.Database.GetTemplateVersionByOrganizationAndName(ctx, database.GetTemplateVersionByOrganizationAndNameParams{ + OrganizationID: organization.ID, + Name: templateVersionName, + }) + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: fmt.Sprintf("No template version found by name %q.", templateVersionName), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template version.", + Detail: err.Error(), + }) + return + } + + previousTemplateVersion, err := api.Database.GetPreviousTemplateVersion(ctx, database.GetPreviousTemplateVersionParams{ + OrganizationID: organization.ID, + Name: templateVersionName, + TemplateID: templateVersion.TemplateID, + }) + + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: fmt.Sprintf("No previous template version found for %q.", templateVersionName), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching the previous template version.", + Detail: err.Error(), + }) + return + } + + job, err := api.Database.GetProvisionerJobByID(ctx, previousTemplateVersion.JobID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching provisioner job.", + Detail: err.Error(), + }) + return + } + + user, err := api.Database.GetUserByID(ctx, templateVersion.CreatedBy) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error on fetching user.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(previousTemplateVersion, convertProvisionerJob(job), user)) +} + func (api *API) patchActiveTemplateVersion(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 65c1ec31d06e6..3c752253f61cb 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -963,3 +963,37 @@ func TestTemplateVersionByOrganizationAndName(t *testing.T) { require.NoError(t, err) }) } + +func TestPreviousTemplateVersion(t *testing.T) { + t.Parallel() + t.Run("Previous version not found", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.PreviousTemplateVersion(ctx, user.OrganizationID, version.Name) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + + t.Run("Previous version found", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + previousVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, previousVersion.ID) + latestVersion := coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, nil, template.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + result, err := client.PreviousTemplateVersion(ctx, user.OrganizationID, latestVersion.Name) + require.NoError(t, err) + require.Equal(t, previousVersion.ID, result.ID) + }) +} diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index 3273b31b28f86..b9ea141f50654 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -182,3 +182,16 @@ func (c *Client) CancelTemplateVersionDryRun(ctx context.Context, version, job u } return nil } + +func (c *Client) PreviousTemplateVersion(ctx context.Context, organization uuid.UUID, versionName string) (TemplateVersion, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/templateversions/%s/previous", organization, versionName), nil) + if err != nil { + return TemplateVersion{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return TemplateVersion{}, readBodyAsError(res) + } + var version TemplateVersion + return version, json.NewDecoder(res.Body).Decode(&version) +}