From 502c7680a2d99551a9e9b40d0bf8f333e276ad7e Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Thu, 3 Aug 2023 18:40:47 -0500 Subject: [PATCH 001/277] chore: fix release and security pipelines (#8891) --- .github/workflows/release.yaml | 12 ++---------- .github/workflows/security.yaml | 12 ++---------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 95f07feda29ca..d78fbe7c5a5b9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -98,16 +98,8 @@ jobs: - name: Setup Go uses: ./.github/actions/setup-go - - name: Cache Node - id: cache-node - uses: buildjet/cache@v3 - with: - path: | - **/node_modules - .eslintcache - key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - js-${{ runner.os }}- + - name: Setup Node + uses: ./.github/actions/setup-node - name: Install nsis and zstd run: sudo apt-get install -y nsis zstd diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index daed19ee5dff5..48c8f16fd9bea 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -69,16 +69,8 @@ jobs: - name: Setup Go uses: ./.github/actions/setup-go - - name: Cache Node - id: cache-node - uses: buildjet/cache@v3 - with: - path: | - **/node_modules - .eslintcache - key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - js-${{ runner.os }}- + - name: Setup Node + uses: ./.github/actions/setup-node - name: Setup sqlc uses: ./.github/actions/setup-sqlc From e43608395cd2bd3aacf0af4103e4a98782d9698e Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 3 Aug 2023 19:46:02 -0500 Subject: [PATCH 002/277] feat: add frontend for locked workspaces (#8655) - Fix workspaces query for locked workspaces. --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/autobuild/lifecycle_executor.go | 2 +- coderd/database/dbauthz/dbauthz.go | 4 +- coderd/database/dbfake/dbfake.go | 20 ++- coderd/database/dbmetrics/dbmetrics.go | 6 +- coderd/database/dbmock/dbmock.go | 7 +- coderd/database/modelqueries.go | 1 + coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 41 ++++- coderd/database/queries/workspaces.sql | 13 +- coderd/searchquery/search.go | 6 + coderd/workspaces.go | 26 ++- coderd/workspaces_test.go | 40 +++++ docs/api/workspaces.md | 164 ++++++++++++++++-- site/src/api/api.ts | 15 ++ .../Dashboard/DashboardProvider.tsx | 10 ++ site/src/components/Workspace/Workspace.tsx | 31 ++-- .../components/WorkspaceActions/Buttons.tsx | 18 ++ .../WorkspaceActions/WorkspaceActions.tsx | 9 +- .../components/WorkspaceActions/constants.ts | 12 +- .../ImpendingDeletionBadge.tsx | 26 +-- .../ImpendingDeletionBanner.tsx | 96 ++++++---- .../WorkspaceStatusBadge.tsx | 6 +- site/src/i18n/en/templateSettingsPage.json | 6 +- .../InactivityDialog.stories.tsx | 2 +- .../TemplateScheduleForm/InactivityDialog.tsx | 35 +++- .../TemplateScheduleForm.tsx | 59 +++++-- .../useWorkspacesToBeDeleted.ts | 63 +++++-- .../TemplateSchedulePage.test.tsx | 8 +- .../WorkspacePage/WorkspaceReadyPage.tsx | 1 + .../pages/WorkspacesPage/WorkspacesPage.tsx | 38 +++- .../WorkspacesPage/WorkspacesPageView.tsx | 39 ++--- .../pages/WorkspacesPage/filter/filter.tsx | 11 +- site/src/utils/filters.ts | 1 + .../xServices/workspace/workspaceXService.ts | 34 +++- 36 files changed, 664 insertions(+), 192 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2c115a30c3261..5d70095ee850c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6097,7 +6097,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Response" + "$ref": "#/definitions/codersdk.Workspace" } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7f7d12a51a53a..57f5c5051aa70 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5379,7 +5379,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Response" + "$ref": "#/definitions/codersdk.Workspace" } } } diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index f7176ae8cd721..b36a265b7ee98 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -178,7 +178,7 @@ func (e *Executor) runOnce(t time.Time) Stats { // Lock the workspace if it has breached the template's // threshold for inactivity. if reason == database.BuildReasonAutolock { - err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{ + ws, err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{ ID: ws.ID, LockedAt: sql.NullTime{ Time: database.Now(), diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ab865e2cc0f70..5e82320832442 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2587,11 +2587,11 @@ func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.Up return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg) } -func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { +func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { fetch := func(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { return q.db.GetWorkspaceByID(ctx, arg.ID) } - return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg) + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg) } func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 0d5c19fa7656d..03ae01182dbd0 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -5250,9 +5250,9 @@ func (q *FakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database. return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { +func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { if err := validateDatabaseType(arg); err != nil { - return err + return database.Workspace{}, err } q.mutex.Lock() defer q.mutex.Unlock() @@ -5274,7 +5274,7 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat } } if template.ID == uuid.Nil { - return xerrors.Errorf("unable to find workspace template") + return database.Workspace{}, xerrors.Errorf("unable to find workspace template") } if template.LockedTTL > 0 { workspace.DeletingAt = sql.NullTime{ @@ -5284,9 +5284,9 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat } } q.workspaces[index] = workspace - return nil + return workspace, nil } - return sql.ErrNoRows + return database.Workspace{}, sql.ErrNoRows } func (q *FakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { @@ -5730,6 +5730,16 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } } + // We omit locked workspaces by default. + if arg.LockedAt.IsZero() && workspace.LockedAt.Valid { + continue + } + + // Filter out workspaces that are locked after the timestamp. + if !arg.LockedAt.IsZero() && workspace.LockedAt.Time.Before(arg.LockedAt) { + continue + } + if len(arg.TemplateIDs) > 0 { match := false for _, id := range arg.TemplateIDs { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index f78d9b44a46c0..ee7f8ae53b433 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1572,11 +1572,11 @@ func (m metricsStore) UpdateWorkspaceLastUsedAt(ctx context.Context, arg databas return err } -func (m metricsStore) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { +func (m metricsStore) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { start := time.Now() - r0 := m.s.UpdateWorkspaceLockedDeletingAt(ctx, arg) + ws, r0 := m.s.UpdateWorkspaceLockedDeletingAt(ctx, arg) m.queryLatencies.WithLabelValues("UpdateWorkspaceLockedDeletingAt").Observe(time.Since(start).Seconds()) - return r0 + return ws, r0 } func (m metricsStore) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index adf573cb99e82..6a0edec4f015d 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3307,11 +3307,12 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceLastUsedAt(arg0, arg1 interface{ } // UpdateWorkspaceLockedDeletingAt mocks base method. -func (m *MockStore) UpdateWorkspaceLockedDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceLockedDeletingAtParams) error { +func (m *MockStore) UpdateWorkspaceLockedDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateWorkspaceLockedDeletingAt", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(database.Workspace) + ret1, _ := ret[1].(error) + return ret0, ret1 } // UpdateWorkspaceLockedDeletingAt indicates an expected call of UpdateWorkspaceLockedDeletingAt. diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index ffa346d04998c..c75f38fc3b596 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -217,6 +217,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, + arg.LockedAt, arg.Offset, arg.Limit, ) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5e9da77b66c0a..783524375f822 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -277,7 +277,7 @@ type sqlcQuerier interface { UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error - UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) error + UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) (Workspace, error) // This allows editing the properties of a workspace proxy. UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ff22cc3120193..ba0f3cfb54188 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9001,6 +9001,14 @@ WHERE ) > 0 ELSE true END + -- Filter by locked workspaces. By default we do not return locked + -- workspaces since they are considered soft-deleted. + AND CASE + WHEN $10 :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN + locked_at IS NOT NULL AND locked_at >= $10 + ELSE + locked_at IS NULL + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY @@ -9012,11 +9020,11 @@ ORDER BY LOWER(workspaces.name) ASC LIMIT CASE - WHEN $11 :: integer > 0 THEN - $11 + WHEN $12 :: integer > 0 THEN + $12 END OFFSET - $10 + $11 ` type GetWorkspacesParams struct { @@ -9029,6 +9037,7 @@ type GetWorkspacesParams struct { Name string `db:"name" json:"name"` HasAgent string `db:"has_agent" json:"has_agent"` AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` + LockedAt time.Time `db:"locked_at" json:"locked_at"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` } @@ -9064,6 +9073,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, + arg.LockedAt, arg.Offset, arg.Limit, ) @@ -9366,7 +9376,7 @@ func (q *sqlQuerier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWo return err } -const updateWorkspaceLockedDeletingAt = `-- name: UpdateWorkspaceLockedDeletingAt :exec +const updateWorkspaceLockedDeletingAt = `-- name: UpdateWorkspaceLockedDeletingAt :one UPDATE workspaces SET @@ -9383,6 +9393,7 @@ WHERE workspaces.template_id = templates.id AND workspaces.id = $1 +RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.locked_at, workspaces.deleting_at ` type UpdateWorkspaceLockedDeletingAtParams struct { @@ -9390,9 +9401,25 @@ type UpdateWorkspaceLockedDeletingAtParams struct { LockedAt sql.NullTime `db:"locked_at" json:"locked_at"` } -func (q *sqlQuerier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceLockedDeletingAt, arg.ID, arg.LockedAt) - return err +func (q *sqlQuerier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) (Workspace, error) { + row := q.db.QueryRowContext(ctx, updateWorkspaceLockedDeletingAt, arg.ID, arg.LockedAt) + var i Workspace + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.Ttl, + &i.LastUsedAt, + &i.LockedAt, + &i.DeletingAt, + ) + return i, err } const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 5e540a0e5c90a..9dd8aa00b5f55 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -259,6 +259,14 @@ WHERE ) > 0 ELSE true END + -- Filter by locked workspaces. By default we do not return locked + -- workspaces since they are considered soft-deleted. + AND CASE + WHEN @locked_at :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN + locked_at IS NOT NULL AND locked_at >= @locked_at + ELSE + locked_at IS NULL + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY @@ -474,7 +482,7 @@ WHERE ) ) AND workspaces.deleted = 'false'; --- name: UpdateWorkspaceLockedDeletingAt :exec +-- name: UpdateWorkspaceLockedDeletingAt :one UPDATE workspaces SET @@ -490,7 +498,8 @@ FROM WHERE workspaces.template_id = templates.id AND - workspaces.id = $1; + workspaces.id = $1 +RETURNING workspaces.*; -- name: UpdateWorkspacesDeletingAtByTemplateID :exec UPDATE diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 9b216d0180e15..3518f0744947e 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -114,9 +114,15 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT filter.Name = parser.String(values, "", "name") filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus])) filter.HasAgent = parser.String(values, "", "has-agent") + filter.LockedAt = parser.Time(values, time.Time{}, "locked_at", "2006-01-02") if _, ok := values["deleting_by"]; ok { postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02")) + // We want to make sure to grab locked workspaces since they + // are omitted by default. + if filter.LockedAt.IsZero() { + filter.LockedAt = time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) + } } parser.ErrorExcessParams(values) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0aa1cb0675155..8ad02faff33fb 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -768,7 +768,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { // @Tags Workspaces // @Param workspace path string true "Workspace ID" format(uuid) // @Param request body codersdk.UpdateWorkspaceLock true "Lock or unlock a workspace" -// @Success 200 {object} codersdk.Response +// @Success 200 {object} codersdk.Workspace // @Router /workspaces/{workspace}/lock [put] func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -779,9 +779,6 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { return } - code := http.StatusOK - resp := codersdk.Response{} - // If the workspace is already in the desired state do nothing! if workspace.LockedAt.Valid == req.Lock { httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{ @@ -797,7 +794,7 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { lockedAt.Time = database.Now() } - err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{ + workspace, err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{ ID: workspace.ID, LockedAt: lockedAt, }) @@ -809,10 +806,21 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { return } - // TODO should we kick off a build to stop the workspace if it's started - // from this endpoint? I'm leaning no to keep things simple and kick - // the responsibility back to the client. - httpapi.Write(ctx, rw, code, resp) + data, err := api.workspaceData(ctx, []database.Workspace{workspace}) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace resources.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace( + workspace, + data.builds[0], + data.templates[0], + findUser(workspace.OwnerID, data.users), + )) } // @Summary Extend workspace deadline by ID diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index db5db020488b7..2213cd4657a70 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1407,6 +1407,46 @@ func TestWorkspaceFilterManual(t *testing.T) { // and template.InactivityTTL should be 0 assert.Len(t, res.Workspaces, 0) }) + + t.Run("LockedAt", func(t *testing.T) { + // this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + // update template with inactivity ttl + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + + // Create another workspace to validate that we do not return unlocked workspaces. + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + + err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{ + Lock: true, + }) + require.NoError(t, err) + + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("locked_at:%s", time.Now().Add(-time.Minute).Format("2006-01-02")), + }) + require.NoError(t, err) + require.Len(t, res.Workspaces, 1) + require.NotNil(t, res.Workspaces[0].LockedAt) + }) } func TestOffsetLimit(t *testing.T) { diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 390a82448886c..6cfb468f6af50 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -931,22 +931,164 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/lock \ ```json { - "detail": "string", - "message": "string", - "validations": [ - { - "detail": "string", - "field": "string" - } - ] + "autostart_schedule": "string", + "created_at": "2019-08-24T14:15:22Z", + "deleting_at": "2019-08-24T14:15:22Z", + "health": { + "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "healthy": false + }, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_used_at": "2019-08-24T14:15:22Z", + "latest_build": { + "build_number": 0, + "created_at": "2019-08-24T14:15:22Z", + "daily_cost": 0, + "deadline": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", + "initiator_name": "string", + "job": { + "canceled_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "error": "string", + "error_code": "MISSING_TEMPLATE_PARAMETER", + "file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "queue_position": 0, + "queue_size": 0, + "started_at": "2019-08-24T14:15:22Z", + "status": "pending", + "tags": { + "property1": "string", + "property2": "string" + }, + "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" + }, + "max_deadline": "2019-08-24T14:15:22Z", + "reason": "initiator", + "resources": [ + { + "agents": [ + { + "apps": [ + { + "command": "string", + "display_name": "string", + "external": true, + "health": "disabled", + "healthcheck": { + "interval": 0, + "threshold": 0, + "url": "string" + }, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "sharing_level": "owner", + "slug": "string", + "subdomain": true, + "url": "string" + } + ], + "architecture": "string", + "connection_timeout_seconds": 0, + "created_at": "2019-08-24T14:15:22Z", + "directory": "string", + "disconnected_at": "2019-08-24T14:15:22Z", + "environment_variables": { + "property1": "string", + "property2": "string" + }, + "expanded_directory": "string", + "first_connected_at": "2019-08-24T14:15:22Z", + "health": { + "healthy": false, + "reason": "agent has lost connection" + }, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "instance_id": "string", + "last_connected_at": "2019-08-24T14:15:22Z", + "latency": { + "property1": { + "latency_ms": 0, + "preferred": true + }, + "property2": { + "latency_ms": 0, + "preferred": true + } + }, + "lifecycle_state": "created", + "login_before_ready": true, + "logs_length": 0, + "logs_overflowed": true, + "name": "string", + "operating_system": "string", + "ready_at": "2019-08-24T14:15:22Z", + "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", + "shutdown_script": "string", + "shutdown_script_timeout_seconds": 0, + "started_at": "2019-08-24T14:15:22Z", + "startup_script": "string", + "startup_script_behavior": "blocking", + "startup_script_timeout_seconds": 0, + "status": "connecting", + "subsystem": "envbox", + "troubleshooting_url": "string", + "updated_at": "2019-08-24T14:15:22Z", + "version": "string" + } + ], + "created_at": "2019-08-24T14:15:22Z", + "daily_cost": 0, + "hide": true, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f", + "metadata": [ + { + "key": "string", + "sensitive": true, + "value": "string" + } + ], + "name": "string", + "type": "string", + "workspace_transition": "start" + } + ], + "status": "pending", + "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "template_version_name": "string", + "transition": "start", + "updated_at": "2019-08-24T14:15:22Z", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", + "workspace_name": "string", + "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", + "workspace_owner_name": "string" + }, + "locked_at": "2019-08-24T14:15:22Z", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "outdated": true, + "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", + "owner_name": "string", + "template_allow_user_cancel_workspace_jobs": true, + "template_display_name": "string", + "template_icon": "string", + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "template_name": "string", + "ttl_ms": 0, + "updated_at": "2019-08-24T14:15:22Z" } ``` ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Workspace](schemas.md#codersdkworkspace) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/api.ts b/site/src/api/api.ts index bd2f36967f74c..4faea039e3792 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -554,6 +554,21 @@ export const cancelWorkspaceBuild = async ( return response.data } +export const updateWorkspaceLock = async ( + workspaceId: string, + lock: boolean, +): Promise => { + const data: TypesGen.UpdateWorkspaceLock = { + lock: lock, + } + + const response = await axios.put( + `/api/v2/workspaces/${workspaceId}/lock`, + data, + ) + return response.data +} + export const restartWorkspace = async ({ workspace, buildParameters, diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index fd9065abdb27a..ed26b64ffc481 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -87,3 +87,13 @@ export const useDashboard = (): DashboardProviderValue => { return context } + +export const useIsWorkspaceActionsEnabled = (): boolean => { + const { entitlements, experiments } = useDashboard() + const allowAdvancedScheduling = + entitlements.features["advanced_template_scheduling"].enabled + // This check can be removed when https://github.com/coder/coder/milestone/19 + // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions") + return allowWorkspaceActions && allowAdvancedScheduling +} diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 364bb50643bba..4e97fa0bb4b12 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,5 +1,6 @@ import Button from "@mui/material/Button" import { makeStyles } from "@mui/styles" +import LockIcon from "@mui/icons-material/Lock" import { Avatar } from "components/Avatar/Avatar" import { AgentRow } from "components/Resources/AgentRow" import { @@ -26,7 +27,7 @@ import { } from "components/PageHeader/FullWidthPageHeader" import { TemplateVersionWarnings } from "components/TemplateVersionWarnings/TemplateVersionWarnings" import { ErrorAlert } from "components/Alert/ErrorAlert" -import { ImpendingDeletionBanner } from "components/WorkspaceDeletion" +import { LockedWorkspaceBanner } from "components/WorkspaceDeletion" import { useLocalStorage } from "hooks" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import AlertTitle from "@mui/material/AlertTitle" @@ -53,6 +54,7 @@ export interface WorkspaceProps { handleCancel: () => void handleSettings: () => void handleChangeVersion: () => void + handleUnlock: () => void isUpdating: boolean isRestarting: boolean workspace: TypesGen.Workspace @@ -86,6 +88,7 @@ export const Workspace: FC> = ({ handleCancel, handleSettings, handleChangeVersion, + handleUnlock, workspace, isUpdating, isRestarting, @@ -167,14 +170,19 @@ export const Workspace: FC> = ({ <> - - {workspace.name} - + {workspace.locked_at ? ( + + ) : ( + + {workspace.name} + + )} +
{workspace.name} {workspace.owner_name} @@ -203,6 +211,7 @@ export const Workspace: FC> = ({ handleCancel={handleCancel} handleSettings={handleSettings} handleChangeVersion={handleChangeVersion} + handleUnlock={handleUnlock} canChangeVersions={canChangeVersions} isUpdating={isUpdating} isRestarting={isRestarting} @@ -253,8 +262,8 @@ export const Workspace: FC> = ({ {/* determines its own visibility */} - = ({ ) } +export const UnlockButton: FC = ({ + handleAction, + loading, +}) => { + return ( + } + onClick={handleAction} + > + Unlock + + ) +} + export const StartButton: FC< Omit & { workspace: Workspace diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 8242b470c8c67..409c77e673eab 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -12,6 +12,7 @@ import { StopButton, RestartButton, UpdateButton, + UnlockButton, } from "./Buttons" import { ButtonMapping, @@ -33,6 +34,7 @@ export interface WorkspaceActionsProps { handleCancel: () => void handleSettings: () => void handleChangeVersion: () => void + handleUnlock: () => void isUpdating: boolean isRestarting: boolean children?: ReactNode @@ -49,6 +51,7 @@ export const WorkspaceActions: FC = ({ handleCancel, handleSettings, handleChangeVersion, + handleUnlock, isUpdating, isRestarting, canChangeVersions, @@ -58,7 +61,7 @@ export const WorkspaceActions: FC = ({ canCancel, canAcceptJobs, actions: actionsByStatus, - } = actionsByWorkspaceStatus(workspace.latest_build.status) + } = actionsByWorkspaceStatus(workspace, workspace.latest_build.status) const canBeUpdated = workspace.outdated && canAcceptJobs const menuTriggerRef = useRef(null) const [isMenuOpen, setIsMenuOpen] = useState(false) @@ -93,6 +96,10 @@ export const WorkspaceActions: FC = ({ [ButtonTypesEnum.canceling]: , [ButtonTypesEnum.deleted]: , [ButtonTypesEnum.pending]: , + [ButtonTypesEnum.unlock]: , + [ButtonTypesEnum.unlocking]: ( + + ), } // Returns a function that will execute the action and close the menu diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index c4e81ca976ca5..c21fdedc98249 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -1,4 +1,4 @@ -import { WorkspaceStatus } from "api/typesGenerated" +import { Workspace, WorkspaceStatus } from "api/typesGenerated" import { ReactNode } from "react" // the button types we have @@ -12,6 +12,8 @@ export enum ButtonTypesEnum { deleting = "deleting", update = "update", updating = "updating", + unlock = "lock", + unlocking = "unlocking", // disabled buttons canceling = "canceling", deleted = "deleted", @@ -29,8 +31,16 @@ interface WorkspaceAbilities { } export const actionsByWorkspaceStatus = ( + workspace: Workspace, status: WorkspaceStatus, ): WorkspaceAbilities => { + if (workspace.locked_at) { + return { + actions: [ButtonTypesEnum.unlock], + canCancel: false, + canAcceptJobs: false, + } + } return statusToActions[status] } diff --git a/site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx b/site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx index eb27db82a3e2e..dc09566fc69e9 100644 --- a/site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx +++ b/site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx @@ -1,31 +1,17 @@ import { Workspace } from "api/typesGenerated" -import { displayImpendingDeletion } from "./utils" -import { useDashboard } from "components/Dashboard/DashboardProvider" +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider" import { Pill } from "components/Pill/Pill" -import ErrorIcon from "@mui/icons-material/ErrorOutline" +import LockIcon from "@mui/icons-material/Lock" -export const ImpendingDeletionBadge = ({ +export const LockedBadge = ({ workspace, }: { workspace: Workspace }): JSX.Element | null => { - const { entitlements, experiments } = useDashboard() - const allowAdvancedScheduling = - entitlements.features["advanced_template_scheduling"].enabled - // This check can be removed when https://github.com/coder/coder/milestone/19 - // is merged up - const allowWorkspaceActions = experiments.includes("workspace_actions") - // return null - - if ( - !displayImpendingDeletion( - workspace, - allowAdvancedScheduling, - allowWorkspaceActions, - ) - ) { + const experimentEnabled = useIsWorkspaceActionsEnabled() + if (!workspace.locked_at || !experimentEnabled) { return null } - return } text="Impending deletion" type="error" /> + return } text="Locked" type="error" /> } diff --git a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx index d793c16d5dc3a..501dd50dfa95f 100644 --- a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx +++ b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx @@ -1,8 +1,7 @@ import { Workspace } from "api/typesGenerated" -import { displayImpendingDeletion } from "./utils" -import { useDashboard } from "components/Dashboard/DashboardProvider" +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider" import { Alert } from "components/Alert/Alert" -import { formatDistanceToNow, differenceInDays, add, format } from "date-fns" +import { formatDistanceToNow } from "date-fns" import Link from "@mui/material/Link" import { Link as RouterLink } from "react-router-dom" @@ -11,69 +10,90 @@ export enum Count { Multiple, } -export const ImpendingDeletionBanner = ({ - workspace, +export const LockedWorkspaceBanner = ({ + workspaces, onDismiss, shouldRedisplayBanner, count = Count.Singular, }: { - workspace?: Workspace + workspaces?: Workspace[] onDismiss: () => void shouldRedisplayBanner: boolean count?: Count }): JSX.Element | null => { - const { entitlements, experiments } = useDashboard() - const allowAdvancedScheduling = - entitlements.features["advanced_template_scheduling"].enabled - // This check can be removed when https://github.com/coder/coder/milestone/19 - // is merged up - const allowWorkspaceActions = experiments.includes("workspace_actions") + const experimentEnabled = useIsWorkspaceActionsEnabled() + + if (!workspaces) { + return null + } + + const hasLockedWorkspaces = workspaces.find( + (workspace) => workspace.locked_at, + ) + + const hasDeletionScheduledWorkspaces = workspaces.find( + (workspace) => workspace.deleting_at, + ) if ( - !workspace || - !displayImpendingDeletion( - workspace, - allowAdvancedScheduling, - allowWorkspaceActions, - ) || + // Only show this if the experiment is included. + !experimentEnabled || + !hasLockedWorkspaces || // Banners should be redisplayed after dismissal when additional workspaces are newly scheduled for deletion !shouldRedisplayBanner ) { return null } - // if deleting_at is 7 days away or less, display an 'error' banner to convey urgency to user - const daysUntilDelete = differenceInDays( - Date.parse(workspace.last_used_at), - new Date(), - ) + const formatDate = (dateStr: string): string => { + const date = new Date(dateStr) + return date.toLocaleDateString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + }) + } - const plusFourteen = add(new Date(), { days: 14 }) + const alertText = (): string => { + if (workspaces.length === 1) { + if ( + hasDeletionScheduledWorkspaces && + hasDeletionScheduledWorkspaces.deleting_at && + hasDeletionScheduledWorkspaces.locked_at + ) { + return `This workspace has been locked since ${formatDistanceToNow( + Date.parse(hasDeletionScheduledWorkspaces.locked_at), + )} and is scheduled to be deleted at ${formatDate( + hasDeletionScheduledWorkspaces.deleting_at, + )} . To keep it you must unlock the workspace.` + } else if (hasLockedWorkspaces && hasLockedWorkspaces.locked_at) { + return `This workspace has been locked since ${formatDate( + hasLockedWorkspaces.locked_at, + )} + and cannot be interacted + with. Locked workspaces are eligible for + permanent deletion. To prevent deletion, unlock + the workspace.` + } + } + return "" + } return ( - + {count === Count.Singular ? ( - `This workspace has been unused for ${formatDistanceToNow( - Date.parse(workspace.last_used_at), - )} and is scheduled for deletion. To keep it, connect via SSH or the web terminal.` + alertText() ) : ( <> There are{" "} workspaces {" "} - that will be deleted soon due to inactivity. To keep these workspaces, - connect to them via SSH or the web terminal. + that may be deleted soon due to inactivity. Unlock the workspaces you + wish to retain. )} diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 7f742dab2ff3c..2ace2d0b903b8 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -5,7 +5,7 @@ import { makeStyles } from "@mui/styles" import { combineClasses } from "utils/combineClasses" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { - ImpendingDeletionBadge, + LockedBadge, ImpendingDeletionText, } from "components/WorkspaceDeletion" import { getDisplayWorkspaceStatus } from "utils/workspace" @@ -25,8 +25,8 @@ export const WorkspaceStatusBadge: FC< return ( {/* determines its own visibility */} - - + + diff --git a/site/src/i18n/en/templateSettingsPage.json b/site/src/i18n/en/templateSettingsPage.json index 11f314e0a7472..d1b0927b5a78a 100644 --- a/site/src/i18n/en/templateSettingsPage.json +++ b/site/src/i18n/en/templateSettingsPage.json @@ -22,9 +22,9 @@ "failureTTLHelperText_zero": "Coder will not automatically stop failed workspaces", "failureTTLHelperText_one": "Coder will attempt to stop failed workspaces after {{count}} day.", "failureTTLHelperText_other": "Coder will attempt to stop failed workspaces after {{count}} days.", - "inactivityTTLHelperText_zero": "Coder will not automatically delete inactive workspaces", - "inactivityTTLHelperText_one": "Coder will automatically delete inactive workspaces after {{count}} day.", - "inactivityTTLHelperText_other": "Coder will automatically delete inactive workspaces after {{count}} days.", + "inactivityTTLHelperText_zero": "Coder will not automatically lock inactive workspaces", + "inactivityTTLHelperText_one": "Coder will automatically lock inactive workspaces after {{count}} day.", + "inactivityTTLHelperText_other": "Coder will automatically lock inactive workspaces after {{count}} days.", "lockedTTLHelperText_zero": "Coder will not automatically delete locked workspaces", "lockedTTLHelperText_one": "Coder will automatically delete locked workspaces after {{count}} day.", "lockedTTLHelperText_other": "Coder will automatically delete locked workspaces after {{count}} days.", diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx index 645c7dfb84ddd..6128700299e28 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx @@ -14,6 +14,6 @@ export const OpenDialog: Story = { submitValues: () => null, isInactivityDialogOpen: true, setIsInactivityDialogOpen: () => null, - workspacesToBeDeletedToday: 2, + workspacesToBeLockedToday: 2, }, } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx index 3f5ec252b08be..a9407f6d5eab5 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx @@ -4,12 +4,12 @@ export const InactivityDialog = ({ submitValues, isInactivityDialogOpen, setIsInactivityDialogOpen, - workspacesToBeDeletedToday, + workspacesToBeLockedToday, }: { submitValues: () => void isInactivityDialogOpen: boolean setIsInactivityDialogOpen: (arg0: boolean) => void - workspacesToBeDeletedToday: number + workspacesToBeLockedToday: number }) => { return ( setIsInactivityDialogOpen(false)} - title="Delete inactive workspaces" + title="Lock inactive workspaces" + confirmText="Lock Workspaces" + description={`There are ${ + workspacesToBeLockedToday ? workspacesToBeLockedToday : "" + } workspaces that already match this filter and will be locked upon form submission. Are you sure you want to proceed?`} + /> + ) +} + +export const DeleteLockedDialog = ({ + submitValues, + isLockedDialogOpen, + setIsLockedDialogOpen, + workspacesToBeDeletedToday, +}: { + submitValues: () => void + isLockedDialogOpen: boolean + setIsLockedDialogOpen: (arg0: boolean) => void + workspacesToBeDeletedToday: number +}) => { + return ( + { + submitValues() + setIsLockedDialogOpen(false) + }} + onClose={() => setIsLockedDialogOpen(false)} + title="Delete Locked Workspaces" confirmText="Delete Workspaces" description={`There are ${ workspacesToBeDeletedToday ? workspacesToBeDeletedToday : "" diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index 598f701338f1e..1efdcec885cfb 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -16,8 +16,11 @@ import Link from "@mui/material/Link" import Checkbox from "@mui/material/Checkbox" import FormControlLabel from "@mui/material/FormControlLabel" import Switch from "@mui/material/Switch" -import { InactivityDialog } from "./InactivityDialog" -import { useWorkspacesToBeDeleted } from "./useWorkspacesToBeDeleted" +import { DeleteLockedDialog, InactivityDialog } from "./InactivityDialog" +import { + useWorkspacesToBeLocked, + useWorkspacesToBeDeleted, +} from "./useWorkspacesToBeDeleted" import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers" import { TTLHelperText } from "./TTLHelperText" import { docs } from "utils/docs" @@ -89,10 +92,16 @@ export const TemplateScheduleForm: FC = ({ onSubmit: () => { if ( form.values.inactivity_cleanup_enabled && + workspacesToBeLockedToday && + workspacesToBeLockedToday.length > 0 + ) { + setIsInactivityDialogOpen(true) + } else if ( + form.values.locked_cleanup_enabled && workspacesToBeDeletedToday && workspacesToBeDeletedToday.length > 0 ) { - setIsInactivityDialogOpen(true) + setIsLockedDialogOpen(true) } else { submitValues() } @@ -106,10 +115,18 @@ export const TemplateScheduleForm: FC = ({ const { t } = useTranslation("templateSettingsPage") const styles = useStyles() - const workspacesToBeDeletedToday = useWorkspacesToBeDeleted(form.values) + const workspacesToBeLockedToday = useWorkspacesToBeLocked( + template, + form.values, + ) + const workspacesToBeDeletedToday = useWorkspacesToBeDeleted( + template, + form.values, + ) const [isInactivityDialogOpen, setIsInactivityDialogOpen] = useState(false) + const [isLockedDialogOpen, setIsLockedDialogOpen] = useState(false) const submitValues = () => { // on submit, convert from hours => ms @@ -324,12 +341,11 @@ export const TemplateScheduleForm: FC = ({ inputProps={{ min: 0, step: "any" }} label="Time until cleanup (days)" type="number" - aria-label="Failure Cleanup" /> @@ -341,7 +357,7 @@ export const TemplateScheduleForm: FC = ({ onChange={handleToggleInactivityCleanup} /> } - label="Enable Inactivity Cleanup" + label="Enable Inactivity TTL" /> = ({ inputProps={{ min: 0, step: "any" }} label="Time until cleanup (days)" type="number" - aria-label="Inactivity Cleanup" /> @@ -375,7 +390,7 @@ export const TemplateScheduleForm: FC = ({ onChange={handleToggleLockedCleanup} /> } - label="Enable Locked Cleanup" + label="Enable Locked TTL" /> = ({ inputProps={{ min: 0, step: "any" }} label="Time until cleanup (days)" type="number" - aria-label="Locked Cleanup" /> )} - + {workspacesToBeLockedToday && workspacesToBeLockedToday.length > 0 && ( + + )} + {workspacesToBeDeletedToday && workspacesToBeDeletedToday.length > 0 && ( + + )} + { + const { data: workspacesData } = useQuery({ + queryKey: ["workspaces"], + queryFn: () => + getWorkspaces({ + q: "template:" + template.name, + }), + enabled: formValues.inactivity_cleanup_enabled, + }) + + return workspacesData?.workspaces?.filter((workspace: Workspace) => { + if (!formValues.inactivity_ttl_ms) { + return + } + + if (workspace.locked_at) { + return + } + + const proposedLocking = new Date( + new Date(workspace.last_used_at).getTime() + + formValues.inactivity_ttl_ms * 86400000, + ) + + if (compareAsc(proposedLocking, new Date()) < 1) { + return workspace + } + }) +} export const useWorkspacesToBeDeleted = ( + template: Template, formValues: TemplateScheduleFormValues, ) => { const { data: workspacesData } = useQuery({ queryKey: ["workspaces"], - queryFn: () => getWorkspaces({}), - enabled: formValues.inactivity_cleanup_enabled, + queryFn: () => + getWorkspaces({ + q: "template:" + template.name, + }), + enabled: formValues.locked_cleanup_enabled, }) return workspacesData?.workspaces?.filter((workspace: Workspace) => { - const isInactive = inactiveStatuses.includes(workspace.latest_build.status) + if (!workspace.locked_at || !formValues.locked_ttl_ms) { + return false + } - const proposedDeletion = add(new Date(workspace.last_used_at), { - days: formValues.inactivity_ttl_ms, - }) + const proposedLocking = new Date( + new Date(workspace.locked_at).getTime() + + formValues.locked_ttl_ms * 86400000, + ) - if (isInactive && compareAsc(proposedDeletion, endOfToday()) < 1) { + if (compareAsc(proposedLocking, new Date()) < 1) { return workspace } }) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index 5b37f01f5b8ce..15612a544d89e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -63,12 +63,12 @@ const fillAndSubmitForm = async ({ await user.type(failureTtlField, failure_ttl_ms.toString()) const inactivityTtlField = screen.getByRole("checkbox", { - name: /Inactivity Cleanup/i, + name: /Inactivity TTL/i, }) await user.type(inactivityTtlField, inactivity_ttl_ms.toString()) const lockedTtlField = screen.getByRole("checkbox", { - name: /Locked Cleanup/i, + name: /Locked TTL/i, }) await user.type(lockedTtlField, locked_ttl_ms.toString()) @@ -76,6 +76,10 @@ const fillAndSubmitForm = async ({ FooterFormLanguage.defaultSubmitLabel, ) await user.click(submitButton) + + // User needs to confirm inactivity and locked ttl + const confirmButton = await screen.findByTestId("confirm-button") + await user.click(confirmButton) } describe("TemplateSchedulePage", () => { diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 4407f8f017775..3fd3124399da3 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -176,6 +176,7 @@ export const WorkspaceReadyPage = ({ handleChangeVersion={() => { setChangeVersionDialogOpen(true) }} + handleUnlock={() => workspaceSend({ type: "UNLOCK" })} resources={workspace.latest_build.resources} builds={builds} canUpdateWorkspace={canUpdateWorkspace} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 112a89ae5eaa1..6194f1fc4add8 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -1,5 +1,7 @@ import { usePagination } from "hooks/usePagination" -import { FC } from "react" +import { Workspace } from "api/typesGenerated" +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider" +import { FC, useEffect, useState } from "react" import { Helmet } from "react-helmet-async" import { pageTitle } from "utils/page" import { useWorkspacesData, useWorkspaceUpdate } from "./data" @@ -9,8 +11,10 @@ import { useTemplateFilterMenu, useStatusFilterMenu } from "./filter/menus" import { useSearchParams } from "react-router-dom" import { useFilter } from "components/Filter/filter" import { useUserFilterMenu } from "components/Filter/UserFilter" +import { getWorkspaces } from "api/api" const WorkspacesPage: FC = () => { + const [lockedWorkspaces, setLockedWorkspaces] = useState([]) // If we use a useSearchParams for each hook, the values will not be in sync. // So we have to use a single one, centralizing the values, and pass it to // each hook. @@ -21,6 +25,37 @@ const WorkspacesPage: FC = () => { ...pagination, query: filterProps.filter.query, }) + + const experimentEnabled = useIsWorkspaceActionsEnabled() + // If workspace actions are enabled we need to fetch the locked + // workspaces as well. This lets us determine whether we should + // show a banner to the user indicating that some of their workspaces + // are at risk of being deleted. + useEffect(() => { + if (experimentEnabled) { + const includesLocked = filterProps.filter.query.includes("locked_at") + const lockedQuery = includesLocked + ? filterProps.filter.query + : filterProps.filter.query + " locked_at:1970-01-01" + + if (includesLocked && data) { + setLockedWorkspaces(data.workspaces) + } else { + getWorkspaces({ q: lockedQuery }) + .then((resp) => { + setLockedWorkspaces(resp.workspaces) + }) + .catch(() => { + // TODO + }) + } + } else { + // If the experiment isn't included then we'll pretend + // like locked workspaces don't exist. + setLockedWorkspaces([]) + } + }, [experimentEnabled, data, filterProps.filter.query]) + const updateWorkspace = useWorkspaceUpdate(queryKey) return ( @@ -31,6 +66,7 @@ const WorkspacesPage: FC = () => { page: number @@ -45,6 +45,7 @@ export const WorkspacesPageView: FC< React.PropsWithChildren > = ({ workspaces, + lockedWorkspaces, error, limit, count, @@ -53,32 +54,14 @@ export const WorkspacesPageView: FC< onUpdateWorkspace, page, }) => { - const { saveLocal, getLocal } = useLocalStorage() + const { saveLocal } = useLocalStorage() - const workspaceIdsWithImpendingDeletions = workspaces + const workspacesDeletionScheduled = lockedWorkspaces ?.filter((workspace) => workspace.deleting_at) .map((workspace) => workspace.id) - /** - * Returns a boolean indicating if there are workspaces that have been - * recently marked for deletion but are not in local storage. - * If there are, we want to alert the user so they can potentially take action - * before deletion takes place. - * @returns {boolean} - */ - const isNewWorkspacesImpendingDeletion = (): boolean => { - const dismissedList = getLocal("dismissedWorkspaceList") - if (!dismissedList) { - return true - } - - const diff = difference( - workspaceIdsWithImpendingDeletions, - JSON.parse(dismissedList), - ) - - return diff && diff.length > 0 - } + const hasLockedWorkspace = + lockedWorkspaces !== undefined && lockedWorkspaces.length > 0 return ( @@ -104,13 +87,13 @@ export const WorkspacesPageView: FC< {/* determines its own visibility */} - workspace.deleting_at)} - shouldRedisplayBanner={isNewWorkspacesImpendingDeletion()} + saveLocal( "dismissedWorkspaceList", - JSON.stringify(workspaceIdsWithImpendingDeletions), + JSON.stringify(workspacesDeletionScheduled), ) } count={Count.Multiple} diff --git a/site/src/pages/WorkspacesPage/filter/filter.tsx b/site/src/pages/WorkspacesPage/filter/filter.tsx index 2772347d995cf..8d98c0b03182f 100644 --- a/site/src/pages/WorkspacesPage/filter/filter.tsx +++ b/site/src/pages/WorkspacesPage/filter/filter.tsx @@ -1,5 +1,6 @@ import { FC } from "react" import Box from "@mui/material/Box" +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider" import { Avatar, AvatarProps } from "components/Avatar/Avatar" import { Palette, PaletteColor } from "@mui/material/styles" import { TemplateFilterMenu, StatusFilterMenu } from "./menus" @@ -43,9 +44,17 @@ export const WorkspacesFilter = ({ status: StatusFilterMenu } }) => { + const presets = [...PRESET_FILTERS] + if (useIsWorkspaceActionsEnabled()) { + presets.push({ + query: workspaceFilterQuery.locked, + name: "Locked workspaces", + }) + } + return ( { + const message = getErrorMessage(data, "Error unlocking workspace.") + displayError(message) + }, assignMissedParameters: assign({ missedParameters: (_, { data }) => { if (!(data instanceof API.MissingBuildParameters)) { @@ -675,6 +695,18 @@ export const workspaceMachine = createMachine( throw Error("Cannot cancel workspace without build id") } }, + unlockWorkspace: (context) => async (send) => { + if (context.workspace) { + const unlockWorkspacePromise = await API.updateWorkspaceLock( + context.workspace.id, + false, + ) + send({ type: "REFRESH_WORKSPACE", data: unlockWorkspacePromise }) + return unlockWorkspacePromise + } else { + throw Error("Cannot unlock workspace without workspace id") + } + }, listening: (context) => (send) => { if (!context.eventSource) { send({ type: "EVENT_SOURCE_ERROR", error: "error initializing sse" }) From 2e45a0ffd7bd4f83bd51837317033dd3d425f07b Mon Sep 17 00:00:00 2001 From: Eric Paulsen Date: Fri, 4 Aug 2023 00:52:59 -0400 Subject: [PATCH 003/277] fix(helm): set correct prom port in helm notes (#8888) --- helm/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/values.yaml b/helm/values.yaml index e3a1298628d5b..43d317507d609 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -10,7 +10,7 @@ coder: # - CODER_TLS_ENABLE: set if tls.secretName is not empty. # - CODER_TLS_CERT_FILE: set if tls.secretName is not empty. # - CODER_TLS_KEY_FILE: set if tls.secretName is not empty. - # - CODER_PROMETHEUS_ADDRESS: set to 0.0.0.0:6060 and cannot be changed. + # - CODER_PROMETHEUS_ADDRESS: set to 0.0.0.0:2112 and cannot be changed. # Prometheus must still be enabled by setting CODER_PROMETHEUS_ENABLE. # - KUBE_POD_IP # - CODER_DERP_SERVER_RELAY_URL From b77d6b2c84ddcecb35d16e7b4a1f80548a9f8606 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 4 Aug 2023 09:55:38 +0300 Subject: [PATCH 004/277] ci: delete comments by github-action[bot] (#8896) --- .github/workflows/pr-cleanup.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/pr-cleanup.yaml b/.github/workflows/pr-cleanup.yaml index 510c8f4299361..bb4f7146c7025 100644 --- a/.github/workflows/pr-cleanup.yaml +++ b/.github/workflows/pr-cleanup.yaml @@ -71,3 +71,10 @@ jobs: run: | set -euxo pipefail kubectl delete certificate "pr${{ steps.pr_number.outputs.PR_NUMBER }}-tls" -n pr-deployment-certs || echo "certificate not found" + + - name: Delete PR Comments + uses: izhangzhihao/delete-comment@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + delete_user_name: github-actions[bot] + issue_number: ${{ github.event.number }} From cb4989cd8d9d67238fcfc39cfd876263f8605a95 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 4 Aug 2023 12:32:28 +0400 Subject: [PATCH 005/277] feat: add PSK for external provisionerd auth (#8877) Signed-off-by: Spike Curtis --- cli/root.go | 18 +- cli/testdata/coder_server_--help.golden | 4 + cli/testdata/server-config.yaml.golden | 3 + coderd/apidoc/docs.go | 3 + coderd/apidoc/swagger.json | 3 + coderd/coderdtest/coderdtest.go | 6 +- codersdk/client.go | 3 + codersdk/deployment.go | 10 + codersdk/organizations.go | 5 +- codersdk/provisionerdaemons.go | 51 +++-- docs/api/general.md | 1 + docs/api/schemas.md | 4 + docs/cli/provisionerd_start.md | 9 + docs/cli/server.md | 10 + enterprise/cli/provisionerdaemons.go | 24 ++- enterprise/cli/provisionerdaemons_test.go | 56 +++++ enterprise/cli/server.go | 1 + .../coder_provisionerd_start_--help.golden | 3 + .../cli/testdata/coder_server_--help.golden | 4 + enterprise/coderd/coderd.go | 24 ++- .../coderd/coderdenttest/coderdenttest.go | 2 + enterprise/coderd/provisionerdaemons.go | 53 +++-- enterprise/coderd/provisionerdaemons_test.go | 192 ++++++++++++++++-- site/src/api/typesGenerated.ts | 1 + 24 files changed, 429 insertions(+), 61 deletions(-) create mode 100644 enterprise/cli/provisionerdaemons_test.go diff --git a/cli/root.go b/cli/root.go index 4c268235a0f96..036be18a01300 100644 --- a/cli/root.go +++ b/cli/root.go @@ -494,6 +494,15 @@ func addTelemetryHeader(client *codersdk.Client, inv *clibase.Invocation) { // InitClient sets client to a new client. // It reads from global configuration files if flags are not set. func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc { + return r.initClientInternal(client, false) +} + +func (r *RootCmd) InitClientMissingTokenOK(client *codersdk.Client) clibase.MiddlewareFunc { + return r.initClientInternal(client, true) +} + +// nolint: revive +func (r *RootCmd) initClientInternal(client *codersdk.Client, allowTokenMissing bool) clibase.MiddlewareFunc { if client == nil { panic("client is nil") } @@ -508,7 +517,7 @@ func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc { rawURL, err := conf.URL().Read() // If the configuration files are absent, the user is logged out if os.IsNotExist(err) { - return (errUnauthenticated) + return errUnauthenticated } if err != nil { return err @@ -524,9 +533,10 @@ func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc { r.token, err = conf.Session().Read() // If the configuration files are absent, the user is logged out if os.IsNotExist(err) { - return (errUnauthenticated) - } - if err != nil { + if !allowTokenMissing { + return errUnauthenticated + } + } else if err != nil { return err } } diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index cb7ca61b4913a..121ce98a98bd7 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -373,6 +373,10 @@ updating, and deleting workspace resources. --provisioner-daemon-poll-jitter duration, $CODER_PROVISIONER_DAEMON_POLL_JITTER (default: 100ms) Random jitter added to the poll interval. + --provisioner-daemon-psk string, $CODER_PROVISIONER_DAEMON_PSK + Pre-shared key to authenticate external provisioner daemons to Coder + server. + --provisioner-daemons int, $CODER_PROVISIONER_DAEMONS (default: 3) Number of provisioner daemons to create on start. If builds are stuck in queued state for a long time, consider increasing this. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index c7a8df03414e6..7eab5aba07ecc 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -327,6 +327,9 @@ provisioning: # Time to force cancel provisioning tasks that are stuck. # (default: 10m0s, type: duration) forceCancelInterval: 10m0s + # Pre-shared key to authenticate external provisioner daemons to Coder server. + # (default: , type: string) + daemonPSK: "" # Enable one or more experiments. These are not ready for production. Separate # multiple experiments with commas, or enter '*' to opt-in to all available # experiments. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5d70095ee850c..8d7920c2d2e8d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8753,6 +8753,9 @@ const docTemplate = `{ "daemon_poll_jitter": { "type": "integer" }, + "daemon_psk": { + "type": "string" + }, "daemons": { "type": "integer" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 57f5c5051aa70..bc4c0e7b8a958 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7863,6 +7863,9 @@ "daemon_poll_jitter": { "type": "integer" }, + "daemon_psk": { + "type": "string" + }, "daemons": { "type": "integer" }, diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 4d4fbb8c5e78e..351a6d0d9a075 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -497,7 +497,11 @@ func NewExternalProvisionerDaemon(t *testing.T, client *codersdk.Client, org uui }() closer := provisionerd.New(func(ctx context.Context) (provisionerdproto.DRPCProvisionerDaemonClient, error) { - return client.ServeProvisionerDaemon(ctx, org, []codersdk.ProvisionerType{codersdk.ProvisionerTypeEcho}, tags) + return client.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: org, + Provisioners: []codersdk.ProvisionerType{codersdk.ProvisionerTypeEcho}, + Tags: tags, + }) }, &provisionerd.Options{ Filesystem: fs, Logger: slogtest.Make(t, nil).Named("provisionerd").Leveled(slog.LevelDebug), diff --git a/codersdk/client.go b/codersdk/client.go index ad9e46ccf7f4a..98a109aa725e1 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -71,6 +71,9 @@ const ( // command that was invoked to produce the request. It is for internal use // only. CLITelemetryHeader = "Coder-CLI-Telemetry" + + // ProvisionerDaemonPSK contains the authentication pre-shared key for an external provisioner daemon + ProvisionerDaemonPSK = "Coder-Provisioner-Daemon-PSK" ) // loggableMimeTypes is a list of MIME types that are safe to log diff --git a/codersdk/deployment.go b/codersdk/deployment.go index af861138e6d7d..e714d9c1c34b5 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -328,6 +328,7 @@ type ProvisionerConfig struct { DaemonPollInterval clibase.Duration `json:"daemon_poll_interval" typescript:",notnull"` DaemonPollJitter clibase.Duration `json:"daemon_poll_jitter" typescript:",notnull"` ForceCancelInterval clibase.Duration `json:"force_cancel_interval" typescript:",notnull"` + DaemonPSK clibase.String `json:"daemon_psk" typescript:",notnull"` } type RateLimitConfig struct { @@ -1230,6 +1231,15 @@ when required by your organization's security policy.`, Group: &deploymentGroupProvisioning, YAML: "forceCancelInterval", }, + { + Name: "Provisioner Daemon Pre-shared Key (PSK)", + Description: "Pre-shared key to authenticate external provisioner daemons to Coder server.", + Flag: "provisioner-daemon-psk", + Env: "CODER_PROVISIONER_DAEMON_PSK", + Value: &c.Provisioner.DaemonPSK, + Group: &deploymentGroupProvisioning, + YAML: "daemonPSK", + }, // RateLimit settings { Name: "Disable All Rate Limits", diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 26290fd4f4761..96b026a3197a5 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -149,10 +149,11 @@ func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, return organization, json.NewDecoder(res.Body).Decode(&organization) } -// ProvisionerDaemonsByOrganization returns provisioner daemons available for an organization. +// ProvisionerDaemons returns provisioner daemons available. func (c *Client) ProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error) { res, err := c.Request(ctx, http.MethodGet, - "/api/v2/provisionerdaemons", + // TODO: the organization path parameter is currently ignored. + "/api/v2/organizations/default/provisionerdaemons", nil, ) if err != nil { diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 1c9378f718b3a..674523055e06f 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -164,38 +164,61 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after }), nil } -// ListenProvisionerDaemon returns the gRPC service for a provisioner daemon +// ServeProvisionerDaemonRequest are the parameters to call ServeProvisionerDaemon with +// @typescript-ignore ServeProvisionerDaemonRequest +type ServeProvisionerDaemonRequest struct { + // Organization is the organization for the URL. At present provisioner daemons ARE NOT scoped to organizations + // and so the organization ID is optional. + Organization uuid.UUID `json:"organization" format:"uuid"` + // Provisioners is a list of provisioner types hosted by the provisioner daemon + Provisioners []ProvisionerType `json:"provisioners"` + // Tags is a map of key-value pairs that tag the jobs this provisioner daemon can handle + Tags map[string]string `json:"tags"` + // PreSharedKey is an authentication key to use on the API instead of the normal session token from the client. + PreSharedKey string `json:"pre_shared_key"` +} + +// ServeProvisionerDaemon returns the gRPC service for a provisioner daemon // implementation. The context is during dial, not during the lifetime of the // client. Client should be closed after use. -func (c *Client) ServeProvisionerDaemon(ctx context.Context, organization uuid.UUID, provisioners []ProvisionerType, tags map[string]string) (proto.DRPCProvisionerDaemonClient, error) { - serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/organizations/%s/provisionerdaemons/serve", organization)) +func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisionerDaemonRequest) (proto.DRPCProvisionerDaemonClient, error) { + serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/organizations/%s/provisionerdaemons/serve", req.Organization)) if err != nil { return nil, xerrors.Errorf("parse url: %w", err) } query := serverURL.Query() - for _, provisioner := range provisioners { + for _, provisioner := range req.Provisioners { query.Add("provisioner", string(provisioner)) } - for key, value := range tags { + for key, value := range req.Tags { query.Add("tag", fmt.Sprintf("%s=%s", key, value)) } serverURL.RawQuery = query.Encode() - jar, err := cookiejar.New(nil) - if err != nil { - return nil, xerrors.Errorf("create cookie jar: %w", err) - } - jar.SetCookies(serverURL, []*http.Cookie{{ - Name: SessionTokenCookie, - Value: c.SessionToken(), - }}) httpClient := &http.Client{ - Jar: jar, Transport: c.HTTPClient.Transport, } + headers := http.Header{} + + if req.PreSharedKey == "" { + // use session token if we don't have a PSK. + jar, err := cookiejar.New(nil) + if err != nil { + return nil, xerrors.Errorf("create cookie jar: %w", err) + } + jar.SetCookies(serverURL, []*http.Cookie{{ + Name: SessionTokenCookie, + Value: c.SessionToken(), + }}) + httpClient.Jar = jar + } else { + headers.Set(ProvisionerDaemonPSK, req.PreSharedKey) + } + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ HTTPClient: httpClient, // Need to disable compression to avoid a data-race. CompressionMode: websocket.CompressionDisabled, + HTTPHeader: headers, }) if err != nil { if res == nil { diff --git a/docs/api/general.md b/docs/api/general.md index bb64823bd86c9..3f1f90a02d851 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -305,6 +305,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "provisioner": { "daemon_poll_interval": 0, "daemon_poll_jitter": 0, + "daemon_psk": "string", "daemons": 0, "daemons_echo": true, "force_cancel_interval": 0 diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 39bc8d16b6e6f..192cc9625eddd 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2099,6 +2099,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "provisioner": { "daemon_poll_interval": 0, "daemon_poll_jitter": 0, + "daemon_psk": "string", "daemons": 0, "daemons_echo": true, "force_cancel_interval": 0 @@ -2456,6 +2457,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "provisioner": { "daemon_poll_interval": 0, "daemon_poll_jitter": 0, + "daemon_psk": "string", "daemons": 0, "daemons_echo": true, "force_cancel_interval": 0 @@ -3485,6 +3487,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "daemon_poll_interval": 0, "daemon_poll_jitter": 0, + "daemon_psk": "string", "daemons": 0, "daemons_echo": true, "force_cancel_interval": 0 @@ -3497,6 +3500,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | ----------------------- | ------- | -------- | ------------ | ----------- | | `daemon_poll_interval` | integer | false | | | | `daemon_poll_jitter` | integer | false | | | +| `daemon_psk` | string | false | | | | `daemons` | integer | false | | | | `daemons_echo` | boolean | false | | | | `force_cancel_interval` | integer | false | | | diff --git a/docs/cli/provisionerd_start.md b/docs/cli/provisionerd_start.md index 583e520389150..b129605933db3 100644 --- a/docs/cli/provisionerd_start.md +++ b/docs/cli/provisionerd_start.md @@ -42,6 +42,15 @@ How often to poll for provisioner jobs. How much to jitter the poll interval by. +### --psk + +| | | +| ----------- | ------------------------------------------ | +| Type | string | +| Environment | $CODER_PROVISIONER_DAEMON_PSK | + +Pre-shared key to authenticate with Coder server. + ### -t, --tag | | | diff --git a/docs/cli/server.md b/docs/cli/server.md index 90c60d7392f00..9591dc8041f9f 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -668,6 +668,16 @@ Collect database metrics (may increase charges for metrics storage). Serve prometheus metrics on the address defined by prometheus address. +### --provisioner-daemon-psk + +| | | +| ----------- | ------------------------------------------ | +| Type | string | +| Environment | $CODER_PROVISIONER_DAEMON_PSK | +| YAML | provisioning.daemonPSK | + +Pre-shared key to authenticate external provisioner daemons to Coder server. + ### --provisioner-daemons | | | diff --git a/enterprise/cli/provisionerdaemons.go b/enterprise/cli/provisionerdaemons.go index f3dfc2ba367d7..baca9fa4b3296 100644 --- a/enterprise/cli/provisionerdaemons.go +++ b/enterprise/cli/provisionerdaemons.go @@ -44,13 +44,14 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { rawTags []string pollInterval time.Duration pollJitter time.Duration + preSharedKey string ) client := new(codersdk.Client) cmd := &clibase.Cmd{ Use: "start", Short: "Run a provisioner daemon", Middleware: clibase.Chain( - r.InitClient(client), + r.InitClientMissingTokenOK(client), ), Handler: func(inv *clibase.Invocation) error { ctx, cancel := context.WithCancel(inv.Context()) @@ -59,11 +60,6 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { notifyCtx, notifyStop := signal.NotifyContext(ctx, agpl.InterruptSignals...) defer notifyStop() - org, err := agpl.CurrentOrganization(inv, client) - if err != nil { - return xerrors.Errorf("get current organization: %w", err) - } - tags, err := agpl.ParseProvisionerTags(rawTags) if err != nil { return err @@ -112,9 +108,13 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(terraformClient), } srv := provisionerd.New(func(ctx context.Context) (provisionerdproto.DRPCProvisionerDaemonClient, error) { - return client.ServeProvisionerDaemon(ctx, org.ID, []codersdk.ProvisionerType{ - codersdk.ProvisionerTypeTerraform, - }, tags) + return client.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeTerraform, + }, + Tags: tags, + PreSharedKey: preSharedKey, + }) }, &provisionerd.Options{ Logger: logger, JobPollInterval: pollInterval, @@ -182,6 +182,12 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { Default: (100 * time.Millisecond).String(), Value: clibase.DurationOf(&pollJitter), }, + { + Flag: "psk", + Env: "CODER_PROVISIONER_DAEMON_PSK", + Description: "Pre-shared key to authenticate with Coder server.", + Value: clibase.StringOf(&preSharedKey), + }, } return cmd diff --git a/enterprise/cli/provisionerdaemons_test.go b/enterprise/cli/provisionerdaemons_test.go new file mode 100644 index 0000000000000..69b23d870757c --- /dev/null +++ b/enterprise/cli/provisionerdaemons_test.go @@ -0,0 +1,56 @@ +package cli_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/enterprise/coderd/license" + "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/testutil" +) + +func TestProvisionerDaemon_PSK(t *testing.T) { + t.Parallel() + + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + ProvisionerDaemonPSK: "provisionersftw", + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + inv, conf := newCLI(t, "provisionerd", "start", "--psk=provisionersftw") + err := conf.URL().Write(client.URL.String()) + require.NoError(t, err) + pty := ptytest.New(t).Attach(inv) + ctx, cancel := context.WithTimeout(inv.Context(), testutil.WaitLong) + defer cancel() + clitest.Start(t, inv) + pty.ExpectMatchContext(ctx, "starting provisioner daemon") +} + +func TestProvisionerDaemon_SessionToken(t *testing.T) { + t.Parallel() + + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + ProvisionerDaemonPSK: "provisionersftw", + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + inv, conf := newCLI(t, "provisionerd", "start") + clitest.SetupConfig(t, client, conf) + pty := ptytest.New(t).Attach(inv) + ctx, cancel := context.WithTimeout(inv.Context(), testutil.WaitLong) + defer cancel() + clitest.Start(t, inv) + pty.ExpectMatchContext(ctx, "starting provisioner daemon") +} diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index b0561a0de1850..70a06ff0548e4 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -66,6 +66,7 @@ func (r *RootCmd) server() *clibase.Cmd { DERPServerRegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()), ProxyHealthInterval: options.DeploymentValues.ProxyHealthStatusInterval.Value(), DefaultQuietHoursSchedule: options.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(), + ProvisionerDaemonPSK: options.DeploymentValues.Provisioner.DaemonPSK.Value(), } api, err := coderd.New(ctx, o) diff --git a/enterprise/cli/testdata/coder_provisionerd_start_--help.golden b/enterprise/cli/testdata/coder_provisionerd_start_--help.golden index 1236cfb5ae7e1..5258c33125173 100644 --- a/enterprise/cli/testdata/coder_provisionerd_start_--help.golden +++ b/enterprise/cli/testdata/coder_provisionerd_start_--help.golden @@ -12,6 +12,9 @@ Run a provisioner daemon --poll-jitter duration, $CODER_PROVISIONERD_POLL_JITTER (default: 100ms) How much to jitter the poll interval by. + --psk string, $CODER_PROVISIONER_DAEMON_PSK + Pre-shared key to authenticate with Coder server. + -t, --tag string-array, $CODER_PROVISIONERD_TAGS Tags to filter provisioner jobs by. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index cb7ca61b4913a..121ce98a98bd7 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -373,6 +373,10 @@ updating, and deleting workspace resources. --provisioner-daemon-poll-jitter duration, $CODER_PROVISIONER_DAEMON_POLL_JITTER (default: 100ms) Random jitter added to the poll interval. + --provisioner-daemon-psk string, $CODER_PROVISIONER_DAEMON_PSK + Pre-shared key to authenticate external provisioner daemons to Coder + server. + --provisioner-daemons int, $CODER_PROVISIONER_DAEMONS (default: 3) Number of provisioner daemons to create on start. If builds are stuck in queued state for a long time, consider increasing this. diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index c3cc5e0be5ccd..71d975e3ef1d6 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -67,6 +67,10 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { AGPL: coderd.New(options.Options), Options: options, + provisionerDaemonAuth: &provisionerDaemonAuth{ + psk: options.ProvisionerDaemonPSK, + authorizer: options.Authorizer, + }, } defer func() { if err != nil { @@ -193,14 +197,21 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/", api.groupByOrganization) }) }) + // TODO: provisioner daemons are not scoped to organizations in the database, so placing them + // under an organization route doesn't make sense. In order to allow the /serve endpoint to + // work with a pre-shared key (PSK) without an API key, these routes will simply ignore the + // value of {organization}. That is, the route will work with any organization ID, whether or + // not it exits. This doesn't leak any information about the existence of organizations, so is + // fine from a security perspective, but might be a little surprising. + // + // We may in future decide to scope provisioner daemons to organizations, so we'll keep the API + // route as is. r.Route("/organizations/{organization}/provisionerdaemons", func(r chi.Router) { r.Use( api.provisionerDaemonsEnabledMW, - apiKeyMiddleware, - httpmw.ExtractOrganizationParam(api.Database), ) - r.Get("/", api.provisionerDaemons) - r.Get("/serve", api.provisionerDaemonServe) + r.With(apiKeyMiddleware).Get("/", api.provisionerDaemons) + r.With(apiKeyMiddlewareOptional).Get("/serve", api.provisionerDaemonServe) }) r.Route("/templates/{template}/acl", func(r chi.Router) { r.Use( @@ -362,6 +373,9 @@ type Options struct { EntitlementsUpdateInterval time.Duration ProxyHealthInterval time.Duration Keys map[string]ed25519.PublicKey + + // optional pre-shared key for authentication of external provisioner daemons + ProvisionerDaemonPSK string } type API struct { @@ -383,6 +397,8 @@ type API struct { entitlementsUpdateMu sync.Mutex entitlementsMu sync.RWMutex entitlements codersdk.Entitlements + + provisionerDaemonAuth *provisionerDaemonAuth } func (api *API) Close() error { diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 92e0b627d60ae..64cb15c740fed 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -56,6 +56,7 @@ type Options struct { DontAddLicense bool DontAddFirstUser bool ReplicaSyncUpdateInterval time.Duration + ProvisionerDaemonPSK string } // New constructs a codersdk client connected to an in-memory Enterprise API instance. @@ -94,6 +95,7 @@ func NewWithAPI(t *testing.T, options *Options) ( Keys: Keys, ProxyHealthInterval: options.ProxyHealthInterval, DefaultQuietHoursSchedule: oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(), + ProvisionerDaemonPSK: options.ProvisionerDaemonPSK, }) require.NoError(t, err) setHandler(coderAPI.AGPL.RootHandler) diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 055704a6bcb11..1b3010d833200 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -2,6 +2,7 @@ package coderd import ( "context" + "crypto/subtle" "database/sql" "encoding/json" "errors" @@ -87,6 +88,40 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, apiDaemons) } +type provisionerDaemonAuth struct { + psk string + authorizer rbac.Authorizer +} + +// authorize returns mutated tags and true if the given HTTP request is authorized to access the provisioner daemon +// protobuf API, and returns nil, false otherwise. +func (p *provisionerDaemonAuth) authorize(r *http.Request, tags map[string]string) (map[string]string, bool) { + ctx := r.Context() + apiKey, ok := httpmw.APIKeyOptional(r) + if ok { + tags = provisionerdserver.MutateTags(apiKey.UserID, tags) + if tags[provisionerdserver.TagScope] == provisionerdserver.ScopeUser { + // Any authenticated user can create provisioner daemons scoped + // for jobs that they own, + return tags, true + } + ua := httpmw.UserAuthorization(r) + if err := p.authorizer.Authorize(ctx, ua.Actor, rbac.ActionCreate, rbac.ResourceProvisionerDaemon); err == nil { + // User is allowed to create provisioner daemons + return tags, true + } + } + + // Check for PSK + if p.psk != "" { + psk := r.Header.Get(codersdk.ProvisionerDaemonPSK) + if subtle.ConstantTimeCompare([]byte(p.psk), []byte(psk)) == 1 { + return tags, true + } + } + return nil, false +} + // Serves the provisioner daemon protobuf API over a WebSocket. // // @Summary Serve provisioner daemon @@ -134,19 +169,11 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) } } - // Any authenticated user can create provisioner daemons scoped - // for jobs that they own, but only authorized users can create - // globally scoped provisioners that attach to all jobs. - apiKey := httpmw.APIKey(r) - tags = provisionerdserver.MutateTags(apiKey.UserID, tags) - - if tags[provisionerdserver.TagScope] == provisionerdserver.ScopeOrganization { - if !api.AGPL.Authorize(r, rbac.ActionCreate, rbac.ResourceProvisionerDaemon) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "You aren't allowed to create provisioner daemons for the organization.", - }) - return - } + tags, authorized := api.provisionerDaemonAuth.authorize(r, tags) + if !authorized { + httpapi.Write(ctx, rw, http.StatusForbidden, + codersdk.Response{Message: "You aren't allowed to create provisioner daemons"}) + return } provisioners := make([]database.ProvisionerType, 0) diff --git a/enterprise/coderd/provisionerdaemons_test.go b/enterprise/coderd/provisionerdaemons_test.go index 28a89431b4f00..1586a92773e73 100644 --- a/enterprise/coderd/provisionerdaemons_test.go +++ b/enterprise/coderd/provisionerdaemons_test.go @@ -17,6 +17,7 @@ import ( "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/testutil" ) func TestProvisionerDaemonServe(t *testing.T) { @@ -28,23 +29,43 @@ func TestProvisionerDaemonServe(t *testing.T) { codersdk.FeatureExternalProvisionerDaemons: 1, }, }}) - srv, err := client.ServeProvisionerDaemon(context.Background(), user.OrganizationID, []codersdk.ProvisionerType{ - codersdk.ProvisionerTypeEcho, - }, map[string]string{}) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + srv, err := client.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{}, + }) require.NoError(t, err) srv.DRPCConn().Close() + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 1) }) t.Run("NoLicense", func(t *testing.T) { t.Parallel() client, user := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) - _, err := client.ServeProvisionerDaemon(context.Background(), user.OrganizationID, []codersdk.ProvisionerType{ - codersdk.ProvisionerTypeEcho, - }, map[string]string{}) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + _, err := client.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{}, + }) require.Error(t, err) var apiError *codersdk.Error require.ErrorAs(t, err, &apiError) require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + + // querying provisioner daemons is forbidden without license + _, err = client.ProvisionerDaemons(ctx) + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) }) t.Run("Organization", func(t *testing.T) { @@ -55,15 +76,24 @@ func TestProvisionerDaemonServe(t *testing.T) { }, }}) another, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleOrgAdmin(user.OrganizationID)) - _, err := another.ServeProvisionerDaemon(context.Background(), user.OrganizationID, []codersdk.ProvisionerType{ - codersdk.ProvisionerTypeEcho, - }, map[string]string{ - provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + _, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, }) require.Error(t, err) var apiError *codersdk.Error require.ErrorAs(t, err, &apiError) require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 0) }) t.Run("OrganizationNoPerms", func(t *testing.T) { @@ -74,15 +104,24 @@ func TestProvisionerDaemonServe(t *testing.T) { }, }}) another, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) - _, err := another.ServeProvisionerDaemon(context.Background(), user.OrganizationID, []codersdk.ProvisionerType{ - codersdk.ProvisionerTypeEcho, - }, map[string]string{ - provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + _, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, }) require.Error(t, err) var apiError *codersdk.Error require.ErrorAs(t, err, &apiError) require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 0) }) t.Run("UserLocal", func(t *testing.T) { @@ -141,4 +180,129 @@ func TestProvisionerDaemonServe(t *testing.T) { workspace := coderdtest.CreateWorkspace(t, another, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) }) + + t.Run("PSK", func(t *testing.T) { + t.Parallel() + client, user := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + ProvisionerDaemonPSK: "provisionersftw", + }) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + another := codersdk.New(client.URL) + srv, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, + PreSharedKey: "provisionersftw", + }) + require.NoError(t, err) + err = srv.DRPCConn().Close() + require.NoError(t, err) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 1) + }) + + t.Run("BadPSK", func(t *testing.T) { + t.Parallel() + client, user := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + ProvisionerDaemonPSK: "provisionersftw", + }) + another := codersdk.New(client.URL) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + _, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, + PreSharedKey: "the wrong key", + }) + require.Error(t, err) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 0) + }) + + t.Run("NoAuth", func(t *testing.T) { + t.Parallel() + client, user := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + ProvisionerDaemonPSK: "provisionersftw", + }) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + another := codersdk.New(client.URL) + _, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, + }) + require.Error(t, err) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 0) + }) + + t.Run("NoPSK", func(t *testing.T) { + t.Parallel() + client, user := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + another := codersdk.New(client.URL) + _, err := another.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ + Organization: user.OrganizationID, + Provisioners: []codersdk.ProvisionerType{ + codersdk.ProvisionerTypeEcho, + }, + Tags: map[string]string{ + provisionerdserver.TagScope: provisionerdserver.ScopeOrganization, + }, + PreSharedKey: "provisionersftw", + }) + require.Error(t, err) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusForbidden, apiError.StatusCode()) + daemons, err := client.ProvisionerDaemons(ctx) + require.NoError(t, err) + require.Len(t, daemons, 0) + }) } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fdfa3542dbf6a..f8b91abb925b6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -714,6 +714,7 @@ export interface ProvisionerConfig { readonly daemon_poll_interval: number readonly daemon_poll_jitter: number readonly force_cancel_interval: number + readonly daemon_psk: string } // From codersdk/provisionerdaemons.go From aff025e78c8e28ee460fb645e95c716a6ed88bb1 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 4 Aug 2023 11:25:24 +0100 Subject: [PATCH 006/277] chore(docs): fix link to helm values highlighting affinity (#8901) --- docs/admin/scale.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin/scale.md b/docs/admin/scale.md index 999a30aeae44a..5b5e2369f54fd 100644 --- a/docs/admin/scale.md +++ b/docs/admin/scale.md @@ -42,7 +42,7 @@ Users accessing workspaces via SSH will consume fewer resources, as SSH connecti Workspace builds are CPU-intensive, as it relies on Terraform. Various [Terraform providers](https://registry.terraform.io/browse/providers) have different resource requirements. When tested with our [kubernetes](https://github.com/coder/coder/tree/main/examples/templates/kubernetes) template, `coderd` will consume roughly 0.25 cores per concurrent workspace build. -For effective provisioning, our helm chart prefers to schedule [one coderd replica per-node](https://github.com/coder/coder/blob/main/helm/values.yaml#L110-L121). +For effective provisioning, our helm chart prefers to schedule [one coderd replica per-node](https://github.com/coder/coder/blob/main/helm/values.yaml#L188-L202). We recommend: From eae15c07893fa67ed29a95964cf7b31374b0c848 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 4 Aug 2023 06:47:15 -0500 Subject: [PATCH 007/277] chore(examples): bump envbuilder version (#8893) --- examples/templates/devcontainer-docker/main.tf | 4 +++- examples/templates/devcontainer-kubernetes/main.tf | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/templates/devcontainer-docker/main.tf b/examples/templates/devcontainer-docker/main.tf index 8769ff1f07078..5941b0c6b9450 100644 --- a/examples/templates/devcontainer-docker/main.tf +++ b/examples/templates/devcontainer-docker/main.tf @@ -206,7 +206,9 @@ data "coder_parameter" "custom_repo_url" { resource "docker_container" "workspace" { count = data.coder_workspace.me.start_count - image = "ghcr.io/coder/envbuilder:0.1.3" + # Find the latest version here: + # https://github.com/coder/envbuilder/tags + image = "ghcr.io/coder/envbuilder:0.2.1" # Uses lower() to avoid Docker restriction on container names. name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" # Hostname makes the shell more user friendly: coder@my-workspace:~$ diff --git a/examples/templates/devcontainer-kubernetes/main.tf b/examples/templates/devcontainer-kubernetes/main.tf index 65694c25989a9..58c183e5be173 100644 --- a/examples/templates/devcontainer-kubernetes/main.tf +++ b/examples/templates/devcontainer-kubernetes/main.tf @@ -187,8 +187,10 @@ resource "kubernetes_deployment" "workspace" { } spec { container { - name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" - image = "ghcr.io/coder/envbuilder:0.1.3" + name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" + # Find the latest version here: + # https://github.com/coder/envbuilder/tags + image = "ghcr.io/coder/envbuilder:0.2.1" env { name = "CODER_AGENT_TOKEN" value = coder_agent.main.token From 1c3ec8743c06a18fb3ac35cbf7d5095cd1d69e38 Mon Sep 17 00:00:00 2001 From: Eric Paulsen Date: Fri, 4 Aug 2023 08:40:48 -0400 Subject: [PATCH 008/277] docs: clean up k8s install steps and order (#8869) --- docs/install/kubernetes.md | 96 ++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index 191ef1aba0338..ade4b058c2dcf 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -7,14 +7,10 @@ to log in and manage templates. ## Install Coder with Helm -> **Warning**: Helm support is new and not yet complete. There may be changes -> to the Helm chart between releases which require manual values updates. Please -> file an issue if you run into any issues. - 1. Create a namespace for Coder, such as `coder`: ```console - $ kubectl create namespace coder + kubectl create namespace coder ``` 1. Create a PostgreSQL deployment. Coder does not manage a database server for @@ -57,12 +53,6 @@ to log in and manage templates. [Postgres operator](https://github.com/zalando/postgres-operator) to manage PostgreSQL deployments on your Kubernetes cluster. -1. Add the Coder Helm repo: - - ```console - helm repo add coder-v2 https://helm.coder.com/v2 - ``` - 1. Create a secret with the database URL: ```console @@ -72,6 +62,12 @@ to log in and manage templates. --from-literal=url="postgres://coder:coder@coder-db-postgresql.coder.svc.cluster.local:5432/coder?sslmode=disable" ``` +1. Add the Coder Helm repo: + + ```console + helm repo add coder-v2 https://helm.coder.com/v2 + ``` + 1. Create a `values.yaml` with the configuration settings you'd like for your deployment. For example: @@ -112,20 +108,52 @@ to log in and manage templates. > [values.yaml](https://github.com/coder/coder/blob/main/helm/values.yaml) > file directly. - If you are deploying Coder on AWS EKS and service is set to `LoadBalancer`, AWS will default to the Classic load balancer. The load balancer external IP will be stuck in a pending status unless sessionAffinity is set to None. +1. Run the following command to install the chart in your cluster. - ```yaml - coder: - service: - type: LoadBalancer - sessionAffinity: None + ```console + helm install coder coder-v2/coder \ + --namespace coder \ + --values values.yaml ``` + You can watch Coder start up by running `kubectl get pods -n coder`. Once Coder has + started, the `coder-*` pods should enter the `Running` state. + +1. Log in to Coder + + Use `kubectl get svc -n coder` to get the IP address of the + LoadBalancer. Visit this in the browser to set up your first account. + + If you do not have a domain, you should set `CODER_ACCESS_URL` + to this URL in the Helm chart and upgrade Coder (see below). + This allows workspaces to connect to the proper Coder URL. + +## Upgrading Coder via Helm + +To upgrade Coder in the future or change values, +you can run the following command: + +```console +helm repo update +helm upgrade coder coder-v2/coder \ + --namespace coder \ + -f values.yaml +``` + ## Load balancing considerations ### AWS -AWS however recommends a Network load balancer in lieu of the Classic load balancer. Use the following `values.yaml` settings to request a Network load balancer: +If you are deploying Coder on AWS EKS and service is set to `LoadBalancer`, AWS will default to the Classic load balancer. The load balancer external IP will be stuck in a pending status unless sessionAffinity is set to None. + +```yaml +coder: + service: + type: LoadBalancer + sessionAffinity: None +``` + +AWS recommends a Network load balancer in lieu of the Classic load balancer. Use the following `values.yaml` settings to request a Network load balancer: ```yaml coder: @@ -152,26 +180,6 @@ coder: value: 10.0.0.1/8 # this will be the CIDR range of your Load Balancer IP address ``` -1. Run the following command to install the chart in your cluster. - - ```console - helm install coder coder-v2/coder \ - --namespace coder \ - --values values.yaml - ``` - - You can watch Coder start up by running `kubectl get pods -n coder`. Once Coder has - started, the `coder-*` pods should enter the `Running` state. - -1. Log in to Coder - - Use `kubectl get svc -n coder` to get the IP address of the - LoadBalancer. Visit this in the browser to set up your first account. - - If you do not have a domain, you should set `CODER_ACCESS_URL` - to this URL in the Helm chart and upgrade Coder (see below). - This allows workspaces to connect to the proper Coder URL. - ### Azure In certain enterprise environments, the [Azure Application Gateway](https://learn.microsoft.com/en-us/azure/application-gateway/ingress-controller-overview) was needed. The Application Gateway supports: @@ -212,18 +220,6 @@ postgres://:@databasehost:/?sslmode=require&sslce > More information on connecting to PostgreSQL databases using certificates can be found [here](https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-CLIENTCERT). -## Upgrading Coder via Helm - -To upgrade Coder in the future or change values, -you can run the following command: - -```console -helm repo update -helm upgrade coder coder-v2/coder \ - --namespace coder \ - -f values.yaml -``` - ## Troubleshooting You can view Coder's logs by getting the pod name from `kubectl get pods` and then running `kubectl logs `. You can also From 5106dfde5219bccfb926ad8e1209d9d7b418bd69 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 4 Aug 2023 11:55:33 -0300 Subject: [PATCH 009/277] refactor(site): refactor the ports button design (#8879) * Refactor button * Move component to where it is used * Add base state of port forward component * Add stories and empty state * Add listening ports to handlers * Add review suggestions * Fix minor thing --- .../PortForwardButton/PortForwardButton.tsx | 211 -------------- site/src/components/Resources/AgentRow.tsx | 2 +- .../Resources/PortForwardButton.stories.tsx | 38 +++ .../Resources/PortForwardButton.tsx | 272 ++++++++++++++++++ .../Tooltips/HelpTooltip/HelpTooltip.tsx | 14 +- site/src/pages/TerminalPage/TerminalPage.tsx | 2 +- site/src/testHelpers/entities.ts | 9 + site/src/testHelpers/handlers.ts | 4 + site/src/utils/portForward.ts | 14 + .../portForward/portForwardXService.ts | 47 --- 10 files changed, 349 insertions(+), 264 deletions(-) delete mode 100644 site/src/components/PortForwardButton/PortForwardButton.tsx create mode 100644 site/src/components/Resources/PortForwardButton.stories.tsx create mode 100644 site/src/components/Resources/PortForwardButton.tsx create mode 100644 site/src/utils/portForward.ts delete mode 100644 site/src/xServices/portForward/portForwardXService.ts diff --git a/site/src/components/PortForwardButton/PortForwardButton.tsx b/site/src/components/PortForwardButton/PortForwardButton.tsx deleted file mode 100644 index 5c8b4943f8b60..0000000000000 --- a/site/src/components/PortForwardButton/PortForwardButton.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import Button from "@mui/material/Button" -import Link from "@mui/material/Link" -import Popover from "@mui/material/Popover" -import { makeStyles } from "@mui/styles" -import TextField from "@mui/material/TextField" -import { Stack } from "components/Stack/Stack" -import { useRef, useState, Fragment } from "react" -import { colors } from "theme/colors" -import { CodeExample } from "../CodeExample/CodeExample" -import { - HelpTooltipLink, - HelpTooltipLinksGroup, - HelpTooltipText, - HelpTooltipTitle, -} from "../Tooltips/HelpTooltip" -import { Maybe } from "components/Conditionals/Maybe" -import { useMachine } from "@xstate/react" -import { portForwardMachine } from "xServices/portForward/portForwardXService" -import { SecondaryAgentButton } from "components/Resources/AgentButton" -import { docs } from "utils/docs" - -export interface PortForwardButtonProps { - host: string - username: string - workspaceName: string - agentName: string - agentId: string -} - -export const portForwardURL = ( - host: string, - port: number, - agentName: string, - workspaceName: string, - username: string, -): string => { - const { location } = window - - const subdomain = `${ - isNaN(port) ? 3000 : port - }--${agentName}--${workspaceName}--${username}` - return `${location.protocol}//${host}`.replace("*", subdomain) -} - -const TooltipView: React.FC = (props) => { - const { host, workspaceName, agentName, agentId, username } = props - - const styles = useStyles() - const [port, setPort] = useState("3000") - const urlExample = portForwardURL( - host, - parseInt(port), - agentName, - workspaceName, - username, - ) - - const [state] = useMachine(portForwardMachine, { - context: { agentId: agentId }, - }) - const ports = state.context.listeningPorts?.ports - - return ( - <> - - Access ports running on the agent with the{" "} - port, agent name, workspace name and{" "} - your username URL schema, as shown below. Port URLs are - only accessible by you. - - - - - - Use the form to open applications in a new tab. - - - - { - setPort(e.currentTarget.value) - }} - /> - - - - - - 0)}> - - {ports && - ports.map((p, i) => { - const url = portForwardURL( - host, - p.port, - agentName, - workspaceName, - username, - ) - let label = `${p.port}` - if (p.process_name) { - label = `${p.process_name} - ${p.port}` - } - - return ( - - {i > 0 && ·} - - {label} - - - ) - })} - - - - - - Learn more about web port forwarding - - - - ) -} - -export const PortForwardButton: React.FC = (props) => { - const anchorRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - const id = isOpen ? "schedule-popover" : undefined - const styles = useStyles() - - const onClose = () => { - setIsOpen(false) - } - - return ( - <> - { - setIsOpen(true) - }} - > - Port forward - - - Port forward - - - - ) -} - -const useStyles = makeStyles((theme) => ({ - popoverPaper: { - padding: `${theme.spacing(2.5)} ${theme.spacing(3.5)} ${theme.spacing( - 3.5, - )}`, - width: theme.spacing(52), - color: theme.palette.text.secondary, - marginTop: theme.spacing(0.25), - }, - - openUrlButton: { - flexShrink: 0, - }, - - portField: { - // The default border don't contrast well with the popover - "& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": { - borderColor: colors.gray[10], - }, - }, - - code: { - margin: theme.spacing(2, 0), - }, - - form: { - margin: theme.spacing(2, 0), - }, -})) diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index f26c8b8fbdd5c..320f6cc11007d 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -8,7 +8,7 @@ import { OpenDropdown, } from "components/DropdownArrows/DropdownArrows" import { LogLine, logLineHeight } from "components/Logs/Logs" -import { PortForwardButton } from "components/PortForwardButton/PortForwardButton" +import { PortForwardButton } from "./PortForwardButton" import { VSCodeDesktopButton } from "components/VSCodeDesktopButton/VSCodeDesktopButton" import { FC, diff --git a/site/src/components/Resources/PortForwardButton.stories.tsx b/site/src/components/Resources/PortForwardButton.stories.tsx new file mode 100644 index 0000000000000..46014bcca71cb --- /dev/null +++ b/site/src/components/Resources/PortForwardButton.stories.tsx @@ -0,0 +1,38 @@ +import Box from "@mui/material/Box" +import { PortForwardPopoverView } from "./PortForwardButton" +import type { Meta, StoryObj } from "@storybook/react" +import { MockListeningPortsResponse } from "testHelpers/entities" + +const meta: Meta = { + title: "components/PortForwardPopoverView", + component: PortForwardPopoverView, + decorators: [ + (Story) => ( + theme.spacing(38), + border: (theme) => `1px solid ${theme.palette.divider}`, + borderRadius: 1, + backgroundColor: (theme) => theme.palette.background.paper, + }} + > + + + ), + ], +} + +export default meta +type Story = StoryObj + +export const WithPorts: Story = { + args: { + ports: MockListeningPortsResponse.ports, + }, +} + +export const Empty: Story = { + args: { + ports: [], + }, +} diff --git a/site/src/components/Resources/PortForwardButton.tsx b/site/src/components/Resources/PortForwardButton.tsx new file mode 100644 index 0000000000000..2e9089649e808 --- /dev/null +++ b/site/src/components/Resources/PortForwardButton.tsx @@ -0,0 +1,272 @@ +import Link from "@mui/material/Link" +import Popover from "@mui/material/Popover" +import { makeStyles } from "@mui/styles" +import { useRef, useState } from "react" +import { colors } from "theme/colors" +import { + HelpTooltipLink, + HelpTooltipLinksGroup, + HelpTooltipText, + HelpTooltipTitle, +} from "../Tooltips/HelpTooltip" +import { SecondaryAgentButton } from "components/Resources/AgentButton" +import { docs } from "utils/docs" +import Box from "@mui/material/Box" +import { useQuery } from "@tanstack/react-query" +import { getAgentListeningPorts } from "api/api" +import { WorkspaceAgentListeningPort } from "api/typesGenerated" +import CircularProgress from "@mui/material/CircularProgress" +import { portForwardURL } from "utils/portForward" +import { MockListeningPortsResponse } from "testHelpers/entities" +import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined" + +export interface PortForwardButtonProps { + host: string + username: string + workspaceName: string + agentName: string + agentId: string +} + +export const PortForwardButton: React.FC = (props) => { + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const id = isOpen ? "schedule-popover" : undefined + const styles = useStyles() + const { data: listeningPorts } = useQuery({ + queryKey: ["portForward", props.agentId], + queryFn: () => getAgentListeningPorts(props.agentId), + initialData: MockListeningPortsResponse, + }) + + const onClose = () => { + setIsOpen(false) + } + + return ( + <> + { + setIsOpen(true) + }} + > + Ports + {listeningPorts ? ( + theme.spacing(0, 1), + borderRadius: 7, + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.gray[11], + ml: 1, + }} + > + {listeningPorts.ports.length} + + ) : ( + + )} + + + + + + ) +} + +export const PortForwardPopoverView: React.FC< + PortForwardButtonProps & { ports?: WorkspaceAgentListeningPort[] } +> = (props) => { + const { host, workspaceName, agentName, username, ports } = props + + return ( + <> + theme.spacing(2.5), + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + Forwarded ports + theme.palette.text.secondary }} + > + {ports?.length === 0 + ? "No open ports were detected." + : "The forwarded ports are exclusively accessible to you."} + + theme.spacing(1.5) }}> + {ports?.map((p) => { + const url = portForwardURL( + host, + p.port, + agentName, + workspaceName, + username, + ) + const label = p.process_name !== "" ? p.process_name : p.port + return ( + theme.palette.text.primary, + fontSize: 14, + display: "flex", + alignItems: "center", + gap: 1, + py: 0.5, + fontWeight: 500, + }} + key={p.port} + href={url} + target="_blank" + rel="noreferrer" + > + + {label} + theme.palette.text.secondary, + fontSize: 13, + fontWeight: 400, + }} + > + {p.port} + + + ) + })} + + + + theme.spacing(2.5), + }} + > + Forward port + theme.palette.text.secondary }} + > + Access ports running on the agent: + + + `1px solid ${theme.palette.divider}`, + borderRadius: "4px", + mt: 2, + display: "flex", + alignItems: "center", + "&:focus-within": { + borderColor: (theme) => theme.palette.primary.main, + }, + }} + onSubmit={(e) => { + e.preventDefault() + const formData = new FormData(e.currentTarget) + const port = Number(formData.get("portNumber")) + const url = portForwardURL( + host, + port, + agentName, + workspaceName, + username, + ) + window.open(url, "_blank") + }} + > + theme.spacing(0, 1.5), + background: "none", + border: 0, + outline: "none", + color: (theme) => theme.palette.text.primary, + appearance: "textfield", + display: "block", + width: "100%", + }} + /> + theme.spacing(1.5), + color: (theme) => theme.palette.text.primary, + }} + /> + + + + + Learn more + + + + + ) +} + +const useStyles = makeStyles((theme) => ({ + popoverPaper: { + padding: 0, + width: theme.spacing(38), + color: theme.palette.text.secondary, + marginTop: theme.spacing(0.25), + }, + + openUrlButton: { + flexShrink: 0, + }, + + portField: { + // The default border don't contrast well with the popover + "& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": { + borderColor: colors.gray[10], + }, + }, + + code: { + margin: theme.spacing(2, 0), + }, + + form: { + margin: theme.spacing(2, 0), + }, +})) diff --git a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx index 114d31234c48b..b248450667266 100644 --- a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx +++ b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx @@ -13,6 +13,7 @@ import { } from "react" import { combineClasses } from "utils/combineClasses" import { Stack } from "../../Stack/Stack" +import Box, { BoxProps } from "@mui/material/Box" type Icon = typeof HelpIcon @@ -131,12 +132,16 @@ export const HelpTooltipTitle: FC> = ({ return

{children}

} -export const HelpTooltipText: FC> = ({ - children, -}) => { +export const HelpTooltipText = (props: BoxProps) => { const styles = useStyles({}) - return

{children}

+ return ( + + ) } export const HelpTooltipLink: FC> = ({ @@ -257,6 +262,7 @@ const useStyles = makeStyles((theme) => ({ marginBottom: theme.spacing(1), color: theme.palette.text.primary, fontSize: 14, + lineHeight: "120%", fontWeight: 600, }, diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 4352ee40c0991..a09f289da9805 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -1,6 +1,5 @@ import { makeStyles, useTheme } from "@mui/styles" import { useMachine } from "@xstate/react" -import { portForwardURL } from "components/PortForwardButton/PortForwardButton" import { Stack } from "components/Stack/Stack" import { FC, useCallback, useEffect, useRef, useState } from "react" import { Helmet } from "react-helmet-async" @@ -23,6 +22,7 @@ import { getLatencyColor } from "utils/latency" import Popover from "@mui/material/Popover" import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency" import TerminalPageAlert, { TerminalPageAlertType } from "./TerminalPageAlert" +import { portForwardURL } from "utils/portForward" export const Language = { workspaceErrorMessagePrefix: "Unable to fetch workspace: ", diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8b208548018ed..3d28ae7ad1ffb 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2281,3 +2281,12 @@ export const MockHealth = { }, coder_version: "v0.27.1-devel+c575292", } + +export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse = + { + ports: [ + { process_name: "web", network: "", port: 3000 }, + { process_name: "go", network: "", port: 8080 }, + { process_name: "", network: "", port: 8081 }, + ], + } diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 9d90e09724c37..8c379985caa4b 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -391,4 +391,8 @@ export const handlers = [ rest.get("/api/v2/debug/health", (_, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockHealth)) }), + + rest.get("/api/v2/workspaceagents/:agent/listening-ports", (_, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockListeningPortsResponse)) + }), ] diff --git a/site/src/utils/portForward.ts b/site/src/utils/portForward.ts new file mode 100644 index 0000000000000..ce03d54dc47bc --- /dev/null +++ b/site/src/utils/portForward.ts @@ -0,0 +1,14 @@ +export const portForwardURL = ( + host: string, + port: number, + agentName: string, + workspaceName: string, + username: string, +): string => { + const { location } = window + + const subdomain = `${ + isNaN(port) ? 3000 : port + }--${agentName}--${workspaceName}--${username}` + return `${location.protocol}//${host}`.replace("*", subdomain) +} diff --git a/site/src/xServices/portForward/portForwardXService.ts b/site/src/xServices/portForward/portForwardXService.ts deleted file mode 100644 index cd908155dbcc1..0000000000000 --- a/site/src/xServices/portForward/portForwardXService.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getAgentListeningPorts } from "api/api" -import { WorkspaceAgentListeningPortsResponse } from "api/typesGenerated" -import { createMachine, assign } from "xstate" - -export const portForwardMachine = createMachine( - { - predictableActionArguments: true, - id: "portForwardMachine", - schema: { - context: {} as { - agentId: string - listeningPorts?: WorkspaceAgentListeningPortsResponse - }, - services: {} as { - getListeningPorts: { - data: WorkspaceAgentListeningPortsResponse - } - }, - }, - tsTypes: {} as import("./portForwardXService.typegen").Typegen0, - initial: "loading", - states: { - loading: { - invoke: { - src: "getListeningPorts", - onDone: { - target: "success", - actions: ["assignListeningPorts"], - }, - }, - }, - success: { - type: "final", - }, - }, - }, - { - services: { - getListeningPorts: ({ agentId }) => getAgentListeningPorts(agentId), - }, - actions: { - assignListeningPorts: assign({ - listeningPorts: (_, { data }) => data, - }), - }, - }, -) From 607cd11724c492171a511c03c1384bba6cd52469 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 4 Aug 2023 16:06:28 +0100 Subject: [PATCH 010/277] fix(cli): address race condition in scaletest_test output (#8902) --- cli/exp_scaletest_test.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/cli/exp_scaletest_test.go b/cli/exp_scaletest_test.go index 4c10b722ca357..a70fba0443b10 100644 --- a/cli/exp_scaletest_test.go +++ b/cli/exp_scaletest_test.go @@ -1,7 +1,6 @@ package cli_test import ( - "bytes" "context" "path/filepath" "testing" @@ -72,9 +71,10 @@ func TestScaleTestWorkspaceTraffic(t *testing.T) { "--ssh", ) clitest.SetupConfig(t, client, root) - var stdout, stderr bytes.Buffer - inv.Stdout = &stdout - inv.Stderr = &stderr + pty := ptytest.New(t) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + err := inv.WithContext(ctx).Run() require.ErrorContains(t, err, "no scaletest workspaces exist") } @@ -98,9 +98,10 @@ func TestScaleTestDashboard(t *testing.T) { "--scaletest-prometheus-wait", "0s", ) clitest.SetupConfig(t, client, root) - var stdout, stderr bytes.Buffer - inv.Stdout = &stdout - inv.Stderr = &stderr + pty := ptytest.New(t) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + err := inv.WithContext(ctx).Run() require.NoError(t, err, "") } From ae88b79fd7074f9ac670568a8f19dc3c22dd254c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 4 Aug 2023 16:15:33 +0100 Subject: [PATCH 011/277] fix(cli): stat: set --host arg in TestStatCPUCmd to avoid test flakes in containers (#8806) --- cli/stat_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/stat_test.go b/cli/stat_test.go index d92574e339b89..7b37a98ce9113 100644 --- a/cli/stat_test.go +++ b/cli/stat_test.go @@ -74,7 +74,7 @@ func TestStatCPUCmd(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) t.Cleanup(cancel) - inv, _ := clitest.New(t, "stat", "cpu", "--output=text") + inv, _ := clitest.New(t, "stat", "cpu", "--output=text", "--host") buf := new(bytes.Buffer) inv.Stdout = buf err := inv.WithContext(ctx).Run() @@ -87,7 +87,7 @@ func TestStatCPUCmd(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) t.Cleanup(cancel) - inv, _ := clitest.New(t, "stat", "cpu", "--output=json") + inv, _ := clitest.New(t, "stat", "cpu", "--output=json", "--host") buf := new(bytes.Buffer) inv.Stdout = buf err := inv.WithContext(ctx).Run() From 9fb18f3ae5c0024474439cf9de6795a2d3e7a125 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 4 Aug 2023 17:00:42 +0100 Subject: [PATCH 012/277] feat(coderd): batch agent stats inserts (#8875) This PR adds support for batching inserts to the workspace_agents_stats table. Up to 1024 stats are batched, and flushed every second in a batch. --- cli/server.go | 11 + coderd/batchstats/batcher.go | 289 ++++++++++++++++++ coderd/batchstats/batcher_internal_test.go | 226 ++++++++++++++ coderd/coderd.go | 12 + coderd/coderdtest/coderdtest.go | 18 +- coderd/database/dbauthz/dbauthz.go | 8 + coderd/database/dbfake/dbfake.go | 51 +++- coderd/database/dbmetrics/dbmetrics.go | 7 + coderd/database/dbmock/dbmock.go | 14 + coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 84 +++++ .../database/queries/workspaceagentstats.sql | 40 +++ .../prometheusmetrics_test.go | 31 +- coderd/workspaceagents.go | 28 +- 14 files changed, 785 insertions(+), 35 deletions(-) create mode 100644 coderd/batchstats/batcher.go create mode 100644 coderd/batchstats/batcher_internal_test.go diff --git a/cli/server.go b/cli/server.go index 170b7c5eb9f00..15cefb364ce3e 100644 --- a/cli/server.go +++ b/cli/server.go @@ -63,6 +63,7 @@ import ( "github.com/coder/coder/cli/config" "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/autobuild" + "github.com/coder/coder/coderd/batchstats" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbfake" "github.com/coder/coder/coderd/database/dbmetrics" @@ -813,6 +814,16 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.SwaggerEndpoint = cfg.Swagger.Enable.Value() } + batcher, closeBatcher, err := batchstats.New(ctx, + batchstats.WithLogger(options.Logger.Named("batchstats")), + batchstats.WithStore(options.Database), + ) + if err != nil { + return xerrors.Errorf("failed to create agent stats batcher: %w", err) + } + options.StatsBatcher = batcher + defer closeBatcher() + closeCheckInactiveUsersFunc := dormancy.CheckInactiveUsers(ctx, logger, options.Database) defer closeCheckInactiveUsersFunc() diff --git a/coderd/batchstats/batcher.go b/coderd/batchstats/batcher.go new file mode 100644 index 0000000000000..fc177fd143d6a --- /dev/null +++ b/coderd/batchstats/batcher.go @@ -0,0 +1,289 @@ +package batchstats + +import ( + "context" + "encoding/json" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbauthz" + "github.com/coder/coder/codersdk/agentsdk" +) + +const ( + defaultBufferSize = 1024 + defaultFlushInterval = time.Second +) + +// Batcher holds a buffer of agent stats and periodically flushes them to +// its configured store. It also updates the workspace's last used time. +type Batcher struct { + store database.Store + log slog.Logger + + mu sync.Mutex + // TODO: make this a buffered chan instead? + buf *database.InsertWorkspaceAgentStatsParams + // NOTE: we batch this separately as it's a jsonb field and + // pq.Array + unnest doesn't play nicely with this. + connectionsByProto []map[string]int64 + batchSize int + + // tickCh is used to periodically flush the buffer. + tickCh <-chan time.Time + ticker *time.Ticker + interval time.Duration + // flushLever is used to signal the flusher to flush the buffer immediately. + flushLever chan struct{} + flushForced atomic.Bool + // flushed is used during testing to signal that a flush has completed. + flushed chan<- int +} + +// Option is a functional option for configuring a Batcher. +type Option func(b *Batcher) + +// WithStore sets the store to use for storing stats. +func WithStore(store database.Store) Option { + return func(b *Batcher) { + b.store = store + } +} + +// WithBatchSize sets the number of stats to store in a batch. +func WithBatchSize(size int) Option { + return func(b *Batcher) { + b.batchSize = size + } +} + +// WithInterval sets the interval for flushes. +func WithInterval(d time.Duration) Option { + return func(b *Batcher) { + b.interval = d + } +} + +// WithLogger sets the logger to use for logging. +func WithLogger(log slog.Logger) Option { + return func(b *Batcher) { + b.log = log + } +} + +// New creates a new Batcher and starts it. +func New(ctx context.Context, opts ...Option) (*Batcher, func(), error) { + b := &Batcher{} + b.log = slog.Make(sloghuman.Sink(os.Stderr)) + b.flushLever = make(chan struct{}, 1) // Buffered so that it doesn't block. + for _, opt := range opts { + opt(b) + } + + if b.store == nil { + return nil, nil, xerrors.Errorf("no store configured for batcher") + } + + if b.interval == 0 { + b.interval = defaultFlushInterval + } + + if b.batchSize == 0 { + b.batchSize = defaultBufferSize + } + + if b.tickCh == nil { + b.ticker = time.NewTicker(b.interval) + b.tickCh = b.ticker.C + } + + cancelCtx, cancelFunc := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { + b.run(cancelCtx) + close(done) + }() + + closer := func() { + cancelFunc() + if b.ticker != nil { + b.ticker.Stop() + } + <-done + } + + return b, closer, nil +} + +// Add adds a stat to the batcher for the given workspace and agent. +func (b *Batcher) Add( + agentID uuid.UUID, + templateID uuid.UUID, + userID uuid.UUID, + workspaceID uuid.UUID, + st agentsdk.Stats, +) error { + b.mu.Lock() + defer b.mu.Unlock() + + now := database.Now() + + b.buf.ID = append(b.buf.ID, uuid.New()) + b.buf.CreatedAt = append(b.buf.CreatedAt, now) + b.buf.AgentID = append(b.buf.AgentID, agentID) + b.buf.UserID = append(b.buf.UserID, userID) + b.buf.TemplateID = append(b.buf.TemplateID, templateID) + b.buf.WorkspaceID = append(b.buf.WorkspaceID, workspaceID) + + // Store the connections by proto separately as it's a jsonb field. We marshal on flush. + // b.buf.ConnectionsByProto = append(b.buf.ConnectionsByProto, st.ConnectionsByProto) + b.connectionsByProto = append(b.connectionsByProto, st.ConnectionsByProto) + + b.buf.ConnectionCount = append(b.buf.ConnectionCount, st.ConnectionCount) + b.buf.RxPackets = append(b.buf.RxPackets, st.RxPackets) + b.buf.RxBytes = append(b.buf.RxBytes, st.RxBytes) + b.buf.TxPackets = append(b.buf.TxPackets, st.TxPackets) + b.buf.TxBytes = append(b.buf.TxBytes, st.TxBytes) + b.buf.SessionCountVSCode = append(b.buf.SessionCountVSCode, st.SessionCountVSCode) + b.buf.SessionCountJetBrains = append(b.buf.SessionCountJetBrains, st.SessionCountJetBrains) + b.buf.SessionCountReconnectingPTY = append(b.buf.SessionCountReconnectingPTY, st.SessionCountReconnectingPTY) + b.buf.SessionCountSSH = append(b.buf.SessionCountSSH, st.SessionCountSSH) + b.buf.ConnectionMedianLatencyMS = append(b.buf.ConnectionMedianLatencyMS, st.ConnectionMedianLatencyMS) + + // If the buffer is over 80% full, signal the flusher to flush immediately. + // We want to trigger flushes early to reduce the likelihood of + // accidentally growing the buffer over batchSize. + filled := float64(len(b.buf.ID)) / float64(b.batchSize) + if filled >= 0.8 && !b.flushForced.Load() { + b.flushLever <- struct{}{} + b.flushForced.Store(true) + } + return nil +} + +// Run runs the batcher. +func (b *Batcher) run(ctx context.Context) { + b.initBuf(b.batchSize) + // nolint:gocritic // This is only ever used for one thing - inserting agent stats. + authCtx := dbauthz.AsSystemRestricted(ctx) + for { + select { + case <-b.tickCh: + b.flush(authCtx, false, "scheduled") + case <-b.flushLever: + // If the flush lever is depressed, flush the buffer immediately. + b.flush(authCtx, true, "reaching capacity") + case <-ctx.Done(): + b.log.Warn(ctx, "context done, flushing before exit") + b.flush(authCtx, true, "exit") + return + } + } +} + +// flush flushes the batcher's buffer. +func (b *Batcher) flush(ctx context.Context, forced bool, reason string) { + b.mu.Lock() + b.flushForced.Store(true) + start := time.Now() + count := len(b.buf.ID) + defer func() { + b.flushForced.Store(false) + b.mu.Unlock() + // Notify that a flush has completed. This only happens in tests. + if b.flushed != nil { + select { + case <-ctx.Done(): + close(b.flushed) + default: + b.flushed <- count + } + } + if count > 0 { + elapsed := time.Since(start) + b.log.Debug(ctx, "flush complete", + slog.F("count", count), + slog.F("elapsed", elapsed), + slog.F("forced", forced), + slog.F("reason", reason), + ) + } + }() + + if len(b.buf.ID) == 0 { + return + } + + // marshal connections by proto + payload, err := json.Marshal(b.connectionsByProto) + if err != nil { + b.log.Error(ctx, "unable to marshal agent connections by proto, dropping data", slog.Error(err)) + b.buf.ConnectionsByProto = json.RawMessage(`[]`) + } else { + b.buf.ConnectionsByProto = payload + } + + err = b.store.InsertWorkspaceAgentStats(ctx, *b.buf) + elapsed := time.Since(start) + if err != nil { + b.log.Error(ctx, "error inserting workspace agent stats", slog.Error(err), slog.F("elapsed", elapsed)) + return + } + + b.resetBuf() +} + +// initBuf resets the buffer. b MUST be locked. +func (b *Batcher) initBuf(size int) { + b.buf = &database.InsertWorkspaceAgentStatsParams{ + ID: make([]uuid.UUID, 0, b.batchSize), + CreatedAt: make([]time.Time, 0, b.batchSize), + UserID: make([]uuid.UUID, 0, b.batchSize), + WorkspaceID: make([]uuid.UUID, 0, b.batchSize), + TemplateID: make([]uuid.UUID, 0, b.batchSize), + AgentID: make([]uuid.UUID, 0, b.batchSize), + ConnectionsByProto: json.RawMessage("[]"), + ConnectionCount: make([]int64, 0, b.batchSize), + RxPackets: make([]int64, 0, b.batchSize), + RxBytes: make([]int64, 0, b.batchSize), + TxPackets: make([]int64, 0, b.batchSize), + TxBytes: make([]int64, 0, b.batchSize), + SessionCountVSCode: make([]int64, 0, b.batchSize), + SessionCountJetBrains: make([]int64, 0, b.batchSize), + SessionCountReconnectingPTY: make([]int64, 0, b.batchSize), + SessionCountSSH: make([]int64, 0, b.batchSize), + ConnectionMedianLatencyMS: make([]float64, 0, b.batchSize), + } + + b.connectionsByProto = make([]map[string]int64, 0, size) +} + +func (b *Batcher) resetBuf() { + b.buf.ID = b.buf.ID[:0] + b.buf.CreatedAt = b.buf.CreatedAt[:0] + b.buf.UserID = b.buf.UserID[:0] + b.buf.WorkspaceID = b.buf.WorkspaceID[:0] + b.buf.TemplateID = b.buf.TemplateID[:0] + b.buf.AgentID = b.buf.AgentID[:0] + b.buf.ConnectionsByProto = json.RawMessage(`[]`) + b.buf.ConnectionCount = b.buf.ConnectionCount[:0] + b.buf.RxPackets = b.buf.RxPackets[:0] + b.buf.RxBytes = b.buf.RxBytes[:0] + b.buf.TxPackets = b.buf.TxPackets[:0] + b.buf.TxBytes = b.buf.TxBytes[:0] + b.buf.SessionCountVSCode = b.buf.SessionCountVSCode[:0] + b.buf.SessionCountJetBrains = b.buf.SessionCountJetBrains[:0] + b.buf.SessionCountReconnectingPTY = b.buf.SessionCountReconnectingPTY[:0] + b.buf.SessionCountSSH = b.buf.SessionCountSSH[:0] + b.buf.ConnectionMedianLatencyMS = b.buf.ConnectionMedianLatencyMS[:0] + b.connectionsByProto = b.connectionsByProto[:0] +} diff --git a/coderd/batchstats/batcher_internal_test.go b/coderd/batchstats/batcher_internal_test.go new file mode 100644 index 0000000000000..a6e28f1a9f389 --- /dev/null +++ b/coderd/batchstats/batcher_internal_test.go @@ -0,0 +1,226 @@ +package batchstats + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbgen" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk/agentsdk" + "github.com/coder/coder/cryptorand" +) + +func TestBatchStats(t *testing.T) { + t.Parallel() + + // Given: a fresh batcher with no data + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + store, _ := dbtestutil.NewDB(t) + + // Set up some test dependencies. + deps1 := setupDeps(t, store) + deps2 := setupDeps(t, store) + tick := make(chan time.Time) + flushed := make(chan int) + + b, closer, err := New(ctx, + WithStore(store), + WithLogger(log), + func(b *Batcher) { + b.tickCh = tick + b.flushed = flushed + }, + ) + require.NoError(t, err) + t.Cleanup(closer) + + // Given: no data points are added for workspace + // When: it becomes time to report stats + t1 := time.Now() + // Signal a tick and wait for a flush to complete. + tick <- t1 + f := <-flushed + require.Equal(t, 0, f, "expected no data to be flushed") + t.Logf("flush 1 completed") + + // Then: it should report no stats. + stats, err := store.GetWorkspaceAgentStats(ctx, t1) + require.NoError(t, err, "should not error getting stats") + require.Empty(t, stats, "should have no stats for workspace") + + // Given: a single data point is added for workspace + t2 := time.Now() + t.Logf("inserting 1 stat") + require.NoError(t, b.Add(deps1.Agent.ID, deps1.User.ID, deps1.Template.ID, deps1.Workspace.ID, randAgentSDKStats(t))) + + // When: it becomes time to report stats + // Signal a tick and wait for a flush to complete. + tick <- t2 + f = <-flushed // Wait for a flush to complete. + require.Equal(t, 1, f, "expected one stat to be flushed") + t.Logf("flush 2 completed") + + // Then: it should report a single stat. + stats, err = store.GetWorkspaceAgentStats(ctx, t2) + require.NoError(t, err, "should not error getting stats") + require.Len(t, stats, 1, "should have stats for workspace") + + // Given: a lot of data points are added for both workspaces + // (equal to batch size) + t3 := time.Now() + done := make(chan struct{}) + + go func() { + defer close(done) + t.Logf("inserting %d stats", defaultBufferSize) + for i := 0; i < defaultBufferSize; i++ { + if i%2 == 0 { + require.NoError(t, b.Add(deps1.Agent.ID, deps1.User.ID, deps1.Template.ID, deps1.Workspace.ID, randAgentSDKStats(t))) + } else { + require.NoError(t, b.Add(deps2.Agent.ID, deps2.User.ID, deps2.Template.ID, deps2.Workspace.ID, randAgentSDKStats(t))) + } + } + }() + + // When: the buffer comes close to capacity + // Then: The buffer will force-flush once. + f = <-flushed + t.Logf("flush 3 completed") + require.Greater(t, f, 819, "expected at least 819 stats to be flushed (>=80% of buffer)") + // And we should finish inserting the stats + <-done + + stats, err = store.GetWorkspaceAgentStats(ctx, t3) + require.NoError(t, err, "should not error getting stats") + require.Len(t, stats, 2, "should have stats for both workspaces") + + // Ensures that a subsequent flush pushes all the remaining data + t4 := time.Now() + tick <- t4 + f2 := <-flushed + t.Logf("flush 4 completed") + expectedCount := defaultBufferSize - f + require.Equal(t, expectedCount, f2, "did not flush expected remaining rows") + + // Ensure that a subsequent flush does not push stale data. + t5 := time.Now() + tick <- t5 + f = <-flushed + require.Zero(t, f, "expected zero stats to have been flushed") + t.Logf("flush 5 completed") + + stats, err = store.GetWorkspaceAgentStats(ctx, t5) + require.NoError(t, err, "should not error getting stats") + require.Len(t, stats, 0, "should have no stats for workspace") + + // Ensure that buf never grew beyond what we expect + require.Equal(t, defaultBufferSize, cap(b.buf.ID), "buffer grew beyond expected capacity") +} + +// randAgentSDKStats returns a random agentsdk.Stats +func randAgentSDKStats(t *testing.T, opts ...func(*agentsdk.Stats)) agentsdk.Stats { + t.Helper() + s := agentsdk.Stats{ + ConnectionsByProto: map[string]int64{ + "ssh": mustRandInt64n(t, 9) + 1, + "vscode": mustRandInt64n(t, 9) + 1, + "jetbrains": mustRandInt64n(t, 9) + 1, + "reconnecting_pty": mustRandInt64n(t, 9) + 1, + }, + ConnectionCount: mustRandInt64n(t, 99) + 1, + ConnectionMedianLatencyMS: float64(mustRandInt64n(t, 99) + 1), + RxPackets: mustRandInt64n(t, 99) + 1, + RxBytes: mustRandInt64n(t, 99) + 1, + TxPackets: mustRandInt64n(t, 99) + 1, + TxBytes: mustRandInt64n(t, 99) + 1, + SessionCountVSCode: mustRandInt64n(t, 9) + 1, + SessionCountJetBrains: mustRandInt64n(t, 9) + 1, + SessionCountReconnectingPTY: mustRandInt64n(t, 9) + 1, + SessionCountSSH: mustRandInt64n(t, 9) + 1, + Metrics: []agentsdk.AgentMetric{}, + } + for _, opt := range opts { + opt(&s) + } + return s +} + +// deps is a set of test dependencies. +type deps struct { + Agent database.WorkspaceAgent + Template database.Template + User database.User + Workspace database.Workspace +} + +// setupDeps sets up a set of test dependencies. +// It creates an organization, user, template, workspace, and agent +// along with all the other miscellaneous plumbing required to link +// them together. +func setupDeps(t *testing.T, store database.Store) deps { + t.Helper() + + org := dbgen.Organization(t, store, database.Organization{}) + user := dbgen.User(t, store, database.User{}) + _, err := store.InsertOrganizationMember(context.Background(), database.InsertOrganizationMemberParams{ + OrganizationID: org.ID, + UserID: user.ID, + Roles: []string{rbac.RoleOrgMember(org.ID)}, + }) + require.NoError(t, err) + tv := dbgen.TemplateVersion(t, store, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + tpl := dbgen.Template(t, store, database.Template{ + CreatedBy: user.ID, + OrganizationID: org.ID, + ActiveVersionID: tv.ID, + }) + ws := dbgen.Workspace(t, store, database.Workspace{ + TemplateID: tpl.ID, + OwnerID: user.ID, + OrganizationID: org.ID, + LastUsedAt: time.Now().Add(-time.Hour), + }) + pj := dbgen.ProvisionerJob(t, store, database.ProvisionerJob{ + InitiatorID: user.ID, + OrganizationID: org.ID, + }) + _ = dbgen.WorkspaceBuild(t, store, database.WorkspaceBuild{ + TemplateVersionID: tv.ID, + WorkspaceID: ws.ID, + JobID: pj.ID, + }) + res := dbgen.WorkspaceResource(t, store, database.WorkspaceResource{ + Transition: database.WorkspaceTransitionStart, + JobID: pj.ID, + }) + agt := dbgen.WorkspaceAgent(t, store, database.WorkspaceAgent{ + ResourceID: res.ID, + }) + return deps{ + Agent: agt, + Template: tpl, + User: user, + Workspace: ws, + } +} + +// mustRandInt64n returns a random int64 in the range [0, n). +func mustRandInt64n(t *testing.T, n int64) int64 { + t.Helper() + i, err := cryptorand.Intn(int(n)) + require.NoError(t, err) + return int64(i) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index d7b80ff273097..58b6c902c7dbc 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -43,6 +43,7 @@ import ( "github.com/coder/coder/buildinfo" "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/awsidentity" + "github.com/coder/coder/coderd/batchstats" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/database/pubsub" @@ -160,6 +161,7 @@ type Options struct { HTTPClient *http.Client UpdateAgentMetrics func(ctx context.Context, username, workspaceName, agentName string, metrics []agentsdk.AgentMetric) + StatsBatcher *batchstats.Batcher } // @title Coder API @@ -180,6 +182,8 @@ type Options struct { // @in header // @name Coder-Session-Token // New constructs a Coder API handler. +// +//nolint:gocyclo func New(options *Options) *API { if options == nil { options = &Options{} @@ -288,6 +292,10 @@ func New(options *Options) *API { options.UserQuietHoursScheduleStore.Store(&v) } + if options.StatsBatcher == nil { + panic("developer error: options.StatsBatcher is nil") + } + siteCacheDir := options.CacheDir if siteCacheDir != "" { siteCacheDir = filepath.Join(siteCacheDir, "site") @@ -462,6 +470,8 @@ func New(options *Options) *API { cors := httpmw.Cors(options.DeploymentValues.Dangerous.AllowAllCors.Value()) prometheusMW := httpmw.Prometheus(options.PrometheusRegistry) + api.statsBatcher = options.StatsBatcher + r.Use( httpmw.Recover(api.Logger), tracing.StatusWriterMiddleware, @@ -994,6 +1004,8 @@ type API struct { healthCheckGroup *singleflight.Group[string, *healthcheck.Report] healthCheckCache atomic.Pointer[healthcheck.Report] + + statsBatcher *batchstats.Batcher } // Close waits for all WebSocket connections to drain before returning. diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 351a6d0d9a075..71e3336ab2e87 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -51,11 +51,13 @@ import ( "tailscale.com/types/nettype" "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/autobuild" "github.com/coder/coder/coderd/awsidentity" + "github.com/coder/coder/coderd/batchstats" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/database/dbtestutil" @@ -140,7 +142,8 @@ type Options struct { SwaggerEndpoint bool // Logger should only be overridden if you expect errors // as part of your test. - Logger *slog.Logger + Logger *slog.Logger + StatsBatcher *batchstats.Batcher } // New constructs a codersdk client connected to an in-memory API instance. @@ -241,6 +244,18 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can if options.FilesRateLimit == 0 { options.FilesRateLimit = -1 } + if options.StatsBatcher == nil { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + batcher, closeBatcher, err := batchstats.New(ctx, + batchstats.WithStore(options.Database), + // Avoid cluttering up test output. + batchstats.WithLogger(slog.Make(sloghuman.Sink(io.Discard))), + ) + require.NoError(t, err, "create stats batcher") + options.StatsBatcher = batcher + t.Cleanup(closeBatcher) + } var templateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore] if options.TemplateScheduleStore == nil { @@ -409,6 +424,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can HealthcheckFunc: options.HealthcheckFunc, HealthcheckTimeout: options.HealthcheckTimeout, HealthcheckRefresh: options.HealthcheckRefresh, + StatsBatcher: options.StatsBatcher, } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5e82320832442..488842dcaf351 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2016,6 +2016,14 @@ func (q *querier) InsertWorkspaceAgentStat(ctx context.Context, arg database.Ins return q.db.InsertWorkspaceAgentStat(ctx, arg) } +func (q *querier) InsertWorkspaceAgentStats(ctx context.Context, arg database.InsertWorkspaceAgentStatsParams) error { + if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil { + return err + } + + return q.db.InsertWorkspaceAgentStats(ctx, arg) +} + func (q *querier) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil { return database.WorkspaceApp{}, err diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 03ae01182dbd0..cc0d05aaee9c3 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -2810,8 +2810,12 @@ func (q *FakeQuerier) GetWorkspaceAgentStats(_ context.Context, createdAfter tim } statByAgent := map[uuid.UUID]database.GetWorkspaceAgentStatsRow{} - for _, agentStat := range latestAgentStats { - stat := statByAgent[agentStat.AgentID] + for agentID, agentStat := range latestAgentStats { + stat := statByAgent[agentID] + stat.AgentID = agentStat.AgentID + stat.TemplateID = agentStat.TemplateID + stat.UserID = agentStat.UserID + stat.WorkspaceID = agentStat.WorkspaceID stat.SessionCountVSCode += agentStat.SessionCountVSCode stat.SessionCountJetBrains += agentStat.SessionCountJetBrains stat.SessionCountReconnectingPTY += agentStat.SessionCountReconnectingPTY @@ -4177,6 +4181,49 @@ func (q *FakeQuerier) InsertWorkspaceAgentStat(_ context.Context, p database.Ins return stat, nil } +func (q *FakeQuerier) InsertWorkspaceAgentStats(_ context.Context, arg database.InsertWorkspaceAgentStatsParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + var connectionsByProto []map[string]int64 + if err := json.Unmarshal(arg.ConnectionsByProto, &connectionsByProto); err != nil { + return err + } + for i := 0; i < len(arg.ID); i++ { + cbp, err := json.Marshal(connectionsByProto[i]) + if err != nil { + return xerrors.Errorf("failed to marshal connections_by_proto: %w", err) + } + stat := database.WorkspaceAgentStat{ + ID: arg.ID[i], + CreatedAt: arg.CreatedAt[i], + WorkspaceID: arg.WorkspaceID[i], + AgentID: arg.AgentID[i], + UserID: arg.UserID[i], + ConnectionsByProto: cbp, + ConnectionCount: arg.ConnectionCount[i], + RxPackets: arg.RxPackets[i], + RxBytes: arg.RxBytes[i], + TxPackets: arg.TxPackets[i], + TxBytes: arg.TxBytes[i], + TemplateID: arg.TemplateID[i], + SessionCountVSCode: arg.SessionCountVSCode[i], + SessionCountJetBrains: arg.SessionCountJetBrains[i], + SessionCountReconnectingPTY: arg.SessionCountReconnectingPTY[i], + SessionCountSSH: arg.SessionCountSSH[i], + ConnectionMedianLatencyMS: arg.ConnectionMedianLatencyMS[i], + } + q.workspaceAgentStats = append(q.workspaceAgentStats, stat) + } + + return nil +} + func (q *FakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { if err := validateDatabaseType(arg); err != nil { return database.WorkspaceApp{}, err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index ee7f8ae53b433..85ee8c26a0d51 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1236,6 +1236,13 @@ func (m metricsStore) InsertWorkspaceAgentStat(ctx context.Context, arg database return stat, err } +func (m metricsStore) InsertWorkspaceAgentStats(ctx context.Context, arg database.InsertWorkspaceAgentStatsParams) error { + start := time.Now() + r0 := m.s.InsertWorkspaceAgentStats(ctx, arg) + m.queryLatencies.WithLabelValues("InsertWorkspaceAgentStats").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) InsertWorkspaceApp(ctx context.Context, arg database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { start := time.Now() app, err := m.s.InsertWorkspaceApp(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 6a0edec4f015d..cb7278369884b 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2598,6 +2598,20 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceAgentStat(arg0, arg1 interface{} return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAgentStat", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAgentStat), arg0, arg1) } +// InsertWorkspaceAgentStats mocks base method. +func (m *MockStore) InsertWorkspaceAgentStats(arg0 context.Context, arg1 database.InsertWorkspaceAgentStatsParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertWorkspaceAgentStats", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// InsertWorkspaceAgentStats indicates an expected call of InsertWorkspaceAgentStats. +func (mr *MockStoreMockRecorder) InsertWorkspaceAgentStats(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAgentStats", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAgentStats), arg0, arg1) +} + // InsertWorkspaceApp mocks base method. func (m *MockStore) InsertWorkspaceApp(arg0 context.Context, arg1 database.InsertWorkspaceAppParams) (database.WorkspaceApp, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 783524375f822..b308589ffc350 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -225,6 +225,7 @@ type sqlcQuerier interface { InsertWorkspaceAgentLogs(ctx context.Context, arg InsertWorkspaceAgentLogsParams) ([]WorkspaceAgentLog, error) InsertWorkspaceAgentMetadata(ctx context.Context, arg InsertWorkspaceAgentMetadataParams) error InsertWorkspaceAgentStat(ctx context.Context, arg InsertWorkspaceAgentStatParams) (WorkspaceAgentStat, error) + InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error InsertWorkspaceBuildParameters(ctx context.Context, arg InsertWorkspaceBuildParametersParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ba0f3cfb54188..3e07fc22f1aa3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7418,6 +7418,90 @@ func (q *sqlQuerier) InsertWorkspaceAgentStat(ctx context.Context, arg InsertWor return i, err } +const insertWorkspaceAgentStats = `-- name: InsertWorkspaceAgentStats :exec +INSERT INTO + workspace_agent_stats ( + id, + created_at, + user_id, + workspace_id, + template_id, + agent_id, + connections_by_proto, + connection_count, + rx_packets, + rx_bytes, + tx_packets, + tx_bytes, + session_count_vscode, + session_count_jetbrains, + session_count_reconnecting_pty, + session_count_ssh, + connection_median_latency_ms + ) +SELECT + unnest($1 :: uuid[]) AS id, + unnest($2 :: timestamptz[]) AS created_at, + unnest($3 :: uuid[]) AS user_id, + unnest($4 :: uuid[]) AS workspace_id, + unnest($5 :: uuid[]) AS template_id, + unnest($6 :: uuid[]) AS agent_id, + jsonb_array_elements($7 :: jsonb) AS connections_by_proto, + unnest($8 :: bigint[]) AS connection_count, + unnest($9 :: bigint[]) AS rx_packets, + unnest($10 :: bigint[]) AS rx_bytes, + unnest($11 :: bigint[]) AS tx_packets, + unnest($12 :: bigint[]) AS tx_bytes, + unnest($13 :: bigint[]) AS session_count_vscode, + unnest($14 :: bigint[]) AS session_count_jetbrains, + unnest($15 :: bigint[]) AS session_count_reconnecting_pty, + unnest($16 :: bigint[]) AS session_count_ssh, + unnest($17 :: double precision[]) AS connection_median_latency_ms +` + +type InsertWorkspaceAgentStatsParams struct { + ID []uuid.UUID `db:"id" json:"id"` + CreatedAt []time.Time `db:"created_at" json:"created_at"` + UserID []uuid.UUID `db:"user_id" json:"user_id"` + WorkspaceID []uuid.UUID `db:"workspace_id" json:"workspace_id"` + TemplateID []uuid.UUID `db:"template_id" json:"template_id"` + AgentID []uuid.UUID `db:"agent_id" json:"agent_id"` + ConnectionsByProto json.RawMessage `db:"connections_by_proto" json:"connections_by_proto"` + ConnectionCount []int64 `db:"connection_count" json:"connection_count"` + RxPackets []int64 `db:"rx_packets" json:"rx_packets"` + RxBytes []int64 `db:"rx_bytes" json:"rx_bytes"` + TxPackets []int64 `db:"tx_packets" json:"tx_packets"` + TxBytes []int64 `db:"tx_bytes" json:"tx_bytes"` + SessionCountVSCode []int64 `db:"session_count_vscode" json:"session_count_vscode"` + SessionCountJetBrains []int64 `db:"session_count_jetbrains" json:"session_count_jetbrains"` + SessionCountReconnectingPTY []int64 `db:"session_count_reconnecting_pty" json:"session_count_reconnecting_pty"` + SessionCountSSH []int64 `db:"session_count_ssh" json:"session_count_ssh"` + ConnectionMedianLatencyMS []float64 `db:"connection_median_latency_ms" json:"connection_median_latency_ms"` +} + +func (q *sqlQuerier) InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error { + _, err := q.db.ExecContext(ctx, insertWorkspaceAgentStats, + pq.Array(arg.ID), + pq.Array(arg.CreatedAt), + pq.Array(arg.UserID), + pq.Array(arg.WorkspaceID), + pq.Array(arg.TemplateID), + pq.Array(arg.AgentID), + arg.ConnectionsByProto, + pq.Array(arg.ConnectionCount), + pq.Array(arg.RxPackets), + pq.Array(arg.RxBytes), + pq.Array(arg.TxPackets), + pq.Array(arg.TxBytes), + pq.Array(arg.SessionCountVSCode), + pq.Array(arg.SessionCountJetBrains), + pq.Array(arg.SessionCountReconnectingPTY), + pq.Array(arg.SessionCountSSH), + pq.Array(arg.ConnectionMedianLatencyMS), + ) + return err +} + const getWorkspaceAppByAgentIDAndSlug = `-- name: GetWorkspaceAppByAgentIDAndSlug :one SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external FROM workspace_apps WHERE agent_id = $1 AND slug = $2 ` diff --git a/coderd/database/queries/workspaceagentstats.sql b/coderd/database/queries/workspaceagentstats.sql index 1a598bd6a6263..daba093a3d9e1 100644 --- a/coderd/database/queries/workspaceagentstats.sql +++ b/coderd/database/queries/workspaceagentstats.sql @@ -22,6 +22,46 @@ INSERT INTO VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING *; +-- name: InsertWorkspaceAgentStats :exec +INSERT INTO + workspace_agent_stats ( + id, + created_at, + user_id, + workspace_id, + template_id, + agent_id, + connections_by_proto, + connection_count, + rx_packets, + rx_bytes, + tx_packets, + tx_bytes, + session_count_vscode, + session_count_jetbrains, + session_count_reconnecting_pty, + session_count_ssh, + connection_median_latency_ms + ) +SELECT + unnest(@id :: uuid[]) AS id, + unnest(@created_at :: timestamptz[]) AS created_at, + unnest(@user_id :: uuid[]) AS user_id, + unnest(@workspace_id :: uuid[]) AS workspace_id, + unnest(@template_id :: uuid[]) AS template_id, + unnest(@agent_id :: uuid[]) AS agent_id, + jsonb_array_elements(@connections_by_proto :: jsonb) AS connections_by_proto, + unnest(@connection_count :: bigint[]) AS connection_count, + unnest(@rx_packets :: bigint[]) AS rx_packets, + unnest(@rx_bytes :: bigint[]) AS rx_bytes, + unnest(@tx_packets :: bigint[]) AS tx_packets, + unnest(@tx_bytes :: bigint[]) AS tx_bytes, + unnest(@session_count_vscode :: bigint[]) AS session_count_vscode, + unnest(@session_count_jetbrains :: bigint[]) AS session_count_jetbrains, + unnest(@session_count_reconnecting_pty :: bigint[]) AS session_count_reconnecting_pty, + unnest(@session_count_ssh :: bigint[]) AS session_count_ssh, + unnest(@connection_median_latency_ms :: double precision[]) AS connection_median_latency_ms; + -- name: GetTemplateDAUs :many SELECT (created_at at TIME ZONE cast(@tz_offset::integer as text))::date as date, diff --git a/coderd/prometheusmetrics/prometheusmetrics_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index 3ea774df1186d..ad39ec840c526 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -11,6 +11,9 @@ import ( "testing" "time" + "github.com/coder/coder/coderd/batchstats" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" @@ -372,9 +375,29 @@ func TestAgents(t *testing.T) { func TestAgentStats(t *testing.T) { t.Parallel() + ctx, cancelFunc := context.WithCancel(context.Background()) + t.Cleanup(cancelFunc) + + db, pubsub := dbtestutil.NewDB(t) + log := slogtest.Make(t, nil) + + batcher, closeBatcher, err := batchstats.New(ctx, + batchstats.WithStore(db), + // We want our stats, and we want them NOW. + batchstats.WithBatchSize(1), + batchstats.WithInterval(time.Hour), + batchstats.WithLogger(log), + ) + require.NoError(t, err, "create stats batcher failed") + t.Cleanup(closeBatcher) + // Build sample workspaces with test agents and fake agent client - client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - db := api.Database + client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: db, + IncludeProvisionerDaemon: true, + Pubsub: pubsub, + StatsBatcher: batcher, + }) user := coderdtest.CreateFirstUser(t, client) @@ -384,11 +407,7 @@ func TestAgentStats(t *testing.T) { registry := prometheus.NewRegistry() - ctx, cancelFunc := context.WithCancel(context.Background()) - defer cancelFunc() - // given - var err error var i int64 for i = 0; i < 3; i++ { _, err = agent1.PostStats(ctx, &agentsdk.Stats{ diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 8567ff1d895b3..0f5607db73436 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1410,36 +1410,12 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques activityBumpWorkspace(ctx, api.Logger.Named("activity_bump"), api.Database, workspace.ID) } - payload, err := json.Marshal(req.ConnectionsByProto) - if err != nil { - api.Logger.Error(ctx, "marshal agent connections by proto", slog.F("workspace_agent_id", workspaceAgent.ID), slog.Error(err)) - payload = json.RawMessage("{}") - } - now := database.Now() var errGroup errgroup.Group errGroup.Go(func() error { - _, err = api.Database.InsertWorkspaceAgentStat(ctx, database.InsertWorkspaceAgentStatParams{ - ID: uuid.New(), - CreatedAt: now, - AgentID: workspaceAgent.ID, - WorkspaceID: workspace.ID, - UserID: workspace.OwnerID, - TemplateID: workspace.TemplateID, - ConnectionsByProto: payload, - ConnectionCount: req.ConnectionCount, - RxPackets: req.RxPackets, - RxBytes: req.RxBytes, - TxPackets: req.TxPackets, - TxBytes: req.TxBytes, - SessionCountVSCode: req.SessionCountVSCode, - SessionCountJetBrains: req.SessionCountJetBrains, - SessionCountReconnectingPTY: req.SessionCountReconnectingPTY, - SessionCountSSH: req.SessionCountSSH, - ConnectionMedianLatencyMS: req.ConnectionMedianLatencyMS, - }) - if err != nil { + if err := api.statsBatcher.Add(workspaceAgent.ID, workspace.TemplateID, workspace.OwnerID, workspace.ID, req); err != nil { + api.Logger.Error(ctx, "failed to add stats to batcher", slog.Error(err)) return xerrors.Errorf("can't insert workspace agent stat: %w", err) } return nil From 9f5ac4d15d4c1c197694e5d91b4d473ea1867d96 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 4 Aug 2023 19:27:12 +0300 Subject: [PATCH 013/277] ci: publish main commit tag to `ghcr.io/coder/coder-preview` (#8897) * wip * push new tag and delete old tag * prune by filtering * fix permission * fix filter * keep last 2 versions * use first 7 characters of sha for tag * do not use gh cli * test * typo * use gh cli again * reduce days to 3 * fixup * typo * keep-last 5 * ready to merge * retain tags from last 7 days * test * ready --- .github/workflows/ci.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 55796e29cb446..3e5838f2a1501 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -701,6 +701,7 @@ jobs: runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }} env: DOCKER_CLI_EXPERIMENTAL: "enabled" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout uses: actions/checkout@v3 @@ -724,6 +725,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Linux amd64 Docker image + id: build_and_push run: | set -euxo pipefail go mod download @@ -738,3 +740,21 @@ jobs: --version $version \ --push \ build/coder_linux_amd64 + + # Get commit sha to be used as package tag + new_package_tag=$(gh api repos/coder/coder/commits/main | jq -r '.sha[0:7]') + + # Tag image with new package tag and push + docker tag ghcr.io/coder/coder-preview:main ghcr.io/coder/coder-preview:main-$new_package_tag + docker push ghcr.io/coder/coder-preview:main-$new_package_tag + + - name: Prune old images + uses: vlaurin/action-ghcr-prune@v0.5.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + organization: coder + container: coder-preview + keep-younger-than: 7 # days + keep-tags-regexes: ^pr + prune-tags-regexes: ^main- + prune-untagged: true From 7224ff2af8d7b251c68765bc4f74d19e224b0ec8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 4 Aug 2023 17:33:05 +0100 Subject: [PATCH 014/277] fix(enterprise/replicasync): fix data race in Manager.Regional (#8910) --- enterprise/replicasync/replicasync.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/enterprise/replicasync/replicasync.go b/enterprise/replicasync/replicasync.go index 42bf402a6682e..d0ca629d25ae8 100644 --- a/enterprise/replicasync/replicasync.go +++ b/enterprise/replicasync/replicasync.go @@ -375,7 +375,13 @@ func (m *Manager) InRegion(regionID int32) []database.Replica { // Regional returns all replicas in the same region excluding itself. func (m *Manager) Regional() []database.Replica { - return m.InRegion(m.self.RegionID) + return m.InRegion(m.regionID()) +} + +func (m *Manager) regionID() int32 { + m.mutex.Lock() + defer m.mutex.Unlock() + return m.self.RegionID } // SetCallback sets a function to execute whenever new peers From 8f7b6a29362625704f79494392583989e7e57ea6 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 4 Aug 2023 15:00:13 -0300 Subject: [PATCH 015/277] fix(site): fix date range on template insights (#8914) --- .../TemplateInsightsPage/TemplateInsightsPage.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index 210458a6f2f44..d6161124d3540 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -24,14 +24,15 @@ import { UserLatencyInsightsResponse, } from "api/typesGenerated" import { ComponentProps } from "react" -import subDays from "date-fns/subDays" +import { subDays, addHours, startOfHour } from "date-fns" export default function TemplateInsightsPage() { + const now = new Date() const { template } = useTemplateLayoutContext() const insightsFilter = { template_ids: template.id, - start_time: toTimeFilter(sevenDaysAgo()), - end_time: toTimeFilter(new Date()), + start_time: toStartTimeFilter(subDays(now, 7)), + end_time: startOfHour(addHours(now, 1)).toISOString(), } const { data: templateInsights } = useQuery({ queryKey: ["templates", template.id, "usage"], @@ -323,12 +324,11 @@ function mapToDAUsResponse( } } -function toTimeFilter(date: Date) { +function toStartTimeFilter(date: Date) { date.setHours(0, 0, 0, 0) const year = date.getUTCFullYear() const month = String(date.getUTCMonth() + 1).padStart(2, "0") const day = String(date.getUTCDate()).padStart(2, "0") - return `${year}-${month}-${day}T00:00:00Z` } @@ -348,7 +348,3 @@ function formatTime(seconds: number): string { return hours.toFixed(1) + " hours" } } - -function sevenDaysAgo() { - return subDays(new Date(), 7) -} From 0c7ff4fb8acb539640e1bcc5dbf21113c71ab110 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 4 Aug 2023 17:03:21 -0500 Subject: [PATCH 016/277] fix(enterprise): ensure SCIM create user can unsuspend (#8916) --- enterprise/coderd/scim.go | 29 ++++++++++++---- enterprise/coderd/scim_test.go | 61 +++++++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/enterprise/coderd/scim.go b/enterprise/coderd/scim.go index efba55b932684..801ca61349ae3 100644 --- a/enterprise/coderd/scim.go +++ b/enterprise/coderd/scim.go @@ -155,7 +155,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { } //nolint:gocritic - user, err := api.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{ + dbUser, err := api.Database.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{ Email: email, Username: sUser.UserName, }) @@ -164,8 +164,22 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { return } if err == nil { - sUser.ID = user.ID.String() - sUser.UserName = user.Username + sUser.ID = dbUser.ID.String() + sUser.UserName = dbUser.Username + + if sUser.Active && dbUser.Status == database.UserStatusSuspended { + //nolint:gocritic + _, err = api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{ + ID: dbUser.ID, + // The user will get transitioned to Active after logging in. + Status: database.UserStatusDormant, + UpdatedAt: database.Now(), + }) + if err != nil { + _ = handlerutil.WriteError(rw, err) + return + } + } httpapi.Write(ctx, rw, http.StatusOK, sUser) return @@ -201,7 +215,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { } //nolint:gocritic // needed for SCIM - user, _, err = api.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, agpl.CreateUserRequest{ + dbUser, _, err = api.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, agpl.CreateUserRequest{ CreateUserRequest: codersdk.CreateUserRequest{ Username: sUser.UserName, Email: email, @@ -214,8 +228,8 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { return } - sUser.ID = user.ID.String() - sUser.UserName = user.Username + sUser.ID = dbUser.ID.String() + sUser.UserName = dbUser.Username httpapi.Write(ctx, rw, http.StatusOK, sUser) } @@ -263,7 +277,8 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) { var status database.UserStatus if sUser.Active { - status = database.UserStatusActive + // The user will get transitioned to Active after logging in. + status = database.UserStatusDormant } else { status = database.UserStatusSuspended } diff --git a/enterprise/coderd/scim_test.go b/enterprise/coderd/scim_test.go index f0778c26b51d0..a74dc9bf3452b 100644 --- a/enterprise/coderd/scim_test.go +++ b/enterprise/coderd/scim_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "testing" @@ -164,6 +165,54 @@ func TestScim(t *testing.T) { assert.Equal(t, sUser.UserName, userRes.Users[0].Username) }) + t.Run("Unsuspend", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + scimAPIKey := []byte("hi") + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + SCIMAPIKey: scimAPIKey, + LicenseOptions: &coderdenttest.LicenseOptions{ + AccountID: "coolin", + Features: license.Features{ + codersdk.FeatureSCIM: 1, + }, + }, + }) + + sUser := makeScimUser(t) + res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey)) + require.NoError(t, err) + defer res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) + err = json.NewDecoder(res.Body).Decode(&sUser) + require.NoError(t, err) + + sUser.Active = false + res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey)) + require.NoError(t, err) + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) + + sUser.Active = true + res, err = client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey)) + require.NoError(t, err) + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + assert.Equal(t, http.StatusOK, res.StatusCode) + + userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value}) + require.NoError(t, err) + require.Len(t, userRes.Users, 1) + + assert.Equal(t, sUser.Emails[0].Value, userRes.Users[0].Email) + assert.Equal(t, sUser.UserName, userRes.Users[0].Username) + assert.Equal(t, codersdk.UserStatusDormant, userRes.Users[0].Status) + }) + t.Run("DomainStrips", func(t *testing.T) { t.Parallel() @@ -185,7 +234,8 @@ func TestScim(t *testing.T) { sUser.UserName = sUser.UserName + "@coder.com" res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey)) require.NoError(t, err) - defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() assert.Equal(t, http.StatusOK, res.StatusCode) userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value}) @@ -220,7 +270,8 @@ func TestScim(t *testing.T) { res, err := client.Request(ctx, "PATCH", "/scim/v2/Users/bob", struct{}{}) require.NoError(t, err) - defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() assert.Equal(t, http.StatusNotFound, res.StatusCode) }) @@ -242,7 +293,8 @@ func TestScim(t *testing.T) { res, err := client.Request(ctx, "PATCH", "/scim/v2/Users/bob", struct{}{}) require.NoError(t, err) - defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() assert.Equal(t, http.StatusInternalServerError, res.StatusCode) }) @@ -276,7 +328,8 @@ func TestScim(t *testing.T) { res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey)) require.NoError(t, err) - defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() assert.Equal(t, http.StatusOK, res.StatusCode) userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value}) From eddaa7781d9fd6f01cdb41db074f8aa603eea3ef Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 4 Aug 2023 17:54:27 -0500 Subject: [PATCH 017/277] fix: don't close cached tailnet on pty close (#8917) --- coderd/workspaceapps/proxy.go | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index 9b2d9c4bfa297..d75970bcfa8d9 100644 --- a/coderd/workspaceapps/proxy.go +++ b/coderd/workspaceapps/proxy.go @@ -669,7 +669,6 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { return } defer release() - defer agentConn.Close() log.Debug(ctx, "dialed workspace agent") ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command")) if err != nil { From 81752d1b84ce837827f42ab6b9c898f934ebe121 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sat, 5 Aug 2023 11:25:37 -0500 Subject: [PATCH 018/277] fix(cli/delete): prompt for confirmation after workspace is found (#8579) --- cli/delete.go | 17 ++++++++++------- cli/delete_test.go | 8 ++++---- codersdk/workspaces.go | 4 ++++ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/cli/delete.go b/cli/delete.go index 867abe0326a30..3a440c6840381 100644 --- a/cli/delete.go +++ b/cli/delete.go @@ -22,16 +22,19 @@ func (r *RootCmd) deleteWorkspace() *clibase.Cmd { r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { - _, err := cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Confirm delete workspace?", - IsConfirm: true, - Default: cliui.ConfirmNo, - }) + workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0]) if err != nil { return err } - workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0]) + sinceLastUsed := time.Since(workspace.LastUsedAt) + cliui.Infof(inv.Stderr, "%v was last used %.0f days ago", workspace.FullName(), sinceLastUsed.Hours()/24) + + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Confirm delete workspace?", + IsConfirm: true, + Default: cliui.ConfirmNo, + }) if err != nil { return err } @@ -51,7 +54,7 @@ func (r *RootCmd) deleteWorkspace() *clibase.Cmd { return err } - _, _ = fmt.Fprintf(inv.Stdout, "\nThe %s workspace has been deleted at %s!\n", cliui.DefaultStyles.Keyword.Render(workspace.Name), cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))) + _, _ = fmt.Fprintf(inv.Stdout, "\n%s has been deleted at %s!\n", cliui.DefaultStyles.Keyword.Render(workspace.FullName()), cliui.DefaultStyles.DateTimeStamp.Render(time.Now().Format(time.Stamp))) return nil }, } diff --git a/cli/delete_test.go b/cli/delete_test.go index 40f5b9ac22168..3530a60097094 100644 --- a/cli/delete_test.go +++ b/cli/delete_test.go @@ -41,7 +41,7 @@ func TestDelete(t *testing.T) { assert.ErrorIs(t, err, io.EOF) } }() - pty.ExpectMatch("workspace has been deleted") + pty.ExpectMatch("has been deleted") <-doneChan }) @@ -68,7 +68,7 @@ func TestDelete(t *testing.T) { assert.ErrorIs(t, err, io.EOF) } }() - pty.ExpectMatch("workspace has been deleted") + pty.ExpectMatch("has been deleted") <-doneChan }) @@ -113,7 +113,7 @@ func TestDelete(t *testing.T) { assert.ErrorIs(t, err, io.EOF) } }() - pty.ExpectMatch("workspace has been deleted") + pty.ExpectMatch("has been deleted") <-doneChan }) @@ -145,7 +145,7 @@ func TestDelete(t *testing.T) { } }() - pty.ExpectMatch("workspace has been deleted") + pty.ExpectMatch("has been deleted") <-doneChan workspace, err = client.Workspace(context.Background(), workspace.ID) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index a2ef823fcb87e..2ef9840ac4be0 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -48,6 +48,10 @@ type Workspace struct { Health WorkspaceHealth `json:"health"` } +func (w Workspace) FullName() string { + return fmt.Sprintf("%s/%s", w.OwnerName, w.Name) +} + type WorkspaceHealth struct { Healthy bool `json:"healthy" example:"false"` // Healthy is true if the workspace is healthy. FailingAgents []uuid.UUID `json:"failing_agents" format:"uuid"` // FailingAgents lists the IDs of the agents that are failing, if any. From e7047726d80a80004c0c1e6cca5a245fd46f48e2 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Mon, 7 Aug 2023 13:00:05 +0300 Subject: [PATCH 019/277] docs: fix a broken link in docs.changelogs/README.md (#8937) --- docs/changelogs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelogs/README.md b/docs/changelogs/README.md index e5740216e4f87..7b1945aa02a2d 100644 --- a/docs/changelogs/README.md +++ b/docs/changelogs/README.md @@ -1,8 +1,8 @@ # Changelogs -These are the changelogs used by [get-changelog.sh](https://github.com/coder/coder/blob/main/scripts/release/changelog.sh) for a release. +These are the changelogs used by [generate_release_notes.sh]https://github.com/coder/coder/blob/main/scripts/release/generate_release_notes.sh) for a release. -These changelogs are currently not kept in-sync with GitHub releases. Use [GitHub releases](https://github.com/coder/coder/releases) for the latest information! +These changelogs are currently not kept in sync with GitHub releases. Use [GitHub releases](https://github.com/coder/coder/releases) for the latest information! ## Writing a changelog From 90c1647fcf4dd6f95dd5265cf22f41e4d2aa2438 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Mon, 7 Aug 2023 16:41:20 +0300 Subject: [PATCH 020/277] ci: change `ghcr.io/coder/coder-preview:main` tag to use version names (#8938) --- .github/workflows/ci.yaml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3e5838f2a1501..7aa367acd3032 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -701,7 +701,6 @@ jobs: runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }} env: DOCKER_CLI_EXPERIMENTAL: "enabled" - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout uses: actions/checkout@v3 @@ -741,12 +740,9 @@ jobs: --push \ build/coder_linux_amd64 - # Get commit sha to be used as package tag - new_package_tag=$(gh api repos/coder/coder/commits/main | jq -r '.sha[0:7]') - # Tag image with new package tag and push - docker tag ghcr.io/coder/coder-preview:main ghcr.io/coder/coder-preview:main-$new_package_tag - docker push ghcr.io/coder/coder-preview:main-$new_package_tag + docker tag ghcr.io/coder/coder-preview:main ghcr.io/coder/coder-preview:main-$version + docker push ghcr.io/coder/coder-preview:main-$version - name: Prune old images uses: vlaurin/action-ghcr-prune@v0.5.0 From 71ea5ace07bf6ad28cdba9317fbfcf55afa68723 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 7 Aug 2023 09:34:39 -0500 Subject: [PATCH 021/277] feat: add login type to users page (#8912) --- site/src/components/UsersTable/UsersTable.tsx | 6 ++++-- site/src/components/UsersTable/UsersTableBody.tsx | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index 6c991ee2a1dd0..d84386a2e968e 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -15,6 +15,7 @@ export const Language = { rolesLabel: "Roles", statusLabel: "Status", lastSeenLabel: "Last Seen", + loginTypeLabel: "Login Type", } export interface UsersTableProps { @@ -69,8 +70,9 @@ export const UsersTable: FC> = ({ - {Language.statusLabel} - {Language.lastSeenLabel} + {Language.loginTypeLabel} + {Language.statusLabel} + {Language.lastSeenLabel} {/* 1% is a trick to make the table cell width fit the content */} {canEditUsers && } diff --git a/site/src/components/UsersTable/UsersTableBody.tsx b/site/src/components/UsersTable/UsersTableBody.tsx index 87fc5725a2de7..ad25de524f3a8 100644 --- a/site/src/components/UsersTable/UsersTableBody.tsx +++ b/site/src/components/UsersTable/UsersTableBody.tsx @@ -155,6 +155,9 @@ export const UsersTableBody: FC< ))} + +
{user.login_type}
+
Date: Mon, 7 Aug 2023 17:35:28 +0300 Subject: [PATCH 022/277] fix: update tag name for `coder-preview` image in ci.yaml (#8945) --- .github/workflows/ci.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7aa367acd3032..dcfbf9fa409d5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -741,8 +741,9 @@ jobs: build/coder_linux_amd64 # Tag image with new package tag and push - docker tag ghcr.io/coder/coder-preview:main ghcr.io/coder/coder-preview:main-$version - docker push ghcr.io/coder/coder-preview:main-$version + tag=$(echo "$version" | sed 's/+/-/g') + docker tag ghcr.io/coder/coder-preview:main ghcr.io/coder/coder-preview:main-$tag + docker push ghcr.io/coder/coder-preview:main-$tag - name: Prune old images uses: vlaurin/action-ghcr-prune@v0.5.0 From 82e0e2e43c59020a914145983f39f935bdccc3ce Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 7 Aug 2023 16:26:16 +0100 Subject: [PATCH 023/277] fix(cli): clistat: accept positional arg for stat disk cmd (#8911) --- cli/stat.go | 8 ++++++++ cli/stat_test.go | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/cli/stat.go b/cli/stat.go index 3e32c4187f93b..3657a4f3c71c9 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -233,8 +233,16 @@ func (*RootCmd) statDisk(s *clistat.Statter) *clibase.Cmd { }, Handler: func(inv *clibase.Invocation) error { pfx := clistat.ParsePrefix(prefixArg) + // Users may also call `coder stat disk `. + if len(inv.Args) > 0 { + pathArg = inv.Args[0] + } ds, err := s.Disk(pfx, pathArg) if err != nil { + if os.IsNotExist(err) { + // fmt.Errorf produces a more concise error. + return fmt.Errorf("not found: %q", pathArg) + } return err } diff --git a/cli/stat_test.go b/cli/stat_test.go index 7b37a98ce9113..001177f9d95dd 100644 --- a/cli/stat_test.go +++ b/cli/stat_test.go @@ -170,4 +170,16 @@ func TestStatDiskCmd(t *testing.T) { require.NotZero(t, *tmp.Total) require.Equal(t, "B", tmp.Unit) }) + + t.Run("PosArg", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, "stat", "disk", "/this/path/does/not/exist", "--output=text") + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.Error(t, err) + require.Contains(t, err.Error(), `not found: "/this/path/does/not/exist"`) + }) } From 67ff2077a63e1cd40814b00402361db668c00a1e Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 7 Aug 2023 08:52:06 -0700 Subject: [PATCH 024/277] feat: add derp only text to proxies list in dashboard (#8932) --- site/src/components/DeploySettingsLayout/Badges.tsx | 8 ++++++-- .../WorkspaceProxyPage/WorkspaceProxyRow.tsx | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/site/src/components/DeploySettingsLayout/Badges.tsx b/site/src/components/DeploySettingsLayout/Badges.tsx index f9d34bca918b6..465422ef03a3f 100644 --- a/site/src/components/DeploySettingsLayout/Badges.tsx +++ b/site/src/components/DeploySettingsLayout/Badges.tsx @@ -23,11 +23,15 @@ export const EntitledBadge: FC = () => { ) } -export const HealthyBadge: FC = () => { +export const HealthyBadge: FC<{ derpOnly: boolean }> = ({ derpOnly }) => { const styles = useStyles() + let text = "Healthy" + if (derpOnly) { + text = "Healthy (DERP Only)" + } return ( - Healthy + {text} ) } diff --git a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyRow.tsx b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyRow.tsx index e106eb61ea973..760d6f5ca8cd8 100644 --- a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyRow.tsx +++ b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyRow.tsx @@ -169,9 +169,14 @@ const DetailedProxyStatus: FC<{ return } + let derpOnly = false + if ("derp_only" in proxy) { + derpOnly = proxy.derp_only + } + switch (proxy.status.status) { case "ok": - return + return case "unhealthy": return case "unreachable": @@ -189,7 +194,7 @@ const ProxyStatus: FC<{ }> = ({ proxy }) => { let icon = if (proxy.healthy) { - icon = + icon = } return icon From 00be8ab875872cebb701a40597be8a7dc5c22835 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Aug 2023 18:54:20 +0300 Subject: [PATCH 025/277] chore: bump the github-actions group with 1 update (#8942) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dcfbf9fa409d5..bc1af416e3901 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -137,7 +137,7 @@ jobs: # Check for any typos - name: Check for typos - uses: crate-ci/typos@v1.16.1 + uses: crate-ci/typos@v1.16.2 with: config: .github/workflows/typos.toml From e8627195a2923295cc96784ad77d87e2694fb887 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 7 Aug 2023 18:11:44 +0200 Subject: [PATCH 026/277] feat(coderd): expose parameter description and type (#8944) --- coderd/apidoc/docs.go | 6 +++ coderd/apidoc/swagger.json | 6 +++ coderd/database/db2sdk/db2sdk.go | 71 +++++++++++++++++++++++----- coderd/database/dbfake/dbfake.go | 2 + coderd/database/queries.sql.go | 10 ++-- coderd/database/queries/insights.sql | 8 ++-- coderd/insights.go | 38 +-------------- coderd/insights_test.go | 6 +++ codersdk/insights.go | 2 + docs/api/insights.md | 2 + docs/api/schemas.md | 8 ++++ site/src/api/typesGenerated.ts | 2 + 12 files changed, 108 insertions(+), 53 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 8d7920c2d2e8d..61a6989ba27fd 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9585,6 +9585,9 @@ const docTemplate = `{ "codersdk.TemplateParameterUsage": { "type": "object", "properties": { + "description": { + "type": "string" + }, "display_name": { "type": "string" }, @@ -9604,6 +9607,9 @@ const docTemplate = `{ "format": "uuid" } }, + "type": { + "type": "string" + }, "values": { "type": "array", "items": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index bc4c0e7b8a958..982ed816264c1 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8665,6 +8665,9 @@ "codersdk.TemplateParameterUsage": { "type": "object", "properties": { + "description": { + "type": "string" + }, "display_name": { "type": "string" }, @@ -8684,6 +8687,9 @@ "format": "uuid" } }, + "type": { + "type": "string" + }, "values": { "type": "array", "items": { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 263611d5b168b..893cfdadea336 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -3,6 +3,7 @@ package db2sdk import ( "encoding/json" + "sort" "github.com/google/uuid" @@ -29,20 +30,10 @@ func WorkspaceBuildParameter(p database.WorkspaceBuildParameter) codersdk.Worksp } func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk.TemplateVersionParameter, error) { - var protoOptions []*proto.RichParameterOption - err := json.Unmarshal(param.Options, &protoOptions) + options, err := templateVersionParameterOptions(param.Options) if err != nil { return codersdk.TemplateVersionParameter{}, err } - options := make([]codersdk.TemplateVersionParameterOption, 0) - for _, option := range protoOptions { - options = append(options, codersdk.TemplateVersionParameterOption{ - Name: option.Name, - Description: option.Description, - Value: option.Value, - Icon: option.Icon, - }) - } descriptionPlaintext, err := parameter.Plaintext(param.Description) if err != nil { @@ -132,3 +123,61 @@ func Role(role rbac.Role) codersdk.Role { Name: role.Name, } } + +func TemplateInsightsParameters(parameterRows []database.GetTemplateParameterInsightsRow) ([]codersdk.TemplateParameterUsage, error) { + parametersByNum := make(map[int64]*codersdk.TemplateParameterUsage) + for _, param := range parameterRows { + if _, ok := parametersByNum[param.Num]; !ok { + var opts []codersdk.TemplateVersionParameterOption + err := json.Unmarshal(param.Options, &opts) + if err != nil { + return nil, err + } + + plaintextDescription, err := parameter.Plaintext(param.Description) + if err != nil { + return nil, err + } + + parametersByNum[param.Num] = &codersdk.TemplateParameterUsage{ + TemplateIDs: param.TemplateIDs, + Name: param.Name, + Type: param.Type, + DisplayName: param.DisplayName, + Description: plaintextDescription, + Options: opts, + } + } + parametersByNum[param.Num].Values = append(parametersByNum[param.Num].Values, codersdk.TemplateParameterValue{ + Value: param.Value, + Count: param.Count, + }) + } + parametersUsage := []codersdk.TemplateParameterUsage{} + for _, param := range parametersByNum { + parametersUsage = append(parametersUsage, *param) + } + + sort.Slice(parametersUsage, func(i, j int) bool { + return parametersUsage[i].Name < parametersUsage[j].Name + }) + return parametersUsage, nil +} + +func templateVersionParameterOptions(rawOptions json.RawMessage) ([]codersdk.TemplateVersionParameterOption, error) { + var protoOptions []*proto.RichParameterOption + err := json.Unmarshal(rawOptions, &protoOptions) + if err != nil { + return nil, err + } + var options []codersdk.TemplateVersionParameterOption + for _, option := range protoOptions { + options = append(options, codersdk.TemplateVersionParameterOption{ + Name: option.Name, + Description: option.Description, + Value: option.Value, + Icon: option.Icon, + }) + } + return options, nil +} diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index cc0d05aaee9c3..c968a52f8d00d 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -2186,6 +2186,7 @@ func (q *FakeQuerier) GetTemplateParameterInsights(ctx context.Context, arg data uniqueTemplateParams[key] = &database.GetTemplateParameterInsightsRow{ Num: num, Name: tvp.Name, + Type: tvp.Type, DisplayName: tvp.DisplayName, Description: tvp.Description, Options: tvp.Options, @@ -2220,6 +2221,7 @@ func (q *FakeQuerier) GetTemplateParameterInsights(ctx context.Context, arg data TemplateIDs: uniqueSortedUUIDs(utp.TemplateIDs), Name: utp.Name, DisplayName: utp.DisplayName, + Type: utp.Type, Description: utp.Description, Options: utp.Options, Value: value, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3e07fc22f1aa3..8748e4d7ca9ed 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1582,16 +1582,18 @@ WITH latest_workspace_builds AS ( tvp.name, tvp.display_name, tvp.description, - tvp.options + tvp.options, + tvp.type FROM latest_workspace_builds wb JOIN template_version_parameters tvp ON (tvp.template_version_id = wb.template_version_id) - GROUP BY tvp.name, tvp.display_name, tvp.description, tvp.options + GROUP BY tvp.name, tvp.display_name, tvp.description, tvp.options, tvp.type ) SELECT utp.num, utp.template_ids, utp.name, + utp.type, utp.display_name, utp.description, utp.options, @@ -1599,7 +1601,7 @@ SELECT COUNT(wbp.value) AS count FROM unique_template_params utp JOIN workspace_build_parameters wbp ON (utp.workspace_build_ids @> ARRAY[wbp.workspace_build_id] AND utp.name = wbp.name) -GROUP BY utp.num, utp.name, utp.display_name, utp.description, utp.options, utp.template_ids, wbp.value +GROUP BY utp.num, utp.name, utp.display_name, utp.description, utp.options, utp.template_ids, utp.type, wbp.value ` type GetTemplateParameterInsightsParams struct { @@ -1612,6 +1614,7 @@ type GetTemplateParameterInsightsRow struct { Num int64 `db:"num" json:"num"` TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` Name string `db:"name" json:"name"` + Type string `db:"type" json:"type"` DisplayName string `db:"display_name" json:"display_name"` Description string `db:"description" json:"description"` Options json.RawMessage `db:"options" json:"options"` @@ -1636,6 +1639,7 @@ func (q *sqlQuerier) GetTemplateParameterInsights(ctx context.Context, arg GetTe &i.Num, pq.Array(&i.TemplateIDs), &i.Name, + &i.Type, &i.DisplayName, &i.Description, &i.Options, diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 94a80117dcc1d..ad8c581161e99 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -149,16 +149,18 @@ WITH latest_workspace_builds AS ( tvp.name, tvp.display_name, tvp.description, - tvp.options + tvp.options, + tvp.type FROM latest_workspace_builds wb JOIN template_version_parameters tvp ON (tvp.template_version_id = wb.template_version_id) - GROUP BY tvp.name, tvp.display_name, tvp.description, tvp.options + GROUP BY tvp.name, tvp.display_name, tvp.description, tvp.options, tvp.type ) SELECT utp.num, utp.template_ids, utp.name, + utp.type, utp.display_name, utp.description, utp.options, @@ -166,4 +168,4 @@ SELECT COUNT(wbp.value) AS count FROM unique_template_params utp JOIN workspace_build_parameters wbp ON (utp.workspace_build_ids @> ARRAY[wbp.workspace_build_id] AND utp.name = wbp.name) -GROUP BY utp.num, utp.name, utp.display_name, utp.description, utp.options, utp.template_ids, wbp.value; +GROUP BY utp.num, utp.name, utp.display_name, utp.description, utp.options, utp.template_ids, utp.type, wbp.value; diff --git a/coderd/insights.go b/coderd/insights.go index b643303dd0df2..509e78a18e234 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -2,10 +2,8 @@ package coderd import ( "context" - "encoding/json" "fmt" "net/http" - "sort" "time" "github.com/google/uuid" @@ -13,6 +11,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/db2sdk" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" @@ -244,7 +243,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { return } - parametersUsage, err := convertTemplateInsightsParameters(parameterRows) + parametersUsage, err := db2sdk.TemplateInsightsParameters(parameterRows) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error converting template parameter insights.", @@ -315,39 +314,6 @@ func convertTemplateInsightsBuiltinApps(usage database.GetTemplateInsightsRow) [ } } -func convertTemplateInsightsParameters(parameterRows []database.GetTemplateParameterInsightsRow) ([]codersdk.TemplateParameterUsage, error) { - parametersByNum := make(map[int64]*codersdk.TemplateParameterUsage) - for _, param := range parameterRows { - if _, ok := parametersByNum[param.Num]; !ok { - var opts []codersdk.TemplateVersionParameterOption - err := json.Unmarshal(param.Options, &opts) - if err != nil { - return nil, xerrors.Errorf("unmarshal template parameter options: %w", err) - } - parametersByNum[param.Num] = &codersdk.TemplateParameterUsage{ - TemplateIDs: param.TemplateIDs, - Name: param.Name, - DisplayName: param.DisplayName, - Options: opts, - } - } - parametersByNum[param.Num].Values = append(parametersByNum[param.Num].Values, codersdk.TemplateParameterValue{ - Value: param.Value, - Count: param.Count, - }) - } - parametersUsage := []codersdk.TemplateParameterUsage{} - for _, param := range parametersByNum { - parametersUsage = append(parametersUsage, *param) - } - - sort.Slice(parametersUsage, func(i, j int) bool { - return parametersUsage[i].Name < parametersUsage[j].Name - }) - - return parametersUsage, nil -} - // parseInsightsStartAndEndTime parses the start and end time query parameters // and returns the parsed values. The client provided timezone must be preserved // when parsing the time. Verification is performed so that the start and end diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 2469cc0f4b362..be75b31f5e80e 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -405,6 +405,8 @@ func TestTemplateInsights(t *testing.T) { // The workspace uses 3 parameters require.Len(t, resp.Report.ParametersUsage, 3) assert.Equal(t, firstParameterName, resp.Report.ParametersUsage[0].Name) + assert.Equal(t, firstParameterType, resp.Report.ParametersUsage[0].Type) + assert.Equal(t, firstParameterDescription, resp.Report.ParametersUsage[0].Description) assert.Equal(t, firstParameterDisplayName, resp.Report.ParametersUsage[0].DisplayName) assert.Contains(t, resp.Report.ParametersUsage[0].Values, codersdk.TemplateParameterValue{ Value: firstParameterValue, @@ -414,6 +416,8 @@ func TestTemplateInsights(t *testing.T) { assert.Empty(t, resp.Report.ParametersUsage[0].Options) assert.Equal(t, secondParameterName, resp.Report.ParametersUsage[1].Name) + assert.Equal(t, secondParameterType, resp.Report.ParametersUsage[1].Type) + assert.Equal(t, secondParameterDescription, resp.Report.ParametersUsage[1].Description) assert.Equal(t, secondParameterDisplayName, resp.Report.ParametersUsage[1].DisplayName) assert.Contains(t, resp.Report.ParametersUsage[1].Values, codersdk.TemplateParameterValue{ Value: secondParameterValue, @@ -423,6 +427,8 @@ func TestTemplateInsights(t *testing.T) { assert.Empty(t, resp.Report.ParametersUsage[1].Options) assert.Equal(t, thirdParameterName, resp.Report.ParametersUsage[2].Name) + assert.Equal(t, thirdParameterType, resp.Report.ParametersUsage[2].Type) + assert.Equal(t, thirdParameterDescription, resp.Report.ParametersUsage[2].Description) assert.Equal(t, thirdParameterDisplayName, resp.Report.ParametersUsage[2].DisplayName) assert.Contains(t, resp.Report.ParametersUsage[2].Values, codersdk.TemplateParameterValue{ Value: thirdParameterValue, diff --git a/codersdk/insights.go b/codersdk/insights.go index bfc51bd208dd6..24e2ba140dd76 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -138,6 +138,8 @@ type TemplateParameterUsage struct { TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` DisplayName string `json:"display_name"` Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` Options []TemplateVersionParameterOption `json:"options,omitempty"` Values []TemplateParameterValue `json:"values"` } diff --git a/docs/api/insights.md b/docs/api/insights.md index 11843e63f0316..3c421c64173cd 100644 --- a/docs/api/insights.md +++ b/docs/api/insights.md @@ -80,6 +80,7 @@ curl -X GET http://coder-server:8080/api/v2/insights/templates \ "end_time": "2019-08-24T14:15:22Z", "parameters_usage": [ { + "description": "string", "display_name": "string", "name": "string", "options": [ @@ -91,6 +92,7 @@ curl -X GET http://coder-server:8080/api/v2/insights/templates \ } ], "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "string", "values": [ { "count": 0, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 192cc9625eddd..2f09bfb6728ae 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4321,6 +4321,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "end_time": "2019-08-24T14:15:22Z", "parameters_usage": [ { + "description": "string", "display_name": "string", "name": "string", "options": [ @@ -4332,6 +4333,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } ], "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "string", "values": [ { "count": 0, @@ -4384,6 +4386,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "end_time": "2019-08-24T14:15:22Z", "parameters_usage": [ { + "description": "string", "display_name": "string", "name": "string", "options": [ @@ -4395,6 +4398,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } ], "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "string", "values": [ { "count": 0, @@ -4420,6 +4424,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "description": "string", "display_name": "string", "name": "string", "options": [ @@ -4431,6 +4436,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } ], "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "string", "values": [ { "count": 0, @@ -4444,10 +4450,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | | -------------- | ------------------------------------------------------------------------------------------- | -------- | ------------ | ----------- | +| `description` | string | false | | | | `display_name` | string | false | | | | `name` | string | false | | | | `options` | array of [codersdk.TemplateVersionParameterOption](#codersdktemplateversionparameteroption) | false | | | | `template_ids` | array of string | false | | | +| `type` | string | false | | | | `values` | array of [codersdk.TemplateParameterValue](#codersdktemplateparametervalue) | false | | | ## codersdk.TemplateParameterValue diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f8b91abb925b6..7b9b79d69b3db 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -987,6 +987,8 @@ export interface TemplateParameterUsage { readonly template_ids: string[] readonly display_name: string readonly name: string + readonly type: string + readonly description: string readonly options?: TemplateVersionParameterOption[] readonly values: TemplateParameterValue[] } From 9f76381fc1c0bc986951dd86c7de32413895a597 Mon Sep 17 00:00:00 2001 From: sharkymark Date: Mon, 7 Aug 2023 11:31:43 -0500 Subject: [PATCH 027/277] chore: add install more providers step in jetbrains docs; update images (#8943) --- docs/ides/gateway.md | 107 +++++++++++------- .../gateway/plugin-connect-to-coder.png | Bin 12715 -> 43688 bytes docs/images/gateway/plugin-ide-list.png | Bin 20753 -> 90242 bytes docs/images/gateway/plugin-select-ide.png | Bin 23405 -> 87207 bytes docs/images/gateway/plugin-session-token.png | Bin 29095 -> 135600 bytes .../gateway/plugin-settings-marketplace.png | Bin 58881 -> 115955 bytes 6 files changed, 64 insertions(+), 43 deletions(-) diff --git a/docs/ides/gateway.md b/docs/ides/gateway.md index cb5b62be53f3f..3f2afbd9b55c3 100644 --- a/docs/ides/gateway.md +++ b/docs/ides/gateway.md @@ -1,43 +1,62 @@ # JetBrains Gateway -JetBrains Gateway is a compact desktop app that allows you to work remotely with a JetBrains IDE without even downloading one. [See JetBrains' website to learn about and Gateway.](https://www.jetbrains.com/remote-development/gateway/) +JetBrains Gateway is a compact desktop app that allows you to work remotely with +a JetBrains IDE without even downloading one. [See JetBrains' website to learn +about and Gateway.](https://www.jetbrains.com/remote-development/gateway/) -Gateway can connect to a Coder workspace by using Coder's Gateway plugin or manually setting up an SSH connection. +Gateway can connect to a Coder workspace by using Coder's Gateway plugin or +manually setting up an SSH connection. ## Using Coder's JetBrains Gateway Plugin -> If you experience problems, please [create a GitHub issue](https://github.com/coder/coder/issues) or share in [our Discord channel](https://discord.gg/coder). +> If you experience problems, please [create a GitHub +> issue](https://github.com/coder/coder/issues) or share in [our Discord +> channel](https://discord.gg/coder). 1. [Install Gateway](https://www.jetbrains.com/help/idea/jetbrains-gateway.html) -1. Open Gateway and click the gear icon at the bottom left and then "Settings" -1. In the Marketplace tab within Plugins, type Coder and then click "Install" and "OK" - ![Gateway Settings and Marketplace](../images/gateway/plugin-settings-marketplace.png) -1. Click the new "Coder" icon on the Gateway home screen - ![Gateway Connect to Coder](../images/gateway/plugin-connect-to-coder.png) -1. Enter your Coder deployment's Access Url and click "Connect" then paste the Session Token and click "OK" - ![Gateway Session Token](../images/gateway/plugin-session-token.png) -1. Click the "+" icon to open a browser and go to the templates page in your Coder deployment to create a workspace -1. If a workspace already exists but is stopped, click the green arrow to start the workspace +1. Open Gateway and click the Coder icon to install the Coder plugin. +1. Click the "Coder" icon under Install More Providers at the bottom of the + Gateway home screen +1. Click "Connect to Coder" at the top of the Gateway home screen to launch the + plugin ![Gateway Connect to +Coder](../images/gateway/plugin-connect-to-coder.png) +1. Enter your Coder deployment's Access Url and click "Connect" then paste the + Session Token and click "OK" ![Gateway Session +Token](../images/gateway/plugin-session-token.png) +1. Click the "+" icon to open a browser and go to the templates page in your + Coder deployment to create a workspace +1. If a workspace already exists but is stopped, click the green arrow to start + the workspace 1. Once the workspace status says Running, click "Select IDE and Project" ![Gateway IDE List](../images/gateway/plugin-select-ide.png) -1. Select the JetBrains IDE for your project and the project directory then click "Start IDE and connect" - ![Gateway Select IDE](../images/gateway/plugin-ide-list.png) - ![Gateway IDE Opened](../images/gateway/gateway-intellij-opened.png) +1. Select the JetBrains IDE for your project and the project directory then + click "Start IDE and connect" ![Gateway Select +IDE](../images/gateway/plugin-ide-list.png) ![Gateway IDE +Opened](../images/gateway/gateway-intellij-opened.png) -> Note the JetBrains IDE is remotely installed into `~/.cache/JetBrains/RemoteDev/dist` +> Note the JetBrains IDE is remotely installed into +> `~/.cache/JetBrains/RemoteDev/dist` + +### Update a Coder plugin version + +1. Click the gear icon at the bottom left of the Gateway home screen and then + "Settings" +1. In the Marketplace tab within Plugins, type Coder and if a newer plugin + release is available, click "Update" and "OK" ![Gateway Settings and +Marketplace](../images/gateway/plugin-settings-marketplace.png) ### Configuring the Gateway plugin to use internal certificates -When attempting to connect to a Coder deployment that uses internally signed certificates, -you may receive the following error in Gateway: +When attempting to connect to a Coder deployment that uses internally signed +certificates, you may receive the following error in Gateway: ```console Failed to configure connection to https://coder.internal.enterprise/: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target ``` -To resolve this issue, you will need to add Coder's certificate to the Java trust store -present on your local machine. Here is the default location of the trust store for -each OS: +To resolve this issue, you will need to add Coder's certificate to the Java +trust store present on your local machine. Here is the default location of the +trust store for each OS: ```console # Linux @@ -52,8 +71,8 @@ C:\Program Files (x86)\\jre\lib\security\cacerts %USERPROFILE%\AppData\Local\JetBrains\Toolbox\bin\jre\lib\security\cacerts # Path for Toolbox installation ``` -To add the certificate to the keystore, you can use the `keytool` utility that ships -with Java: +To add the certificate to the keystore, you can use the `keytool` utility that +ships with Java: ```console keytool -import -alias coder -file -keystore /path/to/trust/store @@ -77,37 +96,37 @@ keytool -import -alias coder -file cacert.pem -keystore /Applications/JetBrains\ ## Manually Configuring A JetBrains Gateway Connection -> This is in lieu of using Coder's Gateway plugin which automatically performs these steps. +> This is in lieu of using Coder's Gateway plugin which automatically performs +> these steps. 1. [Install Gateway](https://www.jetbrains.com/help/idea/jetbrains-gateway.html) 1. [Configure the `coder` CLI](../ides.md#ssh-configuration) 1. Open Gateway, make sure "SSH" is selected under "Remote Development" -1. Click "New Connection" - ![Gateway Home](../images/gateway/gateway-home.png) +1. Click "New Connection" ![Gateway Home](../images/gateway/gateway-home.png) 1. In the resulting dialog, click the gear icon to the right of "Connection:" ![Gateway New Connection](../images/gateway/gateway-new-connection.png) -1. Hit the "+" button to add a new SSH connection - ![Gateway Add Connection](../images/gateway/gateway-add-ssh-configuration.png) +1. Hit the "+" button to add a new SSH connection ![Gateway Add +Connection](../images/gateway/gateway-add-ssh-configuration.png) 1. For the Host, enter `coder.` 1. For the Port, enter `22` (this is ignored by Coder) 1. For the Username, enter your workspace username -1. For the Authentication Type, select "OpenSSH config and authentication - agent" +1. For the Authentication Type, select "OpenSSH config and authentication agent" 1. Make sure the checkbox for "Parse config file ~/.ssh/config" is checked. 1. Click "Test Connection" to validate these settings. -1. Click "OK" - ![Gateway SSH Configuration](../images/gateway/gateway-create-ssh-configuration.png) -1. Select the connection you just added - ![Gateway Welcome](../images/gateway/gateway-welcome.png) -1. Click "Check Connection and Continue" - ![Gateway Continue](../images/gateway/gateway-continue.png) -1. Select the JetBrains IDE for your project and the project directory. - SSH into your server to create a directory or check out code if you haven't already. +1. Click "OK" ![Gateway SSH +Configuration](../images/gateway/gateway-create-ssh-configuration.png) +1. Select the connection you just added ![Gateway +Welcome](../images/gateway/gateway-welcome.png) +1. Click "Check Connection and Continue" ![Gateway +Continue](../images/gateway/gateway-continue.png) +1. Select the JetBrains IDE for your project and the project directory. SSH into + your server to create a directory or check out code if you haven't already. ![Gateway Choose IDE](../images/gateway/gateway-choose-ide.png) - > Note the JetBrains IDE is remotely installed into `~/. cache/JetBrains/RemoteDev/dist` -1. Click "Download and Start IDE" to connect. - ![Gateway IDE Opened](../images/gateway/gateway-intellij-opened.png) + > Note the JetBrains IDE is remotely installed into `~/. +cache/JetBrains/RemoteDev/dist` +1. Click "Download and Start IDE" to connect. ![Gateway IDE +Opened](../images/gateway/gateway-intellij-opened.png) ## Using an existing JetBrains installation in the workspace @@ -121,7 +140,9 @@ cd /opt/idea/bin ./remote-dev-server.sh registerBackendLocationForGateway ``` -> Gateway only works with paid versions of JetBrains IDEs so the script will not be located in the `bin` directory of JetBrains Community editions. +> Gateway only works with paid versions of JetBrains IDEs so the script will not +> be located in the `bin` directory of JetBrains Community editions. -[Here is the JetBrains article](https://www.jetbrains.com/help/idea/remote-development-troubleshooting.html#setup:~:text=Can%20I%20point%20Remote%20Development%20to%20an%20existing%20IDE%20on%20my%20remote%20server%3F%20Is%20it%20possible%20to%20install%20IDE%20manually%3F) +[Here is the JetBrains +article](https://www.jetbrains.com/help/idea/remote-development-troubleshooting.html#setup:~:text=Can%20I%20point%20Remote%20Development%20to%20an%20existing%20IDE%20on%20my%20remote%20server%3F%20Is%20it%20possible%20to%20install%20IDE%20manually%3F) explaining this IDE specification. diff --git a/docs/images/gateway/plugin-connect-to-coder.png b/docs/images/gateway/plugin-connect-to-coder.png index 11cecf4ad69feb1e6e31f32b48476c955fe855e7..295efa78973866b35be36002e30832ed35cfc317 100644 GIT binary patch literal 43688 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 TXXWq|@X=N`_@hMCCj9>Z|D)AT literal 12715 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 dEqHMslcuDQOz={{Y0EHOl}1 diff --git a/docs/images/gateway/plugin-ide-list.png b/docs/images/gateway/plugin-ide-list.png index 40b9f2f71bcc8657795c50c6337318d0304fa301..0f767abfd0dd1af251fdf090d996b357b26c5a03 100644 GIT binary patch literal 90242 zcmeFXWmH|s@&^h8cM0wug1fuJ!JQBYe(>P#?gV$2;O_1ag8RXOySqO!bLY;dDVSaZp^tu zkLvQ-w8Q-*ofSO07Lr8^s{}fRel$)J`o4M-1AQ@Hm*gE>NH&Z-gnmT?J{lTya^#73 z{9y#30I`nB;?D8In*T9OMNXSSrnq=`O-F6Coaop?k3uSwGGr^6KIu zH(5k=+n3P(Ats`!-TWtU>SLQ!8W}lX61ib=XIu^zg;?4V^q3sJLy%cDQx)N0s_W$r%6iuIoNrdPm5;Tr2#cyD|Nhip^aUM!>0&rAOZw?*rC zaW|PqH-;2~XY4AU&!SPXoW=VC;$Bl%+$3Tui(!ZM8euALQ-WI$o!2t$t5Xwii!+f^(e?IdxpP^gM7S7rb5!V;5CaAV@#i&s zg2oJ5>H#de>6rOfyE~FJ<2(9{TMLV@+pIrP1H>X&&Pc#|s!;F^y5P8e`Op*fZHMk6 zw&(rIB2S}D$}iZhZq`f+UGylPu!dNgT%Cmb^4(NYH?;9vZ+i$Z1Lt!43xA($^z|@^ zSx*L6$Pg{@;U#(80Rb!zH9JTAd(3Y1T@VNkRtO5ZN(ctA3zDuIi=Z2muN%9R3$lk4 z5B&=?EGc1)FmkQF0G%({2VeFoqL2EtM?U;j#KznC;vgAR7;%oTzCI6p*w9zFequ(i8a34X-_7 zRf2aCdk+lx>qjp7bhI(7!S4Ib9^23+)pUYJRE-GQLAA|;ucJ-s%W^u@CzK}`nIWbd zi!Ptqs0DC{{joPp*ReM=HfYb?¥F@6zrVJOp3xedQ;}7f|_NJw*6OBbm|^#Xe9U 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 XK6Y!fkg*XD_xDgy?S7HG$?N|GLNRpQ literal 20753 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 literal 29095 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 diff --git a/docs/images/gateway/plugin-settings-marketplace.png b/docs/images/gateway/plugin-settings-marketplace.png index 58ccf8155992e3b9b6a997d2e73ded562ccd322f..7973fa99811a3e763451cbe59bdc487e28a842dc 100644 GIT binary patch literal 115955 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 literal 58881 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 GdGa4E%$X$s From b67ece1cbcb7cbdc7776c05ba8ccc3a11f17684b Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Mon, 7 Aug 2023 09:45:15 -0700 Subject: [PATCH 028/277] fix: image formatting for gateway docs (#8947) --- docs/ides/gateway.md | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/docs/ides/gateway.md b/docs/ides/gateway.md index 3f2afbd9b55c3..89c793dd3e3f0 100644 --- a/docs/ides/gateway.md +++ b/docs/ides/gateway.md @@ -18,11 +18,10 @@ manually setting up an SSH connection. 1. Click the "Coder" icon under Install More Providers at the bottom of the Gateway home screen 1. Click "Connect to Coder" at the top of the Gateway home screen to launch the - plugin ![Gateway Connect to -Coder](../images/gateway/plugin-connect-to-coder.png) -1. Enter your Coder deployment's Access Url and click "Connect" then paste the - Session Token and click "OK" ![Gateway Session -Token](../images/gateway/plugin-session-token.png) + plugin + ![Gateway Connect to Coder](../images/gateway/plugin-connect-to-coder.png) +1. Enter your Coder deployment's Access Url and click "Connect" then paste the Session Token and click "OK" + ![Gateway Session Token](../images/gateway/plugin-session-token.png) 1. Click the "+" icon to open a browser and go to the templates page in your Coder deployment to create a workspace 1. If a workspace already exists but is stopped, click the green arrow to start @@ -30,9 +29,8 @@ Token](../images/gateway/plugin-session-token.png) 1. Once the workspace status says Running, click "Select IDE and Project" ![Gateway IDE List](../images/gateway/plugin-select-ide.png) 1. Select the JetBrains IDE for your project and the project directory then - click "Start IDE and connect" ![Gateway Select -IDE](../images/gateway/plugin-ide-list.png) ![Gateway IDE -Opened](../images/gateway/gateway-intellij-opened.png) + click "Start IDE and connect" ![Gateway Select IDE](../images/gateway/plugin-ide-list.png) + ![Gateway IDE Opened](../images/gateway/gateway-intellij-opened.png) > Note the JetBrains IDE is remotely installed into > `~/.cache/JetBrains/RemoteDev/dist` @@ -42,8 +40,7 @@ Opened](../images/gateway/gateway-intellij-opened.png) 1. Click the gear icon at the bottom left of the Gateway home screen and then "Settings" 1. In the Marketplace tab within Plugins, type Coder and if a newer plugin - release is available, click "Update" and "OK" ![Gateway Settings and -Marketplace](../images/gateway/plugin-settings-marketplace.png) + release is available, click "Update" and "OK" ![Gateway Settings and Marketplace](../images/gateway/plugin-settings-marketplace.png) ### Configuring the Gateway plugin to use internal certificates @@ -116,17 +113,17 @@ Connection](../images/gateway/gateway-add-ssh-configuration.png) 1. Click "Test Connection" to validate these settings. 1. Click "OK" ![Gateway SSH Configuration](../images/gateway/gateway-create-ssh-configuration.png) -1. Select the connection you just added ![Gateway -Welcome](../images/gateway/gateway-welcome.png) -1. Click "Check Connection and Continue" ![Gateway -Continue](../images/gateway/gateway-continue.png) +1. Select the connection you just added + ![Gateway Welcome](../images/gateway/gateway-welcome.png) +1. Click "Check Connection and Continue" + ![Gateway Continue](../images/gateway/gateway-continue.png) 1. Select the JetBrains IDE for your project and the project directory. SSH into your server to create a directory or check out code if you haven't already. ![Gateway Choose IDE](../images/gateway/gateway-choose-ide.png) > Note the JetBrains IDE is remotely installed into `~/. cache/JetBrains/RemoteDev/dist` -1. Click "Download and Start IDE" to connect. ![Gateway IDE -Opened](../images/gateway/gateway-intellij-opened.png) +1. Click "Download and Start IDE" to connect. + ![Gateway IDE Opened](../images/gateway/gateway-intellij-opened.png) ## Using an existing JetBrains installation in the workspace From 3b16e7112dbdb7010c8836990de8d46bae68a28c Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Mon, 7 Aug 2023 10:34:41 -0700 Subject: [PATCH 029/277] fix: improve formatting in Gateway docs (#8949) * fix: image formatting for gateway docs * chore: fix some more spots * more * fmt * space things out more --- docs/ides/gateway.md | 54 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/ides/gateway.md b/docs/ides/gateway.md index 89c793dd3e3f0..b0550c378b668 100644 --- a/docs/ides/gateway.md +++ b/docs/ides/gateway.md @@ -19,17 +19,26 @@ manually setting up an SSH connection. Gateway home screen 1. Click "Connect to Coder" at the top of the Gateway home screen to launch the plugin + ![Gateway Connect to Coder](../images/gateway/plugin-connect-to-coder.png) + 1. Enter your Coder deployment's Access Url and click "Connect" then paste the Session Token and click "OK" + ![Gateway Session Token](../images/gateway/plugin-session-token.png) + 1. Click the "+" icon to open a browser and go to the templates page in your Coder deployment to create a workspace + 1. If a workspace already exists but is stopped, click the green arrow to start the workspace + 1. Once the workspace status says Running, click "Select IDE and Project" + ![Gateway IDE List](../images/gateway/plugin-select-ide.png) + 1. Select the JetBrains IDE for your project and the project directory then click "Start IDE and connect" ![Gateway Select IDE](../images/gateway/plugin-ide-list.png) + ![Gateway IDE Opened](../images/gateway/gateway-intellij-opened.png) > Note the JetBrains IDE is remotely installed into @@ -39,8 +48,11 @@ manually setting up an SSH connection. 1. Click the gear icon at the bottom left of the Gateway home screen and then "Settings" + 1. In the Marketplace tab within Plugins, type Coder and if a newer plugin - release is available, click "Update" and "OK" ![Gateway Settings and Marketplace](../images/gateway/plugin-settings-marketplace.png) + release is available, click "Update" and "OK" + + ![Gateway Settings and Marketplace](../images/gateway/plugin-settings-marketplace.png) ### Configuring the Gateway plugin to use internal certificates @@ -97,32 +109,56 @@ keytool -import -alias coder -file cacert.pem -keystore /Applications/JetBrains\ > these steps. 1. [Install Gateway](https://www.jetbrains.com/help/idea/jetbrains-gateway.html) + 1. [Configure the `coder` CLI](../ides.md#ssh-configuration) + 1. Open Gateway, make sure "SSH" is selected under "Remote Development" -1. Click "New Connection" ![Gateway Home](../images/gateway/gateway-home.png) + +1. Click "New Connection" + + ![Gateway Home](../images/gateway/gateway-home.png) + 1. In the resulting dialog, click the gear icon to the right of "Connection:" + ![Gateway New Connection](../images/gateway/gateway-new-connection.png) -1. Hit the "+" button to add a new SSH connection ![Gateway Add -Connection](../images/gateway/gateway-add-ssh-configuration.png) + +1. Hit the "+" button to add a new SSH connection + + ![Gateway Add Connection](../images/gateway/gateway-add-ssh-configuration.png) 1. For the Host, enter `coder.` + 1. For the Port, enter `22` (this is ignored by Coder) + 1. For the Username, enter your workspace username + 1. For the Authentication Type, select "OpenSSH config and authentication agent" + 1. Make sure the checkbox for "Parse config file ~/.ssh/config" is checked. + 1. Click "Test Connection" to validate these settings. -1. Click "OK" ![Gateway SSH -Configuration](../images/gateway/gateway-create-ssh-configuration.png) + +1. Click "OK" + + ![Gateway SSH Configuration](../images/gateway/gateway-create-ssh-configuration.png) + 1. Select the connection you just added - ![Gateway Welcome](../images/gateway/gateway-welcome.png) + + ![Gateway Welcome](../images/gaGteway/gateway-welcome.png) + 1. Click "Check Connection and Continue" + ![Gateway Continue](../images/gateway/gateway-continue.png) -1. Select the JetBrains IDE for your project and the project directory. SSH into - your server to create a directory or check out code if you haven't already. + +1. Select the JetBrains IDE for your project and the project directory. SSH into your server to create a directory or check out code if you haven't already. + ![Gateway Choose IDE](../images/gateway/gateway-choose-ide.png) + > Note the JetBrains IDE is remotely installed into `~/. cache/JetBrains/RemoteDev/dist` + 1. Click "Download and Start IDE" to connect. + ![Gateway IDE Opened](../images/gateway/gateway-intellij-opened.png) ## Using an existing JetBrains installation in the workspace From b2dc8897ff20f7610a2dff7491451d36b43346d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Aug 2023 14:16:03 -0500 Subject: [PATCH 030/277] chore: bump github.com/go-playground/validator/v10 from 10.14.0 to 10.15.0 (#8941) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fa411a3263e27..5715e42cd08a5 100644 --- a/go.mod +++ b/go.mod @@ -108,7 +108,7 @@ require ( github.com/go-jose/go-jose/v3 v3.0.0 github.com/go-logr/logr v1.2.4 github.com/go-ping/ping v1.1.0 - github.com/go-playground/validator/v10 v10.14.0 + github.com/go-playground/validator/v10 v10.15.0 github.com/gofrs/flock v0.8.1 github.com/gohugoio/hugo v0.116.0 github.com/golang-jwt/jwt v3.2.2+incompatible diff --git a/go.sum b/go.sum index 7e721762b664b..3ef2c00c75a85 100644 --- a/go.sum +++ b/go.sum @@ -334,8 +334,8 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= -github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= -github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-playground/validator/v10 v10.15.0 h1:nDU5XeOKtB3GEa+uB7GNYwhVKsgjAR7VgKoNB6ryXfw= +github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= From 827de08007bf42855ca13468295b820d087066b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Aug 2023 14:30:14 -0500 Subject: [PATCH 031/277] chore: bump the golang-x group with 3 updates (#8940) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 5715e42cd08a5..2bb28a73b9d28 100644 --- a/go.mod +++ b/go.mod @@ -169,14 +169,14 @@ require ( go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.2.1 go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf - golang.org/x/crypto v0.11.0 + golang.org/x/crypto v0.12.0 golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 golang.org/x/mod v0.12.0 - golang.org/x/net v0.12.0 - golang.org/x/oauth2 v0.10.0 + golang.org/x/net v0.14.0 + golang.org/x/oauth2 v0.11.0 golang.org/x/sync v0.3.0 - golang.org/x/sys v0.10.0 - golang.org/x/term v0.10.0 + golang.org/x/sys v0.11.0 + golang.org/x/term v0.11.0 golang.org/x/tools v0.11.0 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b @@ -356,7 +356,7 @@ require ( go.opentelemetry.io/otel/metric v1.16.0 // indirect go.opentelemetry.io/proto/otlp v0.19.0 // indirect go4.org/mem v0.0.0-20210711025021-927187094b94 // indirect - golang.org/x/text v0.11.0 + golang.org/x/text v0.12.0 golang.org/x/time v0.3.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230215201556-9c5414ab4bde // indirect diff --git a/go.sum b/go.sum index 3ef2c00c75a85..5e85b47943f66 100644 --- a/go.sum +++ b/go.sum @@ -947,8 +947,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1035,8 +1035,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1047,8 +1047,8 @@ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= -golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= +golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= +golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1146,14 +1146,14 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= 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= -golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= -golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1165,8 +1165,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= From 758c36822221c4254a51366148270c6bb1fe3a6b Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 7 Aug 2023 21:17:39 -0500 Subject: [PATCH 032/277] chore: fix `TestTailnet/ForcesWebSockets` flake (#8953) --- coderd/batchstats/batcher.go | 1 - tailnet/conn_test.go | 2 +- tailnet/tailnettest/tailnettest.go | 3 ++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/batchstats/batcher.go b/coderd/batchstats/batcher.go index fc177fd143d6a..e7c734862e8bc 100644 --- a/coderd/batchstats/batcher.go +++ b/coderd/batchstats/batcher.go @@ -13,7 +13,6 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/codersdk/agentsdk" diff --git a/tailnet/conn_test.go b/tailnet/conn_test.go index 99a88fabb2263..db526bfd3da02 100644 --- a/tailnet/conn_test.go +++ b/tailnet/conn_test.go @@ -99,7 +99,7 @@ func TestTailnet(t *testing.T) { t.Run("ForcesWebSockets", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) + ctx := testutil.Context(t, testutil.WaitMedium) w1IP := tailnet.IP() derpMap := tailnettest.RunDERPOnlyWebSockets(t) diff --git a/tailnet/tailnettest/tailnettest.go b/tailnet/tailnettest/tailnettest.go index 655568a341ccb..16fc92591cbd3 100644 --- a/tailnet/tailnettest/tailnettest.go +++ b/tailnet/tailnettest/tailnettest.go @@ -77,7 +77,8 @@ func RunDERPOnlyWebSockets(t *testing.T) *tailcfg.DERPMap { handler, closeFunc = tailnet.WithWebsocketSupport(d, handler) server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/derp" { - handler.ServeHTTP(w, r) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello")) return } if r.Header.Get("Upgrade") != "websocket" { From 7e3ff5b66e589c37b25f22b5610849679d8d938e Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 7 Aug 2023 21:55:31 -0500 Subject: [PATCH 033/277] chore: fix `TestBatchStats` flake (#8952) --- coderd/batchstats/batcher.go | 21 +++++++++++---------- coderd/batchstats/batcher_internal_test.go | 16 ++++++++-------- coderd/workspaceagents.go | 2 +- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/coderd/batchstats/batcher.go b/coderd/batchstats/batcher.go index e7c734862e8bc..7185d251e19b6 100644 --- a/coderd/batchstats/batcher.go +++ b/coderd/batchstats/batcher.go @@ -125,6 +125,7 @@ func New(ctx context.Context, opts ...Option) (*Batcher, func(), error) { // Add adds a stat to the batcher for the given workspace and agent. func (b *Batcher) Add( + now time.Time, agentID uuid.UUID, templateID uuid.UUID, userID uuid.UUID, @@ -134,7 +135,7 @@ func (b *Batcher) Add( b.mu.Lock() defer b.mu.Unlock() - now := database.Now() + now = database.Time(now) b.buf.ID = append(b.buf.ID, uuid.New()) b.buf.CreatedAt = append(b.buf.CreatedAt, now) @@ -198,15 +199,6 @@ func (b *Batcher) flush(ctx context.Context, forced bool, reason string) { defer func() { b.flushForced.Store(false) b.mu.Unlock() - // Notify that a flush has completed. This only happens in tests. - if b.flushed != nil { - select { - case <-ctx.Done(): - close(b.flushed) - default: - b.flushed <- count - } - } if count > 0 { elapsed := time.Since(start) b.log.Debug(ctx, "flush complete", @@ -216,6 +208,15 @@ func (b *Batcher) flush(ctx context.Context, forced bool, reason string) { slog.F("reason", reason), ) } + // Notify that a flush has completed. This only happens in tests. + if b.flushed != nil { + select { + case <-ctx.Done(): + close(b.flushed) + default: + b.flushed <- count + } + } }() if len(b.buf.ID) == 0 { diff --git a/coderd/batchstats/batcher_internal_test.go b/coderd/batchstats/batcher_internal_test.go index a6e28f1a9f389..8288442400a3e 100644 --- a/coderd/batchstats/batcher_internal_test.go +++ b/coderd/batchstats/batcher_internal_test.go @@ -46,7 +46,7 @@ func TestBatchStats(t *testing.T) { // Given: no data points are added for workspace // When: it becomes time to report stats - t1 := time.Now() + t1 := database.Now() // Signal a tick and wait for a flush to complete. tick <- t1 f := <-flushed @@ -59,9 +59,9 @@ func TestBatchStats(t *testing.T) { require.Empty(t, stats, "should have no stats for workspace") // Given: a single data point is added for workspace - t2 := time.Now() + t2 := t1.Add(time.Second) t.Logf("inserting 1 stat") - require.NoError(t, b.Add(deps1.Agent.ID, deps1.User.ID, deps1.Template.ID, deps1.Workspace.ID, randAgentSDKStats(t))) + require.NoError(t, b.Add(t2.Add(time.Millisecond), deps1.Agent.ID, deps1.User.ID, deps1.Template.ID, deps1.Workspace.ID, randAgentSDKStats(t))) // When: it becomes time to report stats // Signal a tick and wait for a flush to complete. @@ -77,7 +77,7 @@ func TestBatchStats(t *testing.T) { // Given: a lot of data points are added for both workspaces // (equal to batch size) - t3 := time.Now() + t3 := t2.Add(time.Second) done := make(chan struct{}) go func() { @@ -85,9 +85,9 @@ func TestBatchStats(t *testing.T) { t.Logf("inserting %d stats", defaultBufferSize) for i := 0; i < defaultBufferSize; i++ { if i%2 == 0 { - require.NoError(t, b.Add(deps1.Agent.ID, deps1.User.ID, deps1.Template.ID, deps1.Workspace.ID, randAgentSDKStats(t))) + require.NoError(t, b.Add(t3.Add(time.Millisecond), deps1.Agent.ID, deps1.User.ID, deps1.Template.ID, deps1.Workspace.ID, randAgentSDKStats(t))) } else { - require.NoError(t, b.Add(deps2.Agent.ID, deps2.User.ID, deps2.Template.ID, deps2.Workspace.ID, randAgentSDKStats(t))) + require.NoError(t, b.Add(t3.Add(time.Millisecond), deps2.Agent.ID, deps2.User.ID, deps2.Template.ID, deps2.Workspace.ID, randAgentSDKStats(t))) } } }() @@ -105,7 +105,7 @@ func TestBatchStats(t *testing.T) { require.Len(t, stats, 2, "should have stats for both workspaces") // Ensures that a subsequent flush pushes all the remaining data - t4 := time.Now() + t4 := t3.Add(time.Second) tick <- t4 f2 := <-flushed t.Logf("flush 4 completed") @@ -113,7 +113,7 @@ func TestBatchStats(t *testing.T) { require.Equal(t, expectedCount, f2, "did not flush expected remaining rows") // Ensure that a subsequent flush does not push stale data. - t5 := time.Now() + t5 := t4.Add(time.Second) tick <- t5 f = <-flushed require.Zero(t, f, "expected zero stats to have been flushed") diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 0f5607db73436..545f3e7c6ed84 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1414,7 +1414,7 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques var errGroup errgroup.Group errGroup.Go(func() error { - if err := api.statsBatcher.Add(workspaceAgent.ID, workspace.TemplateID, workspace.OwnerID, workspace.ID, req); err != nil { + if err := api.statsBatcher.Add(time.Now(), workspaceAgent.ID, workspace.TemplateID, workspace.OwnerID, workspace.ID, req); err != nil { api.Logger.Error(ctx, "failed to add stats to batcher", slog.Error(err)) return xerrors.Errorf("can't insert workspace agent stat: %w", err) } From 694729b4f7c4023f01b6accd6ce8f7fc8427d90b Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 7 Aug 2023 22:23:00 -0500 Subject: [PATCH 034/277] chore: disable goleak in windows cli tests (#8955) --- cli/root_internal_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cli/root_internal_test.go b/cli/root_internal_test.go index e8c463e95cc90..2d99ab8247518 100644 --- a/cli/root_internal_test.go +++ b/cli/root_internal_test.go @@ -1,6 +1,8 @@ package cli import ( + "os" + "runtime" "testing" "github.com/stretchr/testify/require" @@ -67,6 +69,11 @@ func Test_formatExamples(t *testing.T) { } func TestMain(m *testing.M) { + if runtime.GOOS == "windows" { + // Don't run goleak on windows tests, they're super flaky right now. + // See: https://github.com/coder/coder/issues/8954 + os.Exit(m.Run()) + } goleak.VerifyTestMain(m, // The lumberjack library is used by by agent and seems to leave // goroutines after Close(), fails TestGitSSH tests. From 73b136e3f0203a593c1c089ad7592423c5ad4fe0 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 7 Aug 2023 21:29:35 -0700 Subject: [PATCH 035/277] fix: add exp backoff to validate fresh git auth tokens (#8956) A customer using GitHub in Australia reported that validating immediately after refreshing the token would intermittently fail with a 401. Waiting a few milliseconds with the exact same token on the exact same request would resolve the issue. It seems likely that the write is not propagating to the read replica in time. --- coderd/gitauth/config.go | 18 +++++++++++++++++- coderd/gitauth/config_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/coderd/gitauth/config.go b/coderd/gitauth/config.go index 29d4804dcd538..1387acee7ebf1 100644 --- a/coderd/gitauth/config.go +++ b/coderd/gitauth/config.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "regexp" + "time" "golang.org/x/oauth2" "golang.org/x/xerrors" @@ -17,6 +18,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/codersdk" + "github.com/coder/retry" ) type OAuth2Config interface { @@ -75,12 +77,26 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, gitAuthLin // we aren't trying to surface an error, we're just trying to obtain a valid token. return gitAuthLink, false, nil } - + r := retry.New(50*time.Millisecond, 200*time.Millisecond) + // See the comment below why the retry and cancel is required. + retryCtx, retryCtxCancel := context.WithTimeout(ctx, time.Second) + defer retryCtxCancel() +validate: valid, _, err := c.ValidateToken(ctx, token.AccessToken) if err != nil { return gitAuthLink, false, xerrors.Errorf("validate git auth token: %w", err) } if !valid { + // A customer using GitHub in Australia reported that validating immediately + // after refreshing the token would intermittently fail with a 401. Waiting + // a few milliseconds with the exact same token on the exact same request + // would resolve the issue. It seems likely that the write is not propagating + // to the read replica in time. + // + // We do an exponential backoff here to give the write time to propagate. + if c.Type == codersdk.GitProviderGitHub && r.Wait(retryCtx) { + goto validate + } // The token is no longer valid! return gitAuthLink, false, nil } diff --git a/coderd/gitauth/config_test.go b/coderd/gitauth/config_test.go index 31d6392341426..f58531fdf773f 100644 --- a/coderd/gitauth/config_test.go +++ b/coderd/gitauth/config_test.go @@ -73,6 +73,39 @@ func TestRefreshToken(t *testing.T) { require.NoError(t, err) require.False(t, refreshed) }) + t.Run("ValidateRetryGitHub", func(t *testing.T) { + t.Parallel() + hit := false + // We need to ensure that the exponential backoff kicks in properly. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !hit { + hit = true + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Not permitted")) + return + } + w.WriteHeader(http.StatusOK) + })) + config := &gitauth.Config{ + ID: "test", + OAuth2Config: &testutil.OAuth2Config{ + Token: &oauth2.Token{ + AccessToken: "updated", + }, + }, + ValidateURL: srv.URL, + Type: codersdk.GitProviderGitHub, + } + db := dbfake.New() + link := dbgen.GitAuthLink(t, db, database.GitAuthLink{ + ProviderID: config.ID, + OAuthAccessToken: "initial", + }) + _, refreshed, err := config.RefreshToken(context.Background(), db, link) + require.NoError(t, err) + require.True(t, refreshed) + require.True(t, hit) + }) t.Run("ValidateNoUpdate", func(t *testing.T) { t.Parallel() validated := make(chan struct{}) From bac3a588b3ecc82bca5e51dd7baf16d0cf43c5fe Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 7 Aug 2023 22:36:46 -0700 Subject: [PATCH 036/277] chore: add e2e test for backwards client ssh compatibility (#8958) * chore: add e2e test for backwards client ssh compatibility This was discussed as part of our regression review for outdated agents, so here is the reverse with an extremely old client. * fmt --- site/e2e/helpers.ts | 18 +++++------ site/e2e/tests/outdatedCLI.spec.ts | 52 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 site/e2e/tests/outdatedCLI.spec.ts diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index dfbd5f99896a2..b86a7c756f3a8 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -65,20 +65,18 @@ export const createTemplate = async ( export const sshIntoWorkspace = async ( page: Page, workspace: string, + binaryPath = "go", + binaryArgs = ["run", coderMainPath()], ): Promise => { const sessionToken = await findSessionToken(page) return new Promise((resolve, reject) => { - const cp = spawn( - "go", - ["run", coderMainPath(), "ssh", "--stdio", workspace], - { - env: { - ...process.env, - CODER_SESSION_TOKEN: sessionToken, - CODER_URL: "http://localhost:3000", - }, + const cp = spawn(binaryPath, [...binaryArgs, "ssh", "--stdio", workspace], { + env: { + ...process.env, + CODER_SESSION_TOKEN: sessionToken, + CODER_URL: "http://localhost:3000", }, - ) + }) cp.on("error", (err) => reject(err)) const proxyStream = new Duplex({ read: (size) => { diff --git a/site/e2e/tests/outdatedCLI.spec.ts b/site/e2e/tests/outdatedCLI.spec.ts new file mode 100644 index 0000000000000..ab143bad27c34 --- /dev/null +++ b/site/e2e/tests/outdatedCLI.spec.ts @@ -0,0 +1,52 @@ +import { test } from "@playwright/test" +import { randomUUID } from "crypto" +import { + createTemplate, + createWorkspace, + downloadCoderVersion, + sshIntoWorkspace, + startAgent, +} from "../helpers" + +const clientVersion = "v0.14.0" + +test("ssh with client " + clientVersion, async ({ page }) => { + const token = randomUUID() + const template = await createTemplate(page, { + apply: [ + { + complete: { + resources: [ + { + agents: [ + { + token, + }, + ], + }, + ], + }, + }, + ], + }) + const workspace = await createWorkspace(page, template) + await startAgent(page, token) + const binaryPath = await downloadCoderVersion(clientVersion) + + const client = await sshIntoWorkspace(page, workspace, binaryPath) + await new Promise((resolve, reject) => { + // We just exec a command to be certain the agent is running! + client.exec("exit 0", (err, stream) => { + if (err) { + return reject(err) + } + stream.on("exit", (code) => { + if (code !== 0) { + return reject(new Error(`Command exited with code ${code}`)) + } + client.end() + resolve() + }) + }) + }) +}) From 31b7de6a3ea9112d2f1fad5e7f364908c39f19e3 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Tue, 8 Aug 2023 09:20:36 +0300 Subject: [PATCH 037/277] chore: upgrade go to 1.20.7 (#8923) * chore: upgrade go to 1.20.7 * remove unused env --- .github/actions/setup-go/action.yaml | 2 +- .github/workflows/ci.yaml | 2 +- .github/workflows/release.yaml | 4 ---- .github/workflows/security.yaml | 3 --- dogfood/Dockerfile | 2 +- 5 files changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/actions/setup-go/action.yaml b/.github/actions/setup-go/action.yaml index 4d696ef298b12..968f1f1ab8d27 100644 --- a/.github/actions/setup-go/action.yaml +++ b/.github/actions/setup-go/action.yaml @@ -4,7 +4,7 @@ description: | inputs: version: description: "The Go version to use." - default: "1.20.6" + default: "1.20.7" runs: using: "composite" steps: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bc1af416e3901..67ecdec43d0f5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -224,7 +224,7 @@ jobs: with: # This doesn't need caching. It's super fast anyways! cache: false - go-version: 1.20.6 + go-version: 1.20.7 - name: Install shfmt run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.5.0 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d78fbe7c5a5b9..ab27aa12d117c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -28,10 +28,6 @@ env: # https://github.blog/changelog/2022-06-10-github-actions-inputs-unified-across-manual-and-reusable-workflows/ CODER_RELEASE: ${{ !inputs.dry_run }} CODER_DRY_RUN: ${{ inputs.dry_run }} - # For some reason, setup-go won't actually pick up a new patch version if - # it has an old one cached. We need to manually specify the versions so we - # can get the latest release. Never use "~1.xx" here! - CODER_GO_VERSION: "1.20.6" jobs: release: diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index 48c8f16fd9bea..04c3b1562147b 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -21,9 +21,6 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }}-security cancel-in-progress: ${{ github.event_name == 'pull_request' }} -env: - CODER_GO_VERSION: "1.20.6" - jobs: codeql: runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }} diff --git a/dogfood/Dockerfile b/dogfood/Dockerfile index c5f1481679147..c5145ecb98629 100644 --- a/dogfood/Dockerfile +++ b/dogfood/Dockerfile @@ -8,7 +8,7 @@ FROM ubuntu:jammy AS go RUN apt-get update && apt-get install --yes curl gcc # Install Go manually, so that we can control the version -ARG GO_VERSION=1.20.6 +ARG GO_VERSION=1.20.7 RUN mkdir --parents /usr/local/go # Boring Go is needed to build FIPS-compliant binaries. From b2a84462abb6caf2dc1982081c8c1f1bd10c5b61 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 8 Aug 2023 05:32:41 -0700 Subject: [PATCH 038/277] chore: fix ruleguard xerrors rules (#8967) --- cli/stat.go | 2 +- scripts/rules.go | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cli/stat.go b/cli/stat.go index 3657a4f3c71c9..6311596ecea2a 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -240,7 +240,7 @@ func (*RootCmd) statDisk(s *clistat.Statter) *clibase.Cmd { ds, err := s.Disk(pfx, pathArg) if err != nil { if os.IsNotExist(err) { - // fmt.Errorf produces a more concise error. + //nolint:gocritic // fmt.Errorf produces a more concise error. return fmt.Errorf("not found: %q", pathArg) } return err diff --git a/scripts/rules.go b/scripts/rules.go index 20d0c43f7b883..1c26b80704b62 100644 --- a/scripts/rules.go +++ b/scripts/rules.go @@ -51,8 +51,12 @@ func xerrors(m dsl.Matcher) { m.Import("fmt") m.Import("golang.org/x/xerrors") - m.Match("fmt.Errorf($*args)"). - Suggest("xerrors.New($args)"). + m.Match("fmt.Errorf($arg)"). + Suggest("xerrors.New($arg)"). + Report("Use xerrors to provide additional stacktrace information!") + + m.Match("fmt.Errorf($arg1, $*args)"). + Suggest("xerrors.Errorf($arg1, $args)"). Report("Use xerrors to provide additional stacktrace information!") m.Match("errors.$_($msg)"). From c20c4faa7c2177b3764e786c324bdb9b2fab4c5d Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Tue, 8 Aug 2023 17:12:51 +0300 Subject: [PATCH 039/277] docs: format CONTRIBUTING.md (#8973) --- docs/CONTRIBUTING.md | 52 ++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e794828520e81..611faf48400c9 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -4,11 +4,11 @@ We recommend using the [Nix](https://nix.dev/) package manager as it makes any pain related to maintaining dependency versions [just disappear](https://twitter.com/mitchellh/status/1491102567296040961). Once nix [has been installed](https://nixos.org/download.html) the development environment can be _manually instantiated_ through the `nix-shell` command: -``` -$ cd ~/code/coder +```shell +cd ~/code/coder # https://nix.dev/tutorials/declarative-and-reproducible-developer-environments -$ nix-shell +nix-shell ... copying path '/nix/store/3ms6cs5210n8vfb5a7jkdvzrzdagqzbp-iana-etc-20210225' from 'https://cache.nixos.org'... @@ -19,18 +19,17 @@ copying path '/nix/store/v2gvj8whv241nj4lzha3flq8pnllcmvv-ignore-5.2.0.tgz' from If [direnv](https://direnv.net/) is installed and the [hooks are configured](https://direnv.net/docs/hook.html) then the development environment can be _automatically instantiated_ by creating the following `.envrc`, thus removing the need to run `nix-shell` by hand! -``` -$ cd ~/code/coder -$ echo "use nix" >.envrc -$ direnv allow +```shell +cd ~/code/coder +echo "use nix" >.envrc +direnv allow ``` -Now, whenever you enter the project folder, `direnv` will prepare the environment for you: +Now, whenever you enter the project folder, [`direnv`](https://direnv.net/docs/hook.html) will prepare the environment for you: -``` -$ cd ~/code/coder +```shell +cd ~/code/coder -# https://direnv.net/docs/hook.html direnv: loading ~/code/coder/.envrc direnv: using nix direnv: export +AR +AS +CC +CONFIG_SHELL +CXX +HOST_PATH +IN_NIX_SHELL +LD +NIX_BINTOOLS +NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_BUILD_CORES +NIX_BUILD_TOP +NIX_CC +NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_CFLAGS_COMPILE +NIX_ENFORCE_NO_NATIVE +NIX_HARDENING_ENABLE +NIX_INDENT_MAKE +NIX_LDFLAGS +NIX_STORE +NM +NODE_PATH +OBJCOPY +OBJDUMP +RANLIB +READELF +SIZE +SOURCE_DATE_EPOCH +STRINGS +STRIP +TEMP +TEMPDIR +TMP +TMPDIR +XDG_DATA_DIRS +buildInputs +buildPhase +builder +cmakeFlags +configureFlags +depsBuildBuild +depsBuildBuildPropagated +depsBuildTarget +depsBuildTargetPropagated +depsHostHost +depsHostHostPropagated +depsTargetTarget +depsTargetTargetPropagated +doCheck +doInstallCheck +mesonFlags +name +nativeBuildInputs +out +outputs +patches +phases +propagatedBuildInputs +propagatedNativeBuildInputs +shell +shellHook +stdenv +strictDeps +system ~PATH @@ -53,15 +52,15 @@ Alternatively if you do not want to use nix then you'll need to install the need - [`pg_dump`](https://stackoverflow.com/a/49689589) - on macOS, run `brew install libpq zstd` - on Linux, install [`zstd`](https://github.com/horta/zstd.install) -- [`pkg-config`]() +- `pkg-config` - on macOS, run `brew install pkg-config` -- [`pixman`]() +- `pixman` - on macOS, run `brew install pixman` -- [`cairo`]() +- `cairo` - on macOS, run `brew install cairo` -- [`pango`]() +- `pango` - on macOS, run `brew install pango` -- [`pandoc`]() +- `pandoc` - on macOS, run `brew install pandocomatic` ### Development workflow @@ -108,13 +107,14 @@ Database migrations are managed with [`migrate`](https://github.com/golang-migra To add new migrations, use the following command: -``` -$ ./coderd/database/migrations/create_fixture.sh my name +```shell +./coderd/database/migrations/create_fixture.sh my name /home/coder/src/coder/coderd/database/migrations/000070_my_name.up.sql /home/coder/src/coder/coderd/database/migrations/000070_my_name.down.sql -Run "make gen" to generate models. ``` +Run "make gen" to generate models. + Then write queries into the generated `.up.sql` and `.down.sql` files and commit them into the repository. The down script should make a best-effort to retain as much data as possible. @@ -140,8 +140,8 @@ migration of multiple features or complex configurations. To add a new partial fixture, run the following command: -``` -$ ./coderd/database/migrations/create_fixture.sh my fixture +```shell +./coderd/database/migrations/create_fixture.sh my fixture /home/coder/src/coder/coderd/database/migrations/testdata/fixtures/000070_my_fixture.up.sql ``` @@ -153,9 +153,9 @@ To create a full dump, run a fully fledged Coder deployment and use it to generate data in the database. Then shut down the deployment and take a snapshot of the database. -``` -$ mkdir -p coderd/database/migrations/testdata/full_dumps/v0.12.2 && cd $_ -$ pg_dump "postgres://coder@localhost:..." -a --inserts >000069_dump_v0.12.2.up.sql +```shell +mkdir -p coderd/database/migrations/testdata/full_dumps/v0.12.2 && cd $_ +pg_dump "postgres://coder@localhost:..." -a --inserts >000069_dump_v0.12.2.up.sql ``` Make sure sensitive data in the dump is desensitized, for instance names, @@ -164,8 +164,8 @@ emails, OAuth tokens and other secrets. Then commit the dump to the project. To find out what the latest migration for a version of Coder is, use the following command: -``` -$ git ls-files v0.12.2 -- coderd/database/migrations/*.up.sql +```shell +git ls-files v0.12.2 -- coderd/database/migrations/*.up.sql ``` This helps in naming the dump (e.g. `000069` above). From 4d3230c9ad85b9fdff3d4e81ac9239df4a6023fd Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 8 Aug 2023 07:35:34 -0700 Subject: [PATCH 040/277] fix: default to executing e2e ssh without args (#8975) This was causing the test to fail consistently! --- site/e2e/helpers.ts | 56 +++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index b86a7c756f3a8..bdfc8b015352d 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -66,8 +66,11 @@ export const sshIntoWorkspace = async ( page: Page, workspace: string, binaryPath = "go", - binaryArgs = ["run", coderMainPath()], + binaryArgs: string[] = [], ): Promise => { + if (binaryPath === "go") { + binaryArgs = ["run", coderMainPath()] + } const sessionToken = await findSessionToken(page) return new Promise((resolve, reject) => { const cp = spawn(binaryPath, [...binaryArgs, "ssh", "--stdio", workspace], { @@ -120,7 +123,7 @@ export const downloadCoderVersion = async ( } const binaryName = "coder-e2e-" + version - const tempDir = "/tmp" + const tempDir = "/tmp/coder-e2e-cache" // The install script adds `./bin` automatically to the path :shrug: const binaryPath = path.join(tempDir, "bin", binaryName) @@ -138,26 +141,35 @@ export const downloadCoderVersion = async ( // Runs our public install script using our options to // install the binary! await new Promise((resolve, reject) => { - const cp = spawn("sh", [ - "-c", + const cp = spawn( + "sh", [ - "curl", - "-L", - "https://coder.com/install.sh", - "|", - "sh", - "-s", - "--", - "--version", - version, - "--method", - "standalone", - "--prefix", - tempDir, - "--binary-name", - binaryName, - ].join(" "), - ]) + "-c", + [ + "curl", + "-L", + "https://coder.com/install.sh", + "|", + "sh", + "-s", + "--", + "--version", + version, + "--method", + "standalone", + "--prefix", + tempDir, + "--binary-name", + binaryName, + ].join(" "), + ], + { + env: { + ...process.env, + XDG_CACHE_HOME: "/tmp/coder-e2e-cache", + }, + }, + ) // eslint-disable-next-line no-console -- Needed for debugging cp.stderr.on("data", (data) => console.log(data.toString())) cp.on("close", (code) => { @@ -189,7 +201,7 @@ export const startAgentWithCommand = async ( buffer = Buffer.concat([buffer, data]) }) try { - await page.getByTestId("agent-status-ready").isVisible() + await page.getByTestId("agent-status-ready").waitFor({ state: "visible" }) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The error is a string } catch (ex: any) { throw new Error(ex.toString() + "\n" + buffer.toString()) From 05054c6a0a73df1326b6be6f55a6c83dd55097cc Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Tue, 8 Aug 2023 17:57:57 +0300 Subject: [PATCH 041/277] ci: make `test-e2e` a required check (#8977) --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 67ecdec43d0f5..8220492528b9a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -668,6 +668,7 @@ jobs: - test-go-pg - test-go-race - test-js + - test-e2e - offlinedocs # Allow this job to run even if the needed jobs fail, are skipped or # cancelled. From 1d4a72f43f805b1903b99c9cfaed8534531b8b91 Mon Sep 17 00:00:00 2001 From: Cem Date: Tue, 8 Aug 2023 18:02:52 +0300 Subject: [PATCH 042/277] perf(coderd/util/slice): refactor unique method for large lists (#8925) --- coderd/util/slice/slice.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/coderd/util/slice/slice.go b/coderd/util/slice/slice.go index 9909fe2b72c21..0bc5920ce3760 100644 --- a/coderd/util/slice/slice.go +++ b/coderd/util/slice/slice.go @@ -38,17 +38,19 @@ func Overlap[T comparable](a []T, b []T) bool { } // Unique returns a new slice with all duplicate elements removed. -// This is a slow function on large lists. -// TODO: Sort elements and implement a faster search algorithm if we -// really start to use this. func Unique[T comparable](a []T) []T { cpy := make([]T, 0, len(a)) + seen := make(map[T]struct{}, len(a)) + for _, v := range a { - v := v - if !Contains(cpy, v) { - cpy = append(cpy, v) + if _, ok := seen[v]; ok { + continue } + + seen[v] = struct{}{} + cpy = append(cpy, v) } + return cpy } From 5339a31532fa2f2a09becc51fe2e87c019c3b4d1 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 8 Aug 2023 10:05:12 -0500 Subject: [PATCH 043/277] fix: remove refresh oauth logic on OIDC login (#8950) * fix: do not do oauth refresh logic on oidc login --- coderd/coderd.go | 2 - coderd/coderdtest/coderdtest.go | 79 ++++++++++++++++++----- coderd/httpmw/apikey.go | 100 ++++++++++++++++------------- coderd/userauth.go | 7 +- coderd/userauth_test.go | 95 ++++++++++++++++++++++++++- enterprise/coderd/userauth_test.go | 2 + 6 files changed, 217 insertions(+), 68 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 58b6c902c7dbc..3407be5ac8de2 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -693,7 +693,6 @@ func New(options *Options) *API { r.Route("/github", func(r chi.Router) { r.Use( httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, nil), - apiKeyMiddlewareOptional, ) r.Get("/callback", api.userOAuth2Github) }) @@ -701,7 +700,6 @@ func New(options *Options) *API { r.Route("/oidc/callback", func(r chi.Router) { r.Use( httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient, oidcAuthURLParams), - apiKeyMiddlewareOptional, ) r.Get("/", api.userOIDC) }) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 71e3336ab2e87..5e2e55d5c032f 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -1022,9 +1022,31 @@ func NewAWSInstanceIdentity(t *testing.T, instanceID string) (awsidentity.Certif type OIDCConfig struct { key *rsa.PrivateKey issuer string + // These are optional + refreshToken string + oidcTokenExpires func() time.Time + tokenSource func() (*oauth2.Token, error) } -func NewOIDCConfig(t *testing.T, issuer string) *OIDCConfig { +func WithRefreshToken(token string) func(cfg *OIDCConfig) { + return func(cfg *OIDCConfig) { + cfg.refreshToken = token + } +} + +func WithTokenExpires(expFunc func() time.Time) func(cfg *OIDCConfig) { + return func(cfg *OIDCConfig) { + cfg.oidcTokenExpires = expFunc + } +} + +func WithTokenSource(src func() (*oauth2.Token, error)) func(cfg *OIDCConfig) { + return func(cfg *OIDCConfig) { + cfg.tokenSource = src + } +} + +func NewOIDCConfig(t *testing.T, issuer string, opts ...func(cfg *OIDCConfig)) *OIDCConfig { t.Helper() block, _ := pem.Decode([]byte(testRSAPrivateKey)) @@ -1035,33 +1057,58 @@ func NewOIDCConfig(t *testing.T, issuer string) *OIDCConfig { issuer = "https://coder.com" } - return &OIDCConfig{ + cfg := &OIDCConfig{ key: pkey, issuer: issuer, } + for _, opt := range opts { + opt(cfg) + } + return cfg } func (*OIDCConfig) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string { return "/?state=" + url.QueryEscape(state) } -func (*OIDCConfig) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { - return nil +type tokenSource struct { + src func() (*oauth2.Token, error) +} + +func (s tokenSource) Token() (*oauth2.Token, error) { + return s.src() +} + +func (cfg *OIDCConfig) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { + if cfg.tokenSource == nil { + return nil + } + return tokenSource{ + src: cfg.tokenSource, + } } -func (*OIDCConfig) Exchange(_ context.Context, code string, _ ...oauth2.AuthCodeOption) (*oauth2.Token, error) { +func (cfg *OIDCConfig) Exchange(_ context.Context, code string, _ ...oauth2.AuthCodeOption) (*oauth2.Token, error) { token, err := base64.StdEncoding.DecodeString(code) if err != nil { return nil, xerrors.Errorf("decode code: %w", err) } + + var exp time.Time + if cfg.oidcTokenExpires != nil { + exp = cfg.oidcTokenExpires() + } + return (&oauth2.Token{ - AccessToken: "token", + AccessToken: "token", + RefreshToken: cfg.refreshToken, + Expiry: exp, }).WithExtra(map[string]interface{}{ "id_token": string(token), }), nil } -func (o *OIDCConfig) EncodeClaims(t *testing.T, claims jwt.MapClaims) string { +func (cfg *OIDCConfig) EncodeClaims(t *testing.T, claims jwt.MapClaims) string { t.Helper() if _, ok := claims["exp"]; !ok { @@ -1069,20 +1116,20 @@ func (o *OIDCConfig) EncodeClaims(t *testing.T, claims jwt.MapClaims) string { } if _, ok := claims["iss"]; !ok { - claims["iss"] = o.issuer + claims["iss"] = cfg.issuer } if _, ok := claims["sub"]; !ok { claims["sub"] = "testme" } - signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(o.key) + signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(cfg.key) require.NoError(t, err) return base64.StdEncoding.EncodeToString([]byte(signed)) } -func (o *OIDCConfig) OIDCConfig(t *testing.T, userInfoClaims jwt.MapClaims, opts ...func(cfg *coderd.OIDCConfig)) *coderd.OIDCConfig { +func (cfg *OIDCConfig) OIDCConfig(t *testing.T, userInfoClaims jwt.MapClaims, opts ...func(cfg *coderd.OIDCConfig)) *coderd.OIDCConfig { // By default, the provider can be empty. // This means it won't support any endpoints! provider := &oidc.Provider{} @@ -1099,10 +1146,10 @@ func (o *OIDCConfig) OIDCConfig(t *testing.T, userInfoClaims jwt.MapClaims, opts } provider = cfg.NewProvider(context.Background()) } - cfg := &coderd.OIDCConfig{ - OAuth2Config: o, - Verifier: oidc.NewVerifier(o.issuer, &oidc.StaticKeySet{ - PublicKeys: []crypto.PublicKey{o.key.Public()}, + newCFG := &coderd.OIDCConfig{ + OAuth2Config: cfg, + Verifier: oidc.NewVerifier(cfg.issuer, &oidc.StaticKeySet{ + PublicKeys: []crypto.PublicKey{cfg.key.Public()}, }, &oidc.Config{ SkipClientIDCheck: true, }), @@ -1113,9 +1160,9 @@ func (o *OIDCConfig) OIDCConfig(t *testing.T, userInfoClaims jwt.MapClaims, opts GroupField: "groups", } for _, opt := range opts { - opt(cfg) + opt(newCFG) } - return cfg + return newCFG } // NewAzureInstanceIdentity returns a metadata client and ID token validator for faking diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 5f0ec0dc263c7..f8f809761787c 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -142,6 +142,56 @@ func ExtractAPIKeyMW(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { } } +func APIKeyFromRequest(ctx context.Context, db database.Store, sessionTokenFunc func(r *http.Request) string, r *http.Request) (*database.APIKey, codersdk.Response, bool) { + tokenFunc := APITokenFromRequest + if sessionTokenFunc != nil { + tokenFunc = sessionTokenFunc + } + + token := tokenFunc(r) + if token == "" { + return nil, codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenCookie), + }, false + } + + keyID, keySecret, err := SplitAPIToken(token) + if err != nil { + return nil, codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: "Invalid API key format: " + err.Error(), + }, false + } + + //nolint:gocritic // System needs to fetch API key to check if it's valid. + key, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), keyID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: "API key is invalid.", + }, false + } + + return nil, codersdk.Response{ + Message: internalErrorMessage, + Detail: fmt.Sprintf("Internal error fetching API key by id. %s", err.Error()), + }, false + } + + // Checking to see if the secret is valid. + hashedSecret := sha256.Sum256([]byte(keySecret)) + if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 { + return nil, codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: "API key secret is invalid.", + }, false + } + + return &key, codersdk.Response{}, true +} + // ExtractAPIKey requires authentication using a valid API key. It handles // extending an API key if it comes close to expiry, updating the last used time // in the database. @@ -179,49 +229,9 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon return nil, nil, false } - tokenFunc := APITokenFromRequest - if cfg.SessionTokenFunc != nil { - tokenFunc = cfg.SessionTokenFunc - } - token := tokenFunc(r) - if token == "" { - return optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: SignedOutErrorMessage, - Detail: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenCookie), - }) - } - - keyID, keySecret, err := SplitAPIToken(token) - if err != nil { - return optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: SignedOutErrorMessage, - Detail: "Invalid API key format: " + err.Error(), - }) - } - - //nolint:gocritic // System needs to fetch API key to check if it's valid. - key, err := cfg.DB.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), keyID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: SignedOutErrorMessage, - Detail: "API key is invalid.", - }) - } - - return write(http.StatusInternalServerError, codersdk.Response{ - Message: internalErrorMessage, - Detail: fmt.Sprintf("Internal error fetching API key by id. %s", err.Error()), - }) - } - - // Checking to see if the secret is valid. - hashedSecret := sha256.Sum256([]byte(keySecret)) - if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 { - return optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: SignedOutErrorMessage, - Detail: "API key secret is invalid.", - }) + key, resp, ok := APIKeyFromRequest(ctx, cfg.DB, cfg.SessionTokenFunc, r) + if !ok { + return optionalWrite(http.StatusUnauthorized, resp) } var ( @@ -232,7 +242,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon ) if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC { //nolint:gocritic // System needs to fetch UserLink to check if it's valid. - link, err = cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{ + link, err := cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{ UserID: key.UserID, LoginType: key.LoginType, }) @@ -427,7 +437,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }.WithCachedASTValue(), } - return &key, &authz, true + return key, &authz, true } // APITokenFromRequest returns the api token from the request. diff --git a/coderd/userauth.go b/coderd/userauth.go index 9b6ba7992bad5..f1e110c08bfdc 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1427,7 +1427,8 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C } var key database.APIKey - if oldKey, ok := httpmw.APIKeyOptional(r); ok && isConvertLoginType { + oldKey, _, ok := httpmw.APIKeyFromRequest(ctx, api.Database, nil, r) + if ok && oldKey != nil && isConvertLoginType { // If this is a convert login type, and it succeeds, then delete the old // session. Force the user to log back in. err := api.Database.DeleteAPIKeyByID(r.Context(), oldKey.ID) @@ -1447,7 +1448,9 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C Secure: api.SecureAuthCookie, HttpOnly: true, }) - key = oldKey + // This is intentional setting the key to the deleted old key, + // as the user needs to be forced to log back in. + key = *oldKey } else { //nolint:gocritic cookie, newKey, err := api.createAPIKey(dbauthz.AsSystemRestricted(ctx), apikey.CreateParams{ diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 6f49222ff8764..efa7673890863 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -9,6 +9,7 @@ import ( "net/http/cookiejar" "strings" "testing" + "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt" @@ -24,12 +25,97 @@ import ( "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/database/dbgen" "github.com/coder/coder/coderd/database/dbtestutil" "github.com/coder/coder/codersdk" "github.com/coder/coder/testutil" ) +// This test specifically tests logging in with OIDC when an expired +// OIDC session token exists. +// The token refreshing should not happen since we are reauthenticating. +func TestOIDCOauthLoginWithExisting(t *testing.T) { + t.Parallel() + + conf := coderdtest.NewOIDCConfig(t, "", + // Provide a refresh token so we use the refresh token flow + coderdtest.WithRefreshToken("refresh_token"), + // We need to set the expire in the future for the first api calls. + coderdtest.WithTokenExpires(func() time.Time { + return time.Now().Add(time.Hour).UTC() + }), + // No refresh should actually happen in this test. + coderdtest.WithTokenSource(func() (*oauth2.Token, error) { + return nil, xerrors.New("token should not require refresh") + }), + ) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + auditor := audit.NewMock() + const username = "alice" + claims := jwt.MapClaims{ + "email": "alice@coder.com", + "email_verified": true, + "preferred_username": username, + } + config := conf.OIDCConfig(t, claims) + + config.AllowSignups = true + config.IgnoreUserInfo = true + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Auditor: auditor, + OIDCConfig: config, + Logger: &logger, + }) + + // Signup alice + resp := oidcCallback(t, client, conf.EncodeClaims(t, claims)) + // Set the client to use this OIDC context + authCookie := authCookieValue(resp.Cookies()) + client.SetSessionToken(authCookie) + _ = resp.Body.Close() + + ctx := testutil.Context(t, testutil.WaitLong) + // Verify the user and oauth link + user, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, username, user.Username) + + // nolint:gocritic + link, err := api.Database.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginType(user.LoginType), + }) + require.NoError(t, err, "failed to get user link") + + // Expire the link + // nolint:gocritic + _, err = api.Database.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{ + OAuthAccessToken: link.OAuthAccessToken, + OAuthRefreshToken: link.OAuthRefreshToken, + OAuthExpiry: time.Now().Add(time.Hour * -1).UTC(), + UserID: link.UserID, + LoginType: link.LoginType, + }) + require.NoError(t, err, "failed to update user link") + + // Log in again with OIDC + loginAgain := oidcCallbackWithState(t, client, conf.EncodeClaims(t, claims), "seconds_login", func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: codersdk.SessionTokenCookie, + Value: authCookie, + Path: "/", + }) + }) + require.Equal(t, http.StatusTemporaryRedirect, loginAgain.StatusCode) + _ = loginAgain.Body.Close() + + // Try to use new login + client.SetSessionToken(authCookieValue(resp.Cookies())) + _, err = client.User(ctx, "me") + require.NoError(t, err, "use new session") +} + func TestUserLogin(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { @@ -819,7 +905,7 @@ func TestUserOIDC(t *testing.T) { }) require.NoError(t, err) - resp := oidcCallbackWithState(t, user, code, convertResponse.StateString) + resp := oidcCallbackWithState(t, user, code, convertResponse.StateString, nil) require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) }) @@ -1045,10 +1131,10 @@ func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { } func oidcCallback(t *testing.T, client *codersdk.Client, code string) *http.Response { - return oidcCallbackWithState(t, client, code, "somestate") + return oidcCallbackWithState(t, client, code, "somestate", nil) } -func oidcCallbackWithState(t *testing.T, client *codersdk.Client, code, state string) *http.Response { +func oidcCallbackWithState(t *testing.T, client *codersdk.Client, code, state string, modify func(r *http.Request)) *http.Response { t.Helper() client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { @@ -1062,6 +1148,9 @@ func oidcCallbackWithState(t *testing.T, client *codersdk.Client, code, state st Name: codersdk.OAuth2StateCookie, Value: state, }) + if modify != nil { + modify(req) + } res, err := client.HTTPClient.Do(req) require.NoError(t, err) defer res.Body.Close() diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index 428cf91a6fef2..2cb110abe987b 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -99,6 +99,7 @@ func TestUserOIDC(t *testing.T) { "roles": []string{"random", oidcRoleName, rbac.RoleOwner()}, })) require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + _ = resp.Body.Close() user, err := client.User(ctx, "alice") require.NoError(t, err) @@ -112,6 +113,7 @@ func TestUserOIDC(t *testing.T) { "roles": []string{"random"}, })) require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + _ = resp.Body.Close() user, err = client.User(ctx, "alice") require.NoError(t, err) From 4a987e991796449c2b321db76dc9bdeb91f12281 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 8 Aug 2023 13:09:31 -0300 Subject: [PATCH 044/277] feat(site): add parameters usage to insights (#8886) --- coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 6 +- codersdk/deployment.go | 4 + docs/api/schemas.md | 1 + site/src/api/typesGenerated.ts | 2 + .../TemplateInsightsPage.stories.tsx | 371 +++++++++++++++++- .../TemplateInsightsPage.tsx | 233 +++++++++++ 7 files changed, 618 insertions(+), 5 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 61a6989ba27fd..9faf9de1611cc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8024,7 +8024,8 @@ const docTemplate = `{ "tailnet_pg_coordinator", "single_tailnet", "template_restart_requirement", - "deployment_health_page" + "deployment_health_page", + "template_parameters_insights" ], "x-enum-varnames": [ "ExperimentMoons", @@ -8032,7 +8033,8 @@ const docTemplate = `{ "ExperimentTailnetPGCoordinator", "ExperimentSingleTailnet", "ExperimentTemplateRestartRequirement", - "ExperimentDeploymentHealthPage" + "ExperimentDeploymentHealthPage", + "ExperimentTemplateParametersInsights" ] }, "codersdk.Feature": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 982ed816264c1..635457901db36 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7185,7 +7185,8 @@ "tailnet_pg_coordinator", "single_tailnet", "template_restart_requirement", - "deployment_health_page" + "deployment_health_page", + "template_parameters_insights" ], "x-enum-varnames": [ "ExperimentMoons", @@ -7193,7 +7194,8 @@ "ExperimentTailnetPGCoordinator", "ExperimentSingleTailnet", "ExperimentTemplateRestartRequirement", - "ExperimentDeploymentHealthPage" + "ExperimentDeploymentHealthPage", + "ExperimentTemplateParametersInsights" ] }, "codersdk.Feature": { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index e714d9c1c34b5..fdc5b92af28de 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1881,6 +1881,9 @@ const ( // Deployment health page ExperimentDeploymentHealthPage Experiment = "deployment_health_page" + // Template parameters insights + ExperimentTemplateParametersInsights Experiment = "template_parameters_insights" + // Add new experiments here! // ExperimentExample Experiment = "example" ) @@ -1891,6 +1894,7 @@ const ( // not be included here and will be essentially hidden. var ExperimentsAll = Experiments{ ExperimentDeploymentHealthPage, + ExperimentTemplateParametersInsights, } // Experiments is a list of experiments that are enabled for the deployment. diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 2f09bfb6728ae..4ab6eceeef96d 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2679,6 +2679,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `single_tailnet` | | `template_restart_requirement` | | `deployment_health_page` | +| `template_parameters_insights` | ## codersdk.Feature diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7b9b79d69b3db..c01f4d94385ba 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1586,6 +1586,7 @@ export type Experiment = | "moons" | "single_tailnet" | "tailnet_pg_coordinator" + | "template_parameters_insights" | "template_restart_requirement" | "workspace_actions" export const Experiments: Experiment[] = [ @@ -1593,6 +1594,7 @@ export const Experiments: Experiment[] = [ "moons", "single_tailnet", "tailnet_pg_coordinator", + "template_parameters_insights", "template_restart_requirement", "workspace_actions", ] diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx index 49678eef08375..4fb65c0b2b5f1 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx @@ -83,7 +83,376 @@ export const Loaded: Story = { seconds: 1020900, }, ], - parameters_usage: [], + parameters_usage: [ + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "", + name: "Compute instances", + type: "number", + description: "Let's set the expected number of instances.", + values: [ + { + value: "3", + count: 2, + }, + ], + }, + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "", + name: "Docker Image", + type: "string", + description: "Docker image for the development container", + values: [ + { + value: "ghcr.io/harrison-ai/coder-dev:base", + count: 2, + }, + ], + }, + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "Very random string", + name: "Optional random string", + type: "string", + description: "This string is optional", + values: [ + { + value: "ksjdlkajs;djálskd'l ;a k;aosdk ;oaids ;li", + count: 1, + }, + { + value: "some other any string here", + count: 1, + }, + ], + }, + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "", + name: "Region", + type: "string", + description: "These are options.", + options: [ + { + name: "US Central", + description: "Select for central!", + value: "us-central1-a", + icon: "/icon/goland.svg", + }, + { + name: "US East", + description: "Select for east!", + value: "us-east1-a", + icon: "/icon/folder.svg", + }, + { + name: "US West", + description: "Select for west!", + value: "us-west2-a", + icon: "", + }, + ], + values: [ + { + value: "us-central1-a", + count: 1, + }, + { + value: "us-west2-a", + count: 1, + }, + ], + }, + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "", + name: "Security groups", + type: "list(string)", + description: "Select appropriate security groups.", + values: [ + { + value: + '["Web Server Security Group","Database Security Group","Backend Security Group"]', + count: 2, + }, + ], + }, + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "Very random string", + name: "buggy-1", + type: "string", + description: "This string is buggy", + values: [ + { + value: "", + count: 2, + }, + ], + }, + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "Force rebuild", + name: "force-rebuild", + type: "bool", + description: "Rebuild the project code", + values: [ + { + value: "false", + count: 2, + }, + ], + }, + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "Location", + name: "location", + type: "string", + description: "What location should your workspace live in?", + options: [ + { + name: "US (Virginia)", + description: "", + value: "eastus", + icon: "/emojis/1f1fa-1f1f8.png", + }, + { + name: "US (Virginia) 2", + description: "", + value: "eastus2", + icon: "/emojis/1f1fa-1f1f8.png", + }, + { + name: "US (Texas)", + description: "", + value: "southcentralus", + icon: "/emojis/1f1fa-1f1f8.png", + }, + { + name: "US (Washington)", + description: "", + value: "westus2", + icon: "/emojis/1f1fa-1f1f8.png", + }, + { + name: "US (Arizona)", + description: "", + value: "westus3", + icon: "/emojis/1f1fa-1f1f8.png", + }, + { + name: "US (Iowa)", + description: "", + value: "centralus", + icon: "/emojis/1f1fa-1f1f8.png", + }, + { + name: "Canada (Toronto)", + description: "", + value: "canadacentral", + icon: "/emojis/1f1e8-1f1e6.png", + }, + { + name: "Brazil (Sao Paulo)", + description: "", + value: "brazilsouth", + icon: "/emojis/1f1e7-1f1f7.png", + }, + { + name: "East Asia (Hong Kong)", + description: "", + value: "eastasia", + icon: "/emojis/1f1f0-1f1f7.png", + }, + { + name: "Southeast Asia (Singapore)", + description: "", + value: "southeastasia", + icon: "/emojis/1f1f0-1f1f7.png", + }, + { + name: "Australia (New South Wales)", + description: "", + value: "australiaeast", + icon: "/emojis/1f1e6-1f1fa.png", + }, + { + name: "China (Hebei)", + description: "", + value: "chinanorth3", + icon: "/emojis/1f1e8-1f1f3.png", + }, + { + name: "India (Pune)", + description: "", + value: "centralindia", + icon: "/emojis/1f1ee-1f1f3.png", + }, + { + name: "Japan (Tokyo)", + description: "", + value: "japaneast", + icon: "/emojis/1f1ef-1f1f5.png", + }, + { + name: "Korea (Seoul)", + description: "", + value: "koreacentral", + icon: "/emojis/1f1f0-1f1f7.png", + }, + { + name: "Europe (Ireland)", + description: "", + value: "northeurope", + icon: "/emojis/1f1ea-1f1fa.png", + }, + { + name: "Europe (Netherlands)", + description: "", + value: "westeurope", + icon: "/emojis/1f1ea-1f1fa.png", + }, + { + name: "France (Paris)", + description: "", + value: "francecentral", + icon: "/emojis/1f1eb-1f1f7.png", + }, + { + name: "Germany (Frankfurt)", + description: "", + value: "germanywestcentral", + icon: "/emojis/1f1e9-1f1ea.png", + }, + { + name: "Norway (Oslo)", + description: "", + value: "norwayeast", + icon: "/emojis/1f1f3-1f1f4.png", + }, + { + name: "Sweden (Gävle)", + description: "", + value: "swedencentral", + icon: "/emojis/1f1f8-1f1ea.png", + }, + { + name: "Switzerland (Zurich)", + description: "", + value: "switzerlandnorth", + icon: "/emojis/1f1e8-1f1ed.png", + }, + { + name: "Qatar (Doha)", + description: "", + value: "qatarcentral", + icon: "/emojis/1f1f6-1f1e6.png", + }, + { + name: "UAE (Dubai)", + description: "", + value: "uaenorth", + icon: "/emojis/1f1e6-1f1ea.png", + }, + { + name: "South Africa (Johannesburg)", + description: "", + value: "southafricanorth", + icon: "/emojis/1f1ff-1f1e6.png", + }, + { + name: "UK (London)", + description: "", + value: "uksouth", + icon: "/emojis/1f1ec-1f1e7.png", + }, + ], + values: [ + { + value: "brazilsouth", + count: 1, + }, + { + value: "switzerlandnorth", + count: 1, + }, + ], + }, + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "", + name: "mtojek_region", + type: "string", + description: "What region should your workspace live in?", + options: [ + { + name: "Los Angeles, CA", + description: "", + value: "Los Angeles, CA", + icon: "", + }, + { + name: "Moncks Corner, SC", + description: "", + value: "Moncks Corner, SC", + icon: "", + }, + { + name: "Eemshaven, NL", + description: "", + value: "Eemshaven, NL", + icon: "", + }, + ], + values: [ + { + value: "Los Angeles, CA", + count: 2, + }, + ], + }, + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "My Project ID", + name: "project_id", + type: "string", + description: "This is the Project ID.", + values: [ + { + value: "12345", + count: 2, + }, + ], + }, + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "Force devcontainer rebuild", + name: "rebuild_devcontainer", + type: "bool", + description: "", + values: [ + { + value: "false", + count: 2, + }, + ], + }, + { + template_ids: ["7dd1d090-3e23-4ada-8894-3945affcad42"], + display_name: "Git Repo URL", + name: "repo_url", + type: "string", + description: + "See sample projects (https://github.com/microsoft/vscode-dev-containers#sample-projects)", + values: [ + { + value: "https://github.com/mtojek/coder", + count: 2, + }, + ], + }, + ], }, interval_reports: [ { diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index d6161124d3540..77bf66a0759e9 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -21,10 +21,17 @@ import { Loader } from "components/Loader/Loader" import { DAUsResponse, TemplateInsightsResponse, + TemplateParameterUsage, + TemplateParameterValue, UserLatencyInsightsResponse, } from "api/typesGenerated" import { ComponentProps } from "react" import { subDays, addHours, startOfHour } from "date-fns" +import { useDashboard } from "components/Dashboard/DashboardProvider" +import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined" +import Link from "@mui/material/Link" +import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined" +import CancelOutlined from "@mui/icons-material/CancelOutlined" export default function TemplateInsightsPage() { const now = new Date() @@ -42,6 +49,10 @@ export default function TemplateInsightsPage() { queryKey: ["templates", template.id, "user-latency"], queryFn: () => getInsightsUserLatency(insightsFilter), }) + const dashboard = useDashboard() + const shouldDisplayParameters = + dashboard.experiments.includes("template_parameters_insights") || + process.env.NODE_ENV === "development" return ( <> @@ -51,6 +62,7 @@ export default function TemplateInsightsPage() { ) @@ -59,9 +71,11 @@ export default function TemplateInsightsPage() { export const TemplateInsightsPageView = ({ templateInsights, userLatency, + shouldDisplayParameters, }: { templateInsights: TemplateInsightsResponse | undefined userLatency: UserLatencyInsightsResponse | undefined + shouldDisplayParameters: boolean }) => { return ( + {shouldDisplayParameters && ( + + )} ) } @@ -261,6 +281,219 @@ const TemplateUsagePanel = ({ ) } +const TemplateParametersUsagePanel = ({ + data, + ...panelProps +}: PanelProps & { + data: TemplateInsightsResponse["report"]["parameters_usage"] | undefined +}) => { + return ( + + + Parameters usage + Last 7 days + + + {!data && } + {data && data.length === 0 && } + {data && + data.length > 0 && + data.map((parameter, parameterIndex) => { + const label = + parameter.display_name !== "" + ? parameter.display_name + : parameter.name + return ( + `1px solid ${theme.palette.divider}`, + width: (theme) => `calc(100% + ${theme.spacing(6)})`, + "&:first-child": { + borderTop: 0, + }, + }} + > + + {label} + theme.palette.text.secondary, + maxWidth: 400, + margin: 0, + }} + > + {parameter.description} + + + + {parameter.values + .sort((a, b) => b.count - a.count) + .map((usage, usageIndex) => ( + + + {usage.count} + + ))} + + + ) + })} + + + ) +} + +const ParameterUsageLabel = ({ + usage, + parameter, +}: { + usage: TemplateParameterValue + parameter: TemplateParameterUsage +}) => { + if (usage.value.trim() === "") { + return ( + theme.palette.text.secondary, + }} + > + Not set + + ) + } + + if (parameter.options) { + const option = parameter.options.find((o) => o.value === usage.value)! + const icon = option.icon + const label = option.name + + return ( + + {icon && ( + + + + )} + {label} + + ) + } + + if (usage.value.startsWith("http")) { + return ( + theme.palette.text.primary, + }} + > + + {usage.value} + + ) + } + + if (parameter.type === "list(string)") { + const values = JSON.parse(usage.value) as string[] + return ( + + {values.map((v, i) => { + return ( + theme.spacing(0.25, 1.5), + borderRadius: 999, + background: (theme) => theme.palette.divider, + whiteSpace: "nowrap", + }} + > + {v} + + ) + })} + + ) + } + + if (parameter.type === "bool") { + return ( + + {usage.value === "false" ? ( + <> + theme.palette.error.light, + }} + /> + False + + ) : ( + <> + theme.palette.success.light, + }} + /> + True + + )} + + ) + } + + return {usage.value} +} + const Panel = styled(Box)(({ theme }) => ({ borderRadius: theme.shape.borderRadius, border: `1px solid ${theme.palette.divider}`, From f4122fa9f5d4b02780a8056696f90003f13f79de Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 8 Aug 2023 11:37:49 -0500 Subject: [PATCH 045/277] feat: add auto group create from OIDC (#8884) * add flag for auto create groups * fixup! add flag for auto create groups * sync missing groups Also added a regex filter to filter out groups that are not important --- cli/clibase/option_test.go | 34 +++ cli/clibase/values.go | 38 ++++ cli/server.go | 2 + cli/testdata/coder_server_--help.golden | 8 + cli/testdata/server-config.yaml.golden | 8 + coderd/apidoc/docs.go | 23 ++ coderd/apidoc/swagger.json | 17 ++ coderd/coderd.go | 14 +- coderd/database/dbauthz/dbauthz.go | 7 + coderd/database/dbfake/dbfake.go | 40 ++++ coderd/database/dbmetrics/dbmetrics.go | 7 + coderd/database/dbmock/dbmock.go | 15 ++ coderd/database/dump.sql | 10 +- .../migrations/000148_group_source.down.sql | 8 + .../migrations/000148_group_source.up.sql | 15 ++ coderd/database/models.go | 60 +++++ coderd/database/querier.go | 5 + coderd/database/queries.sql.go | 77 ++++++- coderd/database/queries/groups.sql | 22 ++ coderd/userauth.go | 56 +++-- codersdk/deployment.go | 22 ++ codersdk/groups.go | 22 +- docs/admin/audit-logs.md | 2 +- docs/api/enterprise.md | 119 +++++----- docs/api/general.md | 2 + docs/api/schemas.md | 98 +++++--- docs/cli/server.md | 22 ++ enterprise/audit/table.go | 1 + .../cli/testdata/coder_server_--help.golden | 8 + enterprise/coderd/groups.go | 1 + enterprise/coderd/userauth.go | 27 ++- enterprise/coderd/userauth_test.go | 214 +++++++++++++++++- site/src/api/typesGenerated.ts | 9 + site/src/testHelpers/entities.ts | 1 + site/src/utils/groups.ts | 1 + 35 files changed, 887 insertions(+), 128 deletions(-) create mode 100644 coderd/database/migrations/000148_group_source.down.sql create mode 100644 coderd/database/migrations/000148_group_source.up.sql diff --git a/cli/clibase/option_test.go b/cli/clibase/option_test.go index cacd8d3a10793..9affda90668e4 100644 --- a/cli/clibase/option_test.go +++ b/cli/clibase/option_test.go @@ -72,6 +72,40 @@ func TestOptionSet_ParseFlags(t *testing.T) { err := os.FlagSet().Parse([]string{"--some-unknown", "foo"}) require.Error(t, err) }) + + t.Run("RegexValid", func(t *testing.T) { + t.Parallel() + + var regexpString clibase.Regexp + + os := clibase.OptionSet{ + clibase.Option{ + Name: "RegexpString", + Value: ®expString, + Flag: "regexp-string", + }, + } + + err := os.FlagSet().Parse([]string{"--regexp-string", "$test^"}) + require.NoError(t, err) + }) + + t.Run("RegexInvalid", func(t *testing.T) { + t.Parallel() + + var regexpString clibase.Regexp + + os := clibase.OptionSet{ + clibase.Option{ + Name: "RegexpString", + Value: ®expString, + Flag: "regexp-string", + }, + } + + err := os.FlagSet().Parse([]string{"--regexp-string", "(("}) + require.Error(t, err) + }) } func TestOptionSet_ParseEnv(t *testing.T) { diff --git a/cli/clibase/values.go b/cli/clibase/values.go index 288a7c372b152..6ec67d2d1bc09 100644 --- a/cli/clibase/values.go +++ b/cli/clibase/values.go @@ -7,6 +7,7 @@ import ( "net" "net/url" "reflect" + "regexp" "strconv" "strings" "time" @@ -461,6 +462,43 @@ func (e *Enum) String() string { return *e.Value } +type Regexp regexp.Regexp + +func (r *Regexp) MarshalYAML() (interface{}, error) { + return yaml.Node{ + Kind: yaml.ScalarNode, + Value: r.String(), + }, nil +} + +func (r *Regexp) UnmarshalYAML(n *yaml.Node) error { + return r.Set(n.Value) +} + +func (r *Regexp) Set(v string) error { + exp, err := regexp.Compile(v) + if err != nil { + return xerrors.Errorf("invalid regex expression: %w", err) + } + *r = Regexp(*exp) + return nil +} + +func (r Regexp) String() string { + return r.Value().String() +} + +func (r *Regexp) Value() *regexp.Regexp { + if r == nil { + return nil + } + return (*regexp.Regexp)(r) +} + +func (Regexp) Type() string { + return "regexp" +} + var _ pflag.Value = (*YAMLConfigPath)(nil) // YAMLConfigPath is a special value type that encodes a path to a YAML diff --git a/cli/server.go b/cli/server.go index 15cefb364ce3e..55a4db844723f 100644 --- a/cli/server.go +++ b/cli/server.go @@ -597,6 +597,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. AuthURLParams: cfg.OIDC.AuthURLParams.Value, IgnoreUserInfo: cfg.OIDC.IgnoreUserInfo.Value(), GroupField: cfg.OIDC.GroupField.String(), + GroupFilter: cfg.OIDC.GroupRegexFilter.Value(), + CreateMissingGroups: cfg.OIDC.GroupAutoCreate.Value(), GroupMapping: cfg.OIDC.GroupMapping.Value, UserRoleField: cfg.OIDC.UserRoleField.String(), UserRoleMapping: cfg.OIDC.UserRoleMapping.Value, diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 121ce98a98bd7..4e1274bb7ad20 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -298,6 +298,9 @@ can safely ignore these settings. GitHub. OIDC Options + --oidc-group-auto-create bool, $CODER_OIDC_GROUP_AUTO_CREATE (default: false) + Automatically creates missing groups from a user's groups claim. + --oidc-allow-signups bool, $CODER_OIDC_ALLOW_SIGNUPS (default: true) Whether new users can sign up with OIDC. @@ -334,6 +337,11 @@ can safely ignore these settings. --oidc-issuer-url string, $CODER_OIDC_ISSUER_URL Issuer URL to use for Login with OIDC. + --oidc-group-regex-filter regexp, $CODER_OIDC_GROUP_REGEX_FILTER (default: .*) + If provided any group name not matching the regex is ignored. This + allows for filtering out groups that are not needed. This filter is + applied after the group mapping. + --oidc-scopes string-array, $CODER_OIDC_SCOPES (default: openid,profile,email) Scopes to grant when authenticating with OIDC. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 7eab5aba07ecc..f3149b31eec7f 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -271,6 +271,14 @@ oidc: # for when OIDC providers only return group IDs. # (default: {}, type: struct[map[string]string]) groupMapping: {} + # Automatically creates missing groups from a user's groups claim. + # (default: false, type: bool) + enableGroupAutoCreate: false + # If provided any group name not matching the regex is ignored. This allows for + # filtering out groups that are not needed. This filter is applied after the group + # mapping. + # (default: .*, type: regexp) + groupRegexFilter: .* # This field must be set if using the user roles sync feature. Set this to the # name of the claim used to store the user's role. The roles should be sent as an # array of strings. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9faf9de1611cc..f930bf74f48fc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6624,6 +6624,9 @@ const docTemplate = `{ } } }, + "clibase.Regexp": { + "type": "object" + }, "clibase.Struct-array_codersdk_GitAuthConfig": { "type": "object", "properties": { @@ -8274,9 +8277,23 @@ const docTemplate = `{ }, "quota_allowance": { "type": "integer" + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" } } }, + "codersdk.GroupSource": { + "type": "string", + "enum": [ + "user", + "oidc" + ], + "x-enum-varnames": [ + "GroupSourceUser", + "GroupSourceOIDC" + ] + }, "codersdk.Healthcheck": { "type": "object", "properties": { @@ -8583,9 +8600,15 @@ const docTemplate = `{ "email_field": { "type": "string" }, + "group_auto_create": { + "type": "boolean" + }, "group_mapping": { "type": "object" }, + "group_regex_filter": { + "$ref": "#/definitions/clibase.Regexp" + }, "groups_field": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 635457901db36..0d691b237b655 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5874,6 +5874,9 @@ } } }, + "clibase.Regexp": { + "type": "object" + }, "clibase.Struct-array_codersdk_GitAuthConfig": { "type": "object", "properties": { @@ -7430,9 +7433,17 @@ }, "quota_allowance": { "type": "integer" + }, + "source": { + "$ref": "#/definitions/codersdk.GroupSource" } } }, + "codersdk.GroupSource": { + "type": "string", + "enum": ["user", "oidc"], + "x-enum-varnames": ["GroupSourceUser", "GroupSourceOIDC"] + }, "codersdk.Healthcheck": { "type": "object", "properties": { @@ -7703,9 +7714,15 @@ "email_field": { "type": "string" }, + "group_auto_create": { + "type": "boolean" + }, "group_mapping": { "type": "object" }, + "group_regex_filter": { + "$ref": "#/definitions/clibase.Regexp" + }, "groups_field": { "type": "string" }, diff --git a/coderd/coderd.go b/coderd/coderd.go index 3407be5ac8de2..ed17e0da48e76 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -127,8 +127,8 @@ type Options struct { BaseDERPMap *tailcfg.DERPMap DERPMapUpdateFrequency time.Duration SwaggerEndpoint bool - SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error - SetUserSiteRoles func(ctx context.Context, tx database.Store, userID uuid.UUID, roles []string) error + SetUserGroups func(ctx context.Context, logger slog.Logger, tx database.Store, userID uuid.UUID, groupNames []string, createMissingGroups bool) error + SetUserSiteRoles func(ctx context.Context, logger slog.Logger, tx database.Store, userID uuid.UUID, roles []string) error TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] // AppSecurityKey is the crypto key used to sign and encrypt tokens related to @@ -262,16 +262,16 @@ func New(options *Options) *API { options.TracerProvider = trace.NewNoopTracerProvider() } if options.SetUserGroups == nil { - options.SetUserGroups = func(ctx context.Context, _ database.Store, userID uuid.UUID, groups []string) error { - options.Logger.Warn(ctx, "attempted to assign OIDC groups without enterprise license", - slog.F("user_id", userID), slog.F("groups", groups), + options.SetUserGroups = func(ctx context.Context, logger slog.Logger, _ database.Store, userID uuid.UUID, groups []string, createMissingGroups bool) error { + logger.Warn(ctx, "attempted to assign OIDC groups without enterprise license", + slog.F("user_id", userID), slog.F("groups", groups), slog.F("create_missing_groups", createMissingGroups), ) return nil } } if options.SetUserSiteRoles == nil { - options.SetUserSiteRoles = func(ctx context.Context, _ database.Store, userID uuid.UUID, roles []string) error { - options.Logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise license", + options.SetUserSiteRoles = func(ctx context.Context, logger slog.Logger, _ database.Store, userID uuid.UUID, roles []string) error { + logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise license", slog.F("user_id", userID), slog.F("roles", roles), ) return nil diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 488842dcaf351..21333e6f470ee 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1853,6 +1853,13 @@ func (q *querier) InsertLicense(ctx context.Context, arg database.InsertLicenseP return q.db.InsertLicense(ctx, arg) } +func (q *querier) InsertMissingGroups(ctx context.Context, arg database.InsertMissingGroupsParams) ([]database.Group, error) { + if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.InsertMissingGroups(ctx, arg) +} + func (q *querier) InsertOrganization(ctx context.Context, arg database.InsertOrganizationParams) (database.Organization, error) { return insert(q.log, q.auth, rbac.ResourceOrganization, q.db.InsertOrganization)(ctx, arg) } diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index c968a52f8d00d..c7ce32c7945b0 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -3641,6 +3641,7 @@ func (q *FakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupPar OrganizationID: arg.OrganizationID, AvatarURL: arg.AvatarURL, QuotaAllowance: arg.QuotaAllowance, + Source: database.GroupSourceUser, } q.groups = append(q.groups, group) @@ -3693,6 +3694,45 @@ func (q *FakeQuerier) InsertLicense( return l, nil } +func (q *FakeQuerier) InsertMissingGroups(_ context.Context, arg database.InsertMissingGroupsParams) ([]database.Group, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + groupNameMap := make(map[string]struct{}) + for _, g := range arg.GroupNames { + groupNameMap[g] = struct{}{} + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, g := range q.groups { + if g.OrganizationID != arg.OrganizationID { + continue + } + delete(groupNameMap, g.Name) + } + + newGroups := make([]database.Group, 0, len(groupNameMap)) + for k := range groupNameMap { + g := database.Group{ + ID: uuid.New(), + Name: k, + OrganizationID: arg.OrganizationID, + AvatarURL: "", + QuotaAllowance: 0, + DisplayName: "", + Source: arg.Source, + } + q.groups = append(q.groups, g) + newGroups = append(newGroups, g) + } + + return newGroups, nil +} + func (q *FakeQuerier) InsertOrganization(_ context.Context, arg database.InsertOrganizationParams) (database.Organization, error) { if err := validateDatabaseType(arg); err != nil { return database.Organization{}, err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 85ee8c26a0d51..1a33f35962f7c 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1110,6 +1110,13 @@ func (m metricsStore) InsertLicense(ctx context.Context, arg database.InsertLice return license, err } +func (m metricsStore) InsertMissingGroups(ctx context.Context, arg database.InsertMissingGroupsParams) ([]database.Group, error) { + start := time.Now() + r0, r1 := m.s.InsertMissingGroups(ctx, arg) + m.queryLatencies.WithLabelValues("InsertMissingGroups").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) InsertOrganization(ctx context.Context, arg database.InsertOrganizationParams) (database.Organization, error) { start := time.Now() organization, err := m.s.InsertOrganization(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index cb7278369884b..c3a8905e565ac 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2332,6 +2332,21 @@ func (mr *MockStoreMockRecorder) InsertLicense(arg0, arg1 interface{}) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertLicense", reflect.TypeOf((*MockStore)(nil).InsertLicense), arg0, arg1) } +// InsertMissingGroups mocks base method. +func (m *MockStore) InsertMissingGroups(arg0 context.Context, arg1 database.InsertMissingGroupsParams) ([]database.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertMissingGroups", arg0, arg1) + ret0, _ := ret[0].([]database.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertMissingGroups indicates an expected call of InsertMissingGroups. +func (mr *MockStoreMockRecorder) InsertMissingGroups(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertMissingGroups", reflect.TypeOf((*MockStore)(nil).InsertMissingGroups), arg0, arg1) +} + // InsertOrganization mocks base method. func (m *MockStore) InsertOrganization(arg0 context.Context, arg1 database.InsertOrganizationParams) (database.Organization, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f121fccf8cebb..9dfb9ded10e64 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -31,6 +31,11 @@ CREATE TYPE build_reason AS ENUM ( 'autodelete' ); +CREATE TYPE group_source AS ENUM ( + 'user', + 'oidc' +); + CREATE TYPE log_level AS ENUM ( 'trace', 'debug', @@ -299,11 +304,14 @@ CREATE TABLE groups ( organization_id uuid NOT NULL, avatar_url text DEFAULT ''::text NOT NULL, quota_allowance integer DEFAULT 0 NOT NULL, - display_name text DEFAULT ''::text NOT NULL + display_name text DEFAULT ''::text NOT NULL, + source group_source DEFAULT 'user'::group_source NOT NULL ); COMMENT ON COLUMN groups.display_name IS 'Display name is a custom, human-friendly group name that user can set. This is not required to be unique and can be the empty string.'; +COMMENT ON COLUMN groups.source IS 'Source indicates how the group was created. It can be created by a user manually, or through some system process like OIDC group sync.'; + CREATE TABLE licenses ( id integer NOT NULL, uploaded_at timestamp with time zone NOT NULL, diff --git a/coderd/database/migrations/000148_group_source.down.sql b/coderd/database/migrations/000148_group_source.down.sql new file mode 100644 index 0000000000000..504c227d186bb --- /dev/null +++ b/coderd/database/migrations/000148_group_source.down.sql @@ -0,0 +1,8 @@ +BEGIN; + +ALTER TABLE groups + DROP COLUMN source; + +DROP TYPE group_source; + +COMMIT; diff --git a/coderd/database/migrations/000148_group_source.up.sql b/coderd/database/migrations/000148_group_source.up.sql new file mode 100644 index 0000000000000..d06e89ca2b1d6 --- /dev/null +++ b/coderd/database/migrations/000148_group_source.up.sql @@ -0,0 +1,15 @@ +BEGIN; + +CREATE TYPE group_source AS ENUM ( + -- User created groups + 'user', + -- Groups created by the system through oidc sync + 'oidc' +); + +ALTER TABLE groups + ADD COLUMN source group_source NOT NULL DEFAULT 'user'; + +COMMENT ON COLUMN groups.source IS 'Source indicates how the group was created. It can be created by a user manually, or through some system process like OIDC group sync.'; + +COMMIT; diff --git a/coderd/database/models.go b/coderd/database/models.go index 4e34989b09ae9..d3b7700b56bfa 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -281,6 +281,64 @@ func AllBuildReasonValues() []BuildReason { } } +type GroupSource string + +const ( + GroupSourceUser GroupSource = "user" + GroupSourceOidc GroupSource = "oidc" +) + +func (e *GroupSource) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = GroupSource(s) + case string: + *e = GroupSource(s) + default: + return fmt.Errorf("unsupported scan type for GroupSource: %T", src) + } + return nil +} + +type NullGroupSource struct { + GroupSource GroupSource `json:"group_source"` + Valid bool `json:"valid"` // Valid is true if GroupSource is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullGroupSource) Scan(value interface{}) error { + if value == nil { + ns.GroupSource, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.GroupSource.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullGroupSource) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.GroupSource), nil +} + +func (e GroupSource) Valid() bool { + switch e { + case GroupSourceUser, + GroupSourceOidc: + return true + } + return false +} + +func AllGroupSourceValues() []GroupSource { + return []GroupSource{ + GroupSourceUser, + GroupSourceOidc, + } +} + type LogLevel string const ( @@ -1498,6 +1556,8 @@ type Group struct { QuotaAllowance int32 `db:"quota_allowance" json:"quota_allowance"` // Display name is a custom, human-friendly group name that user can set. This is not required to be unique and can be the empty string. DisplayName string `db:"display_name" json:"display_name"` + // Source indicates how the group was created. It can be created by a user manually, or through some system process like OIDC group sync. + Source GroupSource `db:"source" json:"source"` } type GroupMember struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b308589ffc350..6c5483a12795a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -206,6 +206,11 @@ type sqlcQuerier interface { InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) + // Inserts any group by name that does not exist. All new groups are given + // a random uuid, are inserted into the same organization. They have the default + // values for avatar, display name, and quota allowance (all zero values). + // If the name conflicts, do nothing. + InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error) InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) InsertProvisionerDaemon(ctx context.Context, arg InsertProvisionerDaemonParams) (ProvisionerDaemon, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 8748e4d7ca9ed..95efcc4369baa 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1180,7 +1180,7 @@ func (q *sqlQuerier) DeleteGroupByID(ctx context.Context, id uuid.UUID) error { const getGroupByID = `-- name: GetGroupByID :one SELECT - id, name, organization_id, avatar_url, quota_allowance, display_name + id, name, organization_id, avatar_url, quota_allowance, display_name, source FROM groups WHERE @@ -1199,13 +1199,14 @@ func (q *sqlQuerier) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, err &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ) return i, err } const getGroupByOrgAndName = `-- name: GetGroupByOrgAndName :one SELECT - id, name, organization_id, avatar_url, quota_allowance, display_name + id, name, organization_id, avatar_url, quota_allowance, display_name, source FROM groups WHERE @@ -1231,13 +1232,14 @@ func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrg &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ) return i, err } const getGroupsByOrganizationID = `-- name: GetGroupsByOrganizationID :many SELECT - id, name, organization_id, avatar_url, quota_allowance, display_name + id, name, organization_id, avatar_url, quota_allowance, display_name, source FROM groups WHERE @@ -1262,6 +1264,7 @@ func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organization &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ); err != nil { return nil, err } @@ -1283,7 +1286,7 @@ INSERT INTO groups ( organization_id ) VALUES - ($1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name + ($1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source ` // We use the organization_id as the id @@ -1299,6 +1302,7 @@ func (q *sqlQuerier) InsertAllUsersGroup(ctx context.Context, organizationID uui &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ) return i, err } @@ -1313,7 +1317,7 @@ INSERT INTO groups ( quota_allowance ) VALUES - ($1, $2, $3, $4, $5, $6) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name + ($1, $2, $3, $4, $5, $6) RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source ` type InsertGroupParams struct { @@ -1342,10 +1346,70 @@ func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Gr &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ) return i, err } +const insertMissingGroups = `-- name: InsertMissingGroups :many +INSERT INTO groups ( + id, + name, + organization_id, + source +) +SELECT + gen_random_uuid(), + group_name, + $1, + $2 +FROM + UNNEST($3 :: text[]) AS group_name +ON CONFLICT DO NOTHING +RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source +` + +type InsertMissingGroupsParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Source GroupSource `db:"source" json:"source"` + GroupNames []string `db:"group_names" json:"group_names"` +} + +// Inserts any group by name that does not exist. All new groups are given +// a random uuid, are inserted into the same organization. They have the default +// values for avatar, display name, and quota allowance (all zero values). +// If the name conflicts, do nothing. +func (q *sqlQuerier) InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error) { + rows, err := q.db.QueryContext(ctx, insertMissingGroups, arg.OrganizationID, arg.Source, pq.Array(arg.GroupNames)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Group + for rows.Next() { + var i Group + if err := rows.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + &i.QuotaAllowance, + &i.DisplayName, + &i.Source, + ); 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 updateGroupByID = `-- name: UpdateGroupByID :one UPDATE groups @@ -1356,7 +1420,7 @@ SET quota_allowance = $4 WHERE id = $5 -RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name +RETURNING id, name, organization_id, avatar_url, quota_allowance, display_name, source ` type UpdateGroupByIDParams struct { @@ -1383,6 +1447,7 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar &i.AvatarURL, &i.QuotaAllowance, &i.DisplayName, + &i.Source, ) return i, err } diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index e1ee6635a5fe0..da47116983c87 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -42,6 +42,28 @@ INSERT INTO groups ( VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; +-- name: InsertMissingGroups :many +-- Inserts any group by name that does not exist. All new groups are given +-- a random uuid, are inserted into the same organization. They have the default +-- values for avatar, display name, and quota allowance (all zero values). +INSERT INTO groups ( + id, + name, + organization_id, + source +) +SELECT + gen_random_uuid(), + group_name, + @organization_id, + @source +FROM + UNNEST(@group_names :: text[]) AS group_name +-- If the name conflicts, do nothing. +ON CONFLICT DO NOTHING +RETURNING *; + + -- We use the organization_id as the id -- for simplicity since all users is -- every member of the org. diff --git a/coderd/userauth.go b/coderd/userauth.go index f1e110c08bfdc..03ae7a6379672 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/mail" + "regexp" "sort" "strconv" "strings" @@ -688,6 +689,13 @@ type OIDCConfig struct { // groups. If the group field is the empty string, then no group updates // will ever come from the OIDC provider. GroupField string + // CreateMissingGroups controls whether groups returned by the OIDC provider + // are automatically created in Coder if they are missing. + CreateMissingGroups bool + // GroupFilter is a regular expression that filters the groups returned by + // the OIDC provider. Any group not matched by this regex will be ignored. + // If the group filter is nil, then no group filtering will occur. + GroupFilter *regexp.Regexp // GroupMapping controls how groups returned by the OIDC provider get mapped // to groups within Coder. // map[oidcGroupName]coderGroupName @@ -1029,19 +1037,21 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { } params := (&oauthLoginParams{ - User: user, - Link: link, - State: state, - LinkedID: oidcLinkedID(idToken), - LoginType: database.LoginTypeOIDC, - AllowSignups: api.OIDCConfig.AllowSignups, - Email: email, - Username: username, - AvatarURL: picture, - UsingGroups: usingGroups, - UsingRoles: api.OIDCConfig.RoleSyncEnabled(), - Roles: roles, - Groups: groups, + User: user, + Link: link, + State: state, + LinkedID: oidcLinkedID(idToken), + LoginType: database.LoginTypeOIDC, + AllowSignups: api.OIDCConfig.AllowSignups, + Email: email, + Username: username, + AvatarURL: picture, + UsingGroups: usingGroups, + UsingRoles: api.OIDCConfig.RoleSyncEnabled(), + Roles: roles, + Groups: groups, + CreateMissingGroups: api.OIDCConfig.CreateMissingGroups, + GroupFilter: api.OIDCConfig.GroupFilter, }).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) { return audit.InitRequest[database.User](rw, params) }) @@ -1125,8 +1135,10 @@ type oauthLoginParams struct { AvatarURL string // Is UsingGroups is true, then the user will be assigned // to the Groups provided. - UsingGroups bool - Groups []string + UsingGroups bool + CreateMissingGroups bool + Groups []string + GroupFilter *regexp.Regexp // Is UsingRoles is true, then the user will be assigned // the roles provided. UsingRoles bool @@ -1342,8 +1354,18 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C // Ensure groups are correct. if params.UsingGroups { + filtered := params.Groups + if params.GroupFilter != nil { + filtered = make([]string, 0, len(params.Groups)) + for _, group := range params.Groups { + if params.GroupFilter.MatchString(group) { + filtered = append(filtered, group) + } + } + } + //nolint:gocritic - err := api.Options.SetUserGroups(dbauthz.AsSystemRestricted(ctx), tx, user.ID, params.Groups) + err := api.Options.SetUserGroups(dbauthz.AsSystemRestricted(ctx), logger, tx, user.ID, filtered, params.CreateMissingGroups) if err != nil { return xerrors.Errorf("set user groups: %w", err) } @@ -1362,7 +1384,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C } //nolint:gocritic - err := api.Options.SetUserSiteRoles(dbauthz.AsSystemRestricted(ctx), tx, user.ID, filtered) + err := api.Options.SetUserSiteRoles(dbauthz.AsSystemRestricted(ctx), logger, tx, user.ID, filtered) if err != nil { return httpError{ code: http.StatusBadRequest, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index fdc5b92af28de..96cb9d19516f3 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -271,6 +271,8 @@ type OIDCConfig struct { EmailField clibase.String `json:"email_field" typescript:",notnull"` AuthURLParams clibase.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"` IgnoreUserInfo clibase.Bool `json:"ignore_user_info" typescript:",notnull"` + GroupAutoCreate clibase.Bool `json:"group_auto_create" typescript:",notnull"` + GroupRegexFilter clibase.Regexp `json:"group_regex_filter" typescript:",notnull"` GroupField clibase.String `json:"groups_field" typescript:",notnull"` GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"` UserRoleField clibase.String `json:"user_role_field" typescript:",notnull"` @@ -1066,6 +1068,26 @@ when required by your organization's security policy.`, Group: &deploymentGroupOIDC, YAML: "groupMapping", }, + { + Name: "Enable OIDC Group Auto Create", + Description: "Automatically creates missing groups from a user's groups claim.", + Flag: "oidc-group-auto-create", + Env: "CODER_OIDC_GROUP_AUTO_CREATE", + Default: "false", + Value: &c.OIDC.GroupAutoCreate, + Group: &deploymentGroupOIDC, + YAML: "enableGroupAutoCreate", + }, + { + Name: "OIDC Regex Group Filter", + Description: "If provided any group name not matching the regex is ignored. This allows for filtering out groups that are not needed. This filter is applied after the group mapping.", + Flag: "oidc-group-regex-filter", + Env: "CODER_OIDC_GROUP_REGEX_FILTER", + Default: ".*", + Value: &c.OIDC.GroupRegexFilter, + Group: &deploymentGroupOIDC, + YAML: "groupRegexFilter", + }, { Name: "OIDC User Role Field", Description: "This field must be set if using the user roles sync feature. Set this to the name of the claim used to store the user's role. The roles should be sent as an array of strings.", diff --git a/codersdk/groups.go b/codersdk/groups.go index c04267e4e0eb2..b50455d693469 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -10,6 +10,13 @@ import ( "golang.org/x/xerrors" ) +type GroupSource string + +const ( + GroupSourceUser GroupSource = "user" + GroupSourceOIDC GroupSource = "oidc" +) + type CreateGroupRequest struct { Name string `json:"name"` DisplayName string `json:"display_name"` @@ -18,13 +25,14 @@ type CreateGroupRequest struct { } type Group struct { - ID uuid.UUID `json:"id" format:"uuid"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` - Members []User `json:"members"` - AvatarURL string `json:"avatar_url"` - QuotaAllowance int `json:"quota_allowance"` + ID uuid.UUID `json:"id" format:"uuid"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + Members []User `json:"members"` + AvatarURL string `json:"avatar_url"` + QuotaAllowance int `json:"quota_allowance"` + Source GroupSource `json:"source"` } func (c *Client) CreateGroup(ctx context.Context, orgID uuid.UUID, req CreateGroupRequest) (Group, error) { diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 882a7274c737f..230e99be53f7e 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -13,7 +13,7 @@ We track the following resources: | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| | AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| | GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| | Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_days_of_weektrue
restart_requirement_weekstrue
updated_atfalse
user_acltrue
| diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index d859ac59ad09b..fc887cd12b6e3 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -197,7 +197,8 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` @@ -258,7 +259,8 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group} \ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` @@ -319,7 +321,8 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` @@ -455,7 +458,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ] ``` @@ -470,28 +474,29 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups Status Code **200** -| Name | Type | Required | Restrictions | Description | -| --------------------- | ---------------------------------------------------- | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» avatar_url` | string | false | | | -| `» display_name` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» members` | array | false | | | -| `»» avatar_url` | string(uri) | false | | | -| `»» created_at` | string(date-time) | true | | | -| `»» email` | string(email) | true | | | -| `»» id` | string(uuid) | true | | | -| `»» last_seen_at` | string(date-time) | false | | | -| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | -| `»» organization_ids` | array | false | | | -| `»» roles` | array | false | | | -| `»»» display_name` | string | false | | | -| `»»» name` | string | false | | | -| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `»» username` | string | true | | | -| `» name` | string | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» quota_allowance` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +| --------------------- | ------------------------------------------------------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» avatar_url` | string | false | | | +| `» display_name` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» members` | array | false | | | +| `»» avatar_url` | string(uri) | false | | | +| `»» created_at` | string(date-time) | true | | | +| `»» email` | string(email) | true | | | +| `»» id` | string(uuid) | true | | | +| `»» last_seen_at` | string(date-time) | false | | | +| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `»» organization_ids` | array | false | | | +| `»» roles` | array | false | | | +| `»»» display_name` | string | false | | | +| `»»» name` | string | false | | | +| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `»» username` | string | true | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» quota_allowance` | integer | false | | | +| `» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | | #### Enumerated Values @@ -504,6 +509,8 @@ Status Code **200** | `login_type` | `none` | | `status` | `active` | | `status` | `suspended` | +| `source` | `user` | +| `source` | `oidc` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -569,7 +576,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` @@ -631,7 +639,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` @@ -1202,7 +1211,8 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ], "users": [ @@ -1238,30 +1248,31 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------------- | ---------------------------------------------------- | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» groups` | array | false | | | -| `»» avatar_url` | string | false | | | -| `»» display_name` | string | false | | | -| `»» id` | string(uuid) | false | | | -| `»» members` | array | false | | | -| `»»» avatar_url` | string(uri) | false | | | -| `»»» created_at` | string(date-time) | true | | | -| `»»» email` | string(email) | true | | | -| `»»» id` | string(uuid) | true | | | -| `»»» last_seen_at` | string(date-time) | false | | | -| `»»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | -| `»»» organization_ids` | array | false | | | -| `»»» roles` | array | false | | | -| `»»»» display_name` | string | false | | | -| `»»»» name` | string | false | | | -| `»»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | -| `»»» username` | string | true | | | -| `»» name` | string | false | | | -| `»» organization_id` | string(uuid) | false | | | -| `»» quota_allowance` | integer | false | | | -| `» users` | array | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------- | ------------------------------------------------------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» groups` | array | false | | | +| `»» avatar_url` | string | false | | | +| `»» display_name` | string | false | | | +| `»» id` | string(uuid) | false | | | +| `»» members` | array | false | | | +| `»»» avatar_url` | string(uri) | false | | | +| `»»» created_at` | string(date-time) | true | | | +| `»»» email` | string(email) | true | | | +| `»»» id` | string(uuid) | true | | | +| `»»» last_seen_at` | string(date-time) | false | | | +| `»»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `»»» organization_ids` | array | false | | | +| `»»» roles` | array | false | | | +| `»»»» display_name` | string | false | | | +| `»»»» name` | string | false | | | +| `»»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `»»» username` | string | true | | | +| `»» name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» quota_allowance` | integer | false | | | +| `»» source` | [codersdk.GroupSource](schemas.md#codersdkgroupsource) | false | | | +| `» users` | array | false | | | #### Enumerated Values @@ -1274,6 +1285,8 @@ Status Code **200** | `login_type` | `none` | | `status` | `active` | | `status` | `suspended` | +| `source` | `user` | +| `source` | `oidc` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/general.md b/docs/api/general.md index 3f1f90a02d851..62341f1dcbf27 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -260,7 +260,9 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "client_secret": "string", "email_domain": ["string"], "email_field": "string", + "group_auto_create": true, "group_mapping": {}, + "group_regex_filter": {}, "groups_field": "string", "icon_url": { "forceQuery": true, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 4ab6eceeef96d..7aed5d4f60022 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -595,6 +595,16 @@ | `value_source` | [clibase.ValueSource](#clibasevaluesource) | false | | | | `yaml` | string | false | | Yaml is the YAML key used to configure this option. If unset, YAML configuring is disabled. | +## clibase.Regexp + +```json +{} +``` + +### Properties + +_None_ + ## clibase.Struct-array_codersdk_GitAuthConfig ```json @@ -788,7 +798,8 @@ ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ], "users": [ @@ -2054,7 +2065,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "client_secret": "string", "email_domain": ["string"], "email_field": "string", + "group_auto_create": true, "group_mapping": {}, + "group_regex_filter": {}, "groups_field": "string", "icon_url": { "forceQuery": true, @@ -2412,7 +2425,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "client_secret": "string", "email_domain": ["string"], "email_field": "string", + "group_auto_create": true, "group_mapping": {}, + "group_regex_filter": {}, "groups_field": "string", "icon_url": { "forceQuery": true, @@ -2959,21 +2974,38 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ], "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "quota_allowance": 0 + "quota_allowance": 0, + "source": "user" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------- | --------------------------------------- | -------- | ------------ | ----------- | -| `avatar_url` | string | false | | | -| `display_name` | string | false | | | -| `id` | string | false | | | -| `members` | array of [codersdk.User](#codersdkuser) | false | | | -| `name` | string | false | | | -| `organization_id` | string | false | | | -| `quota_allowance` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +| ----------------- | -------------------------------------------- | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | +| `display_name` | string | false | | | +| `id` | string | false | | | +| `members` | array of [codersdk.User](#codersdkuser) | false | | | +| `name` | string | false | | | +| `organization_id` | string | false | | | +| `quota_allowance` | integer | false | | | +| `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | | + +## codersdk.GroupSource + +```json +"user" +``` + +### Properties + +#### Enumerated Values + +| Value | +| ------ | +| `user` | +| `oidc` | ## codersdk.Healthcheck @@ -3305,7 +3337,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "client_secret": "string", "email_domain": ["string"], "email_field": "string", + "group_auto_create": true, "group_mapping": {}, + "group_regex_filter": {}, "groups_field": "string", "icon_url": { "forceQuery": true, @@ -3334,26 +3368,28 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------------- | -------------------------- | -------- | ------------ | ----------- | -| `allow_signups` | boolean | false | | | -| `auth_url_params` | object | false | | | -| `client_id` | string | false | | | -| `client_secret` | string | false | | | -| `email_domain` | array of string | false | | | -| `email_field` | string | false | | | -| `group_mapping` | object | false | | | -| `groups_field` | string | false | | | -| `icon_url` | [clibase.URL](#clibaseurl) | false | | | -| `ignore_email_verified` | boolean | false | | | -| `ignore_user_info` | boolean | false | | | -| `issuer_url` | string | false | | | -| `scopes` | array of string | false | | | -| `sign_in_text` | string | false | | | -| `user_role_field` | string | false | | | -| `user_role_mapping` | object | false | | | -| `user_roles_default` | array of string | false | | | -| `username_field` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ----------------------- | -------------------------------- | -------- | ------------ | ----------- | +| `allow_signups` | boolean | false | | | +| `auth_url_params` | object | false | | | +| `client_id` | string | false | | | +| `client_secret` | string | false | | | +| `email_domain` | array of string | false | | | +| `email_field` | string | false | | | +| `group_auto_create` | boolean | false | | | +| `group_mapping` | object | false | | | +| `group_regex_filter` | [clibase.Regexp](#clibaseregexp) | false | | | +| `groups_field` | string | false | | | +| `icon_url` | [clibase.URL](#clibaseurl) | false | | | +| `ignore_email_verified` | boolean | false | | | +| `ignore_user_info` | boolean | false | | | +| `issuer_url` | string | false | | | +| `scopes` | array of string | false | | | +| `sign_in_text` | string | false | | | +| `user_role_field` | string | false | | | +| `user_role_mapping` | object | false | | | +| `user_roles_default` | array of string | false | | | +| `username_field` | string | false | | | ## codersdk.Organization diff --git a/docs/cli/server.md b/docs/cli/server.md index 9591dc8041f9f..27658ee0d16e8 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -243,6 +243,17 @@ Disable automatic session expiry bumping due to activity. This forces all sessio Specifies the custom docs URL. +### --oidc-group-auto-create + +| | | +| ----------- | ------------------------------------------ | +| Type | bool | +| Environment | $CODER_OIDC_GROUP_AUTO_CREATE | +| YAML | oidc.enableGroupAutoCreate | +| Default | false | + +Automatically creates missing groups from a user's groups claim. + ### --enable-terraform-debug-mode | | | @@ -521,6 +532,17 @@ Ignore the userinfo endpoint and only use the ID token for user information. Issuer URL to use for Login with OIDC. +### --oidc-group-regex-filter + +| | | +| ----------- | ------------------------------------------- | +| Type | regexp | +| Environment | $CODER_OIDC_GROUP_REGEX_FILTER | +| YAML | oidc.groupRegexFilter | +| Default | .\* | + +If provided any group name not matching the regex is ignored. This allows for filtering out groups that are not needed. This filter is applied after the group mapping. + ### --oidc-scopes | | | diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index b5ab50457c963..17c91ff8adfb6 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -156,6 +156,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "avatar_url": ActionTrack, "quota_allowance": ActionTrack, "members": ActionTrack, + "source": ActionIgnore, }, &database.APIKey{}: { "id": ActionIgnore, diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 121ce98a98bd7..4e1274bb7ad20 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -298,6 +298,9 @@ can safely ignore these settings. GitHub. OIDC Options + --oidc-group-auto-create bool, $CODER_OIDC_GROUP_AUTO_CREATE (default: false) + Automatically creates missing groups from a user's groups claim. + --oidc-allow-signups bool, $CODER_OIDC_ALLOW_SIGNUPS (default: true) Whether new users can sign up with OIDC. @@ -334,6 +337,11 @@ can safely ignore these settings. --oidc-issuer-url string, $CODER_OIDC_ISSUER_URL Issuer URL to use for Login with OIDC. + --oidc-group-regex-filter regexp, $CODER_OIDC_GROUP_REGEX_FILTER (default: .*) + If provided any group name not matching the regex is ignored. This + allows for filtering out groups that are not needed. This filter is + applied after the group mapping. + --oidc-scopes string-array, $CODER_OIDC_SCOPES (default: openid,profile,email) Scopes to grant when authenticating with OIDC. diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index b6f126e1f62e0..5119f3f91a5d8 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -409,6 +409,7 @@ func convertGroup(g database.Group, users []database.User) codersdk.Group { AvatarURL: g.AvatarURL, QuotaAllowance: int(g.QuotaAllowance), Members: convertUsers(users, orgs), + Source: codersdk.GroupSource(g.Source), } } diff --git a/enterprise/coderd/userauth.go b/enterprise/coderd/userauth.go index 86aa9f0ddf88b..98833263355e3 100644 --- a/enterprise/coderd/userauth.go +++ b/enterprise/coderd/userauth.go @@ -9,10 +9,12 @@ import ( "cdr.dev/slog" "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/codersdk" ) -func (api *API) setUserGroups(ctx context.Context, db database.Store, userID uuid.UUID, groupNames []string) error { +// nolint: revive +func (api *API) setUserGroups(ctx context.Context, logger slog.Logger, db database.Store, userID uuid.UUID, groupNames []string, createMissingGroups bool) error { api.entitlementsMu.RLock() enabled := api.entitlements.Features[codersdk.FeatureTemplateRBAC].Enabled api.entitlementsMu.RUnlock() @@ -39,6 +41,25 @@ func (api *API) setUserGroups(ctx context.Context, db database.Store, userID uui return xerrors.Errorf("delete user groups: %w", err) } + if createMissingGroups { + // This is the system creating these additional groups, so we use the system restricted context. + // nolint:gocritic + created, err := tx.InsertMissingGroups(dbauthz.AsSystemRestricted(ctx), database.InsertMissingGroupsParams{ + OrganizationID: orgs[0].ID, + GroupNames: groupNames, + Source: database.GroupSourceOidc, + }) + if err != nil { + return xerrors.Errorf("insert missing groups: %w", err) + } + if len(created) > 0 { + logger.Debug(ctx, "auto created missing groups", + slog.F("org_id", orgs[0].ID), + slog.F("created", created), + ) + } + } + // Re-add the user to all groups returned by the auth provider. err = tx.InsertUserGroupsByName(ctx, database.InsertUserGroupsByNameParams{ UserID: userID, @@ -53,13 +74,13 @@ func (api *API) setUserGroups(ctx context.Context, db database.Store, userID uui }, nil) } -func (api *API) setUserSiteRoles(ctx context.Context, db database.Store, userID uuid.UUID, roles []string) error { +func (api *API) setUserSiteRoles(ctx context.Context, logger slog.Logger, db database.Store, userID uuid.UUID, roles []string) error { api.entitlementsMu.RLock() enabled := api.entitlements.Features[codersdk.FeatureUserRoleManagement].Enabled api.entitlementsMu.RUnlock() if !enabled { - api.Logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise entitlement, roles left unchanged", + logger.Warn(ctx, "attempted to assign OIDC user roles without enterprise entitlement, roles left unchanged", slog.F("user_id", userID), slog.F("roles", roles), ) return nil diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index 2cb110abe987b..176a308595205 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -5,10 +5,9 @@ import ( "fmt" "io" "net/http" + "regexp" "testing" - "github.com/coder/coder/enterprise/coderd/license" - "github.com/golang-jwt/jwt" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -16,9 +15,13 @@ import ( "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/testutil" ) @@ -354,6 +357,213 @@ func TestUserOIDC(t *testing.T) { }) } +func TestGroupSync(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + modCfg func(cfg *coderd.OIDCConfig) + // initialOrgGroups is initial groups in the org + initialOrgGroups []string + // initialUserGroups is initial groups for the user + initialUserGroups []string + // expectedUserGroups is expected groups for the user + expectedUserGroups []string + // expectedOrgGroups is expected all groups on the system + expectedOrgGroups []string + claims jwt.MapClaims + }{ + { + name: "NoGroups", + modCfg: func(cfg *coderd.OIDCConfig) { + }, + initialOrgGroups: []string{}, + expectedUserGroups: []string{}, + expectedOrgGroups: []string{}, + claims: jwt.MapClaims{}, + }, + { + name: "GroupSyncDisabled", + modCfg: func(cfg *coderd.OIDCConfig) { + // Disable group sync + cfg.GroupField = "" + cfg.GroupFilter = regexp.MustCompile(".*") + }, + initialOrgGroups: []string{"a", "b", "c", "d"}, + initialUserGroups: []string{"b", "c", "d"}, + expectedUserGroups: []string{"b", "c", "d"}, + expectedOrgGroups: []string{"a", "b", "c", "d"}, + claims: jwt.MapClaims{}, + }, + { + // From a,c,b -> b,c,d + name: "ChangeUserGroups", + modCfg: func(cfg *coderd.OIDCConfig) { + cfg.GroupMapping = map[string]string{ + "D": "d", + } + }, + initialOrgGroups: []string{"a", "b", "c", "d"}, + initialUserGroups: []string{"a", "b", "c"}, + expectedUserGroups: []string{"b", "c", "d"}, + expectedOrgGroups: []string{"a", "b", "c", "d"}, + claims: jwt.MapClaims{ + // D -> d mapped + "groups": []string{"b", "c", "D"}, + }, + }, + { + // From a,c,b -> [] + name: "RemoveAllGroups", + modCfg: func(cfg *coderd.OIDCConfig) { + cfg.GroupFilter = regexp.MustCompile(".*") + }, + initialOrgGroups: []string{"a", "b", "c", "d"}, + initialUserGroups: []string{"a", "b", "c"}, + expectedUserGroups: []string{}, + expectedOrgGroups: []string{"a", "b", "c", "d"}, + claims: jwt.MapClaims{ + // No claim == no groups + }, + }, + { + // From a,c,b -> b,c,d,e,f + name: "CreateMissingGroups", + modCfg: func(cfg *coderd.OIDCConfig) { + cfg.CreateMissingGroups = true + }, + initialOrgGroups: []string{"a", "b", "c", "d"}, + initialUserGroups: []string{"a", "b", "c"}, + expectedUserGroups: []string{"b", "c", "d", "e", "f"}, + expectedOrgGroups: []string{"a", "b", "c", "d", "e", "f"}, + claims: jwt.MapClaims{ + "groups": []string{"b", "c", "d", "e", "f"}, + }, + }, + { + // From a,c,b -> b,c,d,e,f + name: "CreateMissingGroupsFilter", + modCfg: func(cfg *coderd.OIDCConfig) { + cfg.CreateMissingGroups = true + // Only single letter groups + cfg.GroupFilter = regexp.MustCompile("^[a-z]$") + cfg.GroupMapping = map[string]string{ + // Does not match the filter, but does after being mapped! + "zebra": "z", + } + }, + initialOrgGroups: []string{"a", "b", "c", "d"}, + initialUserGroups: []string{"a", "b", "c"}, + expectedUserGroups: []string{"b", "c", "d", "e", "f", "z"}, + expectedOrgGroups: []string{"a", "b", "c", "d", "e", "f", "z"}, + claims: jwt.MapClaims{ + "groups": []string{ + "b", "c", "d", "e", "f", + // These groups are ignored + "excess", "ignore", "dumb", "foobar", "zebra", + }, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + conf := coderdtest.NewOIDCConfig(t, "") + + config := conf.OIDCConfig(t, jwt.MapClaims{}, tc.modCfg) + + client, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + OIDCConfig: config, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureTemplateRBAC: 1}, + }, + }) + + admin, err := client.User(ctx, "me") + require.NoError(t, err) + require.Len(t, admin.OrganizationIDs, 1) + + // Setup + initialGroups := make(map[string]codersdk.Group) + for _, group := range tc.initialOrgGroups { + newGroup, err := client.CreateGroup(ctx, admin.OrganizationIDs[0], codersdk.CreateGroupRequest{ + Name: group, + }) + require.NoError(t, err) + require.Len(t, newGroup.Members, 0) + initialGroups[group] = newGroup + } + + // Create the user and add them to their initial groups + _, user := coderdtest.CreateAnotherUser(t, client, admin.OrganizationIDs[0]) + for _, group := range tc.initialUserGroups { + _, err := client.PatchGroup(ctx, initialGroups[group].ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user.ID.String()}, + }) + require.NoError(t, err) + } + + // nolint:gocritic + _, err = api.Database.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{ + NewLoginType: database.LoginTypeOIDC, + UserID: user.ID, + }) + require.NoError(t, err, "user must be oidc type") + + // Log in the new user + tc.claims["email"] = user.Email + resp := oidcCallback(t, client, conf.EncodeClaims(t, tc.claims)) + assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + _ = resp.Body.Close() + + orgGroups, err := client.GroupsByOrganization(ctx, admin.OrganizationIDs[0]) + require.NoError(t, err) + + for _, group := range orgGroups { + if slice.Contains(tc.initialOrgGroups, group.Name) { + require.Equal(t, group.Source, codersdk.GroupSourceUser) + } else { + require.Equal(t, group.Source, codersdk.GroupSourceOIDC) + } + } + + orgGroupsMap := make(map[string]struct{}) + for _, group := range orgGroups { + orgGroupsMap[group.Name] = struct{}{} + } + + for _, expected := range tc.expectedOrgGroups { + if _, ok := orgGroupsMap[expected]; !ok { + t.Errorf("expected group %s not found", expected) + } + delete(orgGroupsMap, expected) + } + require.Empty(t, orgGroupsMap, "unexpected groups found") + + expectedUserGroups := make(map[string]struct{}) + for _, group := range tc.expectedUserGroups { + expectedUserGroups[group] = struct{}{} + } + + for _, group := range orgGroups { + userInGroup := slice.ContainsCompare(group.Members, codersdk.User{Email: user.Email}, func(a, b codersdk.User) bool { + return a.Email == b.Email + }) + if _, ok := expectedUserGroups[group.Name]; ok { + require.Truef(t, userInGroup, "user should be in group %s", group.Name) + } else { + require.Falsef(t, userInGroup, "user should not be in group %s", group.Name) + } + } + }) + } +} + func oidcCallback(t *testing.T, client *codersdk.Client, code string) *http.Response { t.Helper() client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c01f4d94385ba..3e5acc5671299 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -513,6 +513,7 @@ export interface Group { readonly members: User[] readonly avatar_url: string readonly quota_allowance: number + readonly source: GroupSource } // From codersdk/workspaceapps.go @@ -626,6 +627,10 @@ export interface OIDCConfig { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type readonly auth_url_params: any readonly ignore_user_info: boolean + readonly group_auto_create: boolean + // Named type "github.com/coder/coder/cli/clibase.Regexp" unknown, using "any" + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type + readonly group_regex_filter: any readonly groups_field: string // Named type "github.com/coder/coder/cli/clibase.Struct[map[string]string]" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type @@ -1639,6 +1644,10 @@ export const GitProviders: GitProvider[] = [ "gitlab", ] +// From codersdk/groups.go +export type GroupSource = "oidc" | "user" +export const GroupSources: GroupSource[] = ["oidc", "user"] + // From codersdk/insights.go export type InsightsReportInterval = "day" export const InsightsReportIntervals: InsightsReportInterval[] = ["day"] diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 3d28ae7ad1ffb..94eaa3add0211 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1670,6 +1670,7 @@ export const MockGroup: TypesGen.Group = { organization_id: MockOrganization.id, members: [MockUser, MockUser2], quota_allowance: 5, + source: "user", } export const MockTemplateACL: TypesGen.TemplateACL = { diff --git a/site/src/utils/groups.ts b/site/src/utils/groups.ts index 9afc048ce7cef..88dad942beddb 100644 --- a/site/src/utils/groups.ts +++ b/site/src/utils/groups.ts @@ -8,6 +8,7 @@ export const everyOneGroup = (organizationId: string): Group => ({ members: [], avatar_url: "", quota_allowance: 0, + source: "user", }) export const getGroupSubtitle = (group: Group): string => { From 76b1594670ca892640a4dfbf157b44223423e866 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 8 Aug 2023 13:43:21 -0300 Subject: [PATCH 046/277] feat(site): add date range picker for the template insights (#8976) --- site/package.json | 2 + site/pnpm-lock.yaml | 44 ++++ .../TemplateInsightsPage/DateRange.tsx | 234 ++++++++++++++++++ .../TemplateInsightsPage.tsx | 86 ++++--- .../TemplateInsightsPage/utils.test.ts | 36 +++ .../TemplateInsightsPage/utils.ts | 26 ++ 6 files changed, 391 insertions(+), 37 deletions(-) create mode 100644 site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx create mode 100644 site/src/pages/TemplatePage/TemplateInsightsPage/utils.test.ts create mode 100644 site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts diff --git a/site/package.json b/site/package.json index 538e158218292..8d08e287ebf1a 100644 --- a/site/package.json +++ b/site/package.json @@ -48,6 +48,7 @@ "@types/color-convert": "2.0.0", "@types/lodash": "4.14.196", "@types/react-color": "3.0.6", + "@types/react-date-range": "1.4.4", "@types/semver": "7.5.0", "@vitejs/plugin-react": "4.0.1", "@xstate/inspect": "0.8.0", @@ -77,6 +78,7 @@ "react-chartjs-2": "5.2.0", "react-color": "2.19.3", "react-confetti": "6.1.0", + "react-date-range": "1.4.0", "react-dom": "18.2.0", "react-headless-tabs": "6.0.3", "react-helmet-async": "1.3.0", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index abb876c024c61..0746844223c01 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -60,6 +60,9 @@ dependencies: '@types/react-color': specifier: 3.0.6 version: 3.0.6 + '@types/react-date-range': + specifier: 1.4.4 + version: 1.4.4 '@types/semver': specifier: 7.5.0 version: 7.5.0 @@ -147,6 +150,9 @@ dependencies: react-confetti: specifier: 6.1.0 version: 6.1.0(react@18.2.0) + react-date-range: + specifier: 1.4.0 + version: 1.4.0(date-fns@2.30.0)(react@18.2.0) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) @@ -4952,6 +4958,13 @@ packages: '@types/reactcss': 1.2.6 dev: false + /@types/react-date-range@1.4.4: + resolution: {integrity: sha512-9Y9NyNgaCsEVN/+O4HKuxzPbVjRVBGdOKRxMDcsTRWVG62lpYgnxefNckTXDWup8FvczoqPW0+ESZR6R1yymDg==} + dependencies: + '@types/react': 18.2.6 + date-fns: 2.30.0 + dev: false + /@types/react-dom@18.2.4: resolution: {integrity: sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==} dependencies: @@ -6226,6 +6239,10 @@ packages: resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} dev: true + /classnames@2.3.2: + resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} + dev: false + /clean-regexp@1.0.0: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} @@ -11179,6 +11196,20 @@ packages: tween-functions: 1.2.0 dev: false + /react-date-range@1.4.0(date-fns@2.30.0)(react@18.2.0): + resolution: {integrity: sha512-+9t0HyClbCqw1IhYbpWecjsiaftCeRN5cdhsi9v06YdimwyMR2yYHWcgVn3URwtN/txhqKpEZB6UX1fHpvK76w==} + peerDependencies: + date-fns: 2.0.0-alpha.7 || >=2.0.0 + react: ^0.14 || ^15.0.0-rc || >=15.0 + dependencies: + classnames: 2.3.2 + date-fns: 2.30.0 + prop-types: 15.8.1 + react: 18.2.0 + react-list: 0.8.17(react@18.2.0) + shallow-equal: 1.2.1 + dev: false + /react-docgen-typescript@2.2.2(typescript@5.1.6): resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==} peerDependencies: @@ -11313,6 +11344,15 @@ packages: /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + /react-list@0.8.17(react@18.2.0): + resolution: {integrity: sha512-pgmzGi0G5uGrdHzMhgO7KR1wx5ZXVvI3SsJUmkblSAKtewIhMwbQiMuQiTE83ozo04BQJbe0r3WIWzSO0dR1xg==} + peerDependencies: + react: 0.14 || 15 - 18 + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /react-markdown@8.0.3(@types/react@18.2.6)(react@18.2.0): resolution: {integrity: sha512-We36SfqaKoVNpN1QqsZwWSv/OZt5J15LNgTLWynwAN5b265hrQrsjMtlRNwUvS+YyR3yDM8HpTNc4pK9H/Gc0A==} peerDependencies: @@ -12001,6 +12041,10 @@ packages: kind-of: 6.0.3 dev: true + /shallow-equal@1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + dev: false + /shallowequal@1.1.0: resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} dev: false diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx new file mode 100644 index 0000000000000..60774242dad33 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx @@ -0,0 +1,234 @@ +import Box from "@mui/material/Box" +import { styled } from "@mui/material/styles" +import { ComponentProps, useRef, useState } from "react" +import "react-date-range/dist/styles.css" +import "react-date-range/dist/theme/default.css" +import Button from "@mui/material/Button" +import ArrowRightAltOutlined from "@mui/icons-material/ArrowRightAltOutlined" +import Popover from "@mui/material/Popover" +import { DateRangePicker, createStaticRanges } from "react-date-range" +import { format, subDays } from "date-fns" + +// The type definition from @types is wrong +declare module "react-date-range" { + export function createStaticRanges( + ranges: Omit[], + ): StaticRange[] +} + +export type DateRangeValue = { + startDate: Date + endDate: Date +} + +type RangesState = NonNullable["ranges"]> + +export const DateRange = ({ + value, + onChange, +}: { + value: DateRangeValue + onChange: (value: DateRangeValue) => void +}) => { + const selectionStatusRef = useRef<"idle" | "selecting">("idle") + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [ranges, setRanges] = useState([ + { + ...value, + key: "selection", + }, + ]) + const currentRange = { + startDate: ranges[0].startDate as Date, + endDate: ranges[0].endDate as Date, + } + const handleClose = () => { + onChange({ + startDate: currentRange.startDate, + endDate: currentRange.endDate, + }) + setIsOpen(false) + } + + return ( + <> + + + { + const range = item.selection + setRanges([range]) + + // When it is the first selection, we don't want to close the popover + // We have to do that ourselves because the library doesn't provide a way to do it + if (selectionStatusRef.current === "idle") { + selectionStatusRef.current = "selecting" + return + } + + selectionStatusRef.current = "idle" + const startDate = range.startDate as Date + const endDate = range.endDate as Date + onChange({ + startDate, + endDate, + }) + setIsOpen(false) + }} + moveRangeOnFirstSelection={false} + months={2} + ranges={ranges} + maxDate={new Date()} + direction="horizontal" + staticRanges={createStaticRanges([ + { + label: "Today", + range: () => ({ + startDate: new Date(), + endDate: new Date(), + }), + }, + { + label: "Yesterday", + range: () => ({ + startDate: subDays(new Date(), 1), + endDate: subDays(new Date(), 1), + }), + }, + { + label: "Last 7 days", + range: () => ({ + startDate: subDays(new Date(), 6), + endDate: new Date(), + }), + }, + { + label: "Last 14 days", + range: () => ({ + startDate: subDays(new Date(), 13), + endDate: new Date(), + }), + }, + { + label: "Last 30 days", + range: () => ({ + startDate: subDays(new Date(), 29), + endDate: new Date(), + }), + }, + ])} + /> + + + ) +} + +const DateRangePickerWrapper: typeof Box = styled(Box)(({ theme }) => ({ + "& .rdrDefinedRangesWrapper": { + background: theme.palette.background.paper, + borderColor: theme.palette.divider, + }, + + "& .rdrStaticRange": { + background: theme.palette.background.paper, + border: 0, + fontSize: 14, + color: theme.palette.text.secondary, + + "&:hover .rdrStaticRangeLabel": { + background: theme.palette.background.paperLight, + color: theme.palette.text.primary, + }, + + "&.rdrStaticRangeSelected": { + color: `${theme.palette.text.primary} !important`, + }, + }, + + "& .rdrInputRanges": { + display: "none", + }, + + "& .rdrDateDisplayWrapper": { + backgroundColor: theme.palette.background.paper, + }, + + "& .rdrCalendarWrapper": { + backgroundColor: theme.palette.background.paperLight, + }, + + "& .rdrDateDisplayItem": { + background: "transparent", + borderColor: theme.palette.divider, + + "& input": { + color: theme.palette.text.secondary, + }, + + "&.rdrDateDisplayItemActive": { + borderColor: theme.palette.text.primary, + backgroundColor: theme.palette.background.paperLight, + + "& input": { + color: theme.palette.text.primary, + }, + }, + }, + + "& .rdrMonthPicker select, & .rdrYearPicker select": { + color: theme.palette.text.primary, + appearance: "auto", + background: "transparent", + }, + + "& .rdrMonthName, & .rdrWeekDay": { + color: theme.palette.text.secondary, + }, + + "& .rdrDayPassive .rdrDayNumber span": { + color: theme.palette.text.disabled, + }, + + "& .rdrDayNumber span": { + color: theme.palette.text.primary, + }, + + "& .rdrDayToday .rdrDayNumber span": { + fontWeight: 900, + + "&:after": { + display: "none", + }, + }, + + "& .rdrInRange, & .rdrEndEdge, & .rdrStartEdge": { + color: theme.palette.primary.main, + }, + + "& .rdrDayDisabled": { + backgroundColor: "transparent", + + "& .rdrDayNumber span": { + color: theme.palette.text.disabled, + }, + }, +})) diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index 77bf66a0759e9..19de025f0648c 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -25,28 +25,40 @@ import { TemplateParameterValue, UserLatencyInsightsResponse, } from "api/typesGenerated" -import { ComponentProps } from "react" -import { subDays, addHours, startOfHour } from "date-fns" +import { ComponentProps, ReactNode, useState } from "react" +import { subDays, isToday } from "date-fns" +import "react-date-range/dist/styles.css" +import "react-date-range/dist/theme/default.css" +import { DateRange, DateRangeValue } from "./DateRange" import { useDashboard } from "components/Dashboard/DashboardProvider" import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined" import Link from "@mui/material/Link" import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined" import CancelOutlined from "@mui/icons-material/CancelOutlined" +import { getDateRangeFilter } from "./utils" export default function TemplateInsightsPage() { const now = new Date() + const [dateRangeValue, setDateRangeValue] = useState({ + startDate: subDays(now, 6), + endDate: now, + }) const { template } = useTemplateLayoutContext() const insightsFilter = { template_ids: template.id, - start_time: toStartTimeFilter(subDays(now, 7)), - end_time: startOfHour(addHours(now, 1)).toISOString(), + ...getDateRangeFilter({ + startDate: dateRangeValue.startDate, + endDate: dateRangeValue.endDate, + now, + isToday, + }), } const { data: templateInsights } = useQuery({ - queryKey: ["templates", template.id, "usage"], + queryKey: ["templates", template.id, "usage", insightsFilter], queryFn: () => getInsightsTemplate(insightsFilter), }) const { data: userLatency } = useQuery({ - queryKey: ["templates", template.id, "user-latency"], + queryKey: ["templates", template.id, "user-latency", insightsFilter], queryFn: () => getInsightsUserLatency(insightsFilter), }) const dashboard = useDashboard() @@ -60,6 +72,9 @@ export default function TemplateInsightsPage() { {getTemplatePageTitle("Insights", template)} + } templateInsights={templateInsights} userLatency={userLatency} shouldDisplayParameters={shouldDisplayParameters} @@ -72,36 +87,41 @@ export const TemplateInsightsPageView = ({ templateInsights, userLatency, shouldDisplayParameters, + dateRange, }: { templateInsights: TemplateInsightsResponse | undefined userLatency: UserLatencyInsightsResponse | undefined shouldDisplayParameters: boolean + dateRange: ReactNode }) => { return ( - theme.spacing(3), - }} - > - - - - {shouldDisplayParameters && ( - + {dateRange} + theme.spacing(3), + }} + > + + + - )} - + {shouldDisplayParameters && ( + + )} + + ) } @@ -551,20 +571,12 @@ function mapToDAUsResponse( entries: data.map((d) => { return { amount: d.active_users, - date: d.end_time, + date: d.start_time, } }), } } -function toStartTimeFilter(date: Date) { - date.setHours(0, 0, 0, 0) - const year = date.getUTCFullYear() - const month = String(date.getUTCMonth() + 1).padStart(2, "0") - const day = String(date.getUTCDate()).padStart(2, "0") - return `${year}-${month}-${day}T00:00:00Z` -} - function formatTime(seconds: number): string { if (seconds < 60) { return seconds + " seconds" diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/utils.test.ts b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.test.ts new file mode 100644 index 0000000000000..e029f69fb9035 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.test.ts @@ -0,0 +1,36 @@ +import { getDateRangeFilter } from "./utils" + +describe("getDateRangeFilter", () => { + it("returns the start time at the start of the day", () => { + const date = new Date("2020-01-01T12:00:00.000Z") + const { start_time } = getDateRangeFilter({ + startDate: date, + endDate: date, + now: date, + isToday: () => false, + }) + expect(start_time).toEqual("2020-01-01T00:00:00+00:00") + }) + + it("returns the end time at the start of the next day", () => { + const date = new Date("2020-01-01T12:00:00.000Z") + const { end_time } = getDateRangeFilter({ + startDate: date, + endDate: date, + now: date, + isToday: () => false, + }) + expect(end_time).toEqual("2020-01-02T00:00:00+00:00") + }) + + it("returns the end time at the start of the next hour if the end date is today", () => { + const date = new Date("2020-01-01T12:00:00.000Z") + const { end_time } = getDateRangeFilter({ + startDate: date, + endDate: date, + now: date, + isToday: () => true, + }) + expect(end_time).toEqual("2020-01-01T13:00:00+00:00") + }) +}) diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts new file mode 100644 index 0000000000000..bfc751efe4c01 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts @@ -0,0 +1,26 @@ +import { addDays, addHours, format, startOfDay, startOfHour } from "date-fns" + +export function getDateRangeFilter({ + startDate, + endDate, + now, + isToday, +}: { + startDate: Date + endDate: Date + now: Date + isToday: (date: Date) => boolean +}) { + return { + start_time: toISOLocal(startOfDay(startDate)), + end_time: toISOLocal( + isToday(endDate) + ? startOfHour(addHours(now, 1)) + : startOfDay(addDays(endDate, 1)), + ), + } +} + +function toISOLocal(d: Date) { + return format(d, "yyyy-MM-dd'T'HH:mm:ssxxx") +} From a5c59b9934e9c4b9b1a8cb212698a638d1ea1c0b Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 8 Aug 2023 12:21:38 -0500 Subject: [PATCH 047/277] chore: upgrade to alpine `3.18.3` (#8980) --- scripts/Dockerfile.base | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/Dockerfile.base b/scripts/Dockerfile.base index 738b66f01090e..d5e8283d0399b 100644 --- a/scripts/Dockerfile.base +++ b/scripts/Dockerfile.base @@ -1,7 +1,7 @@ # This is the base image used for Coder images. It's a multi-arch image that is # built in depot.dev for all supported architectures. Since it's built on real # hardware and not cross-compiled, it can have "RUN" commands. -FROM alpine:3.18.2 +FROM alpine:3.18.3 # We use a single RUN command to reduce the number of layers in the image. # NOTE: Keep the Terraform version in sync with minTerraformVersion and @@ -12,8 +12,6 @@ RUN apk add --no-cache \ bash \ jq \ git \ - # Fixes CVE-2023-3446 and CVE-2023-2975. Only necessary until Alpine 3.18.3. - openssl \ openssh-client && \ # Use the edge repo, since Terraform doesn't seem to be backported to 3.18. apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community \ From 3c52b018508b40c5d2339148ab838519c6769403 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 8 Aug 2023 10:56:08 -0700 Subject: [PATCH 048/277] chore: add tailscale magicsock debug logging controls (#8982) --- agent/agent.go | 48 +++++++++++++++++++---- agent/agent_test.go | 92 ++++++++++++++++++++++++++++++++++++++++++++- tailnet/conn.go | 25 ++++++++++++ 3 files changed, 157 insertions(+), 8 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 52c423787fb44..e435b795d5fc9 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -22,6 +22,7 @@ import ( "time" "github.com/armon/circbuf" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/spf13/afero" @@ -1408,24 +1409,57 @@ func (a *agent) isClosed() bool { } func (a *agent) HTTPDebug() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r := chi.NewRouter() + + requireNetwork := func(w http.ResponseWriter) (*tailnet.Conn, bool) { a.closeMutex.Lock() network := a.network a.closeMutex.Unlock() if network == nil { - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte("network is not ready yet")) + return nil, false + } + + return network, true + } + + r.Get("/debug/magicsock", func(w http.ResponseWriter, r *http.Request) { + network, ok := requireNetwork(w) + if !ok { return } + network.MagicsockServeHTTPDebug(w, r) + }) - if r.URL.Path == "/debug/magicsock" { - network.MagicsockServeHTTPDebug(w, r) - } else { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte("404 not found")) + r.Get("/debug/magicsock/debug-logging/{state}", func(w http.ResponseWriter, r *http.Request) { + state := chi.URLParam(r, "state") + stateBool, err := strconv.ParseBool(state) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(w, "invalid state %q, must be a boolean", state) + return + } + + network, ok := requireNetwork(w) + if !ok { + return } + + network.MagicsockSetDebugLoggingEnabled(stateBool) + a.logger.Info(r.Context(), "updated magicsock debug logging due to debug request", slog.F("new_state", stateBool)) + + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, "updated magicsock debug logging to %v", stateBool) }) + + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("404 not found")) + }) + + return r } func (a *agent) Close() error { diff --git a/agent/agent_test.go b/agent/agent_test.go index 34637992536b7..92be40764f209 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -1932,6 +1932,96 @@ func TestAgent_WriteVSCodeConfigs(t *testing.T) { }, testutil.WaitShort, testutil.IntervalFast) } +func TestAgent_DebugServer(t *testing.T) { + t.Parallel() + + derpMap, _ := tailnettest.RunDERPAndSTUN(t) + //nolint:dogsled + conn, _, _, _, agnt := setupAgent(t, agentsdk.Manifest{ + DERPMap: derpMap, + }, 0) + + awaitReachableCtx := testutil.Context(t, testutil.WaitLong) + ok := conn.AwaitReachable(awaitReachableCtx) + require.True(t, ok) + _ = conn.Close() + + srv := httptest.NewServer(agnt.HTTPDebug()) + t.Cleanup(srv.Close) + + t.Run("MagicsockDebug", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/magicsock", nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Contains(t, string(resBody), "

magicsock

") + }) + + t.Run("MagicsockDebugLogging", func(t *testing.T) { + t.Parallel() + + t.Run("Enable", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/magicsock/debug-logging/t", nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Contains(t, string(resBody), "updated magicsock debug logging to true") + }) + + t.Run("Disable", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/magicsock/debug-logging/0", nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Contains(t, string(resBody), "updated magicsock debug logging to false") + }) + + t.Run("Invalid", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/magicsock/debug-logging/blah", nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Contains(t, string(resBody), `invalid state "blah", must be a boolean`) + }) + }) +} + func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) (*ptytest.PTYCmd, pty.Process) { //nolint:dogsled agentConn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0) @@ -2013,7 +2103,7 @@ func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Durati *agenttest.Client, <-chan *agentsdk.Stats, afero.Fs, - io.Closer, + agent.Agent, ) { logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) if metadata.DERPMap == nil { diff --git a/tailnet/conn.go b/tailnet/conn.go index 089a83ff79ff9..ebe57d2606b1c 100644 --- a/tailnet/conn.go +++ b/tailnet/conn.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/netip" + "os" "reflect" "strconv" "sync" @@ -51,6 +52,14 @@ const ( WorkspaceAgentSpeedtestPort = 3 ) +// EnvMagicsockDebugLogging enables super-verbose logging for the magicsock +// internals. A logger must be supplied to the connection with the debug level +// enabled. +// +// With this disabled, you still get a lot of output if you have a valid logger +// with the debug level enabled. +const EnvMagicsockDebugLogging = "CODER_MAGICSOCK_DEBUG_LOGGING" + func init() { // Globally disable network namespacing. All networking happens in // userspace. @@ -175,6 +184,18 @@ func NewConn(options *Options) (conn *Conn, err error) { magicConn.SetDERPHeader(options.DERPHeader.Clone()) } + if v, ok := os.LookupEnv(EnvMagicsockDebugLogging); ok { + vBool, err := strconv.ParseBool(v) + if err != nil { + options.Logger.Debug(context.Background(), fmt.Sprintf("magicsock debug logging disabled due to invalid value %s=%q, use true or false", EnvMagicsockDebugLogging, v)) + } else { + magicConn.SetDebugLoggingEnabled(vBool) + options.Logger.Debug(context.Background(), fmt.Sprintf("magicsock debug logging set by %s=%t", EnvMagicsockDebugLogging, vBool)) + } + } else { + options.Logger.Debug(context.Background(), fmt.Sprintf("magicsock debug logging disabled, use %s=true to enable", EnvMagicsockDebugLogging)) + } + // Update the keys for the magic connection! err = magicConn.SetPrivateKey(nodePrivateKey) if err != nil { @@ -361,6 +382,10 @@ type Conn struct { trafficStats *connstats.Statistics } +func (c *Conn) MagicsockSetDebugLoggingEnabled(enabled bool) { + c.magicConn.SetDebugLoggingEnabled(enabled) +} + func (c *Conn) SetAddresses(ips []netip.Prefix) error { c.mutex.Lock() defer c.mutex.Unlock() From f7a35e0559622f4318dde2c0cea674d5d108d8fc Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 8 Aug 2023 11:29:35 -0700 Subject: [PATCH 049/277] chore: add workspace proxies to telemetry (#8963) --- coderd/telemetry/telemetry.go | 38 ++++++++++++++++++++++++++++- coderd/telemetry/telemetry_test.go | 3 +++ enterprise/coderd/workspaceproxy.go | 5 ++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index ced52a58fd273..834118a0d8a98 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -22,7 +22,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/buildinfo" "github.com/coder/coder/coderd/database" ) @@ -460,6 +459,17 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { } return nil }) + eg.Go(func() error { + proxies, err := r.options.Database.GetWorkspaceProxies(ctx) + if err != nil { + return xerrors.Errorf("get workspace proxies: %w", err) + } + snapshot.WorkspaceProxies = make([]WorkspaceProxy, 0, len(proxies)) + for _, proxy := range proxies { + snapshot.WorkspaceProxies = append(snapshot.WorkspaceProxies, ConvertWorkspaceProxy(proxy)) + } + return nil + }) err := eg.Wait() if err != nil { @@ -665,6 +675,19 @@ func ConvertLicense(license database.License) License { } } +// ConvertWorkspaceProxy anonymizes a workspace proxy. +func ConvertWorkspaceProxy(proxy database.WorkspaceProxy) WorkspaceProxy { + return WorkspaceProxy{ + ID: proxy.ID, + Name: proxy.Name, + DisplayName: proxy.DisplayName, + DerpEnabled: proxy.DerpEnabled, + DerpOnly: proxy.DerpOnly, + CreatedAt: proxy.CreatedAt, + UpdatedAt: proxy.UpdatedAt, + } +} + // Snapshot represents a point-in-time anonymized database dump. // Data is aggregated by latest on the server-side, so partial data // can be sent without issue. @@ -684,6 +707,7 @@ type Snapshot struct { WorkspaceBuilds []WorkspaceBuild `json:"workspace_build"` WorkspaceResources []WorkspaceResource `json:"workspace_resources"` WorkspaceResourceMetadata []WorkspaceResourceMetadata `json:"workspace_resource_metadata"` + WorkspaceProxies []WorkspaceProxy `json:"workspace_proxies"` CLIInvocations []CLIInvocation `json:"cli_invocations"` } @@ -872,6 +896,18 @@ type CLIInvocation struct { InvokedAt time.Time `json:"invoked_at"` } +type WorkspaceProxy struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + // No URLs since we don't send deployment URL. + DerpEnabled bool `json:"derp_enabled"` + DerpOnly bool `json:"derp_only"` + // No Status since it may contain sensitive information. + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type noopReporter struct{} func (*noopReporter) Report(_ *Snapshot) {} diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 93e1a5295475b..28569f1ca98e4 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -81,6 +81,8 @@ func TestTelemetry(t *testing.T) { UUID: uuid.New(), }) assert.NoError(t, err) + _, _ = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{}) + _, snapshot := collectSnapshot(t, db) require.Len(t, snapshot.ProvisionerJobs, 1) require.Len(t, snapshot.Licenses, 1) @@ -93,6 +95,7 @@ func TestTelemetry(t *testing.T) { require.Len(t, snapshot.WorkspaceBuilds, 1) require.Len(t, snapshot.WorkspaceResources, 1) require.Len(t, snapshot.WorkspaceAgentStats, 1) + require.Len(t, snapshot.WorkspaceProxies, 1) wsa := snapshot.WorkspaceAgents[0] require.Equal(t, string(database.WorkspaceAgentSubsystemEnvbox), wsa.Subsystem) diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go index 591f2fa33374f..54279953920de 100644 --- a/enterprise/coderd/workspaceproxy.go +++ b/enterprise/coderd/workspaceproxy.go @@ -23,6 +23,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/workspaceapps" "github.com/coder/coder/codersdk" "github.com/coder/coder/cryptorand" @@ -369,6 +370,10 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) { return } + api.Telemetry.Report(&telemetry.Snapshot{ + WorkspaceProxies: []telemetry.WorkspaceProxy{telemetry.ConvertWorkspaceProxy(proxy)}, + }) + aReq.New = proxy httpapi.Write(ctx, rw, http.StatusCreated, codersdk.UpdateWorkspaceProxyResponse{ Proxy: convertProxy(proxy, proxyhealth.ProxyStatus{ From d4e115d267a1a27f7a28b3820e0ca8d17c37986e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 8 Aug 2023 15:33:08 -0500 Subject: [PATCH 050/277] chore: show basic experiment set value (#8984) This value is pre-parsed, meaning the experiments listed may not be valid. This is a very basic display for helping debuging purposes. --- .../GeneralSettingsPage/GeneralSettingsPageView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx index 8e9df03477f0f..f798bcbcdd734 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx @@ -43,6 +43,7 @@ export const GeneralSettingsPageView = ({ deploymentOptions, "Access URL", "Wildcard Access URL", + "Experiments", )} /> From 70bd23a40a07cc7bb2342e083c90cfc90c4b58ca Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 8 Aug 2023 18:48:53 -0300 Subject: [PATCH 051/277] refactor(site): add default title (#8985) --- site/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/site/index.html b/site/index.html index 92801202a5d88..fb62af8b53a2d 100644 --- a/site/index.html +++ b/site/index.html @@ -10,6 +10,7 @@ + Coder From 07fd73c4a0f90406c843b69a6a806910fbc56212 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 8 Aug 2023 22:10:28 -0700 Subject: [PATCH 052/277] chore: allow multiple agent subsystems, add exectrace (#8933) --- agent/agent.go | 8 +-- cli/agent.go | 17 +++++- cli/agent_test.go | 10 ++-- coderd/apidoc/docs.go | 22 +++++--- coderd/apidoc/swagger.json | 22 +++++--- coderd/database/dbauthz/dbauthz_test.go | 6 ++- coderd/database/dbfake/dbfake.go | 19 ++++++- coderd/database/dump.sql | 8 +-- .../000148_agent_multiple_subsystems.down.sql | 17 ++++++ .../000148_agent_multiple_subsystems.up.sql | 21 ++++++++ coderd/database/models.go | 11 ++-- coderd/database/queries.sql.go | 40 +++++++------- coderd/database/queries/workspaceagents.sql | 2 +- coderd/telemetry/telemetry.go | 9 +++- coderd/telemetry/telemetry_test.go | 13 +++-- coderd/workspaceagents.go | 54 ++++++++++++++++--- coderd/workspaceagents_test.go | 20 ++++--- codersdk/agentsdk/agentsdk.go | 6 +-- codersdk/workspaceagents.go | 15 +++++- docs/api/agents.md | 2 +- docs/api/builds.md | 18 +++---- docs/api/schemas.md | 32 +++++------ docs/api/templates.md | 10 ++-- docs/api/workspaces.md | 10 ++-- site/src/api/typesGenerated.ts | 10 ++-- site/src/testHelpers/entities.ts | 2 +- 26 files changed, 285 insertions(+), 119 deletions(-) create mode 100644 coderd/database/migrations/000148_agent_multiple_subsystems.down.sql create mode 100644 coderd/database/migrations/000148_agent_multiple_subsystems.up.sql diff --git a/agent/agent.go b/agent/agent.go index e435b795d5fc9..3d9b8947b18b1 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -64,7 +64,7 @@ type Options struct { IgnorePorts map[int]string SSHMaxTimeout time.Duration TailnetListenPort uint16 - Subsystem codersdk.AgentSubsystem + Subsystems []codersdk.AgentSubsystem Addresses []netip.Prefix PrometheusRegistry *prometheus.Registry ReportMetadataInterval time.Duration @@ -145,7 +145,7 @@ func New(options Options) Agent { reportMetadataInterval: options.ReportMetadataInterval, serviceBannerRefreshInterval: options.ServiceBannerRefreshInterval, sshMaxTimeout: options.SSHMaxTimeout, - subsystem: options.Subsystem, + subsystems: options.Subsystems, addresses: options.Addresses, prometheusRegistry: prometheusRegistry, @@ -167,7 +167,7 @@ type agent struct { // listing all listening ports. This is helpful to hide ports that // are used by the agent, that the user does not care about. ignorePorts map[int]string - subsystem codersdk.AgentSubsystem + subsystems []codersdk.AgentSubsystem reconnectingPTYs sync.Map reconnectingPTYTimeout time.Duration @@ -609,7 +609,7 @@ func (a *agent) run(ctx context.Context) error { err = a.client.PostStartup(ctx, agentsdk.PostStartupRequest{ Version: buildinfo.Version(), ExpandedDirectory: manifest.Directory, - Subsystem: a.subsystem, + Subsystems: a.subsystems, }) if err != nil { return xerrors.Errorf("update workspace agent version: %w", err) diff --git a/cli/agent.go b/cli/agent.go index 1d9a2ba02d51c..a217c9b0acc67 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -12,6 +12,7 @@ import ( "path/filepath" "runtime" "strconv" + "strings" "sync" "time" @@ -253,7 +254,19 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd { } prometheusRegistry := prometheus.NewRegistry() - subsystem := inv.Environ.Get(agent.EnvAgentSubsystem) + subsystemsRaw := inv.Environ.Get(agent.EnvAgentSubsystem) + subsystems := []codersdk.AgentSubsystem{} + for _, s := range strings.Split(subsystemsRaw, ",") { + subsystem := codersdk.AgentSubsystem(strings.TrimSpace(s)) + if subsystem == "" { + continue + } + if !subsystem.Valid() { + return xerrors.Errorf("invalid subsystem %q", subsystem) + } + subsystems = append(subsystems, subsystem) + } + agnt := agent.New(agent.Options{ Client: client, Logger: logger, @@ -275,7 +288,7 @@ func (r *RootCmd) workspaceAgent() *clibase.Cmd { }, IgnorePorts: ignorePorts, SSHMaxTimeout: sshMaxTimeout, - Subsystem: codersdk.AgentSubsystem(subsystem), + Subsystems: subsystems, PrometheusRegistry: prometheusRegistry, }) diff --git a/cli/agent_test.go b/cli/agent_test.go index 462ef3c204541..34f04b70705b2 100644 --- a/cli/agent_test.go +++ b/cli/agent_test.go @@ -2,6 +2,7 @@ package cli_test import ( "context" + "fmt" "os" "path/filepath" "runtime" @@ -264,8 +265,8 @@ func TestWorkspaceAgent(t *testing.T) { "--agent-url", client.URL.String(), "--log-dir", logDir, ) - // Set the subsystem for the agent. - inv.Environ.Set(agent.EnvAgentSubsystem, string(codersdk.AgentSubsystemEnvbox)) + // Set the subsystems for the agent. + inv.Environ.Set(agent.EnvAgentSubsystem, fmt.Sprintf("%s,%s", codersdk.AgentSubsystemExectrace, codersdk.AgentSubsystemEnvbox)) pty := ptytest.New(t).Attach(inv) @@ -275,6 +276,9 @@ func TestWorkspaceAgent(t *testing.T) { resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) require.Len(t, resources, 1) require.Len(t, resources[0].Agents, 1) - require.Equal(t, codersdk.AgentSubsystemEnvbox, resources[0].Agents[0].Subsystem) + require.Len(t, resources[0].Agents[0].Subsystems, 2) + // Sorted + require.Equal(t, codersdk.AgentSubsystemEnvbox, resources[0].Agents[0].Subsystems[0]) + require.Equal(t, codersdk.AgentSubsystemExectrace, resources[0].Agents[0].Subsystems[1]) }) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f930bf74f48fc..e83cf37d3206c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6447,8 +6447,11 @@ const docTemplate = `{ "expanded_directory": { "type": "string" }, - "subsystem": { - "$ref": "#/definitions/codersdk.AgentSubsystem" + "subsystems": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentSubsystem" + } }, "version": { "type": "string" @@ -6903,10 +6906,14 @@ const docTemplate = `{ "codersdk.AgentSubsystem": { "type": "string", "enum": [ - "envbox" + "envbox", + "envbuilder", + "exectrace" ], "x-enum-varnames": [ - "AgentSubsystemEnvbox" + "AgentSubsystemEnvbox", + "AgentSubsystemEnvbuilder", + "AgentSubsystemExectrace" ] }, "codersdk.AppHostResponse": { @@ -10556,8 +10563,11 @@ const docTemplate = `{ "status": { "$ref": "#/definitions/codersdk.WorkspaceAgentStatus" }, - "subsystem": { - "$ref": "#/definitions/codersdk.AgentSubsystem" + "subsystems": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentSubsystem" + } }, "troubleshooting_url": { "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0d691b237b655..0206c57062720 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5697,8 +5697,11 @@ "expanded_directory": { "type": "string" }, - "subsystem": { - "$ref": "#/definitions/codersdk.AgentSubsystem" + "subsystems": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentSubsystem" + } }, "version": { "type": "string" @@ -6130,8 +6133,12 @@ }, "codersdk.AgentSubsystem": { "type": "string", - "enum": ["envbox"], - "x-enum-varnames": ["AgentSubsystemEnvbox"] + "enum": ["envbox", "envbuilder", "exectrace"], + "x-enum-varnames": [ + "AgentSubsystemEnvbox", + "AgentSubsystemEnvbuilder", + "AgentSubsystemExectrace" + ] }, "codersdk.AppHostResponse": { "type": "object", @@ -9571,8 +9578,11 @@ "status": { "$ref": "#/definitions/codersdk.WorkspaceAgentStatus" }, - "subsystem": { - "$ref": "#/definitions/codersdk.AgentSubsystem" + "subsystems": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentSubsystem" + } }, "troubleshooting_url": { "type": "string" diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index f3313c768053f..d6ad41f51408a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1064,8 +1064,10 @@ func (s *MethodTestSuite) TestWorkspace() { res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) check.Args(database.UpdateWorkspaceAgentStartupByIDParams{ - ID: agt.ID, - Subsystem: database.WorkspaceAgentSubsystemNone, + ID: agt.ID, + Subsystems: []database.WorkspaceAgentSubsystem{ + database.WorkspaceAgentSubsystemEnvbox, + }, }).Asserts(ws, rbac.ActionUpdate).Returns() })) s.Run("GetWorkspaceAgentLogsAfter", s.Subtest(func(db database.Store, check *expects) { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index c7ce32c7945b0..156fe957d69a8 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -5203,6 +5203,23 @@ func (q *FakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat return err } + if len(arg.Subsystems) > 0 { + seen := map[database.WorkspaceAgentSubsystem]struct{}{ + arg.Subsystems[0]: {}, + } + for i := 1; i < len(arg.Subsystems); i++ { + s := arg.Subsystems[i] + if _, ok := seen[s]; ok { + return xerrors.Errorf("duplicate subsystem %q", s) + } + seen[s] = struct{}{} + + if arg.Subsystems[i-1] > arg.Subsystems[i] { + return xerrors.Errorf("subsystems not sorted: %q > %q", arg.Subsystems[i-1], arg.Subsystems[i]) + } + } + } + q.mutex.Lock() defer q.mutex.Unlock() @@ -5213,7 +5230,7 @@ func (q *FakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat agent.Version = arg.Version agent.ExpandedDirectory = arg.ExpandedDirectory - agent.Subsystem = arg.Subsystem + agent.Subsystems = arg.Subsystems q.workspaceAgents[index] = agent return nil } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 9dfb9ded10e64..95a1e3534ae8b 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -148,7 +148,8 @@ CREATE TYPE workspace_agent_log_source AS ENUM ( CREATE TYPE workspace_agent_subsystem AS ENUM ( 'envbuilder', 'envbox', - 'none' + 'none', + 'exectrace' ); CREATE TYPE workspace_app_health AS ENUM ( @@ -775,11 +776,12 @@ CREATE TABLE workspace_agents ( shutdown_script_timeout_seconds integer DEFAULT 0 NOT NULL, logs_length integer DEFAULT 0 NOT NULL, logs_overflowed boolean DEFAULT false NOT NULL, - subsystem workspace_agent_subsystem DEFAULT 'none'::workspace_agent_subsystem NOT NULL, startup_script_behavior startup_script_behavior DEFAULT 'non-blocking'::startup_script_behavior NOT NULL, started_at timestamp with time zone, ready_at timestamp with time zone, - CONSTRAINT max_logs_length CHECK ((logs_length <= 1048576)) + subsystems workspace_agent_subsystem[] DEFAULT '{}'::workspace_agent_subsystem[], + CONSTRAINT max_logs_length CHECK ((logs_length <= 1048576)), + CONSTRAINT subsystems_not_none CHECK ((NOT ('none'::workspace_agent_subsystem = ANY (subsystems)))) ); COMMENT ON COLUMN workspace_agents.version IS 'Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.'; diff --git a/coderd/database/migrations/000148_agent_multiple_subsystems.down.sql b/coderd/database/migrations/000148_agent_multiple_subsystems.down.sql new file mode 100644 index 0000000000000..05bea6c620502 --- /dev/null +++ b/coderd/database/migrations/000148_agent_multiple_subsystems.down.sql @@ -0,0 +1,17 @@ +BEGIN; + +-- Bring back the subsystem column. +ALTER TABLE workspace_agents ADD COLUMN subsystem workspace_agent_subsystem NOT NULL DEFAULT 'none'; + +-- Update all existing workspace_agents to have subsystem = subsystems[0] unless +-- subsystems is empty. +UPDATE workspace_agents SET subsystem = subsystems[1] WHERE cardinality(subsystems) > 0; + +-- Drop the subsystems column from workspace_agents. +ALTER TABLE workspace_agents DROP COLUMN subsystems; + +-- We cannot drop the "exectrace" value from the workspace_agent_subsystem type +-- because you cannot drop values from an enum type. +UPDATE workspace_agents SET subsystem = 'none' WHERE subsystem = 'exectrace'; + +COMMIT; diff --git a/coderd/database/migrations/000148_agent_multiple_subsystems.up.sql b/coderd/database/migrations/000148_agent_multiple_subsystems.up.sql new file mode 100644 index 0000000000000..9ebb71d5bdf5e --- /dev/null +++ b/coderd/database/migrations/000148_agent_multiple_subsystems.up.sql @@ -0,0 +1,21 @@ +BEGIN; + +-- Add "exectrace" to workspace_agent_subsystem type. +ALTER TYPE workspace_agent_subsystem ADD VALUE 'exectrace'; + +-- Create column subsystems in workspace_agents table, with default value being +-- an empty array. +ALTER TABLE workspace_agents ADD COLUMN subsystems workspace_agent_subsystem[] DEFAULT '{}'; + +-- Add a constraint that the subsystems cannot contain the deprecated value +-- 'none'. +ALTER TABLE workspace_agents ADD CONSTRAINT subsystems_not_none CHECK (NOT ('none' = ANY (subsystems))); + +-- Update all existing workspace_agents to have subsystems = [subsystem] unless +-- the subsystem is 'none'. +UPDATE workspace_agents SET subsystems = ARRAY[subsystem] WHERE subsystem != 'none'; + +-- Drop the subsystem column from workspace_agents. +ALTER TABLE workspace_agents DROP COLUMN subsystem; + +COMMIT; diff --git a/coderd/database/models.go b/coderd/database/models.go index d3b7700b56bfa..0c1b4a7002227 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1307,6 +1307,7 @@ const ( WorkspaceAgentSubsystemEnvbuilder WorkspaceAgentSubsystem = "envbuilder" WorkspaceAgentSubsystemEnvbox WorkspaceAgentSubsystem = "envbox" WorkspaceAgentSubsystemNone WorkspaceAgentSubsystem = "none" + WorkspaceAgentSubsystemExectrace WorkspaceAgentSubsystem = "exectrace" ) func (e *WorkspaceAgentSubsystem) Scan(src interface{}) error { @@ -1348,7 +1349,8 @@ func (e WorkspaceAgentSubsystem) Valid() bool { switch e { case WorkspaceAgentSubsystemEnvbuilder, WorkspaceAgentSubsystemEnvbox, - WorkspaceAgentSubsystemNone: + WorkspaceAgentSubsystemNone, + WorkspaceAgentSubsystemExectrace: return true } return false @@ -1359,6 +1361,7 @@ func AllWorkspaceAgentSubsystemValues() []WorkspaceAgentSubsystem { WorkspaceAgentSubsystemEnvbuilder, WorkspaceAgentSubsystemEnvbox, WorkspaceAgentSubsystemNone, + WorkspaceAgentSubsystemExectrace, } } @@ -1944,14 +1947,14 @@ type WorkspaceAgent struct { // Total length of startup logs LogsLength int32 `db:"logs_length" json:"logs_length"` // Whether the startup logs overflowed in length - LogsOverflowed bool `db:"logs_overflowed" json:"logs_overflowed"` - Subsystem WorkspaceAgentSubsystem `db:"subsystem" json:"subsystem"` + LogsOverflowed bool `db:"logs_overflowed" json:"logs_overflowed"` // When startup script behavior is non-blocking, the workspace will be ready and accessible upon agent connection, when it is blocking, workspace will wait for the startup script to complete before becoming ready and accessible. StartupScriptBehavior StartupScriptBehavior `db:"startup_script_behavior" json:"startup_script_behavior"` // The time the agent entered the starting lifecycle state StartedAt sql.NullTime `db:"started_at" json:"started_at"` // The time the agent entered the ready or start_error lifecycle state - ReadyAt sql.NullTime `db:"ready_at" json:"ready_at"` + ReadyAt sql.NullTime `db:"ready_at" json:"ready_at"` + Subsystems []WorkspaceAgentSubsystem `db:"subsystems" json:"subsystems"` } type WorkspaceAgentLog struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 95efcc4369baa..c3c9552266c99 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6239,7 +6239,7 @@ func (q *sqlQuerier) DeleteOldWorkspaceAgentLogs(ctx context.Context) error { const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, startup_script_behavior, started_at, ready_at, subsystems FROM workspace_agents WHERE @@ -6281,17 +6281,17 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ) return i, err } const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, startup_script_behavior, started_at, ready_at, subsystems FROM workspace_agents WHERE @@ -6331,17 +6331,17 @@ func (q *sqlQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (W &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ) return i, err } const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, startup_script_behavior, started_at, ready_at, subsystems FROM workspace_agents WHERE @@ -6383,10 +6383,10 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ) return i, err } @@ -6506,7 +6506,7 @@ func (q *sqlQuerier) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAge const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, startup_script_behavior, started_at, ready_at, subsystems FROM workspace_agents WHERE @@ -6552,10 +6552,10 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ); err != nil { return nil, err } @@ -6571,7 +6571,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] } const getWorkspaceAgentsCreatedAfter = `-- name: GetWorkspaceAgentsCreatedAfter :many -SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at FROM workspace_agents WHERE created_at > $1 +SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, startup_script_behavior, started_at, ready_at, subsystems FROM workspace_agents WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) { @@ -6613,10 +6613,10 @@ func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, created &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ); err != nil { return nil, err } @@ -6633,7 +6633,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, created const getWorkspaceAgentsInLatestBuildByWorkspaceID = `-- name: GetWorkspaceAgentsInLatestBuildByWorkspaceID :many SELECT - workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.startup_script, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.startup_script_timeout_seconds, workspace_agents.expanded_directory, workspace_agents.shutdown_script, workspace_agents.shutdown_script_timeout_seconds, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.subsystem, workspace_agents.startup_script_behavior, workspace_agents.started_at, workspace_agents.ready_at + workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.startup_script, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.startup_script_timeout_seconds, workspace_agents.expanded_directory, workspace_agents.shutdown_script, workspace_agents.shutdown_script_timeout_seconds, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.startup_script_behavior, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems FROM workspace_agents JOIN @@ -6691,10 +6691,10 @@ func (q *sqlQuerier) GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx context.Co &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ); err != nil { return nil, err } @@ -6735,7 +6735,7 @@ INSERT INTO shutdown_script_timeout_seconds ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, subsystem, startup_script_behavior, started_at, ready_at + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, logs_length, logs_overflowed, startup_script_behavior, started_at, ready_at, subsystems ` type InsertWorkspaceAgentParams struct { @@ -6817,10 +6817,10 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa &i.ShutdownScriptTimeoutSeconds, &i.LogsLength, &i.LogsOverflowed, - &i.Subsystem, &i.StartupScriptBehavior, &i.StartedAt, &i.ReadyAt, + pq.Array(&i.Subsystems), ) return i, err } @@ -7040,16 +7040,16 @@ UPDATE SET version = $2, expanded_directory = $3, - subsystem = $4 + subsystems = $4 WHERE id = $1 ` type UpdateWorkspaceAgentStartupByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - Version string `db:"version" json:"version"` - ExpandedDirectory string `db:"expanded_directory" json:"expanded_directory"` - Subsystem WorkspaceAgentSubsystem `db:"subsystem" json:"subsystem"` + ID uuid.UUID `db:"id" json:"id"` + Version string `db:"version" json:"version"` + ExpandedDirectory string `db:"expanded_directory" json:"expanded_directory"` + Subsystems []WorkspaceAgentSubsystem `db:"subsystems" json:"subsystems"` } func (q *sqlQuerier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error { @@ -7057,7 +7057,7 @@ func (q *sqlQuerier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg Up arg.ID, arg.Version, arg.ExpandedDirectory, - arg.Subsystem, + pq.Array(arg.Subsystems), ) return err } diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index 4025ac7e59a1b..dcc15081615e2 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -83,7 +83,7 @@ UPDATE SET version = $2, expanded_directory = $3, - subsystem = $4 + subsystems = $4 WHERE id = $1; diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 834118a0d8a98..615be6949da02 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -544,6 +544,11 @@ func ConvertProvisionerJob(job database.ProvisionerJob) ProvisionerJob { // ConvertWorkspaceAgent anonymizes a workspace agent. func ConvertWorkspaceAgent(agent database.WorkspaceAgent) WorkspaceAgent { + subsystems := []string{} + for _, subsystem := range agent.Subsystems { + subsystems = append(subsystems, string(subsystem)) + } + snapAgent := WorkspaceAgent{ ID: agent.ID, CreatedAt: agent.CreatedAt, @@ -556,7 +561,7 @@ func ConvertWorkspaceAgent(agent database.WorkspaceAgent) WorkspaceAgent { Directory: agent.Directory != "", ConnectionTimeoutSeconds: agent.ConnectionTimeoutSeconds, ShutdownScript: agent.ShutdownScript.Valid, - Subsystem: string(agent.Subsystem), + Subsystems: subsystems, } if agent.FirstConnectedAt.Valid { snapAgent.FirstConnectedAt = &agent.FirstConnectedAt.Time @@ -792,7 +797,7 @@ type WorkspaceAgent struct { DisconnectedAt *time.Time `json:"disconnected_at"` ConnectionTimeoutSeconds int32 `json:"connection_timeout_seconds"` ShutdownScript bool `json:"shutdown_script"` - Subsystem string `json:"subsystem"` + Subsystems []string `json:"subsystems"` } type WorkspaceAgentStat struct { diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 28569f1ca98e4..5592bda5dbec8 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -54,15 +54,16 @@ func TestTelemetry(t *testing.T) { SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, }) - wsagent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ - Subsystem: database.WorkspaceAgentSubsystemEnvbox, - }) + wsagent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{}) // Update the workspace agent to have a valid subsystem. err = db.UpdateWorkspaceAgentStartupByID(ctx, database.UpdateWorkspaceAgentStartupByIDParams{ ID: wsagent.ID, Version: wsagent.Version, ExpandedDirectory: wsagent.ExpandedDirectory, - Subsystem: database.WorkspaceAgentSubsystemEnvbox, + Subsystems: []database.WorkspaceAgentSubsystem{ + database.WorkspaceAgentSubsystemEnvbox, + database.WorkspaceAgentSubsystemExectrace, + }, }) require.NoError(t, err) @@ -98,7 +99,9 @@ func TestTelemetry(t *testing.T) { require.Len(t, snapshot.WorkspaceProxies, 1) wsa := snapshot.WorkspaceAgents[0] - require.Equal(t, string(database.WorkspaceAgentSubsystemEnvbox), wsa.Subsystem) + require.Len(t, wsa.Subsystems, 2) + require.Equal(t, string(database.WorkspaceAgentSubsystemEnvbox), wsa.Subsystems[0]) + require.Equal(t, string(database.WorkspaceAgentSubsystemExectrace), wsa.Subsystems[1]) }) t.Run("HashedEmail", func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 545f3e7c6ed84..3fa5baf231997 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -14,6 +14,7 @@ import ( "net/netip" "net/url" "runtime/pprof" + "sort" "strconv" "strings" "sync" @@ -219,11 +220,31 @@ func (api *API) postWorkspaceAgentStartup(rw http.ResponseWriter, r *http.Reques return } + // Validate subsystems. + seen := make(map[codersdk.AgentSubsystem]bool) + for _, s := range req.Subsystems { + if !s.Valid() { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid workspace agent subsystem provided.", + Detail: fmt.Sprintf("invalid subsystem: %q", s), + }) + return + } + if seen[s] { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid workspace agent subsystem provided.", + Detail: fmt.Sprintf("duplicate subsystem: %q", s), + }) + return + } + seen[s] = true + } + if err := api.Database.UpdateWorkspaceAgentStartupByID(ctx, database.UpdateWorkspaceAgentStartupByIDParams{ ID: apiAgent.ID, Version: req.Version, ExpandedDirectory: req.ExpandedDirectory, - Subsystem: convertWorkspaceAgentSubsystem(req.Subsystem), + Subsystems: convertWorkspaceAgentSubsystems(req.Subsystems), }); err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Error setting agent version", @@ -1277,6 +1298,11 @@ func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordin if dbAgent.TroubleshootingURL != "" { troubleshootingURL = dbAgent.TroubleshootingURL } + subsystems := make([]codersdk.AgentSubsystem, len(dbAgent.Subsystems)) + for i, subsystem := range dbAgent.Subsystems { + subsystems[i] = codersdk.AgentSubsystem(subsystem) + } + workspaceAgent := codersdk.WorkspaceAgent{ ID: dbAgent.ID, CreatedAt: dbAgent.CreatedAt, @@ -1302,7 +1328,7 @@ func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordin LoginBeforeReady: dbAgent.StartupScriptBehavior != database.StartupScriptBehaviorBlocking, ShutdownScript: dbAgent.ShutdownScript.String, ShutdownScriptTimeoutSeconds: dbAgent.ShutdownScriptTimeoutSeconds, - Subsystem: codersdk.AgentSubsystem(dbAgent.Subsystem), + Subsystems: subsystems, } node := coordinator.Node(dbAgent.ID) if node != nil { @@ -2114,11 +2140,23 @@ func convertWorkspaceAgentLog(logEntry database.WorkspaceAgentLog) codersdk.Work } } -func convertWorkspaceAgentSubsystem(ss codersdk.AgentSubsystem) database.WorkspaceAgentSubsystem { - switch ss { - case codersdk.AgentSubsystemEnvbox: - return database.WorkspaceAgentSubsystemEnvbox - default: - return database.WorkspaceAgentSubsystemNone +func convertWorkspaceAgentSubsystems(ss []codersdk.AgentSubsystem) []database.WorkspaceAgentSubsystem { + out := make([]database.WorkspaceAgentSubsystem, 0, len(ss)) + for _, s := range ss { + switch s { + case codersdk.AgentSubsystemEnvbox: + out = append(out, database.WorkspaceAgentSubsystemEnvbox) + case codersdk.AgentSubsystemEnvbuilder: + out = append(out, database.WorkspaceAgentSubsystemEnvbuilder) + case codersdk.AgentSubsystemExectrace: + out = append(out, database.WorkspaceAgentSubsystemExectrace) + default: + // Invalid, drop it. + } } + + sort.Slice(out, func(i, j int) bool { + return out[i] < out[j] + }) + return out } diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 12cb19efac012..b85dd9d3550d9 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1195,16 +1195,23 @@ func TestWorkspaceAgent_Startup(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) - const ( - expectedVersion = "v1.2.3" - expectedDir = "/home/coder" - expectedSubsystem = codersdk.AgentSubsystemEnvbox + var ( + expectedVersion = "v1.2.3" + expectedDir = "/home/coder" + expectedSubsystems = []codersdk.AgentSubsystem{ + codersdk.AgentSubsystemEnvbox, + codersdk.AgentSubsystemExectrace, + } ) err := agentClient.PostStartup(ctx, agentsdk.PostStartupRequest{ Version: expectedVersion, ExpandedDirectory: expectedDir, - Subsystem: expectedSubsystem, + Subsystems: []codersdk.AgentSubsystem{ + // Not sorted. + expectedSubsystems[1], + expectedSubsystems[0], + }, }) require.NoError(t, err) @@ -1215,7 +1222,8 @@ func TestWorkspaceAgent_Startup(t *testing.T) { require.NoError(t, err) require.Equal(t, expectedVersion, wsagent.Version) require.Equal(t, expectedDir, wsagent.ExpandedDirectory) - require.Equal(t, expectedSubsystem, wsagent.Subsystem) + // Sorted + require.Equal(t, expectedSubsystems, wsagent.Subsystems) }) t.Run("InvalidSemver", func(t *testing.T) { diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index e2189e1cc53d2..d5e038bdf6b39 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -612,9 +612,9 @@ func (c *Client) PostLifecycle(ctx context.Context, req PostLifecycleRequest) er } type PostStartupRequest struct { - Version string `json:"version"` - ExpandedDirectory string `json:"expanded_directory"` - Subsystem codersdk.AgentSubsystem `json:"subsystem"` + Version string `json:"version"` + ExpandedDirectory string `json:"expanded_directory"` + Subsystems []codersdk.AgentSubsystem `json:"subsystems"` } func (c *Client) PostStartup(ctx context.Context, req PostStartupRequest) error { diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index e9aad8421e36a..71153a0e89b8a 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -167,7 +167,7 @@ type WorkspaceAgent struct { LoginBeforeReady bool `json:"login_before_ready"` ShutdownScript string `json:"shutdown_script,omitempty"` ShutdownScriptTimeoutSeconds int32 `json:"shutdown_script_timeout_seconds"` - Subsystem AgentSubsystem `json:"subsystem"` + Subsystems []AgentSubsystem `json:"subsystems"` Health WorkspaceAgentHealth `json:"health"` // Health reports the health of the agent. } @@ -754,9 +754,20 @@ type WorkspaceAgentLog struct { type AgentSubsystem string const ( - AgentSubsystemEnvbox AgentSubsystem = "envbox" + AgentSubsystemEnvbox AgentSubsystem = "envbox" + AgentSubsystemEnvbuilder AgentSubsystem = "envbuilder" + AgentSubsystemExectrace AgentSubsystem = "exectrace" ) +func (s AgentSubsystem) Valid() bool { + switch s { + case AgentSubsystemEnvbox, AgentSubsystemEnvbuilder, AgentSubsystemExectrace: + return true + default: + return false + } +} + type WorkspaceAgentLogSource string const ( diff --git a/docs/api/agents.md b/docs/api/agents.md index 2316fdaafde51..8bf6f10619e50 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -695,7 +695,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent} \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" diff --git a/docs/api/builds.md b/docs/api/builds.md index daf7958de387f..782e41a6f61aa 100644 --- a/docs/api/builds.md +++ b/docs/api/builds.md @@ -120,7 +120,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -282,7 +282,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -583,7 +583,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/res "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -672,7 +672,7 @@ Status Code **200** | `»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | -| `»» subsystem` | [codersdk.AgentSubsystem](schemas.md#codersdkagentsubsystem) | false | | | +| `»» subsystems` | array | false | | | | `»» troubleshooting_url` | string | false | | | | `»» updated_at` | string(date-time) | false | | | | `»» version` | string | false | | | @@ -716,7 +716,6 @@ Status Code **200** | `status` | `connected` | | `status` | `disconnected` | | `status` | `timeout` | -| `subsystem` | `envbox` | | `workspace_transition` | `start` | | `workspace_transition` | `stop` | | `workspace_transition` | `delete` | @@ -841,7 +840,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -1008,7 +1007,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -1133,7 +1132,7 @@ Status Code **200** | `»»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | | | `»»» startup_script_timeout_seconds` | integer | false | | »»startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | -| `»»» subsystem` | [codersdk.AgentSubsystem](schemas.md#codersdkagentsubsystem) | false | | | +| `»»» subsystems` | array | false | | | | `»»» troubleshooting_url` | string | false | | | | `»»» updated_at` | string(date-time) | false | | | | `»»» version` | string | false | | | @@ -1197,7 +1196,6 @@ Status Code **200** | `status` | `connected` | | `status` | `disconnected` | | `status` | `timeout` | -| `subsystem` | `envbox` | | `workspace_transition` | `start` | | `workspace_transition` | `stop` | | `workspace_transition` | `delete` | @@ -1356,7 +1354,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 7aed5d4f60022..048eff66adecb 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -377,18 +377,18 @@ ```json { "expanded_directory": "string", - "subsystem": "envbox", + "subsystems": ["envbox"], "version": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------------- | -------------------------------------------------- | -------- | ------------ | ----------- | -| `expanded_directory` | string | false | | | -| `subsystem` | [codersdk.AgentSubsystem](#codersdkagentsubsystem) | false | | | -| `version` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------------- | ----------------------------------------------------------- | -------- | ------------ | ----------- | +| `expanded_directory` | string | false | | | +| `subsystems` | array of [codersdk.AgentSubsystem](#codersdkagentsubsystem) | false | | | +| `version` | string | false | | | ## agentsdk.Stats @@ -913,9 +913,11 @@ _None_ #### Enumerated Values -| Value | -| -------- | -| `envbox` | +| Value | +| ------------ | +| `envbox` | +| `envbuilder` | +| `exectrace` | ## codersdk.AppHostResponse @@ -5402,7 +5404,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -5543,7 +5545,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -5585,7 +5587,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](#codersdkworkspaceagentstartupscriptbehavior) | false | | | | `startup_script_timeout_seconds` | integer | false | | Startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `status` | [codersdk.WorkspaceAgentStatus](#codersdkworkspaceagentstatus) | false | | | -| `subsystem` | [codersdk.AgentSubsystem](#codersdkagentsubsystem) | false | | | +| `subsystems` | array of [codersdk.AgentSubsystem](#codersdkagentsubsystem) | false | | | | `troubleshooting_url` | string | false | | | | `updated_at` | string | false | | | | `version` | string | false | | | @@ -6001,7 +6003,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -6312,7 +6314,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -6524,7 +6526,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" diff --git a/docs/api/templates.md b/docs/api/templates.md index f8a1612161f81..14ce5dec7d29f 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -1633,7 +1633,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/d "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -1722,7 +1722,7 @@ Status Code **200** | `»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | -| `»» subsystem` | [codersdk.AgentSubsystem](schemas.md#codersdkagentsubsystem) | false | | | +| `»» subsystems` | array | false | | | | `»» troubleshooting_url` | string | false | | | | `»» updated_at` | string(date-time) | false | | | | `»» version` | string | false | | | @@ -1766,7 +1766,6 @@ Status Code **200** | `status` | `connected` | | `status` | `disconnected` | | `status` | `timeout` | -| `subsystem` | `envbox` | | `workspace_transition` | `start` | | `workspace_transition` | `stop` | | `workspace_transition` | `delete` | @@ -2025,7 +2024,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/r "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -2114,7 +2113,7 @@ Status Code **200** | `»» startup_script_behavior` | [codersdk.WorkspaceAgentStartupScriptBehavior](schemas.md#codersdkworkspaceagentstartupscriptbehavior) | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | -| `»» subsystem` | [codersdk.AgentSubsystem](schemas.md#codersdkagentsubsystem) | false | | | +| `»» subsystems` | array | false | | | | `»» troubleshooting_url` | string | false | | | | `»» updated_at` | string(date-time) | false | | | | `»» version` | string | false | | | @@ -2158,7 +2157,6 @@ Status Code **200** | `status` | `connected` | | `status` | `disconnected` | | `status` | `timeout` | -| `subsystem` | `envbox` | | `workspace_transition` | `start` | | `workspace_transition` | `stop` | | `workspace_transition` | `delete` | diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 6cfb468f6af50..f5c4aadd729c5 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -148,7 +148,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -336,7 +336,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -523,7 +523,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -712,7 +712,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" @@ -1034,7 +1034,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/lock \ "startup_script_behavior": "blocking", "startup_script_timeout_seconds": 0, "status": "connecting", - "subsystem": "envbox", + "subsystems": ["envbox"], "troubleshooting_url": "string", "updated_at": "2019-08-24T14:15:22Z", "version": "string" diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3e5acc5671299..c1aa96d872994 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1340,7 +1340,7 @@ export interface WorkspaceAgent { readonly login_before_ready: boolean readonly shutdown_script?: string readonly shutdown_script_timeout_seconds: number - readonly subsystem: AgentSubsystem + readonly subsystems: AgentSubsystem[] readonly health: WorkspaceAgentHealth } @@ -1545,8 +1545,12 @@ export type APIKeyScope = "all" | "application_connect" export const APIKeyScopes: APIKeyScope[] = ["all", "application_connect"] // From codersdk/workspaceagents.go -export type AgentSubsystem = "envbox" -export const AgentSubsystems: AgentSubsystem[] = ["envbox"] +export type AgentSubsystem = "envbox" | "envbuilder" | "exectrace" +export const AgentSubsystems: AgentSubsystem[] = [ + "envbox", + "envbuilder", + "exectrace", +] // From codersdk/audit.go export type AuditAction = diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 94eaa3add0211..d8ed5bbe20f58 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -563,7 +563,7 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = { logs_overflowed: false, startup_script_timeout_seconds: 120, shutdown_script_timeout_seconds: 120, - subsystem: "envbox", + subsystems: ["envbox", "exectrace"], health: { healthy: true, }, From 00a8221e514da7ca7b17484c84ae3fe6289fb3eb Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 8 Aug 2023 22:49:13 -0700 Subject: [PATCH 053/277] fix: rename duplicate migration (#8989) --- ...systems.down.sql => 000149_agent_multiple_subsystems.down.sql} | 0 ..._subsystems.up.sql => 000149_agent_multiple_subsystems.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000148_agent_multiple_subsystems.down.sql => 000149_agent_multiple_subsystems.down.sql} (100%) rename coderd/database/migrations/{000148_agent_multiple_subsystems.up.sql => 000149_agent_multiple_subsystems.up.sql} (100%) diff --git a/coderd/database/migrations/000148_agent_multiple_subsystems.down.sql b/coderd/database/migrations/000149_agent_multiple_subsystems.down.sql similarity index 100% rename from coderd/database/migrations/000148_agent_multiple_subsystems.down.sql rename to coderd/database/migrations/000149_agent_multiple_subsystems.down.sql diff --git a/coderd/database/migrations/000148_agent_multiple_subsystems.up.sql b/coderd/database/migrations/000149_agent_multiple_subsystems.up.sql similarity index 100% rename from coderd/database/migrations/000148_agent_multiple_subsystems.up.sql rename to coderd/database/migrations/000149_agent_multiple_subsystems.up.sql From 9941f4905679fdacf5aeb5a6cacdaae106bc465b Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 9 Aug 2023 02:31:25 -0700 Subject: [PATCH 054/277] fix: remove stun nodes from workspace proxy regions (#8990) --- enterprise/coderd/coderd.go | 39 ++++++++++++++++-------------- enterprise/wsproxy/wsproxy_test.go | 26 +++++++++++--------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 71d975e3ef1d6..1679165750b16 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -659,7 +659,7 @@ var ( lastDerpConflictLog time.Time ) -func derpMapper(logger slog.Logger, cfg *codersdk.DeploymentValues, proxyHealth *proxyhealth.ProxyHealth) func(*tailcfg.DERPMap) *tailcfg.DERPMap { +func derpMapper(logger slog.Logger, _ *codersdk.DeploymentValues, proxyHealth *proxyhealth.ProxyHealth) func(*tailcfg.DERPMap) *tailcfg.DERPMap { return func(derpMap *tailcfg.DERPMap) *tailcfg.DERPMap { derpMap = derpMap.Clone() @@ -754,25 +754,28 @@ func derpMapper(logger slog.Logger, cfg *codersdk.DeploymentValues, proxyHealth } var stunNodes []*tailcfg.DERPNode - if !cfg.DERP.Config.BlockDirect.Value() { - stunNodes, err = agpltailnet.STUNNodes(regionID, cfg.DERP.Server.STUNAddresses) - if err != nil { - // Log a warning if we haven't logged one in the last - // minute. - lastDerpConflictMutex.Lock() - shouldLog := lastDerpConflictLog.IsZero() || time.Since(lastDerpConflictLog) > time.Minute - if shouldLog { - lastDerpConflictLog = time.Now() + // TODO(@dean): potentially re-enable this depending on impact + /* + if !cfg.DERP.Config.BlockDirect.Value() { + stunNodes, err = agpltailnet.STUNNodes(regionID, cfg.DERP.Server.STUNAddresses) + if err != nil { + // Log a warning if we haven't logged one in the last + // minute. + lastDerpConflictMutex.Lock() + shouldLog := lastDerpConflictLog.IsZero() || time.Since(lastDerpConflictLog) > time.Minute + if shouldLog { + lastDerpConflictLog = time.Now() + } + lastDerpConflictMutex.Unlock() + if shouldLog { + logger.Error(context.Background(), "failed to calculate STUN nodes", slog.Error(err)) + } + + // No continue because we can keep going. + stunNodes = []*tailcfg.DERPNode{} } - lastDerpConflictMutex.Unlock() - if shouldLog { - logger.Error(context.Background(), "failed to calculate STUN nodes", slog.Error(err)) - } - - // No continue because we can keep going. - stunNodes = []*tailcfg.DERPNode{} } - } + */ nodes := append(stunNodes, &tailcfg.DERPNode{ Name: fmt.Sprintf("%da", regionID), diff --git a/enterprise/wsproxy/wsproxy_test.go b/enterprise/wsproxy/wsproxy_test.go index afcb3d1f16143..d9d4e1a96bc8d 100644 --- a/enterprise/wsproxy/wsproxy_test.go +++ b/enterprise/wsproxy/wsproxy_test.go @@ -244,24 +244,24 @@ resourceLoop: require.Equal(t, "coder_best-proxy", proxy1Region.RegionCode) require.Equal(t, 10001, proxy1Region.RegionID) require.False(t, proxy1Region.EmbeddedRelay) - require.Len(t, proxy1Region.Nodes, 2) // proxy + stun - require.Equal(t, "10001a", proxy1Region.Nodes[1].Name) - require.Equal(t, 10001, proxy1Region.Nodes[1].RegionID) - require.Equal(t, proxyAPI1.Options.AccessURL.Hostname(), proxy1Region.Nodes[1].HostName) - require.Equal(t, proxyAPI1.Options.AccessURL.Port(), fmt.Sprint(proxy1Region.Nodes[1].DERPPort)) - require.Equal(t, proxyAPI1.Options.AccessURL.Scheme == "http", proxy1Region.Nodes[1].ForceHTTP) + require.Len(t, proxy1Region.Nodes, 1) + require.Equal(t, "10001a", proxy1Region.Nodes[0].Name) + require.Equal(t, 10001, proxy1Region.Nodes[0].RegionID) + require.Equal(t, proxyAPI1.Options.AccessURL.Hostname(), proxy1Region.Nodes[0].HostName) + require.Equal(t, proxyAPI1.Options.AccessURL.Port(), fmt.Sprint(proxy1Region.Nodes[0].DERPPort)) + require.Equal(t, proxyAPI1.Options.AccessURL.Scheme == "http", proxy1Region.Nodes[0].ForceHTTP) // The second proxy region: require.Equal(t, "worst-proxy", proxy2Region.RegionName) require.Equal(t, "coder_worst-proxy", proxy2Region.RegionCode) require.Equal(t, 10002, proxy2Region.RegionID) require.False(t, proxy2Region.EmbeddedRelay) - require.Len(t, proxy2Region.Nodes, 2) // proxy + stun - require.Equal(t, "10002a", proxy2Region.Nodes[1].Name) - require.Equal(t, 10002, proxy2Region.Nodes[1].RegionID) - require.Equal(t, proxyAPI2.Options.AccessURL.Hostname(), proxy2Region.Nodes[1].HostName) - require.Equal(t, proxyAPI2.Options.AccessURL.Port(), fmt.Sprint(proxy2Region.Nodes[1].DERPPort)) - require.Equal(t, proxyAPI2.Options.AccessURL.Scheme == "http", proxy2Region.Nodes[1].ForceHTTP) + require.Len(t, proxy2Region.Nodes, 1) + require.Equal(t, "10002a", proxy2Region.Nodes[0].Name) + require.Equal(t, 10002, proxy2Region.Nodes[0].RegionID) + require.Equal(t, proxyAPI2.Options.AccessURL.Hostname(), proxy2Region.Nodes[0].HostName) + require.Equal(t, proxyAPI2.Options.AccessURL.Port(), fmt.Sprint(proxy2Region.Nodes[0].DERPPort)) + require.Equal(t, proxyAPI2.Options.AccessURL.Scheme == "http", proxy2Region.Nodes[0].ForceHTTP) }) t.Run("ConnectDERP", func(t *testing.T) { @@ -313,6 +313,8 @@ resourceLoop: func TestDERPMapStunNodes(t *testing.T) { t.Parallel() + // See: enterprise/coderd/coderd.go + t.Skip("STUN nodes are removed from proxy regions in the DERP map for now") deploymentValues := coderdtest.DeploymentValues(t) deploymentValues.Experiments = []string{ From 1730d354673803ece5981438c0f82471e05087d8 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Wed, 9 Aug 2023 03:05:46 -0700 Subject: [PATCH 055/277] Revert "fix: hide experiment CTA from OIDC copy (#8695)" (#8825) This reverts commit adbabe4e09b890fbf25691d3ce580b9389c34547. --- coderd/userauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/userauth.go b/coderd/userauth.go index 03ae7a6379672..a61b6d126d0e0 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1673,7 +1673,7 @@ func clearOAuthConvertCookie() *http.Cookie { func wrongLoginTypeHTTPError(user database.LoginType, params database.LoginType) httpError { addedMsg := "" if user == database.LoginTypePassword { - addedMsg = " Try logging in with your password." + addedMsg = " You can convert your account to use this login type by visiting your account settings." } return httpError{ code: http.StatusForbidden, From 0d382d1e050cbadaed41526478b8f65e4b8cfc96 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 9 Aug 2023 13:00:25 +0200 Subject: [PATCH 056/277] feat(cli): provide parameter values via command line (#8898) --- cli/create.go | 153 +++++----------- cli/create_test.go | 36 ++++ cli/parameter.go | 123 ++++++++----- cli/parameter_internal_test.go | 14 +- cli/parameterresolver.go | 224 +++++++++++++++++++++++ cli/restart.go | 27 ++- cli/restart_test.go | 54 ++++++ cli/start.go | 84 ++++----- cli/start_test.go | 40 ++++ cli/testdata/coder_create_--help.golden | 3 + cli/testdata/coder_restart_--help.golden | 3 + cli/testdata/coder_start_--help.golden | 3 + cli/testdata/coder_update_--help.golden | 6 + cli/update.go | 58 +++--- cli/update_test.go | 56 +++++- docs/cli/create.md | 9 + docs/cli/restart.md | 9 + docs/cli/start.md | 9 + docs/cli/update.md | 18 ++ 19 files changed, 682 insertions(+), 247 deletions(-) create mode 100644 cli/parameterresolver.go diff --git a/cli/create.go b/cli/create.go index 602b7b40a45bc..7a8c4ec417fac 100644 --- a/cli/create.go +++ b/cli/create.go @@ -18,11 +18,12 @@ import ( func (r *RootCmd) create() *clibase.Cmd { var ( - richParameterFile string - templateName string - startAt string - stopAfter time.Duration - workspaceName string + templateName string + startAt string + stopAfter time.Duration + workspaceName string + + parameterFlags workspaceParameterFlags ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -129,10 +130,18 @@ func (r *RootCmd) create() *clibase.Cmd { schedSpec = ptr.Ref(sched.String()) } - buildParams, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ - Template: template, - RichParameterFile: richParameterFile, - NewWorkspaceName: workspaceName, + cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters) + if err != nil { + return xerrors.Errorf("can't parse given parameter values: %w", err) + } + + richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ + Action: WorkspaceCreate, + Template: template, + NewWorkspaceName: workspaceName, + + RichParameterFile: parameterFlags.richParameterFile, + RichParameters: cliRichParameters, }) if err != nil { return xerrors.Errorf("prepare build: %w", err) @@ -156,7 +165,7 @@ func (r *RootCmd) create() *clibase.Cmd { Name: workspaceName, AutostartSchedule: schedSpec, TTLMillis: ttlMillis, - RichParameterValues: buildParams.richParameters, + RichParameterValues: richParameters, }) if err != nil { return xerrors.Errorf("create workspace: %w", err) @@ -179,12 +188,6 @@ func (r *RootCmd) create() *clibase.Cmd { Description: "Specify a template name.", Value: clibase.StringOf(&templateName), }, - clibase.Option{ - Flag: "rich-parameter-file", - Env: "CODER_RICH_PARAMETER_FILE", - Description: "Specify a file path with values for rich parameters defined in the template.", - Value: clibase.StringOf(&richParameterFile), - }, clibase.Option{ Flag: "start-at", Env: "CODER_WORKSPACE_START_AT", @@ -199,99 +202,59 @@ func (r *RootCmd) create() *clibase.Cmd { }, cliui.SkipPromptOption(), ) + cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...) return cmd } type prepWorkspaceBuildArgs struct { - Template codersdk.Template - ExistingRichParams []codersdk.WorkspaceBuildParameter - RichParameterFile string - NewWorkspaceName string - - UpdateWorkspace bool - BuildOptions bool - WorkspaceID uuid.UUID -} + Action WorkspaceCLIAction + Template codersdk.Template + NewWorkspaceName string + WorkspaceID uuid.UUID + + LastBuildParameters []codersdk.WorkspaceBuildParameter + + PromptBuildOptions bool + BuildOptions []codersdk.WorkspaceBuildParameter -type buildParameters struct { - // Rich parameters stores values for build parameters annotated with description, icon, type, etc. - richParameters []codersdk.WorkspaceBuildParameter + PromptRichParameters bool + RichParameters []codersdk.WorkspaceBuildParameter + RichParameterFile string } // prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version. -// Any missing params will be prompted to the user. It supports legacy and rich parameters. -func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) (*buildParameters, error) { +// Any missing params will be prompted to the user. It supports rich parameters. +func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.WorkspaceBuildParameter, error) { ctx := inv.Context() templateVersion, err := client.TemplateVersion(ctx, args.Template.ActiveVersionID) if err != nil { - return nil, err + return nil, xerrors.Errorf("get template version: %w", err) } - // Rich parameters templateVersionParameters, err := client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID) if err != nil { return nil, xerrors.Errorf("get template version rich parameters: %w", err) } - parameterMapFromFile := map[string]string{} - useParamFile := false + parameterFile := map[string]string{} if args.RichParameterFile != "" { - useParamFile = true - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Paragraph.Render("Attempting to read the variables from the rich parameter file.")+"\r\n") - parameterMapFromFile, err = createParameterMapFromFile(args.RichParameterFile) - if err != nil { - return nil, err - } - } - disclaimerPrinted := false - richParameters := make([]codersdk.WorkspaceBuildParameter, 0) -PromptRichParamLoop: - for _, templateVersionParameter := range templateVersionParameters { - if !args.BuildOptions && templateVersionParameter.Ephemeral { - continue - } - - if !disclaimerPrinted { - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Paragraph.Render("This template has customizable parameters. Values can be changed after create, but may have unintended side effects (like data loss).")+"\r\n") - disclaimerPrinted = true - } - - // Param file is all or nothing - if !useParamFile && !templateVersionParameter.Ephemeral { - for _, e := range args.ExistingRichParams { - if e.Name == templateVersionParameter.Name { - // If the param already exists, we do not need to prompt it again. - // The workspace scope will reuse params for each build. - continue PromptRichParamLoop - } - } - } - - if args.UpdateWorkspace && !templateVersionParameter.Mutable { - // Check if the immutable parameter was used in the previous build. If so, then it isn't a fresh one - // and the user should be warned. - exists, err := workspaceBuildParameterExists(ctx, client, args.WorkspaceID, templateVersionParameter) - if err != nil { - return nil, err - } - - if exists { - _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Warn.Render(fmt.Sprintf(`Parameter %q is not mutable, so can't be customized after workspace creation.`, templateVersionParameter.Name))) - continue - } - } - - parameterValue, err := getWorkspaceBuildParameterValueFromMapOrInput(inv, parameterMapFromFile, templateVersionParameter) + parameterFile, err = parseParameterMapFile(args.RichParameterFile) if err != nil { - return nil, err + return nil, xerrors.Errorf("can't parse parameter map file: %w", err) } - - richParameters = append(richParameters, *parameterValue) } - if disclaimerPrinted { - _, _ = fmt.Fprintln(inv.Stdout) + resolver := new(ParameterResolver). + WithLastBuildParameters(args.LastBuildParameters). + WithPromptBuildOptions(args.PromptBuildOptions). + WithBuildOptions(args.BuildOptions). + WithPromptRichParameters(args.PromptRichParameters). + WithRichParameters(args.RichParameters). + WithRichParametersFile(parameterFile) + buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters) + if err != nil { + return nil, err } err = cliui.GitAuth(ctx, inv.Stdout, cliui.GitAuthOptions{ @@ -306,7 +269,7 @@ PromptRichParamLoop: // Run a dry-run with the given parameters to check correctness dryRun, err := client.CreateTemplateVersionDryRun(inv.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{ WorkspaceName: args.NewWorkspaceName, - RichParameterValues: richParameters, + RichParameterValues: buildParameters, }) if err != nil { return nil, xerrors.Errorf("begin workspace dry-run: %w", err) @@ -346,21 +309,5 @@ PromptRichParamLoop: return nil, xerrors.Errorf("get resources: %w", err) } - return &buildParameters{ - richParameters: richParameters, - }, nil -} - -func workspaceBuildParameterExists(ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID, templateVersionParameter codersdk.TemplateVersionParameter) (bool, error) { - lastBuildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceID) - if err != nil { - return false, xerrors.Errorf("can't fetch last workspace build parameters: %w", err) - } - - for _, p := range lastBuildParameters { - if p.Name == templateVersionParameter.Name { - return true, nil - } - } - return false, nil + return buildParameters, nil } diff --git a/cli/create_test.go b/cli/create_test.go index 8f2bb6719a377..a86113eea854d 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -2,6 +2,7 @@ package cli_test import ( "context" + "fmt" "net/http" "os" "regexp" @@ -357,6 +358,41 @@ func TestCreateWithRichParameters(t *testing.T) { } <-doneChan }) + + t.Run("ParameterFlags", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, + "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue), + "--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue), + "--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue)) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + matches := []string{ + "Confirm create?", "yes", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + pty.WriteLine(value) + } + <-doneChan + }) } func TestCreateValidateRichParameters(t *testing.T) { diff --git a/cli/parameter.go b/cli/parameter.go index 77e8ccdc8ee67..efc415692eae0 100644 --- a/cli/parameter.go +++ b/cli/parameter.go @@ -4,71 +4,98 @@ import ( "encoding/json" "fmt" "os" + "strings" "golang.org/x/xerrors" "gopkg.in/yaml.v3" "github.com/coder/coder/cli/clibase" - "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" ) -// Reads a YAML file and populates a string -> string map. -// Throws an error if the file name is empty. -func createParameterMapFromFile(parameterFile string) (map[string]string, error) { - if parameterFile != "" { - parameterFileContents, err := os.ReadFile(parameterFile) - if err != nil { - return nil, err - } +// workspaceParameterFlags are used by commands processing rich parameters and/or build options. +type workspaceParameterFlags struct { + promptBuildOptions bool + buildOptions []string - mapStringInterface := make(map[string]interface{}) - err = yaml.Unmarshal(parameterFileContents, &mapStringInterface) - if err != nil { - return nil, err - } + richParameterFile string + richParameters []string +} - parameterMap := map[string]string{} - for k, v := range mapStringInterface { - switch val := v.(type) { - case string, bool, int: - parameterMap[k] = fmt.Sprintf("%v", val) - case []interface{}: - b, err := json.Marshal(&val) - if err != nil { - return nil, err - } - parameterMap[k] = string(b) - default: - return nil, xerrors.Errorf("invalid parameter type: %T", v) - } - } - return parameterMap, nil +func (wpf *workspaceParameterFlags) cliBuildOptions() []clibase.Option { + return clibase.OptionSet{ + { + Flag: "build-option", + Env: "CODER_BUILD_OPTION", + Description: `Build option value in the format "name=value".`, + Value: clibase.StringArrayOf(&wpf.buildOptions), + }, + { + Flag: "build-options", + Description: "Prompt for one-time build options defined with ephemeral parameters.", + Value: clibase.BoolOf(&wpf.promptBuildOptions), + }, + } +} + +func (wpf *workspaceParameterFlags) cliParameters() []clibase.Option { + return clibase.OptionSet{ + clibase.Option{ + Flag: "parameter", + Env: "CODER_RICH_PARAMETER", + Description: `Rich parameter value in the format "name=value".`, + Value: clibase.StringArrayOf(&wpf.richParameters), + }, + clibase.Option{ + Flag: "rich-parameter-file", + Env: "CODER_RICH_PARAMETER_FILE", + Description: "Specify a file path with values for rich parameters defined in the template.", + Value: clibase.StringOf(&wpf.richParameterFile), + }, } +} - return nil, xerrors.Errorf("Parameter file name is not specified") +func asWorkspaceBuildParameters(nameValuePairs []string) ([]codersdk.WorkspaceBuildParameter, error) { + var params []codersdk.WorkspaceBuildParameter + for _, nameValue := range nameValuePairs { + split := strings.SplitN(nameValue, "=", 2) + if len(split) < 2 { + return nil, xerrors.Errorf("format key=value expected, but got %s", nameValue) + } + params = append(params, codersdk.WorkspaceBuildParameter{ + Name: split[0], + Value: split[1], + }) + } + return params, nil } -func getWorkspaceBuildParameterValueFromMapOrInput(inv *clibase.Invocation, parameterMap map[string]string, templateVersionParameter codersdk.TemplateVersionParameter) (*codersdk.WorkspaceBuildParameter, error) { - var parameterValue string - var err error - if parameterMap != nil { - var ok bool - parameterValue, ok = parameterMap[templateVersionParameter.Name] - if !ok { - parameterValue, err = cliui.RichParameter(inv, templateVersionParameter) +func parseParameterMapFile(parameterFile string) (map[string]string, error) { + parameterFileContents, err := os.ReadFile(parameterFile) + if err != nil { + return nil, err + } + + mapStringInterface := make(map[string]interface{}) + err = yaml.Unmarshal(parameterFileContents, &mapStringInterface) + if err != nil { + return nil, err + } + + parameterMap := map[string]string{} + for k, v := range mapStringInterface { + switch val := v.(type) { + case string, bool, int: + parameterMap[k] = fmt.Sprintf("%v", val) + case []interface{}: + b, err := json.Marshal(&val) if err != nil { return nil, err } - } - } else { - parameterValue, err = cliui.RichParameter(inv, templateVersionParameter) - if err != nil { - return nil, err + parameterMap[k] = string(b) + default: + return nil, xerrors.Errorf("invalid parameter type: %T", v) } } - return &codersdk.WorkspaceBuildParameter{ - Name: templateVersionParameter.Name, - Value: parameterValue, - }, nil + return parameterMap, nil } diff --git a/cli/parameter_internal_test.go b/cli/parameter_internal_test.go index 81dfcefdf49b2..935486c6eae26 100644 --- a/cli/parameter_internal_test.go +++ b/cli/parameter_internal_test.go @@ -16,7 +16,7 @@ func TestCreateParameterMapFromFile(t *testing.T) { parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") _, _ = parameterFile.WriteString("region: \"bananas\"\ndisk: \"20\"\n") - parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name()) + parameterMapFromFile, err := parseParameterMapFile(parameterFile.Name()) expectedMap := map[string]string{ "region": "bananas", @@ -28,18 +28,10 @@ func TestCreateParameterMapFromFile(t *testing.T) { removeTmpDirUntilSuccess(t, tempDir) }) - t.Run("WithEmptyFilename", func(t *testing.T) { - t.Parallel() - - parameterMapFromFile, err := createParameterMapFromFile("") - - assert.Nil(t, parameterMapFromFile) - assert.EqualError(t, err, "Parameter file name is not specified") - }) t.Run("WithInvalidFilename", func(t *testing.T) { t.Parallel() - parameterMapFromFile, err := createParameterMapFromFile("invalidFile.yaml") + parameterMapFromFile, err := parseParameterMapFile("invalidFile.yaml") assert.Nil(t, parameterMapFromFile) @@ -57,7 +49,7 @@ func TestCreateParameterMapFromFile(t *testing.T) { parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml") _, _ = parameterFile.WriteString("region = \"bananas\"\ndisk = \"20\"\n") - parameterMapFromFile, err := createParameterMapFromFile(parameterFile.Name()) + parameterMapFromFile, err := parseParameterMapFile(parameterFile.Name()) assert.Nil(t, parameterMapFromFile) assert.EqualError(t, err, "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `region ...` into map[string]interface {}") diff --git a/cli/parameterresolver.go b/cli/parameterresolver.go new file mode 100644 index 0000000000000..9e803356b45bf --- /dev/null +++ b/cli/parameterresolver.go @@ -0,0 +1,224 @@ +package cli + +import ( + "fmt" + + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" +) + +type WorkspaceCLIAction int + +const ( + WorkspaceCreate WorkspaceCLIAction = iota + WorkspaceStart + WorkspaceUpdate + WorkspaceRestart +) + +type ParameterResolver struct { + lastBuildParameters []codersdk.WorkspaceBuildParameter + + richParameters []codersdk.WorkspaceBuildParameter + richParametersFile map[string]string + buildOptions []codersdk.WorkspaceBuildParameter + + promptRichParameters bool + promptBuildOptions bool +} + +func (pr *ParameterResolver) WithLastBuildParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver { + pr.lastBuildParameters = params + return pr +} + +func (pr *ParameterResolver) WithRichParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver { + pr.richParameters = params + return pr +} + +func (pr *ParameterResolver) WithBuildOptions(params []codersdk.WorkspaceBuildParameter) *ParameterResolver { + pr.buildOptions = params + return pr +} + +func (pr *ParameterResolver) WithRichParametersFile(fileMap map[string]string) *ParameterResolver { + pr.richParametersFile = fileMap + return pr +} + +func (pr *ParameterResolver) WithPromptRichParameters(promptRichParameters bool) *ParameterResolver { + pr.promptRichParameters = promptRichParameters + return pr +} + +func (pr *ParameterResolver) WithPromptBuildOptions(promptBuildOptions bool) *ParameterResolver { + pr.promptBuildOptions = promptBuildOptions + return pr +} + +func (pr *ParameterResolver) Resolve(inv *clibase.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) { + var staged []codersdk.WorkspaceBuildParameter + var err error + + staged = pr.resolveWithParametersMapFile(staged) + staged = pr.resolveWithCommandLineOrEnv(staged) + staged = pr.resolveWithLastBuildParameters(staged, templateVersionParameters) + if err = pr.verifyConstraints(staged, action, templateVersionParameters); err != nil { + return nil, err + } + if staged, err = pr.resolveWithInput(staged, inv, action, templateVersionParameters); err != nil { + return nil, err + } + return staged, nil +} + +func (pr *ParameterResolver) resolveWithParametersMapFile(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter { + for name, value := range pr.richParametersFile { + for i, r := range resolved { + if r.Name == name { + resolved[i].Value = value + goto done + } + } + + resolved = append(resolved, codersdk.WorkspaceBuildParameter{ + Name: name, + Value: value, + }) + done: + } + return resolved +} + +func (pr *ParameterResolver) resolveWithCommandLineOrEnv(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter { + for _, richParameter := range pr.richParameters { + for i, r := range resolved { + if r.Name == richParameter.Name { + resolved[i].Value = richParameter.Value + goto richParameterDone + } + } + + resolved = append(resolved, richParameter) + richParameterDone: + } + + for _, buildOption := range pr.buildOptions { + for i, r := range resolved { + if r.Name == buildOption.Name { + resolved[i].Value = buildOption.Value + goto buildOptionDone + } + } + + resolved = append(resolved, buildOption) + buildOptionDone: + } + return resolved +} + +func (pr *ParameterResolver) resolveWithLastBuildParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter { + if pr.promptRichParameters { + return resolved // don't pull parameters from last build + } + + for _, buildParameter := range pr.lastBuildParameters { + tvp := findTemplateVersionParameter(buildParameter, templateVersionParameters) + if tvp == nil { + continue // it looks like this parameter is not present anymore + } + + if tvp.Ephemeral { + continue // ephemeral parameters should not be passed to consecutive builds + } + + if !tvp.Mutable { + continue // immutables should not be passed to consecutive builds + } + + for i, r := range resolved { + if r.Name == buildParameter.Name { + resolved[i].Value = buildParameter.Value + goto done + } + } + + resolved = append(resolved, buildParameter) + done: + } + return resolved +} + +func (pr *ParameterResolver) verifyConstraints(resolved []codersdk.WorkspaceBuildParameter, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) error { + for _, r := range resolved { + tvp := findTemplateVersionParameter(r, templateVersionParameters) + if tvp == nil { + return xerrors.Errorf("parameter %q is not present in the template", r.Name) + } + + if tvp.Ephemeral && !pr.promptBuildOptions && len(pr.buildOptions) == 0 { + return xerrors.Errorf("ephemeral parameter %q can be used only with --build-options or --build-option flag", r.Name) + } + + if !tvp.Mutable && action != WorkspaceCreate { + return xerrors.Errorf("parameter %q is immutable and cannot be updated", r.Name) + } + } + return nil +} + +func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuildParameter, inv *clibase.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) { + for _, tvp := range templateVersionParameters { + p := findWorkspaceBuildParameter(tvp, resolved) + if p != nil { + continue + } + + firstTimeUse := pr.isFirstTimeUse(tvp) + + if (tvp.Ephemeral && pr.promptBuildOptions) || + tvp.Required || + (action == WorkspaceUpdate && !tvp.Mutable && firstTimeUse) || + (action == WorkspaceUpdate && tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) || + (action == WorkspaceCreate && !tvp.Ephemeral) { + parameterValue, err := cliui.RichParameter(inv, tvp) + if err != nil { + return nil, err + } + + resolved = append(resolved, codersdk.WorkspaceBuildParameter{ + Name: tvp.Name, + Value: parameterValue, + }) + } else if action == WorkspaceUpdate && !tvp.Mutable && !firstTimeUse { + _, _ = fmt.Fprintln(inv.Stdout, cliui.DefaultStyles.Warn.Render(fmt.Sprintf("Parameter %q is not mutable, and cannot be customized after workspace creation.", tvp.Name))) + } + } + return resolved, nil +} + +func (pr *ParameterResolver) isFirstTimeUse(tvp codersdk.TemplateVersionParameter) bool { + return findWorkspaceBuildParameter(tvp, pr.lastBuildParameters) == nil +} + +func findTemplateVersionParameter(workspaceBuildParameter codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) *codersdk.TemplateVersionParameter { + for _, tvp := range templateVersionParameters { + if tvp.Name == workspaceBuildParameter.Name { + return &tvp + } + } + return nil +} + +func findWorkspaceBuildParameter(tvp codersdk.TemplateVersionParameter, params []codersdk.WorkspaceBuildParameter) *codersdk.WorkspaceBuildParameter { + for _, p := range params { + if p.Name == tvp.Name { + return &p + } + } + return nil +} diff --git a/cli/restart.go b/cli/restart.go index 4cff7ac7571d7..f03b1ab0c4755 100644 --- a/cli/restart.go +++ b/cli/restart.go @@ -4,6 +4,8 @@ import ( "fmt" "time" + "golang.org/x/xerrors" + "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" @@ -21,7 +23,7 @@ func (r *RootCmd) restart() *clibase.Cmd { clibase.RequireNArgs(1), r.InitClient(client), ), - Options: append(parameterFlags.options(), cliui.SkipPromptOption()), + Options: append(parameterFlags.cliBuildOptions(), cliui.SkipPromptOption()), Handler: func(inv *clibase.Invocation) error { ctx := inv.Context() out := inv.Stdout @@ -31,14 +33,29 @@ func (r *RootCmd) restart() *clibase.Cmd { return err } + lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) + if err != nil { + return err + } + template, err := client.Template(inv.Context(), workspace.TemplateID) if err != nil { return err } - buildParams, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ - Template: template, - BuildOptions: parameterFlags.buildOptions, + buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) + if err != nil { + return xerrors.Errorf("can't parse build options: %w", err) + } + + buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ + Action: WorkspaceRestart, + Template: template, + + LastBuildParameters: lastBuildParameters, + + PromptBuildOptions: parameterFlags.promptBuildOptions, + BuildOptions: buildOptions, }) if err != nil { return err @@ -65,7 +82,7 @@ func (r *RootCmd) restart() *clibase.Cmd { build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, - RichParameterValues: buildParams.richParameters, + RichParameterValues: buildParameters, }) if err != nil { return err diff --git a/cli/restart_test.go b/cli/restart_test.go index 83b066e4defc5..be2c6ea423416 100644 --- a/cli/restart_test.go +++ b/cli/restart_test.go @@ -2,6 +2,7 @@ package cli_test import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -126,4 +127,57 @@ func TestRestart(t *testing.T) { Value: ephemeralParameterValue, }) }) + + t.Run("BuildOptionFlags", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + inv, root := clitest.New(t, "restart", workspace.Name, + "--build-option", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue)) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + matches := []string{ + "Confirm restart workspace?", "yes", + "Stopping workspace", "", + "Starting workspace", "", + "workspace has been restarted", "", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + pty.ExpectMatch(match) + + if value != "" { + pty.WriteLine(value) + } + } + <-doneChan + + // Verify if build option is set + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspace, err := client.WorkspaceByOwnerAndName(ctx, user.UserID.String(), workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{ + Name: ephemeralParameterName, + Value: ephemeralParameterValue, + }) + }) } diff --git a/cli/start.go b/cli/start.go index 5bd35867fd105..212ee1abbcb85 100644 --- a/cli/start.go +++ b/cli/start.go @@ -11,21 +11,6 @@ import ( "github.com/coder/coder/codersdk" ) -// workspaceParameterFlags are used by "start", "restart", and "update". -type workspaceParameterFlags struct { - buildOptions bool -} - -func (wpf *workspaceParameterFlags) options() []clibase.Option { - return clibase.OptionSet{ - { - Flag: "build-options", - Description: "Prompt for one-time build options defined with ephemeral parameters.", - Value: clibase.BoolOf(&wpf.buildOptions), - }, - } -} - func (r *RootCmd) start() *clibase.Cmd { var parameterFlags workspaceParameterFlags @@ -38,21 +23,36 @@ func (r *RootCmd) start() *clibase.Cmd { clibase.RequireNArgs(1), r.InitClient(client), ), - Options: append(parameterFlags.options(), cliui.SkipPromptOption()), + Options: append(parameterFlags.cliBuildOptions(), cliui.SkipPromptOption()), Handler: func(inv *clibase.Invocation) error { workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0]) if err != nil { return err } + lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) + if err != nil { + return err + } + template, err := client.Template(inv.Context(), workspace.TemplateID) if err != nil { return err } - buildParams, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ - Template: template, - BuildOptions: parameterFlags.buildOptions, + buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) + if err != nil { + return xerrors.Errorf("unable to parse build options: %w", err) + } + + buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ + Action: WorkspaceStart, + Template: template, + + LastBuildParameters: lastBuildParameters, + + PromptBuildOptions: parameterFlags.promptBuildOptions, + BuildOptions: buildOptions, }) if err != nil { return err @@ -60,7 +60,7 @@ func (r *RootCmd) start() *clibase.Cmd { build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, - RichParameterValues: buildParams.richParameters, + RichParameterValues: buildParameters, }) if err != nil { return err @@ -79,16 +79,21 @@ func (r *RootCmd) start() *clibase.Cmd { } type prepStartWorkspaceArgs struct { - Template codersdk.Template - BuildOptions bool + Action WorkspaceCLIAction + Template codersdk.Template + + LastBuildParameters []codersdk.WorkspaceBuildParameter + + PromptBuildOptions bool + BuildOptions []codersdk.WorkspaceBuildParameter } -func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args prepStartWorkspaceArgs) (*buildParameters, error) { +func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args prepStartWorkspaceArgs) ([]codersdk.WorkspaceBuildParameter, error) { ctx := inv.Context() templateVersion, err := client.TemplateVersion(ctx, args.Template.ActiveVersionID) if err != nil { - return nil, err + return nil, xerrors.Errorf("get template version: %w", err) } templateVersionParameters, err := client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID) @@ -96,30 +101,9 @@ func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args p return nil, xerrors.Errorf("get template version rich parameters: %w", err) } - richParameters := make([]codersdk.WorkspaceBuildParameter, 0) - if !args.BuildOptions { - return &buildParameters{ - richParameters: richParameters, - }, nil - } - - for _, templateVersionParameter := range templateVersionParameters { - if !templateVersionParameter.Ephemeral { - continue - } - - parameterValue, err := cliui.RichParameter(inv, templateVersionParameter) - if err != nil { - return nil, err - } - - richParameters = append(richParameters, codersdk.WorkspaceBuildParameter{ - Name: templateVersionParameter.Name, - Value: parameterValue, - }) - } - - return &buildParameters{ - richParameters: richParameters, - }, nil + resolver := new(ParameterResolver). + WithLastBuildParameters(args.LastBuildParameters). + WithPromptBuildOptions(args.PromptBuildOptions). + WithBuildOptions(args.BuildOptions) + return resolver.Resolve(inv, args.Action, templateVersionParameters) } diff --git a/cli/start_test.go b/cli/start_test.go index a302fe2ac1c40..b532500fb0777 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -99,4 +100,43 @@ func TestStart(t *testing.T) { Value: ephemeralParameterValue, }) }) + + t.Run("BuildOptionFlags", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + inv, root := clitest.New(t, "start", workspace.Name, + "--build-option", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue)) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + pty.ExpectMatch("workspace has been started") + <-doneChan + + // Verify if build option is set + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{ + Name: ephemeralParameterName, + Value: ephemeralParameterValue, + }) + }) } diff --git a/cli/testdata/coder_create_--help.golden b/cli/testdata/coder_create_--help.golden index 6c8bcc46908c9..2e080b6e85ca7 100644 --- a/cli/testdata/coder_create_--help.golden +++ b/cli/testdata/coder_create_--help.golden @@ -7,6 +7,9 @@ Create a workspace  $ coder create /  Options + --parameter string-array, $CODER_RICH_PARAMETER + Rich parameter value in the format "name=value". + --rich-parameter-file string, $CODER_RICH_PARAMETER_FILE Specify a file path with values for rich parameters defined in the template. diff --git a/cli/testdata/coder_restart_--help.golden b/cli/testdata/coder_restart_--help.golden index c2079b9065dca..e16a6f9ff7e99 100644 --- a/cli/testdata/coder_restart_--help.golden +++ b/cli/testdata/coder_restart_--help.golden @@ -3,6 +3,9 @@ Usage: coder restart [flags] Restart a workspace Options + --build-option string-array, $CODER_BUILD_OPTION + Build option value in the format "name=value". + --build-options bool Prompt for one-time build options defined with ephemeral parameters. diff --git a/cli/testdata/coder_start_--help.golden b/cli/testdata/coder_start_--help.golden index aa447240e9bbb..b03c9975925f4 100644 --- a/cli/testdata/coder_start_--help.golden +++ b/cli/testdata/coder_start_--help.golden @@ -3,6 +3,9 @@ Usage: coder start [flags] Start a workspace Options + --build-option string-array, $CODER_BUILD_OPTION + Build option value in the format "name=value". + --build-options bool Prompt for one-time build options defined with ephemeral parameters. diff --git a/cli/testdata/coder_update_--help.golden b/cli/testdata/coder_update_--help.golden index 40e899cd37348..669bda831caa6 100644 --- a/cli/testdata/coder_update_--help.golden +++ b/cli/testdata/coder_update_--help.golden @@ -9,9 +9,15 @@ Use --always-prompt to change the parameter values of the workspace. Always prompt all parameters. Does not pull parameter values from existing workspace. + --build-option string-array, $CODER_BUILD_OPTION + Build option value in the format "name=value". + --build-options bool Prompt for one-time build options defined with ephemeral parameters. + --parameter string-array, $CODER_RICH_PARAMETER + Rich parameter value in the format "name=value". + --rich-parameter-file string, $CODER_RICH_PARAMETER_FILE Specify a file path with values for rich parameters defined in the template. diff --git a/cli/update.go b/cli/update.go index 64710217bb996..02ef238b36e21 100644 --- a/cli/update.go +++ b/cli/update.go @@ -3,14 +3,15 @@ package cli import ( "fmt" + "golang.org/x/xerrors" + "github.com/coder/coder/cli/clibase" "github.com/coder/coder/codersdk" ) func (r *RootCmd) update() *clibase.Cmd { var ( - richParameterFile string - alwaysPrompt bool + alwaysPrompt bool parameterFlags workspaceParameterFlags ) @@ -30,33 +31,45 @@ func (r *RootCmd) update() *clibase.Cmd { if err != nil { return err } - if !workspace.Outdated && !alwaysPrompt && !parameterFlags.buildOptions { + if !workspace.Outdated && !alwaysPrompt && !parameterFlags.promptBuildOptions && len(parameterFlags.buildOptions) == 0 { _, _ = fmt.Fprintf(inv.Stdout, "Workspace isn't outdated!\n") return nil } + + buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) + if err != nil { + return err + } + template, err := client.Template(inv.Context(), workspace.TemplateID) if err != nil { return err } - var existingRichParams []codersdk.WorkspaceBuildParameter - if !alwaysPrompt { - existingRichParams, err = client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) - if err != nil { - return err - } + lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) + if err != nil { + return err + } + + cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters) + if err != nil { + return xerrors.Errorf("can't parse given parameter values: %w", err) } - buildParams, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ - Template: template, - ExistingRichParams: existingRichParams, - RichParameterFile: richParameterFile, - NewWorkspaceName: workspace.Name, + buildParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ + Action: WorkspaceUpdate, + Template: template, + NewWorkspaceName: workspace.Name, + WorkspaceID: workspace.LatestBuild.ID, - UpdateWorkspace: true, - WorkspaceID: workspace.LatestBuild.ID, + LastBuildParameters: lastBuildParameters, - BuildOptions: parameterFlags.buildOptions, + PromptBuildOptions: parameterFlags.promptBuildOptions, + BuildOptions: buildOptions, + + PromptRichParameters: alwaysPrompt, + RichParameters: cliRichParameters, + RichParameterFile: parameterFlags.richParameterFile, }) if err != nil { return err @@ -65,7 +78,7 @@ func (r *RootCmd) update() *clibase.Cmd { build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: template.ActiveVersionID, Transition: codersdk.WorkspaceTransitionStart, - RichParameterValues: buildParams.richParameters, + RichParameterValues: buildParameters, }) if err != nil { return err @@ -92,13 +105,8 @@ func (r *RootCmd) update() *clibase.Cmd { Description: "Always prompt all parameters. Does not pull parameter values from existing workspace.", Value: clibase.BoolOf(&alwaysPrompt), }, - { - Flag: "rich-parameter-file", - Description: "Specify a file path with values for rich parameters defined in the template.", - Env: "CODER_RICH_PARAMETER_FILE", - Value: clibase.StringOf(&richParameterFile), - }, } - cmd.Options = append(cmd.Options, parameterFlags.options()...) + cmd.Options = append(cmd.Options, parameterFlags.cliBuildOptions()...) + cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...) return cmd } diff --git a/cli/update_test.go b/cli/update_test.go index 886adf9bea264..e830aabbde435 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -159,7 +159,7 @@ func TestUpdateWithRichParameters(t *testing.T) { matches := []string{ firstParameterDescription, firstParameterValue, - fmt.Sprintf("Parameter %q is not mutable, so can't be customized after workspace creation.", immutableParameterName), "", + fmt.Sprintf("Parameter %q is not mutable, and cannot be customized after workspace creation.", immutableParameterName), "", secondParameterDescription, secondParameterValue, } for i := 0; i < len(matches); i += 2 { @@ -236,6 +236,55 @@ func TestUpdateWithRichParameters(t *testing.T) { Value: ephemeralParameterValue, }) }) + + t.Run("BuildOptionFlags", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, echoResponses) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + const workspaceName = "my-workspace" + + inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", + "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue), + "--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue), + "--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue)) + clitest.SetupConfig(t, client, root) + err := inv.Run() + assert.NoError(t, err) + + inv, root = clitest.New(t, "update", workspaceName, + "--build-option", fmt.Sprintf("%s=%s", ephemeralParameterName, ephemeralParameterValue)) + clitest.SetupConfig(t, client, root) + + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + pty.ExpectMatch("Planning workspace") + <-doneChan + + // Verify if build option is set + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspace, err := client.WorkspaceByOwnerAndName(ctx, user.UserID.String(), workspaceName, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{ + Name: ephemeralParameterName, + Value: ephemeralParameterValue, + }) + }) } func TestUpdateValidateRichParameters(t *testing.T) { @@ -545,14 +594,11 @@ func TestUpdateValidateRichParameters(t *testing.T) { }() matches := []string{ - "added_parameter", "", - `Enter a value (default: "foobar")`, "abc", + "Planning workspace...", "", } for i := 0; i < len(matches); i += 2 { match := matches[i] - value := matches[i+1] pty.ExpectMatch(match) - pty.WriteLine(value) } <-doneChan }) diff --git a/docs/cli/create.md b/docs/cli/create.md index 3f7ff099b16c8..8c36ea44a3dd7 100644 --- a/docs/cli/create.md +++ b/docs/cli/create.md @@ -20,6 +20,15 @@ coder create [flags] [name] ## Options +### --parameter + +| | | +| ----------- | ---------------------------------- | +| Type | string-array | +| Environment | $CODER_RICH_PARAMETER | + +Rich parameter value in the format "name=value". + ### --rich-parameter-file | | | diff --git a/docs/cli/restart.md b/docs/cli/restart.md index 72daa5dec405d..d3b6010a92c2e 100644 --- a/docs/cli/restart.md +++ b/docs/cli/restart.md @@ -12,6 +12,15 @@ coder restart [flags] ## Options +### --build-option + +| | | +| ----------- | -------------------------------- | +| Type | string-array | +| Environment | $CODER_BUILD_OPTION | + +Build option value in the format "name=value". + ### --build-options | | | diff --git a/docs/cli/start.md b/docs/cli/start.md index 8c3fd7f71276e..120edfde679eb 100644 --- a/docs/cli/start.md +++ b/docs/cli/start.md @@ -12,6 +12,15 @@ coder start [flags] ## Options +### --build-option + +| | | +| ----------- | -------------------------------- | +| Type | string-array | +| Environment | $CODER_BUILD_OPTION | + +Build option value in the format "name=value". + ### --build-options | | | diff --git a/docs/cli/update.md b/docs/cli/update.md index 0b3d31a9755b8..b81172df6b9ca 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -26,6 +26,15 @@ Use --always-prompt to change the parameter values of the workspace. Always prompt all parameters. Does not pull parameter values from existing workspace. +### --build-option + +| | | +| ----------- | -------------------------------- | +| Type | string-array | +| Environment | $CODER_BUILD_OPTION | + +Build option value in the format "name=value". + ### --build-options | | | @@ -34,6 +43,15 @@ Always prompt all parameters. Does not pull parameter values from existing works Prompt for one-time build options defined with ephemeral parameters. +### --parameter + +| | | +| ----------- | ---------------------------------- | +| Type | string-array | +| Environment | $CODER_RICH_PARAMETER | + +Rich parameter value in the format "name=value". + ### --rich-parameter-file | | | From e0f644c5989948b64405886cf591635402ca3bb0 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 9 Aug 2023 16:09:25 +0400 Subject: [PATCH 057/277] test(coderd): fix TestWorkspaceWatcher hang waiting for update (#8992) Signed-off-by: Spike Curtis --- coderd/workspaces_test.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 2213cd4657a70..5fecd02dbea88 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2121,9 +2121,14 @@ func TestWorkspaceWatcher(t *testing.T) { case w, ok := <-wc: require.True(t, ok, "watch channel closed: %s", event) if ready == nil || ready(w) { - logger.Info(ctx, "done waiting for event", slog.F("event", event)) + logger.Info(ctx, "done waiting for event", + slog.F("event", event), + slog.F("workspace", w)) return } + logger.Info(ctx, "skipped update for event", + slog.F("event", event), + slog.F("workspace", w)) } } } @@ -2194,12 +2199,23 @@ func TestWorkspaceWatcher(t *testing.T) { }) // We want to verify pending state here, but it's possible that we reach // failed state fast enough that we never see pending. + sawFailed := false wait("workspace build pending or failed", func(w codersdk.Workspace) bool { - return w.LatestBuild.Status == codersdk.WorkspaceStatusPending || w.LatestBuild.Status == codersdk.WorkspaceStatusFailed - }) - wait("workspace build failed", func(w codersdk.Workspace) bool { - return w.LatestBuild.Status == codersdk.WorkspaceStatusFailed + switch w.LatestBuild.Status { + case codersdk.WorkspaceStatusPending: + return true + case codersdk.WorkspaceStatusFailed: + sawFailed = true + return true + default: + return false + } }) + if !sawFailed { + wait("workspace build failed", func(w codersdk.Workspace) bool { + return w.LatestBuild.Status == codersdk.WorkspaceStatusFailed + }) + } closeFunc.Close() build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart) From 73e518b0fb25d89bef9fd12226ffdb54ec459476 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 9 Aug 2023 09:46:14 -0300 Subject: [PATCH 058/277] refactor(site): remove last 7 days label (#8986) --- .../TemplateInsightsPage/TemplateInsightsPage.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index 19de025f0648c..584f36828139e 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -137,7 +137,6 @@ const DailyUsersPanel = ({ - Last 7 days {!data && } @@ -167,7 +166,6 @@ const UserLatencyPanel = ({ - Last 7 days {!data && } @@ -228,7 +226,6 @@ const TemplateUsagePanel = ({ App & IDE Usage - Last 7 days {!data && } @@ -311,7 +308,6 @@ const TemplateParametersUsagePanel = ({ Parameters usage - Last 7 days {!data && } @@ -533,11 +529,6 @@ const PanelTitle = styled(Box)(() => ({ fontWeight: 500, })) -const PanelSubtitle = styled(Box)(({ theme }) => ({ - fontSize: 13, - color: theme.palette.text.secondary, -})) - const PanelContent = styled(Box)(({ theme }) => ({ padding: theme.spacing(0, 3, 3), flex: 1, From a6716ca829f6428dbbcef55f358763e03c97ab56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Aug 2023 09:54:03 -0300 Subject: [PATCH 059/277] chore: bump chart.js from 3.9.1 to 4.3.3 in /site (#8874) * chore: bump chart.js from 3.9.1 to 4.3.3 in /site Bumps [chart.js](https://github.com/chartjs/Chart.js) from 3.9.1 to 4.3.3. - [Release notes](https://github.com/chartjs/Chart.js/releases) - [Commits](https://github.com/chartjs/Chart.js/compare/v3.9.1...v4.3.3) --- updated-dependencies: - dependency-name: chart.js dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Remove timescale * Fix step size --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Muhammad Atif Ali Co-authored-by: BrunoQuaresma --- site/package.json | 2 +- site/pnpm-lock.yaml | 27 ++++++++++++++--------- site/src/components/DAUChart/DAUChart.tsx | 5 +++-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/site/package.json b/site/package.json index 8d08e287ebf1a..d3b89d966336b 100644 --- a/site/package.json +++ b/site/package.json @@ -56,7 +56,7 @@ "ansi-to-html": "0.7.2", "axios": "1.3.4", "canvas": "2.11.0", - "chart.js": "3.9.1", + "chart.js": "4.3.3", "chartjs-adapter-date-fns": "3.0.0", "chroma-js": "2.4.2", "color-convert": "2.0.1", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 0746844223c01..00d76bb9c3efc 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -85,11 +85,11 @@ dependencies: specifier: 2.11.0 version: 2.11.0 chart.js: - specifier: 3.9.1 - version: 3.9.1 + specifier: 4.3.3 + version: 4.3.3 chartjs-adapter-date-fns: specifier: 3.0.0 - version: 3.0.0(chart.js@3.9.1)(date-fns@2.30.0) + version: 3.0.0(chart.js@4.3.3)(date-fns@2.30.0) chroma-js: specifier: 2.4.2 version: 2.4.2 @@ -143,7 +143,7 @@ dependencies: version: 18.2.0 react-chartjs-2: specifier: 5.2.0 - version: 5.2.0(chart.js@3.9.1)(react@18.2.0) + version: 5.2.0(chart.js@4.3.3)(react@18.2.0) react-color: specifier: 2.19.3 version: 2.19.3(react@18.2.0) @@ -2516,6 +2516,10 @@ packages: resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} dev: true + /@kurkle/color@0.3.2: + resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} + dev: false + /@mapbox/node-pre-gyp@1.0.11: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true @@ -6185,17 +6189,20 @@ packages: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} dev: true - /chart.js@3.9.1: - resolution: {integrity: sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==} + /chart.js@4.3.3: + resolution: {integrity: sha512-aTk7pBw+x6sQYhon/NR3ikfUJuym/LdgpTlgZRe2PaEhjUMKBKyNaFCMVRAyTEWYFNO7qRu7iQVqOw/OqzxZxQ==} + engines: {pnpm: '>=7'} + dependencies: + '@kurkle/color': 0.3.2 dev: false - /chartjs-adapter-date-fns@3.0.0(chart.js@3.9.1)(date-fns@2.30.0): + /chartjs-adapter-date-fns@3.0.0(chart.js@4.3.3)(date-fns@2.30.0): resolution: {integrity: sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==} peerDependencies: chart.js: '>=2.8.0' date-fns: '>=2.0.0' dependencies: - chart.js: 3.9.1 + chart.js: 4.3.3 date-fns: 2.30.0 dev: false @@ -11151,13 +11158,13 @@ packages: unpipe: 1.0.0 dev: true - /react-chartjs-2@5.2.0(chart.js@3.9.1)(react@18.2.0): + /react-chartjs-2@5.2.0(chart.js@4.3.3)(react@18.2.0): resolution: {integrity: sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==} peerDependencies: chart.js: ^4.1.1 react: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: - chart.js: 3.9.1 + chart.js: 4.3.3 react: 18.2.0 dev: false diff --git a/site/src/components/DAUChart/DAUChart.tsx b/site/src/components/DAUChart/DAUChart.tsx index add0b6aa17db0..fe6d92e5fb332 100644 --- a/site/src/components/DAUChart/DAUChart.tsx +++ b/site/src/components/DAUChart/DAUChart.tsx @@ -69,11 +69,12 @@ export const DAUChart: FC = ({ daus }) => { }, }, x: { - ticks: {}, + ticks: { + stepSize: 2, + }, type: "time", time: { unit: "day", - stepSize: 2, }, }, }, From d73e3ad3f36688fcb8c4a8ea1fccdb96a3fcb4c9 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 9 Aug 2023 11:26:43 -0300 Subject: [PATCH 060/277] fix(site): show user avatar on group page (#8997) --- site/src/components/UserAvatar/UserAvatar.tsx | 1 - site/src/pages/GroupsPage/GroupPage.tsx | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/site/src/components/UserAvatar/UserAvatar.tsx b/site/src/components/UserAvatar/UserAvatar.tsx index 95183a8001546..49349a328901b 100644 --- a/site/src/components/UserAvatar/UserAvatar.tsx +++ b/site/src/components/UserAvatar/UserAvatar.tsx @@ -9,7 +9,6 @@ export type UserAvatarProps = { export const UserAvatar: FC = ({ username, avatarURL, - ...avatarProps }) => { return ( diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index 13bec3399bcb8..30801f2823362 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -34,6 +34,7 @@ import { groupMachine } from "xServices/groups/groupXService" import { Maybe } from "components/Conditionals/Maybe" import { makeStyles } from "@mui/styles" import { PaginationStatus } from "components/PaginationStatus/PaginationStatus" +import { UserAvatar } from "components/UserAvatar/UserAvatar" const AddGroupMember: React.FC<{ isLoading: boolean @@ -188,6 +189,12 @@ export const GroupPage: React.FC = () => { + } title={member.username} subtitle={member.email} /> From 7fceb9aaff6ef9fd2e90d8458cf4ae664487fbb6 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 9 Aug 2023 11:26:56 -0300 Subject: [PATCH 061/277] fix(site): make stats bar scrollable on smaller viewports (#8996) --- .../components/DeploymentBanner/DeploymentBannerView.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/site/src/components/DeploymentBanner/DeploymentBannerView.tsx b/site/src/components/DeploymentBanner/DeploymentBannerView.tsx index edee6dc836491..d27511911a74f 100644 --- a/site/src/components/DeploymentBanner/DeploymentBannerView.tsx +++ b/site/src/components/DeploymentBanner/DeploymentBannerView.tsx @@ -268,12 +268,8 @@ const useStyles = makeStyles((theme) => ({ fontSize: 12, gap: theme.spacing(4), borderTop: `1px solid ${theme.palette.divider}`, - - [theme.breakpoints.down("lg")]: { - flexDirection: "column", - gap: theme.spacing(1), - alignItems: "left", - }, + overflowX: "auto", + whiteSpace: "nowrap", }, group: { display: "flex", From 7b35f3b3adeb5ced1e591e92474f63b15d6cc87d Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 9 Aug 2023 11:28:25 -0300 Subject: [PATCH 062/277] fix(site): add horizontal scroll when having many tabs (#8998) --- site/src/components/TemplateFiles/TemplateFiles.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/src/components/TemplateFiles/TemplateFiles.tsx b/site/src/components/TemplateFiles/TemplateFiles.tsx index 11bc49397f4bb..73aaad4b1f38b 100644 --- a/site/src/components/TemplateFiles/TemplateFiles.tsx +++ b/site/src/components/TemplateFiles/TemplateFiles.tsx @@ -87,6 +87,7 @@ const useStyles = makeStyles((theme) => ({ alignItems: "baseline", borderBottom: `1px solid ${theme.palette.divider}`, gap: 1, + overflowX: "auto", }, tab: { @@ -101,6 +102,7 @@ const useStyles = makeStyles((theme) => ({ gap: theme.spacing(0.5), position: "relative", color: theme.palette.text.secondary, + whiteSpace: "nowrap", "& svg": { width: 22, From a9e01bf3f1c43c2ed373e47c1c32162a38a5ad46 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Wed, 9 Aug 2023 13:11:03 -0500 Subject: [PATCH 063/277] chore: fix terraform tests (#9006) --- .github/actions/setup-tf/action.yaml | 2 +- provisioner/terraform/provision_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup-tf/action.yaml b/.github/actions/setup-tf/action.yaml index 16472c9fafd6e..63a539a3fd922 100644 --- a/.github/actions/setup-tf/action.yaml +++ b/.github/actions/setup-tf/action.yaml @@ -7,5 +7,5 @@ runs: - name: Install Terraform uses: hashicorp/setup-terraform@v2 with: - terraform_version: ~1.5 + terraform_version: 1.5.5 terraform_wrapper: false diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index c882807a8d434..28d88f568c123 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -351,7 +351,7 @@ func TestProvision(t *testing.T) { Files: map[string]string{ "main.tf": `a`, }, - ErrorContains: "plan terraform", + ErrorContains: "initialize terraform", ExpectLogContains: "Argument or block definition required", }, { @@ -359,7 +359,7 @@ func TestProvision(t *testing.T) { Files: map[string]string{ "main.tf": `;asdf;`, }, - ErrorContains: "plan terraform", + ErrorContains: "initialize terraform", ExpectLogContains: `The ";" character is not valid.`, }, { From 919f5c6fe94a5731898e677fd9c6d7f686ff81a8 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Wed, 9 Aug 2023 13:50:27 -0500 Subject: [PATCH 064/277] chore: increase e2e timeout to 60s (#9007) --- site/e2e/playwright.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index 0ff9a6fe74622..78eb4495ce371 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ use: { storageState: STORAGE_STATE, }, + timeout: 60000, }, ], use: { From f334b66178c4861a51fbbd4ae23c0a3ae6965e78 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 9 Aug 2023 13:56:13 -0500 Subject: [PATCH 065/277] chore: do not allow resetting password of non password users (#9003) --- coderd/users.go | 7 +++++++ site/src/components/UsersTable/UsersTable.stories.tsx | 8 ++++++++ site/src/components/UsersTable/UsersTableBody.tsx | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/coderd/users.go b/coderd/users.go index 017e20d408586..b34b447b8c456 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -733,6 +733,13 @@ func (api *API) putUserPassword(rw http.ResponseWriter, r *http.Request) { return } + if user.LoginType != database.LoginTypePassword { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Users without password login type cannot change their password.", + }) + return + } + err := userpassword.Validate(params.Password) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ diff --git a/site/src/components/UsersTable/UsersTable.stories.tsx b/site/src/components/UsersTable/UsersTable.stories.tsx index ce019b4d3e72b..536c39253fa44 100644 --- a/site/src/components/UsersTable/UsersTable.stories.tsx +++ b/site/src/components/UsersTable/UsersTable.stories.tsx @@ -42,6 +42,14 @@ Editable.args = { roles: [], status: "suspended", }, + { + ...MockUser, + username: "OIDC User", + email: "oidc.user@coder.com", + roles: [], + status: "active", + login_type: "oidc", + }, ], roles: MockAssignableSiteRoles, canEditUsers: true, diff --git a/site/src/components/UsersTable/UsersTableBody.tsx b/site/src/components/UsersTable/UsersTableBody.tsx index ad25de524f3a8..82e154a3d0d0b 100644 --- a/site/src/components/UsersTable/UsersTableBody.tsx +++ b/site/src/components/UsersTable/UsersTableBody.tsx @@ -205,7 +205,7 @@ export const UsersTableBody: FC< { label: t("resetPasswordMenuItem"), onClick: onResetUserPassword, - disabled: false, + disabled: user.login_type !== "password", }, { label: t("listWorkspacesMenuItem"), From 612f1c6a5544eef429b975d79c62d7cdc33d2f09 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Wed, 9 Aug 2023 14:03:02 -0500 Subject: [PATCH 066/277] chore: use echo provisioners in logging tests (#9008) --- cli/server_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cli/server_test.go b/cli/server_test.go index ee00499c4d2a6..ad971398c8e02 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -1309,6 +1309,7 @@ func TestServer(t *testing.T) { "--in-memory", "--http-address", ":0", "--access-url", "http://example.com", + "--provisioner-daemons-echo", "--log-human", fiName, ) clitest.Start(t, root) @@ -1326,6 +1327,7 @@ func TestServer(t *testing.T) { "--in-memory", "--http-address", ":0", "--access-url", "http://example.com", + "--provisioner-daemons-echo", "--log-human", fi, ) clitest.Start(t, root) @@ -1343,6 +1345,7 @@ func TestServer(t *testing.T) { "--in-memory", "--http-address", ":0", "--access-url", "http://example.com", + "--provisioner-daemons-echo", "--log-json", fi, ) clitest.Start(t, root) @@ -1363,6 +1366,7 @@ func TestServer(t *testing.T) { "--in-memory", "--http-address", ":0", "--access-url", "http://example.com", + "--provisioner-daemons-echo", "--log-stackdriver", fi, ) // Attach pty so we get debug output from the command if this test @@ -1397,6 +1401,7 @@ func TestServer(t *testing.T) { "--in-memory", "--http-address", ":0", "--access-url", "http://example.com", + "--provisioner-daemons-echo", "--log-human", fi1, "--log-json", fi2, "--log-stackdriver", fi3, From 53f26b313dc0f75e73e1ccea70f7c8052af6408c Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Wed, 9 Aug 2023 21:22:46 +0200 Subject: [PATCH 067/277] fix(scripts): check if PR list is empty (#8805) Co-authored-by: Mathias Fredriksson --- scripts/release/check_commit_metadata.sh | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/scripts/release/check_commit_metadata.sh b/scripts/release/check_commit_metadata.sh index 7ad78992d0e0c..02a39525365d4 100755 --- a/scripts/release/check_commit_metadata.sh +++ b/scripts/release/check_commit_metadata.sh @@ -82,16 +82,20 @@ main() { --json mergeCommit,labels,author \ --jq '.[] | "\( .mergeCommit.oid ) author:\( .author.login ) labels:\(["label:\( .labels[].name )"] | join(" "))"' )" - mapfile -t pr_metadata_raw <<<"$pr_list_out" + declare -A authors labels - for entry in "${pr_metadata_raw[@]}"; do - commit_sha_long=${entry%% *} - commit_author=${entry#* author:} - commit_author=${commit_author%% *} - authors[$commit_sha_long]=$commit_author - all_labels=${entry#* labels:} - labels[$commit_sha_long]=$all_labels - done + if [[ -n $pr_list_out ]]; then + mapfile -t pr_metadata_raw <<<"$pr_list_out" + + for entry in "${pr_metadata_raw[@]}"; do + commit_sha_long=${entry%% *} + commit_author=${entry#* author:} + commit_author=${commit_author%% *} + authors[$commit_sha_long]=$commit_author + all_labels=${entry#* labels:} + labels[$commit_sha_long]=$all_labels + done + fi for commit in "${commits[@]}"; do mapfile -d ' ' -t parts <<<"$commit" From 5b9dc2ee8b19bcc140e83b712265d46ca4b3d195 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 9 Aug 2023 16:31:12 -0300 Subject: [PATCH 068/277] fix(site): add search params to auth redirect (#9005) --- site/src/components/RequireAuth/RequireAuth.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/components/RequireAuth/RequireAuth.tsx b/site/src/components/RequireAuth/RequireAuth.tsx index dd7bcddf25662..c4031603774d0 100644 --- a/site/src/components/RequireAuth/RequireAuth.tsx +++ b/site/src/components/RequireAuth/RequireAuth.tsx @@ -10,7 +10,9 @@ export const RequireAuth: FC = () => { const [authState] = useAuth() const location = useLocation() const isHomePage = location.pathname === "/" - const navigateTo = isHomePage ? "/login" : embedRedirect(location.pathname) + const navigateTo = isHomePage + ? "/login" + : embedRedirect(`${location.pathname}${location.search}`) if (authState.matches("signedOut")) { return From bc862fa493b81d9d21cfc0992f84f413e18e26cc Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Wed, 9 Aug 2023 14:50:26 -0500 Subject: [PATCH 069/277] chore: upgrade tailscale to v1.46.1 (#8913) --- cli/clibase/cmd.go | 10 +- cli/configssh.go | 5 +- cli/create.go | 5 +- coderd/apidoc/docs.go | 26 ++- coderd/apidoc/swagger.json | 26 ++- coderd/database/dbfake/dbfake.go | 68 +++---- coderd/devtunnel/servers.go | 5 +- coderd/healthcheck/derp.go | 2 +- coderd/insights.go | 5 +- coderd/metricscache/metricscache.go | 10 +- coderd/prometheusmetrics/collector_test.go | 6 +- coderd/users_test.go | 7 +- coderd/util/slice/slice.go | 18 ++ coderd/util/slice/slice_test.go | 16 ++ coderd/workspaceagents.go | 5 +- docs/api/agents.md | 16 ++ docs/api/debug.md | 4 + docs/api/schemas.md | 71 +++++++- enterprise/tailnet/pgcoord.go | 17 +- go.mod | 88 +++++---- go.sum | 198 +++++++++++---------- scripts/ci-report/main.go | 7 +- scripts/metricsdocgen/main.go | 8 +- tailnet/conn.go | 120 ++++--------- tailnet/coordinator.go | 21 +-- 25 files changed, 467 insertions(+), 297 deletions(-) diff --git a/cli/clibase/cmd.go b/cli/clibase/cmd.go index 3e7dfe3903633..3e4328dbc05e4 100644 --- a/cli/clibase/cmd.go +++ b/cli/clibase/cmd.go @@ -14,6 +14,8 @@ import ( "golang.org/x/exp/slices" "golang.org/x/xerrors" "gopkg.in/yaml.v3" + + "github.com/coder/coder/coderd/util/slice" ) // Cmd describes an executable command. @@ -102,11 +104,11 @@ func (c *Cmd) PrepareAll() error { } } - slices.SortFunc(c.Options, func(a, b Option) bool { - return a.Name < b.Name + slices.SortFunc(c.Options, func(a, b Option) int { + return slice.Ascending(a.Name, b.Name) }) - slices.SortFunc(c.Children, func(a, b *Cmd) bool { - return a.Name() < b.Name() + slices.SortFunc(c.Children, func(a, b *Cmd) int { + return slice.Ascending(a.Name(), b.Name()) }) for _, child := range c.Children { child.Parent = c diff --git a/cli/configssh.go b/cli/configssh.go index 162c3c2a95855..897742fd5a7bb 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" ) @@ -367,8 +368,8 @@ func (r *RootCmd) configSSH() *clibase.Cmd { } // Ensure stable sorting of output. - slices.SortFunc(workspaceConfigs, func(a, b sshWorkspaceConfig) bool { - return a.Name < b.Name + slices.SortFunc(workspaceConfigs, func(a, b sshWorkspaceConfig) int { + return slice.Ascending(a.Name, b.Name) }) for _, wc := range workspaceConfigs { sort.Strings(wc.Hosts) diff --git a/cli/create.go b/cli/create.go index 7a8c4ec417fac..9ed55af0b853c 100644 --- a/cli/create.go +++ b/cli/create.go @@ -13,6 +13,7 @@ import ( "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" ) @@ -81,8 +82,8 @@ func (r *RootCmd) create() *clibase.Cmd { return err } - slices.SortFunc(templates, func(a, b codersdk.Template) bool { - return a.ActiveUserCount > b.ActiveUserCount + slices.SortFunc(templates, func(a, b codersdk.Template) int { + return slice.Descending(a.ActiveUserCount, b.ActiveUserCount) }) templateNames := make([]string, 0, len(templates)) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e83cf37d3206c..6d590b02d4904 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11543,11 +11543,31 @@ const docTemplate = `{ } } }, + "tailcfg.DERPHomeParams": { + "type": "object", + "properties": { + "regionScore": { + "description": "RegionScore scales latencies of DERP regions by a given scaling\nfactor when determining which region to use as the home\n(\"preferred\") DERP. Scores in the range (0, 1) will cause this\nregion to be proportionally more preferred, and scores in the range\n(1, ∞) will penalize a region.\n\nIf a region is not present in this map, it is treated as having a\nscore of 1.0.\n\nScores should not be 0 or negative; such scores will be ignored.\n\nA nil map means no change from the previous value (if any); an empty\nnon-nil map can be sent to reset all scores back to 1.0.", + "type": "object", + "additionalProperties": { + "type": "number" + } + } + } + }, "tailcfg.DERPMap": { "type": "object", "properties": { + "homeParams": { + "description": "HomeParams, if non-nil, is a change in home parameters.\n\nThe rest of the DEPRMap fields, if zero, means unchanged.", + "allOf": [ + { + "$ref": "#/definitions/tailcfg.DERPHomeParams" + } + ] + }, "omitDefaultRegions": { - "description": "OmitDefaultRegions specifies to not use Tailscale's DERP servers, and only use those\nspecified in this DERPMap. If there are none set outside of the defaults, this is a noop.", + "description": "OmitDefaultRegions specifies to not use Tailscale's DERP servers, and only use those\nspecified in this DERPMap. If there are none set outside of the defaults, this is a noop.\n\nThis field is only meaningful if the Regions map is non-nil (indicating a change).", "type": "boolean" }, "regions": { @@ -11562,6 +11582,10 @@ const docTemplate = `{ "tailcfg.DERPNode": { "type": "object", "properties": { + "canPort80": { + "description": "CanPort80 specifies whether this DERP node is accessible over HTTP\non port 80 specifically. This is used for captive portal checks.", + "type": "boolean" + }, "certName": { "description": "CertName optionally specifies the expected TLS cert common\nname. If empty, HostName is used. If CertName is non-empty,\nHostName is only used for the TCP dial (if IPv4/IPv6 are\nnot present) + TLS ClientHello.", "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0206c57062720..09aef2e0872b4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10521,11 +10521,31 @@ } } }, + "tailcfg.DERPHomeParams": { + "type": "object", + "properties": { + "regionScore": { + "description": "RegionScore scales latencies of DERP regions by a given scaling\nfactor when determining which region to use as the home\n(\"preferred\") DERP. Scores in the range (0, 1) will cause this\nregion to be proportionally more preferred, and scores in the range\n(1, ∞) will penalize a region.\n\nIf a region is not present in this map, it is treated as having a\nscore of 1.0.\n\nScores should not be 0 or negative; such scores will be ignored.\n\nA nil map means no change from the previous value (if any); an empty\nnon-nil map can be sent to reset all scores back to 1.0.", + "type": "object", + "additionalProperties": { + "type": "number" + } + } + } + }, "tailcfg.DERPMap": { "type": "object", "properties": { + "homeParams": { + "description": "HomeParams, if non-nil, is a change in home parameters.\n\nThe rest of the DEPRMap fields, if zero, means unchanged.", + "allOf": [ + { + "$ref": "#/definitions/tailcfg.DERPHomeParams" + } + ] + }, "omitDefaultRegions": { - "description": "OmitDefaultRegions specifies to not use Tailscale's DERP servers, and only use those\nspecified in this DERPMap. If there are none set outside of the defaults, this is a noop.", + "description": "OmitDefaultRegions specifies to not use Tailscale's DERP servers, and only use those\nspecified in this DERPMap. If there are none set outside of the defaults, this is a noop.\n\nThis field is only meaningful if the Regions map is non-nil (indicating a change).", "type": "boolean" }, "regions": { @@ -10540,6 +10560,10 @@ "tailcfg.DERPNode": { "type": "object", "properties": { + "canPort80": { + "description": "CanPort80 specifies whether this DERP node is accessible over HTTP\non port 80 specifically. This is used for captive portal checks.", + "type": "boolean" + }, "certName": { "description": "CertName optionally specifies the expected TLS cert common\nname. If empty, HostName is used. If CertName is non-empty,\nHostName is only used for the TCP dial (if IPv4/IPv6 are\nnot present) + TLS ClientHello.", "type": "string" diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 156fe957d69a8..b9502c92c8f20 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -605,8 +605,8 @@ func uniqueSortedUUIDs(uuids []uuid.UUID) []uuid.UUID { for id := range set { unique = append(unique, id) } - slices.SortFunc(unique, func(a, b uuid.UUID) bool { - return a.String() < b.String() + slices.SortFunc(unique, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) }) return unique } @@ -2060,8 +2060,8 @@ func (q *FakeQuerier) GetTemplateDailyInsights(_ context.Context, arg database.G for templateID := range ds.templateIDSet { templateIDs = append(templateIDs, templateID) } - slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool { - return a.String() < b.String() + slices.SortFunc(templateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) }) result = append(result, database.GetTemplateDailyInsightsRow{ StartTime: ds.startTime, @@ -2119,8 +2119,8 @@ func (q *FakeQuerier) GetTemplateInsights(_ context.Context, arg database.GetTem for templateID := range templateIDSet { templateIDs = append(templateIDs, templateID) } - slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool { - return a.String() < b.String() + slices.SortFunc(templateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) }) result := database.GetTemplateInsightsRow{ TemplateIDs: templateIDs, @@ -2343,13 +2343,16 @@ func (q *FakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg dat } // Database orders by created_at - slices.SortFunc(version, func(a, b database.TemplateVersion) bool { + slices.SortFunc(version, func(a, b database.TemplateVersion) int { if a.CreatedAt.Equal(b.CreatedAt) { // Technically the postgres database also orders by uuid. So match // that behavior - return a.ID.String() < b.ID.String() + return slice.Ascending(a.ID.String(), b.ID.String()) } - return a.CreatedAt.Before(b.CreatedAt) + if a.CreatedAt.Before(b.CreatedAt) { + return -1 + } + return 1 }) if arg.AfterID != uuid.Nil { @@ -2408,11 +2411,11 @@ func (q *FakeQuerier) GetTemplates(_ context.Context) ([]database.Template, erro defer q.mutex.RUnlock() templates := slices.Clone(q.templates) - slices.SortFunc(templates, func(i, j database.TemplateTable) bool { - if i.Name != j.Name { - return i.Name < j.Name + slices.SortFunc(templates, func(a, b database.TemplateTable) int { + if a.Name != b.Name { + return slice.Ascending(a.Name, b.Name) } - return i.ID.String() < j.ID.String() + return slice.Ascending(a.ID.String(), b.ID.String()) }) return q.templatesWithUserNoLock(templates), nil @@ -2525,8 +2528,8 @@ func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.Get for templateID := range templateIDSet { templateIDs = append(templateIDs, templateID) } - slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool { - return a.String() < b.String() + slices.SortFunc(templateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) }) user, err := q.getUserByIDNoLock(userID) if err != nil { @@ -2542,8 +2545,8 @@ func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.Get } rows = append(rows, row) } - slices.SortFunc(rows, func(a, b database.GetUserLatencyInsightsRow) bool { - return a.UserID.String() < b.UserID.String() + slices.SortFunc(rows, func(a, b database.GetUserLatencyInsightsRow) int { + return slice.Ascending(a.UserID.String(), b.UserID.String()) }) return rows, nil @@ -2590,8 +2593,8 @@ func (q *FakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams copy(users, q.users) // Database orders by username - slices.SortFunc(users, func(a, b database.User) bool { - return strings.ToLower(a.Username) < strings.ToLower(b.Username) + slices.SortFunc(users, func(a, b database.User) int { + return slice.Ascending(strings.ToLower(a.Username), strings.ToLower(b.Username)) }) // Filter out deleted since they should never be returned.. @@ -2799,14 +2802,14 @@ func (q *FakeQuerier) GetWorkspaceAgentStats(_ context.Context, createdAfter tim agentStatsCreatedAfter := make([]database.WorkspaceAgentStat, 0) for _, agentStat := range q.workspaceAgentStats { - if agentStat.CreatedAt.After(createdAfter) { + if agentStat.CreatedAt.After(createdAfter) || agentStat.CreatedAt.Equal(createdAfter) { agentStatsCreatedAfter = append(agentStatsCreatedAfter, agentStat) } } latestAgentStats := map[uuid.UUID]database.WorkspaceAgentStat{} for _, agentStat := range q.workspaceAgentStats { - if agentStat.CreatedAt.After(createdAfter) { + if agentStat.CreatedAt.After(createdAfter) || agentStat.CreatedAt.Equal(createdAfter) { latestAgentStats[agentStat.AgentID] = agentStat } } @@ -3132,9 +3135,8 @@ func (q *FakeQuerier) GetWorkspaceBuildsByWorkspaceID(_ context.Context, } // Order by build_number - slices.SortFunc(history, func(a, b database.WorkspaceBuild) bool { - // use greater than since we want descending order - return a.BuildNumber > b.BuildNumber + slices.SortFunc(history, func(a, b database.WorkspaceBuild) int { + return slice.Descending(a.BuildNumber, b.BuildNumber) }) if params.AfterID != uuid.Nil { @@ -3533,8 +3535,14 @@ func (q *FakeQuerier) InsertAuditLog(_ context.Context, arg database.InsertAudit alog := database.AuditLog(arg) q.auditLogs = append(q.auditLogs, alog) - slices.SortFunc(q.auditLogs, func(a, b database.AuditLog) bool { - return a.Time.Before(b.Time) + slices.SortFunc(q.auditLogs, func(a, b database.AuditLog) int { + if a.Time.Before(b.Time) { + return -1 + } else if a.Time.Equal(b.Time) { + return 0 + } else { + return 1 + } }) return alog, nil @@ -5588,11 +5596,11 @@ func (q *FakeQuerier) GetAuthorizedTemplates(ctx context.Context, arg database.G templates = append(templates, template) } if len(templates) > 0 { - slices.SortFunc(templates, func(i, j database.Template) bool { - if i.Name != j.Name { - return i.Name < j.Name + slices.SortFunc(templates, func(a, b database.Template) int { + if a.Name != b.Name { + return slice.Ascending(a.Name, b.Name) } - return i.ID.String() < j.ID.String() + return slice.Ascending(a.ID.String(), b.ID.String()) }) return templates, nil } diff --git a/coderd/devtunnel/servers.go b/coderd/devtunnel/servers.go index 1ac1b6ce26a7c..e2b92d4982eb8 100644 --- a/coderd/devtunnel/servers.go +++ b/coderd/devtunnel/servers.go @@ -10,6 +10,7 @@ import ( "golang.org/x/sync/errgroup" "golang.org/x/xerrors" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/cryptorand" ) @@ -115,8 +116,8 @@ func FindClosestNode(nodes []Node) (Node, error) { return Node{}, err } - slices.SortFunc(nodes, func(i, j Node) bool { - return i.AvgLatency < j.AvgLatency + slices.SortFunc(nodes, func(a, b Node) int { + return slice.Ascending(a.AvgLatency, b.AvgLatency) }) return nodes[0], nil } diff --git a/coderd/healthcheck/derp.go b/coderd/healthcheck/derp.go index 9fc88a37e40db..fd718b55031fa 100644 --- a/coderd/healthcheck/derp.go +++ b/coderd/healthcheck/derp.go @@ -118,7 +118,7 @@ func (r *DERPReport) Run(ctx context.Context, opts *DERPReportOptions) { mu.Unlock() } nc := &netcheck.Client{ - PortMapper: portmapper.NewClient(tslogger.WithPrefix(ncLogf, "portmap: "), nil), + PortMapper: portmapper.NewClient(tslogger.WithPrefix(ncLogf, "portmap: "), nil, nil, nil), Logf: tslogger.WithPrefix(ncLogf, "netcheck: "), } ncReport, netcheckErr := nc.GetReport(ctx, opts.DERPMap) diff --git a/coderd/insights.go b/coderd/insights.go index 509e78a18e234..b452b963277ed 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -14,6 +14,7 @@ import ( "github.com/coder/coder/coderd/database/db2sdk" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" ) @@ -131,8 +132,8 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { for templateID := range templateIDSet { seenTemplateIDs = append(seenTemplateIDs, templateID) } - slices.SortFunc(seenTemplateIDs, func(a, b uuid.UUID) bool { - return a.String() < b.String() + slices.SortFunc(seenTemplateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) }) resp := codersdk.UserLatencyInsightsResponse{ diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index d25b716f2df15..c99c72702a3d6 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -146,8 +146,14 @@ func convertDAUResponse[T dauRow](rows []T, tzOffset int) codersdk.DAUsResponse } dates := maps.Keys(respMap) - slices.SortFunc(dates, func(a, b time.Time) bool { - return a.Before(b) + slices.SortFunc(dates, func(a, b time.Time) int { + if a.Before(b) { + return -1 + } else if a.Equal(b) { + return 0 + } else { + return 1 + } }) var resp codersdk.DAUsResponse diff --git a/coderd/prometheusmetrics/collector_test.go b/coderd/prometheusmetrics/collector_test.go index 9d63f6669113d..df50182e61618 100644 --- a/coderd/prometheusmetrics/collector_test.go +++ b/coderd/prometheusmetrics/collector_test.go @@ -115,11 +115,11 @@ func TestCollector_Set_Add(t *testing.T) { assert.Equal(t, 6, int(metrics[1].Gauge.GetValue())) // Metric value } -func collectAndSortMetrics(t *testing.T, collector prometheus.Collector, count int) []dto.Metric { +func collectAndSortMetrics(t *testing.T, collector prometheus.Collector, count int) []*dto.Metric { ch := make(chan prometheus.Metric, count) defer close(ch) - var metrics []dto.Metric + var metrics []*dto.Metric collector.Collect(ch) for i := 0; i < count; i++ { @@ -129,7 +129,7 @@ func collectAndSortMetrics(t *testing.T, collector prometheus.Collector, count i err := m.Write(&metric) require.NoError(t, err) - metrics = append(metrics, metric) + metrics = append(metrics, &metric) } // Ensure always the same order of metrics diff --git a/coderd/users_test.go b/coderd/users_test.go index eff3174ad83a2..9b130133cd58a 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "sort" "strings" "testing" "time" @@ -12,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" "golang.org/x/sync/errgroup" "github.com/coder/coder/cli/clibase" @@ -20,6 +20,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" "github.com/coder/coder/testutil" ) @@ -1804,8 +1805,8 @@ func assertPagination(ctx context.Context, t *testing.T, client *codersdk.Client // sortUsers sorts by (created_at, id) func sortUsers(users []codersdk.User) { - sort.Slice(users, func(i, j int) bool { - return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username) + slices.SortFunc(users, func(a, b codersdk.User) int { + return slice.Ascending(strings.ToLower(a.Username), strings.ToLower(b.Username)) }) } diff --git a/coderd/util/slice/slice.go b/coderd/util/slice/slice.go index 0bc5920ce3760..c366b04f91d8d 100644 --- a/coderd/util/slice/slice.go +++ b/coderd/util/slice/slice.go @@ -1,5 +1,9 @@ package slice +import ( + "golang.org/x/exp/constraints" +) + // SameElements returns true if the 2 lists have the same elements in any // order. func SameElements[T comparable](a []T, b []T) bool { @@ -69,3 +73,17 @@ func OverlapCompare[T any](a []T, b []T, equal func(a, b T) bool) bool { func New[T any](items ...T) []T { return items } + +func Ascending[T constraints.Ordered](a, b T) int { + if a < b { + return -1 + } else if a == b { + return 0 + } else { + return 1 + } +} + +func Descending[T constraints.Ordered](a, b T) int { + return -Ascending[T](a, b) +} diff --git a/coderd/util/slice/slice_test.go b/coderd/util/slice/slice_test.go index b21e0cc0b52a5..73f38e9a3f255 100644 --- a/coderd/util/slice/slice_test.go +++ b/coderd/util/slice/slice_test.go @@ -107,3 +107,19 @@ func assertSetContains[T comparable](t *testing.T, set []T, in []T, out []T) { require.False(t, slice.Contains(set, e), "expect element in set") } } + +func TestAscending(t *testing.T) { + t.Parallel() + + assert.Equal(t, -1, slice.Ascending(1, 2)) + assert.Equal(t, 0, slice.Ascending(1, 1)) + assert.Equal(t, 1, slice.Ascending(2, 1)) +} + +func TestDescending(t *testing.T) { + t.Parallel() + + assert.Equal(t, 1, slice.Descending(1, 2)) + assert.Equal(t, 0, slice.Descending(1, 1)) + assert.Equal(t, -1, slice.Descending(2, 1)) +} diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 3fa5baf231997..53808d75d4241 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -39,6 +39,7 @@ import ( "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" "github.com/coder/coder/tailnet" @@ -1616,8 +1617,8 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ }) return } - slices.SortFunc(lastDBMeta, func(i, j database.WorkspaceAgentMetadatum) bool { - return i.Key < j.Key + slices.SortFunc(lastDBMeta, func(a, b database.WorkspaceAgentMetadatum) int { + return slice.Ascending(a.Key, b.Key) }) // Avoid sending refresh if the client is about to get a diff --git a/docs/api/agents.md b/docs/api/agents.md index 8bf6f10619e50..73b4abf8ec84b 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -393,6 +393,12 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/manifest \ } ], "derpmap": { + "homeParams": { + "regionScore": { + "property1": 0, + "property2": 0 + } + }, "omitDefaultRegions": true, "regions": { "property1": { @@ -400,6 +406,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/manifest \ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -423,6 +430,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/manifest \ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -736,6 +744,12 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con ```json { "derp_map": { + "homeParams": { + "regionScore": { + "property1": 0, + "property2": 0 + } + }, "omitDefaultRegions": true, "regions": { "property1": { @@ -743,6 +757,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -766,6 +781,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, diff --git a/docs/api/debug.md b/docs/api/debug.md index e3382c6586504..5016f6a87b256 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -102,6 +102,7 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -134,6 +135,7 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -164,6 +166,7 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -196,6 +199,7 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 048eff66adecb..cfcf28701ac1f 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -202,6 +202,12 @@ } ], "derpmap": { + "homeParams": { + "regionScore": { + "property1": 0, + "property2": 0 + } + }, "omitDefaultRegions": true, "regions": { "property1": { @@ -209,6 +215,7 @@ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -232,6 +239,7 @@ "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -5597,6 +5605,12 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { "derp_map": { + "homeParams": { + "regionScore": { + "property1": 0, + "property2": 0 + } + }, "omitDefaultRegions": true, "regions": { "property1": { @@ -5604,6 +5618,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -5627,6 +5642,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6637,6 +6653,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6695,6 +6712,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6727,6 +6745,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6807,6 +6826,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6839,6 +6859,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6869,6 +6890,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -6901,6 +6923,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7043,6 +7066,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7075,6 +7099,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7105,6 +7130,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "error": "string", "healthy": true, "node": { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7137,6 +7163,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7281,10 +7308,38 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `time` | string | false | | | | `valid` | boolean | false | | Valid is true if Time is not NULL | +## tailcfg.DERPHomeParams + +```json +{ + "regionScore": { + "property1": 0, + "property2": 0 + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------- | ------ | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `regionScore` | object | false | | Regionscore scales latencies of DERP regions by a given scaling factor when determining which region to use as the home ("preferred") DERP. Scores in the range (0, 1) will cause this region to be proportionally more preferred, and scores in the range (1, ∞) will penalize a region. | + +If a region is not present in this map, it is treated as having a score of 1.0. +Scores should not be 0 or negative; such scores will be ignored. +A nil map means no change from the previous value (if any); an empty non-nil map can be sent to reset all scores back to 1.0.| +|» `[any property]`|number|false||| + ## tailcfg.DERPMap ```json { + "homeParams": { + "regionScore": { + "property1": 0, + "property2": 0 + } + }, "omitDefaultRegions": true, "regions": { "property1": { @@ -7292,6 +7347,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7315,6 +7371,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7339,10 +7396,13 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------------- | ------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `omitDefaultRegions` | boolean | false | | Omitdefaultregions specifies to not use Tailscale's DERP servers, and only use those specified in this DERPMap. If there are none set outside of the defaults, this is a noop. | -| `regions` | object | false | | Regions is the set of geographic regions running DERP node(s). | +| Name | Type | Required | Restrictions | Description | +| ---------------------------------------------------------------------------------- | ------------------------------------------------ | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `homeParams` | [tailcfg.DERPHomeParams](#tailcfgderphomeparams) | false | | Homeparams if non-nil, is a change in home parameters. | +| The rest of the DEPRMap fields, if zero, means unchanged. | +| `omitDefaultRegions` | boolean | false | | Omitdefaultregions specifies to not use Tailscale's DERP servers, and only use those specified in this DERPMap. If there are none set outside of the defaults, this is a noop. | +| This field is only meaningful if the Regions map is non-nil (indicating a change). | +| `regions` | object | false | | Regions is the set of geographic regions running DERP node(s). | It's keyed by the DERPRegion.RegionID. The numbers are not necessarily contiguous.| @@ -7352,6 +7412,7 @@ The numbers are not necessarily contiguous.| ```json { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, @@ -7371,6 +7432,7 @@ The numbers are not necessarily contiguous.| | Name | Type | Required | Restrictions | Description | | --------------------------------------------------------------------------------------------------------------------- | ------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `canPort80` | boolean | false | | Canport80 specifies whether this DERP node is accessible over HTTP on port 80 specifically. This is used for captive portal checks. | | `certName` | string | false | | Certname optionally specifies the expected TLS cert common name. If empty, HostName is used. If CertName is non-empty, HostName is only used for the TCP dial (if IPv4/IPv6 are not present) + TLS ClientHello. | | `derpport` | integer | false | | Derpport optionally provides an alternate TLS port number for the DERP HTTPS server. | | If zero, 443 is used. | @@ -7394,6 +7456,7 @@ The numbers are not necessarily contiguous.| "embeddedRelay": true, "nodes": [ { + "canPort80": true, "certName": "string", "derpport": 0, "forceHTTP": true, diff --git a/enterprise/tailnet/pgcoord.go b/enterprise/tailnet/pgcoord.go index 8693d8e9a5bdd..03593a238201e 100644 --- a/enterprise/tailnet/pgcoord.go +++ b/enterprise/tailnet/pgcoord.go @@ -22,6 +22,7 @@ import ( "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/database/pubsub" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/util/slice" agpl "github.com/coder/coder/tailnet" ) @@ -1351,8 +1352,8 @@ func (c *pgCoord) htmlDebug(ctx context.Context) (agpl.HTMLDebug, error) { Node: conn.Node, }) } - slices.SortFunc(htmlAgent.Connections, func(a, b *agpl.HTMLClient) bool { - return a.Name < b.Name + slices.SortFunc(htmlAgent.Connections, func(a, b *agpl.HTMLClient) int { + return slice.Ascending(a.Name, b.Name) }) data.Agents = append(data.Agents, htmlAgent) @@ -1362,8 +1363,8 @@ func (c *pgCoord) htmlDebug(ctx context.Context) (agpl.HTMLDebug, error) { Node: agent.Node, }) } - slices.SortFunc(data.Agents, func(a, b *agpl.HTMLAgent) bool { - return a.Name < b.Name + slices.SortFunc(data.Agents, func(a, b *agpl.HTMLAgent) int { + return slice.Ascending(a.Name, b.Name) }) for agentID, conns := range clients { @@ -1389,14 +1390,14 @@ func (c *pgCoord) htmlDebug(ctx context.Context) (agpl.HTMLDebug, error) { Node: conn.Node, }) } - slices.SortFunc(agent.Connections, func(a, b *agpl.HTMLClient) bool { - return a.Name < b.Name + slices.SortFunc(agent.Connections, func(a, b *agpl.HTMLClient) int { + return slice.Ascending(a.Name, b.Name) }) data.MissingAgents = append(data.MissingAgents, agent) } - slices.SortFunc(data.MissingAgents, func(a, b *agpl.HTMLAgent) bool { - return a.Name < b.Name + slices.SortFunc(data.MissingAgents, func(a, b *agpl.HTMLAgent) int { + return slice.Ascending(a.Name, b.Name) }) return data, nil diff --git a/go.mod b/go.mod index 2bb28a73b9d28..4ac487adf127c 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,14 @@ replace github.com/dlclark/regexp2 => github.com/dlclark/regexp2 v1.7.0 // There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here: // https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main -replace tailscale.com => github.com/coder/tailscale v0.0.0-20230731105344-d1b7f8087191 +replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20230809183309-7a9c6c71e16c + +// This is replaced to include a fix that causes a deadlock when closing the +// wireguard network. +// The branch used is from https://github.com/coder/wireguard-go/tree/colin/tailscale +// It is based on https://github.com/tailscale/wireguard-go/tree/tailscale, but +// includes the upstream fix https://github.com/WireGuard/wireguard-go/commit/b7cd547315bed421a648d0a0f1ee5a0fc1b1151e +replace github.com/tailscale/wireguard-go => github.com/coder/wireguard-go v0.0.0-20230807234434-d825b45ccbf5 // Use our tempfork of gvisor that includes a fix for TCP connection stalls: // https://github.com/coder/coder/issues/7388 @@ -117,6 +124,7 @@ require ( github.com/golang/mock v1.6.0 github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405 github.com/google/uuid v1.3.0 + github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-reap v0.0.0-20170704170343-bf58d8a43e7b github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/golang-lru/v2 v2.0.1 @@ -130,7 +138,7 @@ require ( github.com/jmoiron/sqlx v1.3.5 github.com/justinas/nosurf v1.1.1 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f - github.com/klauspost/compress v1.16.3 + github.com/klauspost/compress v1.16.5 github.com/lib/pq v1.10.6 github.com/mattn/go-isatty v0.0.19 github.com/mitchellh/go-wordwrap v1.0.1 @@ -144,7 +152,7 @@ require ( github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e github.com/pkg/sftp v1.13.6-0.20221018182125-7da137aa03f0 github.com/prometheus/client_golang v1.16.0 - github.com/prometheus/client_model v0.3.0 + github.com/prometheus/client_model v0.4.0 github.com/prometheus/common v0.42.0 github.com/quasilyte/go-ruleguard/dsl v0.3.21 github.com/robfig/cron/v3 v3.0.1 @@ -168,15 +176,16 @@ require ( go.opentelemetry.io/otel/trace v1.16.0 go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.2.1 - go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf + go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 golang.org/x/crypto v0.12.0 - golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 + golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b golang.org/x/mod v0.12.0 golang.org/x/net v0.14.0 golang.org/x/oauth2 v0.11.0 golang.org/x/sync v0.3.0 golang.org/x/sys v0.11.0 golang.org/x/term v0.11.0 + golang.org/x/text v0.12.0 golang.org/x/tools v0.11.0 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b @@ -185,23 +194,23 @@ require ( google.golang.org/protobuf v1.31.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 - gvisor.dev/gvisor v0.0.0-20221203005347-703fd9b7fbc0 + gvisor.dev/gvisor v0.0.0-20230504175454-7b0a1988a28f nhooyr.io/websocket v1.8.7 storj.io/drpc v0.0.33-0.20230420154621-9716137f6037 - tailscale.com v1.32.3 + tailscale.com v1.46.1 ) require ( cloud.google.com/go/compute v1.23.0 // indirect cloud.google.com/go/logging v1.7.0 // indirect cloud.google.com/go/longrunning v0.5.1 // indirect - filippo.io/edwards25519 v1.0.0-rc.1 // indirect + filippo.io/edwards25519 v1.0.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect - github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/agnivade/levenshtein v1.1.1 // indirect github.com/akutz/memconn v0.1.0 // indirect @@ -210,6 +219,19 @@ require ( github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.18.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.18.22 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27 // indirect + github.com/aws/aws-sdk-go-v2/service/ssm v1.36.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.12.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.18.10 // indirect + github.com/aws/smithy-go v1.13.5 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -225,8 +247,8 @@ require ( github.com/containerd/continuity v0.4.1 // indirect github.com/coreos/go-iptables v0.6.0 // indirect github.com/dlclark/regexp2 v1.10.0 // indirect - github.com/docker/cli v20.10.17+incompatible // indirect - github.com/docker/docker v23.0.3+incompatible // indirect + github.com/docker/cli v23.0.5+incompatible // indirect + github.com/docker/docker v23.0.5+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/elastic/go-windows v1.0.0 // indirect @@ -234,12 +256,13 @@ require ( github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/gin-gonic/gin v1.9.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/spec v0.20.6 // indirect - github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-openapi/swag v0.22.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-test/deep v1.0.8 // indirect @@ -255,17 +278,18 @@ require ( github.com/google/flatbuffers v23.1.21+incompatible // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c // indirect github.com/google/s2a-go v0.1.4 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect github.com/gorilla/css v1.0.0 // indirect + github.com/gorilla/mux v1.8.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.15.1 // indirect github.com/h2non/filetype v1.1.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 // indirect github.com/hashicorp/go-hclog v1.2.1 // indirect - github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.17.0 // indirect @@ -273,14 +297,15 @@ require ( github.com/hashicorp/terraform-plugin-go v0.12.0 // indirect github.com/hashicorp/terraform-plugin-log v0.7.0 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.20.0 // indirect - github.com/hdevalence/ed25519consensus v0.0.0-20220222234857-c00d1f31bab3 // indirect + github.com/hdevalence/ed25519consensus v0.1.0 // indirect github.com/illarion/gonotify v1.0.1 // indirect - github.com/imdario/mergo v0.3.13 // indirect - github.com/insomniacslk/dhcp v0.0.0-20221215072855-de60144f33f8 // indirect + github.com/imdario/mergo v0.3.15 // indirect + github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect - github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b // indirect + github.com/jsimonetti/rtnetlink v1.3.2 // indirect github.com/juju/errors v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect @@ -292,13 +317,13 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect - github.com/mdlayher/genetlink v1.2.0 // indirect - github.com/mdlayher/netlink v1.6.2 // indirect + github.com/mdlayher/genetlink v1.3.2 // indirect + github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/sdnotify v1.0.0 // indirect - github.com/mdlayher/socket v0.2.3 // indirect + github.com/mdlayher/socket v0.4.1 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/microcosm-cc/bluemonday v1.0.23 // indirect - github.com/miekg/dns v1.1.45 // indirect + github.com/miekg/dns v1.1.55 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect @@ -314,6 +339,7 @@ require ( github.com/opencontainers/image-spec v1.1.0-rc4 // indirect github.com/opencontainers/runc v1.1.5 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pierrec/lz4/v4 v4.1.17 // indirect github.com/pion/transport v0.14.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -326,18 +352,18 @@ require ( github.com/swaggo/files/v2 v2.0.0 // indirect github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d // indirect - github.com/tailscale/golang-x-crypto v0.0.0-20221102133106-bc99ab8c2d17 // indirect + github.com/tailscale/golang-x-crypto v0.0.0-20230713185742-f0b76a10a08e // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect - github.com/tailscale/wireguard-go v0.0.0-20221219190806-4fa124729667 // indirect + github.com/tailscale/wireguard-go v0.0.0-20230710185534-bb2c8f22eccf // indirect github.com/tchap/go-patricia/v2 v2.3.1 // indirect github.com/tcnksm/go-httpstat v0.2.0 // indirect github.com/tdewolff/parse/v2 v2.6.6 // indirect github.com/tdewolff/test v1.0.9 // indirect - github.com/u-root/uio v0.0.0-20221213070652-c3537552635f // indirect + github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect github.com/ulikunitz/xz v0.5.11 // indirect github.com/vishvananda/netlink v1.2.1-beta.2 // indirect - github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect + github.com/vishvananda/netns v0.0.4 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v4 v4.3.12 // indirect github.com/vmihailenco/tagparser v0.1.1 // indirect @@ -355,8 +381,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.16.0 // indirect go.opentelemetry.io/otel/metric v1.16.0 // indirect go.opentelemetry.io/proto/otlp v0.19.0 // indirect - go4.org/mem v0.0.0-20210711025021-927187094b94 // indirect - golang.org/x/text v0.12.0 + go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect golang.org/x/time v0.3.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230215201556-9c5414ab4bde // indirect @@ -369,8 +394,3 @@ require ( howett.net/plist v1.0.0 // indirect inet.af/peercred v0.0.0-20210906144145-0893ea02156a // indirect ) - -require ( - github.com/go-ini/ini v1.67.0 // indirect - github.com/gorilla/mux v1.8.0 // indirect -) diff --git a/go.sum b/go.sum index 5e85b47943f66..3fc732489b5e3 100644 --- a/go.sum +++ b/go.sum @@ -46,15 +46,14 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU= -filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= -filippo.io/mkcert v1.4.3 h1:axpnmtrZMM8u5Hf4N3UXxboGemMOV+Tn+e+pkHM6E3o= +filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek= +filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= github.com/AlecAivazis/survey/v2 v2.3.5 h1:A8cYupsAZkjaUmhtTYv3sSqc7LO5mp1XDfqe5E/9wRQ= github.com/AlecAivazis/survey/v2 v2.3.5/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -68,8 +67,8 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= -github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756 h1:L6S7kR7SlhQKplIBpkra3s6yhcZV51lhRnXmYc4HohI= +github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= @@ -109,6 +108,32 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= +github.com/aws/aws-sdk-go-v2 v1.18.0 h1:882kkTpSFhdgYRKVZ/VCgf7sd0ru57p2JCxz4/oN5RY= +github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= +github.com/aws/aws-sdk-go-v2/config v1.18.22 h1:7vkUEmjjv+giht4wIROqLs+49VWmiQMMHSduxmoNKLU= +github.com/aws/aws-sdk-go-v2/config v1.18.22/go.mod h1:mN7Li1wxaPxSSy4Xkr6stFuinJGf3VZW3ZSNvO0q6sI= +github.com/aws/aws-sdk-go-v2/credentials v1.13.21 h1:VRiXnPEaaPeGeoFcXvMZOB5K/yfIXOYE3q97Kgb0zbU= +github.com/aws/aws-sdk-go-v2/credentials v1.13.21/go.mod h1:90Dk1lJoMyspa/EDUrldTxsPns0wn6+KpRKpdAWc0uA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3 h1:jJPgroehGvjrde3XufFIJUZVK5A2L9a3KwSFgKy9n8w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3/go.mod h1:4Q0UFP0YJf0NrsEuEYHpM9fTSEVnD16Z3uyEF7J9JGM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33 h1:kG5eQilShqmJbv11XL1VpyDbaEJzWxd4zRiCG30GSn4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33/go.mod h1:7i0PF1ME/2eUPFcjkVIwq+DOygHEoK92t5cDqNgYbIw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27 h1:vFQlirhuM8lLlpI7imKOMsjdQLuN9CPi+k44F/OFVsk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27/go.mod h1:UrHnn3QV/d0pBZ6QBAEQcqFLf8FAzLmoUfPVIueOvoM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34 h1:gGLG7yKaXG02/jBlg210R7VgQIotiQntNhsCFejawx8= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34/go.mod h1:Etz2dj6UHYuw+Xw830KfzCfWGMzqvUTCjUj5b76GVDc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27 h1:0iKliEXAcCa2qVtRs7Ot5hItA2MsufrphbRFlz1Owxo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27/go.mod h1:EOwBD4J4S5qYszS5/3DpkejfuK+Z5/1uzICfPaZLtqw= +github.com/aws/aws-sdk-go-v2/service/ssm v1.36.3 h1:TQZH0Djie8VVgTBDOQ02M4zVHJFrNzLMsYMbNfRitVM= +github.com/aws/aws-sdk-go-v2/service/ssm v1.36.3/go.mod h1:p6MaesK9061w6NTiFmZpUzEkKUY5blKlwD2zYyErxKA= +github.com/aws/aws-sdk-go-v2/service/sso v1.12.9 h1:GAiaQWuQhQQui76KjuXeShmyXqECwQ0mGRMc/rwsL+c= +github.com/aws/aws-sdk-go-v2/service/sso v1.12.9/go.mod h1:ouy2P4z6sJN70fR3ka3wD3Ro3KezSxU6eKGQI2+2fjI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.9 h1:TraLwncRJkWqtIBVKI/UqBymq4+hL+3MzUOtUATuzkA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.9/go.mod h1:AFvkxc8xfBe8XA+5St5XIHHrQQtkxqrRincx4hmMHOk= +github.com/aws/aws-sdk-go-v2/service/sts v1.18.10 h1:6UbNM/KJhMBfOI5+lpVcJ/8OA7cBSz0O6OX37SRKlSw= +github.com/aws/aws-sdk-go-v2/service/sts v1.18.10/go.mod h1:BgQOMsg8av8jset59jelyPW7NoZcZXLVpDsXunGDrk8= +github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= +github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -161,8 +186,7 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhD github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= -github.com/cilium/ebpf v0.8.1/go.mod h1:f5zLIM0FSNuAkSyLAN7X+Hy6yznlF1mNiWUMfxMtrgk= -github.com/cilium/ebpf v0.9.3 h1:5KtxXZU+scyERvkJMEm16TbScVvuuMrlhPly78ZMbSc= +github.com/cilium/ebpf v0.10.0 h1:nk5HPMeoBXtOzbkZBWym+ZWq1GIiHUsBFXxwewXAHLQ= github.com/clbanning/mxj/v2 v2.5.7 h1:7q5lvUpaPF/WOkqgIDiwjBJaznaLCCBd78pi8ZyAnE0= github.com/clbanning/mxj/v2 v2.5.7/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= @@ -196,12 +220,14 @@ github.com/coder/retry v1.4.0 h1:g0fojHFxcdgM3sBULqgjFDxw1UIvaCqk4ngUDu0EWag= github.com/coder/retry v1.4.0/go.mod h1:blHMk9vs6LkoRT9ZHyuZo360cufXEhrxqvEzeMtRGoY= github.com/coder/ssh v0.0.0-20230621095435-9a7e23486f1c h1:TI7TzdFI0UvQmwgyQhtI1HeyYNRxAQpr8Tw/rjT8VSA= github.com/coder/ssh v0.0.0-20230621095435-9a7e23486f1c/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ= -github.com/coder/tailscale v0.0.0-20230731105344-d1b7f8087191 h1:FweUPlasdC67APP0jS8LTUdVlWcl49cX4NqTkr0ZL/4= -github.com/coder/tailscale v0.0.0-20230731105344-d1b7f8087191/go.mod h1:jpg+77g19FpXL43U1VoIqoSg1K/Vh5CVxycGldQ8KhA= +github.com/coder/tailscale v1.1.1-0.20230809183309-7a9c6c71e16c h1:4NR1TCdxl6Dw2iQ37KDyvsLZgYYWvBSKCnsotofZrFg= +github.com/coder/tailscale v1.1.1-0.20230809183309-7a9c6c71e16c/go.mod h1:L8tPrwSi31RAMEMV8rjb0vYTGs7rXt8rAHbqY/p41j4= github.com/coder/terraform-provider-coder v0.11.1 h1:1sXcHfQrX8XhmLbtKxBED2lZ5jk3/ezBtaw6uVhpJZ4= github.com/coder/terraform-provider-coder v0.11.1/go.mod h1:UIfU3bYNeSzJJvHyJ30tEKjD6Z9utloI+HUM/7n94CY= github.com/coder/wgtunnel v0.1.5 h1:WP3sCj/3iJ34eKvpMQEp1oJHvm24RYh0NHbj1kfUKfs= github.com/coder/wgtunnel v0.1.5/go.mod h1:bokoUrHnUFY4lu9KOeSYiIcHTI2MO1KwqumU4DPDyJI= +github.com/coder/wireguard-go v0.0.0-20230807234434-d825b45ccbf5 h1:eDk/42Kj4xN4yfE504LsvcFEo3dWUiCOaBiWJ2uIH2A= +github.com/coder/wireguard-go v0.0.0-20230807234434-d825b45ccbf5/go.mod h1:QRIcq2+DbdIC5sKh/gcAZhuqu6WT6L6G8/ALPN5wqYw= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/continuity v0.4.1 h1:wQnVrjIyQ8vhU2sgOiL5T07jo+ouqc2bnKsv5/EqGhU= @@ -221,7 +247,7 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/dave/dst v0.27.2 h1:4Y5VFTkhGLC1oddtNwuxxe36pnyLxMFXT51FOzH8Ekc= github.com/dave/dst v0.27.2/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= -github.com/dave/jennifer v1.5.0 h1:HmgPN93bVDpkQyYbqhCHj5QlgvUkvEOzMyEvKLgCRrg= +github.com/dave/jennifer v1.6.1 h1:T4T/67t6RAA5AIV6+NP8Uk/BIsXgDoqEowgycdQQLuk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -232,11 +258,11 @@ github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8 github.com/dhui/dktest v0.3.16 h1:i6gq2YQEtcrjKbeJpBkWjE8MmLZPYllcjOFbTZuPDnw= github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v20.10.17+incompatible h1:eO2KS7ZFeov5UJeaDmIs1NFEDRf32PaqRpvoEkKBy5M= -github.com/docker/cli v20.10.17+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v23.0.5+incompatible h1:ufWmAOuD3Vmr7JP2G5K3cyuNC4YZWiAsuDEvFVVDafE= +github.com/docker/cli v23.0.5+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= -github.com/docker/docker v23.0.3+incompatible h1:9GhVsShNWz1hO//9BNg/dpMnZW25KydO4wtVxWAIbho= -github.com/docker/docker v23.0.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v23.0.5+incompatible h1:DaxtlTJjFSnLOXVNUBU1+6kXGz2lpDoEAH6QoxaSg8k= +github.com/docker/docker v23.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= @@ -255,7 +281,6 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= @@ -269,7 +294,6 @@ github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8 github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI= github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= -github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og= github.com/frankban/quicktest v1.14.2/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa h1:RDBNVkRviHZtvDvId8XSGPu3rmpmSe+wKRcEWNgsfWU= @@ -314,15 +338,18 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-ping/ping v1.1.0 h1:3MCGhVX4fyEUuhsfwPrsEdQw6xspHkv5zHsiSoDFZYw= github.com/go-ping/ping v1.1.0/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -423,6 +450,7 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v43 v43.0.1-0.20220414155304-00e42332e405 h1:DdHws/YnnPrSywrjNYu2lEHqYHWp/LnEx56w59esd54= @@ -433,6 +461,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c h1:06RMfw+TMMHtRuUOroMeatRCCgSMWXCJQeABvHU69YQ= +github.com/google/nftables v0.1.1-0.20230115205135-9aa6fdf5a28c/go.mod h1:BVIYo3cdnT4qSylnYqcd5YtmXhr51cJPGtnLBe/uLBU= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= @@ -458,7 +488,6 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= @@ -516,45 +545,42 @@ github.com/hashicorp/terraform-registry-address v0.0.0-20220623143253-7d51757b57 github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 h1:HKLsbzeOsfXmKNpr3GiT18XAblV0BjCbzL8KQAMZGa0= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= -github.com/hdevalence/ed25519consensus v0.0.0-20220222234857-c00d1f31bab3 h1:aSVUgRRRtOrZOC1fYmY9gV0e9z/Iu+xNVSASWjsuyGU= -github.com/hdevalence/ed25519consensus v0.0.0-20220222234857-c00d1f31bab3/go.mod h1:5PC6ZNPde8bBqU/ewGZig35+UIZtw9Ytxez8/q5ZyFE= +github.com/hdevalence/ed25519consensus v0.1.0 h1:jtBwzzcHuTmFrQN6xQZn6CQEO/V9f7HsjsjeEZ6auqU= +github.com/hdevalence/ed25519consensus v0.1.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= -github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= github.com/iancoleman/orderedmap v0.2.0 h1:sq1N/TFpYH++aViPcaKjys3bDClUEU7s5B+z6jq8pNA= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= -github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= -github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= -github.com/insomniacslk/dhcp v0.0.0-20221215072855-de60144f33f8 h1:Z72DOke2yOK0Ms4Z2LK1E1OrRJXOxSj5DllTz2FYTRg= -github.com/insomniacslk/dhcp v0.0.0-20221215072855-de60144f33f8/go.mod h1:m5WMe03WCvWcXjRnhvaAbAAXdCnu20J5P+mmH44ZzpE= +github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= +github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16 h1:+aAGyK41KRn8jbF2Q7PLL0Sxwg6dShGcQSeCC7nZQ8E= +github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16/go.mod h1:IKrnDWs3/Mqq5n0lI+RxA2sB7MvN/vbMBP3ehXg65UI= github.com/jedib0t/go-pretty/v6 v6.4.0 h1:YlI/2zYDrweA4MThiYMKtGRfT+2qZOO65ulej8GTcVI= github.com/jedib0t/go-pretty/v6 v6.4.0/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= -github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= -github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= -github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok= -github.com/jsimonetti/rtnetlink v0.0.0-20201110080708-d2c240429e6c/go.mod h1:huN4d1phzjhlOsNIjFsw2SVRbwIHj3fJDMEU2SDPTmg= -github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b h1:Yws7RV6kZr2O7PPdT+RkbSmmOponA8i/1DuGHe8BRsM= -github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b/go.mod h1:TzDCVOZKUa79z6iXbbXqhtAflVgUKaFkZ21M5tK5tzY= +github.com/jsimonetti/rtnetlink v1.3.2 h1:dcn0uWkfxycEEyNy0IGfx3GrhQ38LH7odjxAghimsVI= +github.com/jsimonetti/rtnetlink v1.3.2/go.mod h1:BBu4jZCpTjP6Gk0/wfrO8qcqymnN3g0hoFqObRmUo6U= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/juju/errors v1.0.0 h1:yiq7kjCLll1BiaRuNY53MGI0+EQ3rF6GB+wvboZDefM= github.com/juju/errors v1.0.0/go.mod h1:B5x9thDqx0wIMH3+aLIMP9HjItInYWObRovoCFM5Qe8= github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= @@ -566,8 +592,8 @@ github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDS github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= -github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= +github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= @@ -632,30 +658,21 @@ github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y= -github.com/mdlayher/genetlink v1.2.0 h1:4yrIkRV5Wfk1WfpWTcoOlGmsWgQj3OtQN9ZsbrE+XtU= -github.com/mdlayher/genetlink v1.2.0/go.mod h1:ra5LDov2KrUCZJiAtEvXXZBxGMInICMXIwshlJ+qRxQ= -github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= -github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= -github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY= -github.com/mdlayher/netlink v1.1.1/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o= -github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA= -github.com/mdlayher/netlink v1.6.2 h1:D2zGSkvYsJ6NreeED3JiVTu1lj2sIYATqSaZlhPzUgQ= -github.com/mdlayher/netlink v1.6.2/go.mod h1:O1HXX2sIWSMJ3Qn1BYZk1yZM+7iMki/uYGGiwGyq/iU= -github.com/mdlayher/raw v0.0.0-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= -github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= -github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs= -github.com/mdlayher/socket v0.2.3 h1:XZA2X2TjdOwNoNPVPclRCURoX/hokBY8nkTmRZFEheM= -github.com/mdlayher/socket v0.2.3/go.mod h1:bz12/FozYNH/VbvC3q7TRIK/Y6dH1kCKsXaUeXi/FmY= +github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY= github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= -github.com/miekg/dns v1.1.45 h1:g5fRIhm9nx7g8osrAvgb16QJfmyMsyOCb+J7LSv+Qzk= -github.com/miekg/dns v1.1.45/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= +github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= +github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= @@ -717,6 +734,9 @@ github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4a github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= +github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40= github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI= @@ -738,8 +758,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= @@ -769,8 +789,6 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= @@ -796,6 +814,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= @@ -812,14 +831,12 @@ github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG0 github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d h1:K3j02b5j2Iw1xoggN9B2DIEkhWGheqFOeDkdJdBrJI8= github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d/go.mod h1:2P+hpOwd53e7JMX/L4f3VXkv1G+33ES6IWZSrkIeWNs= -github.com/tailscale/golang-x-crypto v0.0.0-20221102133106-bc99ab8c2d17 h1:cSm67hIDABvL13S0n9TNoVhzYwjb24M46znbABLll18= -github.com/tailscale/golang-x-crypto v0.0.0-20221102133106-bc99ab8c2d17/go.mod h1:95n9fbUCixVSI4QXLEvdKJjnYK2eUlkTx9+QwLPXFKU= +github.com/tailscale/golang-x-crypto v0.0.0-20230713185742-f0b76a10a08e h1:JyeJF/HuSwvxWtsR1c0oKX1lzaSH5Wh4aX+MgiStaGQ= +github.com/tailscale/golang-x-crypto v0.0.0-20230713185742-f0b76a10a08e/go.mod h1:DjoeCULdP6vTJ/xY+nzzR9LaUHprkbZEpNidX0aqEEk= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk= github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= -github.com/tailscale/wireguard-go v0.0.0-20221219190806-4fa124729667 h1:etWp6uUwKu8NEj37K2OuMBnZ7EnVMKA7gJg5AqPFy/o= -github.com/tailscale/wireguard-go v0.0.0-20221219190806-4fa124729667/go.mod h1:iiClgxBTruKI+nmzlQxbFw6c3nB/wb4Td/WCyX2berY= github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes= github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/tdewolff/parse/v2 v2.6.6 h1:Yld+0CrKUJaCV78DL1G2nk3C9lKrxyRTux5aaK/AkDo= @@ -831,8 +848,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90 h1:zTk5683I9K62wtZ6eUa6vu6IWwVHXPnoKK5n2unAwv0= github.com/u-root/u-root v0.11.0 h1:6gCZLOeRyevw7gbTwMj3fKxnr9+yHFlgF3N7udUVNO8= github.com/u-root/u-root v0.11.0/go.mod h1:DBkDtiZyONk9hzVEdB/PWI9B4TxDkElWlVTHseglrZY= -github.com/u-root/uio v0.0.0-20221213070652-c3537552635f h1:dpx1PHxYqAnXzbryJrWP1NQLzEjwcVgFLhkknuFQ7ww= -github.com/u-root/uio v0.0.0-20221213070652-c3537552635f/go.mod h1:IogEAUBXDEwX7oR/BMmCctShYs80ql4hF0ySdzGxf7E= +github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 h1:YcojQL98T/OO+rybuzn2+5KrD5dBwXIvYBvQ2cD3Avg= +github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= @@ -850,8 +867,8 @@ github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0m github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= -github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg= -github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -925,12 +942,10 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0 go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= -go4.org/intern v0.0.0-20211027215823-ae77deb06f29 h1:UXLjNohABv4S58tHmeuIZDO6e3mHpW2Dx33gaNt03LE= -go4.org/mem v0.0.0-20210711025021-927187094b94 h1:OAAkygi2Js191AJP1Ds42MhJRgeofeKGjuoUqNp1QC4= -go4.org/mem v0.0.0-20210711025021-927187094b94/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= -go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf h1:IdwJUzqoIo5lkr2EOyKoe5qipUaEjbOKKY5+fzPBZ3A= -go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf/go.mod h1:+QXzaoURFd0rGDIjDNpyIkv+F9R7EmeKorvlKRnhqgA= -go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 h1:FyBZqvoA/jbNzuAWLQE2kG820zMAkcilx6BMjGbL/E4= +go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8= +go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 h1:X66ZEoMN2SuaoI/dfZVYobB6E5zjZyyHUMWlCA7MgGE= +go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516/go.mod h1:TQvodOM+hJTioNQJilmLXu08JNb8i+ccq418+KWu1/Y= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -947,6 +962,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -959,9 +975,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= -golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a h1:Jw5wfR+h9mnIYH+OtGT2im5wV1YGGDora5vTv/aa5bE= +golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b h1:r+vk0EmXNmekl0S0BascoeeoHk/L7wmaW2QF90K+kYI= +golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -987,6 +1002,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -996,14 +1012,11 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190419010253-1f3472d942ba/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -1018,7 +1031,6 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -1027,14 +1039,12 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1061,7 +1071,6 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= @@ -1069,20 +1078,15 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606122018-79a91cf218c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1105,8 +1109,6 @@ golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1131,7 +1133,6 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1145,6 +1146,7 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1152,6 +1154,8 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= 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= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1165,6 +1169,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1178,7 +1184,6 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -1224,8 +1229,8 @@ golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8= golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1356,6 +1361,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= @@ -1367,7 +1373,6 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= @@ -1378,7 +1383,6 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.4.2 h1:6qXr+R5w+ktL5UkwEbPp+fEvfyoMPche6GkOpGHZcLc= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= inet.af/peercred v0.0.0-20210906144145-0893ea02156a h1:qdkS8Q5/i10xU2ArJMKYhVa1DORzBfYS/qA2UK2jheg= diff --git a/scripts/ci-report/main.go b/scripts/ci-report/main.go index 9e3ae7e39d6bb..e6a2cf736b524 100644 --- a/scripts/ci-report/main.go +++ b/scripts/ci-report/main.go @@ -12,6 +12,8 @@ import ( "golang.org/x/exp/slices" "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/util/slice" ) func main() { @@ -161,9 +163,8 @@ func parseCIReport(report GotestsumReport) (CIReport, error) { } timeouts = timeoutsNorm - sortAZ := func(a, b string) bool { return a < b } - slices.SortFunc(packagesSortedByName, sortAZ) - slices.SortFunc(testSortedByName, sortAZ) + slices.SortFunc(packagesSortedByName, slice.Ascending[string]) + slices.SortFunc(testSortedByName, slice.Ascending[string]) var rep CIReport diff --git a/scripts/metricsdocgen/main.go b/scripts/metricsdocgen/main.go index fbeb148715c54..8589653172005 100644 --- a/scripts/metricsdocgen/main.go +++ b/scripts/metricsdocgen/main.go @@ -56,13 +56,13 @@ func main() { } } -func readMetrics() ([]dto.MetricFamily, error) { +func readMetrics() ([]*dto.MetricFamily, error) { f, err := os.Open(metricsFile) if err != nil { return nil, xerrors.New("can't open metrics file") } - var metrics []dto.MetricFamily + var metrics []*dto.MetricFamily decoder := expfmt.NewDecoder(f, expfmt.FmtProtoText) for { @@ -73,7 +73,7 @@ func readMetrics() ([]dto.MetricFamily, error) { } else if err != nil { return nil, err } - metrics = append(metrics, m) + metrics = append(metrics, &m) } sort.Slice(metrics, func(i, j int) bool { @@ -90,7 +90,7 @@ func readPrometheusDoc() ([]byte, error) { return doc, nil } -func updatePrometheusDoc(doc []byte, metricFamilies []dto.MetricFamily) ([]byte, error) { +func updatePrometheusDoc(doc []byte, metricFamilies []*dto.MetricFamily) ([]byte, error) { i := bytes.Index(doc, generatorPrefix) if i < 0 { return nil, xerrors.New("generator prefix tag not found") diff --git a/tailnet/conn.go b/tailnet/conn.go index ebe57d2606b1c..945402b43da89 100644 --- a/tailnet/conn.go +++ b/tailnet/conn.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "net" "net/http" "net/netip" @@ -24,10 +23,12 @@ import ( "tailscale.com/ipn/ipnstate" "tailscale.com/net/connstats" "tailscale.com/net/dns" + "tailscale.com/net/netmon" "tailscale.com/net/netns" "tailscale.com/net/tsdial" "tailscale.com/net/tstun" "tailscale.com/tailcfg" + "tailscale.com/tsd" "tailscale.com/types/ipproto" "tailscale.com/types/key" tslogger "tailscale.com/types/logger" @@ -36,7 +37,6 @@ import ( "tailscale.com/wgengine" "tailscale.com/wgengine/filter" "tailscale.com/wgengine/magicsock" - "tailscale.com/wgengine/monitor" "tailscale.com/wgengine/netstack" "tailscale.com/wgengine/router" "tailscale.com/wgengine/wgcfg/nmcfg" @@ -138,7 +138,7 @@ func NewConn(options *Options) (conn *Conn, err error) { AllowedIPs: options.Addresses, } - wireguardMonitor, err := monitor.New(Logger(options.Logger.Named("net.wgmonitor"))) + wireguardMonitor, err := netmon.New(Logger(options.Logger.Named("net.wgmonitor"))) if err != nil { return nil, xerrors.Errorf("create wireguard link monitor: %w", err) } @@ -148,14 +148,15 @@ func NewConn(options *Options) (conn *Conn, err error) { } }() - IP() dialer := &tsdial.Dialer{ Logf: Logger(options.Logger.Named("net.tsdial")), } + sys := new(tsd.System) wireguardEngine, err := wgengine.NewUserspaceEngine(Logger(options.Logger.Named("net.wgengine")), wgengine.Config{ - LinkMonitor: wireguardMonitor, - Dialer: dialer, - ListenPort: options.ListenPort, + NetMon: wireguardMonitor, + Dialer: dialer, + ListenPort: options.ListenPort, + SetSubsystem: sys.Set, }) if err != nil { return nil, xerrors.Errorf("create wgengine: %w", err) @@ -170,16 +171,9 @@ func NewConn(options *Options) (conn *Conn, err error) { return ok } - // This is taken from Tailscale: - // https://github.com/tailscale/tailscale/blob/0f05b2c13ff0c305aa7a1655fa9c17ed969d65be/tsnet/tsnet.go#L247-L255 - wireguardInternals, ok := wireguardEngine.(wgengine.InternalsGetter) - if !ok { - return nil, xerrors.Errorf("wireguard engine isn't the correct type %T", wireguardEngine) - } - tunDevice, magicConn, dnsManager, ok := wireguardInternals.GetInternals() - if !ok { - return nil, xerrors.New("get wireguard internals") - } + sys.Set(wireguardEngine) + + magicConn := sys.MagicSock.Get() if options.DERPHeader != nil { magicConn.SetDERPHeader(options.DERPHeader.Clone()) } @@ -205,11 +199,11 @@ func NewConn(options *Options) (conn *Conn, err error) { netStack, err := netstack.Create( Logger(options.Logger.Named("net.netstack")), - tunDevice, + sys.Tun.Get(), wireguardEngine, magicConn, dialer, - dnsManager, + sys.DNSManager.Get(), ) if err != nil { return nil, xerrors.Errorf("create netstack: %w", err) @@ -252,7 +246,7 @@ func NewConn(options *Options) (conn *Conn, err error) { listeners: map[listenKey]*listener{}, peerMap: map[tailcfg.NodeID]*tailcfg.Node{}, lastDERPForcedWebsockets: map[int]string{}, - tunDevice: tunDevice, + tunDevice: sys.Tun.Get(), netMap: netMap, netStack: netStack, wireguardMonitor: wireguardMonitor, @@ -313,8 +307,7 @@ func NewConn(options *Options) (conn *Conn, err error) { server.sendNode() }) - netStack.ForwardTCPIn = server.forwardTCP - netStack.ForwardTCPSockOpts = server.forwardTCPSockOpts + netStack.GetTCPHandlerForFlow = server.forwardTCP err = netStack.Start(nil) if err != nil { @@ -363,7 +356,7 @@ type Conn struct { netMap *netmap.NetworkMap netStack *netstack.Impl magicConn *magicsock.Conn - wireguardMonitor *monitor.Mon + wireguardMonitor *netmon.Monitor wireguardRouter *router.Config wireguardEngine wgengine.Engine listeners map[listenKey]*listener @@ -449,6 +442,11 @@ func (c *Conn) SetDERPRegionDialer(dialer func(ctx context.Context, region *tail func (c *Conn) UpdateNodes(nodes []*Node, replacePeers bool) error { c.mutex.Lock() defer c.mutex.Unlock() + + if c.isClosed() { + return xerrors.New("connection closed") + } + status := c.Status() if replacePeers { c.netMap.Peers = []*tailcfg.Node{} @@ -481,7 +479,6 @@ func (c *Conn) UpdateNodes(nodes []*Node, replacePeers bool) error { } c.logger.Debug(context.Background(), "adding node", slog.F("node", node)) - peerStatus, ok := status.Peer[node.Key] peerNode := &tailcfg.Node{ ID: node.ID, Created: time.Now(), @@ -492,10 +489,6 @@ func (c *Conn) UpdateNodes(nodes []*Node, replacePeers bool) error { Endpoints: node.Endpoints, DERP: fmt.Sprintf("%s:%d", tailcfg.DerpMagicIP, node.PreferredDERP), Hostinfo: hostinfo.New().View(), - // Starting KeepAlive messages at the initialization - // of a connection cause it to hang for an unknown - // reason. TODO: @kylecarbs debug this! - KeepAlive: ok && peerStatus.Active, } if c.blockEndpoints { peerNode.Endpoints = nil @@ -823,68 +816,31 @@ func (c *Conn) DialContextUDP(ctx context.Context, ipp netip.AddrPort) (*gonet.U return c.netStack.DialContextUDP(ctx, ipp) } -func (c *Conn) forwardTCP(conn net.Conn, port uint16) { +func (c *Conn) forwardTCP(_, dst netip.AddrPort) (handler func(net.Conn), opts []tcpip.SettableSocketOption, intercept bool) { c.mutex.Lock() - ln, ok := c.listeners[listenKey{"tcp", "", fmt.Sprint(port)}] + ln, ok := c.listeners[listenKey{"tcp", "", fmt.Sprint(dst.Port())}] c.mutex.Unlock() if !ok { - c.forwardTCPToLocal(conn, port) - return + return nil, nil, false } - - t := time.NewTimer(time.Second) - defer t.Stop() - select { - case ln.conn <- conn: - return - case <-ln.closed: - case <-c.closed: - case <-t.C: - } - _ = conn.Close() -} - -func (*Conn) forwardTCPSockOpts(port uint16) []tcpip.SettableSocketOption { - opts := []tcpip.SettableSocketOption{} - // See: https://github.com/tailscale/tailscale/blob/c7cea825aea39a00aca71ea02bab7266afc03e7c/wgengine/netstack/netstack.go#L888 - if port == WorkspaceAgentSSHPort || port == 22 { - opt := tcpip.KeepaliveIdleOption(72*time.Hour + time.Minute) // Default ssh-max-timeout is 72h, so let's add some extra time. + if dst.Port() == WorkspaceAgentSSHPort || dst.Port() == 22 { + opt := tcpip.KeepaliveIdleOption(72 * time.Hour) opts = append(opts, &opt) } - return opts -} - -func (c *Conn) forwardTCPToLocal(conn net.Conn, port uint16) { - defer conn.Close() - dialAddrStr := net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port))) - var stdDialer net.Dialer - server, err := stdDialer.DialContext(c.dialContext, "tcp", dialAddrStr) - if err != nil { - c.logger.Debug(c.dialContext, "dial local port", slog.F("port", port), slog.Error(err)) - return - } - defer server.Close() - - connClosed := make(chan error, 2) - go func() { - _, err := io.Copy(server, conn) - connClosed <- err - }() - go func() { - _, err := io.Copy(conn, server) - connClosed <- err - }() - select { - case err = <-connClosed: - case <-c.closed: - return - } - if err != nil { - c.logger.Debug(c.dialContext, "proxy connection closed with error", slog.Error(err)) - } - c.logger.Debug(c.dialContext, "forwarded connection closed", slog.F("local_addr", dialAddrStr)) + return func(conn net.Conn) { + t := time.NewTimer(time.Second) + defer t.Stop() + select { + case ln.conn <- conn: + return + case <-ln.closed: + case <-c.closed: + case <-t.C: + } + _ = conn.Close() + }, opts, true } // SetConnStatsCallback sets a callback to be called after maxPeriod or diff --git a/tailnet/coordinator.go b/tailnet/coordinator.go index d37ea5c290b5f..de0248527f3fd 100644 --- a/tailnet/coordinator.go +++ b/tailnet/coordinator.go @@ -21,6 +21,7 @@ import ( "tailscale.com/types/key" "cdr.dev/slog" + "github.com/coder/coder/coderd/util/slice" ) // Coordinator exchanges nodes with agents to establish connections. @@ -683,14 +684,14 @@ func HTTPDebugFromLocal( LastWriteAge: now.Sub(time.Unix(lastWrite, 0)).Round(time.Second), }) } - slices.SortFunc(agent.Connections, func(a, b *HTMLClient) bool { - return a.Name < b.Name + slices.SortFunc(agent.Connections, func(a, b *HTMLClient) int { + return slice.Ascending(a.Name, b.Name) }) data.Agents = append(data.Agents, agent) } - slices.SortFunc(data.Agents, func(a, b *HTMLAgent) bool { - return a.Name < b.Name + slices.SortFunc(data.Agents, func(a, b *HTMLAgent) int { + return slice.Ascending(a.Name, b.Name) }) for agentID, conns := range agentToConnectionSocketsMap { @@ -719,14 +720,14 @@ func HTTPDebugFromLocal( LastWriteAge: now.Sub(time.Unix(lastWrite, 0)).Round(time.Second), }) } - slices.SortFunc(agent.Connections, func(a, b *HTMLClient) bool { - return a.Name < b.Name + slices.SortFunc(agent.Connections, func(a, b *HTMLClient) int { + return slice.Ascending(a.Name, b.Name) }) data.MissingAgents = append(data.MissingAgents, agent) } - slices.SortFunc(data.MissingAgents, func(a, b *HTMLAgent) bool { - return a.Name < b.Name + slices.SortFunc(data.MissingAgents, func(a, b *HTMLAgent) int { + return slice.Ascending(a.Name, b.Name) }) for id, node := range nodesMap { @@ -737,8 +738,8 @@ func HTTPDebugFromLocal( Node: node, }) } - slices.SortFunc(data.Nodes, func(a, b *HTMLNode) bool { - return a.Name+a.ID.String() < b.Name+b.ID.String() + slices.SortFunc(data.Nodes, func(a, b *HTMLNode) int { + return slice.Ascending(a.Name+a.ID.String(), b.Name+b.ID.String()) }) return data From 3245e91a328d43e826287da3c254b3588158a085 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 9 Aug 2023 16:53:32 -0300 Subject: [PATCH 070/277] fix(site): set default color and display error on appearance form (#9004) --- .../AppearanceSettingsPageView.tsx | 4 +++- .../appearance/appearanceXService.ts | 23 +++++++------------ 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 78e23d7434afb..d14aad57ca3ae 100644 --- a/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -21,6 +21,7 @@ import { Stack } from "components/Stack/Stack" import { useFormik } from "formik" import { useTheme } from "@mui/styles" import Link from "@mui/material/Link" +import { colors } from "theme/colors" export type AppearanceSettingsPageViewProps = { appearance: UpdateAppearanceConfig @@ -53,7 +54,8 @@ export const AppearanceSettingsPageView = ({ initialValues: { message: appearance.service_banner.message, enabled: appearance.service_banner.enabled, - background_color: appearance.service_banner.background_color, + background_color: + appearance.service_banner.background_color ?? colors.blue[7], }, onSubmit: (values) => updateAppearance( diff --git a/site/src/xServices/appearance/appearanceXService.ts b/site/src/xServices/appearance/appearanceXService.ts index 267d07608b726..901854a4529c6 100644 --- a/site/src/xServices/appearance/appearanceXService.ts +++ b/site/src/xServices/appearance/appearanceXService.ts @@ -1,12 +1,12 @@ -import { displaySuccess } from "components/GlobalSnackbar/utils" +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils" import { assign, createMachine } from "xstate" import * as API from "../../api/api" import { AppearanceConfig } from "../../api/typesGenerated" +import { getErrorMessage } from "api/errors" export type AppearanceContext = { appearance?: AppearanceConfig getAppearanceError?: unknown - setAppearanceError?: unknown preview: boolean } @@ -39,11 +39,7 @@ export const appearanceMachine = createMachine( idle: { on: { SET_PREVIEW_APPEARANCE: { - actions: [ - "clearGetAppearanceError", - "clearSetAppearanceError", - "assignPreviewAppearance", - ], + actions: ["clearGetAppearanceError", "assignPreviewAppearance"], }, SAVE_APPEARANCE: "savingAppearance", }, @@ -64,7 +60,6 @@ export const appearanceMachine = createMachine( }, }, savingAppearance: { - entry: "clearSetAppearanceError", invoke: { id: "setAppearance", src: "setAppearance", @@ -74,7 +69,11 @@ export const appearanceMachine = createMachine( }, onError: { target: "idle", - actions: ["assignSetAppearanceError"], + actions: (_, error) => { + displayError( + getErrorMessage(error, "Failed to update appearance settings."), + ) + }, }, }, }, @@ -99,12 +98,6 @@ export const appearanceMachine = createMachine( clearGetAppearanceError: assign({ getAppearanceError: (_) => undefined, }), - assignSetAppearanceError: assign({ - setAppearanceError: (_, event) => event.data, - }), - clearSetAppearanceError: assign({ - setAppearanceError: (_) => undefined, - }), }, services: { getAppearance: async () => { From fb5e0c4bbad2e236beead79025e15b8d7d449187 Mon Sep 17 00:00:00 2001 From: Eric Paulsen Date: Wed, 9 Aug 2023 17:00:22 -0400 Subject: [PATCH 071/277] docs: add TLS config steps for K8s (#9011) * docs: add TLS config steps for K8s * add note on wildcard cert --- docs/admin/configure.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/admin/configure.md b/docs/admin/configure.md index e74d447c0b4e1..25f6d87583763 100644 --- a/docs/admin/configure.md +++ b/docs/admin/configure.md @@ -55,6 +55,36 @@ The Coder server can directly use TLS certificates with `CODER_TLS_ENABLE` and a - [Caddy](https://github.com/coder/coder/tree/main/examples/web-server/caddy) - [NGINX](https://github.com/coder/coder/tree/main/examples/web-server/nginx) +### Kubernetes TLS configuration + +Below are the steps to configure Coder to terminate TLS when running on Kubernetes. +You must have the certificate `.key` and `.crt` files in your working directory prior to step 1. + +1. Create the TLS secret in your Kubernetes cluster + +```console +kubectl create secret tls coder-tls -n --key="tls.key" --cert="tls.crt" +``` + +> You can use a single certificate for the both the access URL and wildcard access URL. +> The certificate CN must match the wildcard domain, such as `*.example.coder.com`. + +1. Reference the TLS secret in your Coder Helm chart values + +```yaml +coder: + tls: + secretName: + - coder-tls + + # Alternatively, if you use an Ingress controller to terminate TLS, + # set the following values: + ingress: + enable: true + secretName: coder-tls + wildcardSecretName: coder-tls +``` + ## PostgreSQL Database Coder uses a PostgreSQL database to store users, workspace metadata, and other deployment information. From c0d1cacc4946e1af4341aece37566fb0909ec606 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 9 Aug 2023 18:22:13 -0300 Subject: [PATCH 072/277] fix(site): fix storybook error and inconsistent snapshots (#9010) --- .../components/Resources/AgentMetadata.tsx | 3 +- .../GeneralSettingsPageView.stories.tsx | 63 ++++++++++++------- .../WorkspaceBuildLogsSection.tsx | 5 ++ 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/site/src/components/Resources/AgentMetadata.tsx b/site/src/components/Resources/AgentMetadata.tsx index 53d6ad4feeb92..0c309492b86f4 100644 --- a/site/src/components/Resources/AgentMetadata.tsx +++ b/site/src/components/Resources/AgentMetadata.tsx @@ -201,7 +201,8 @@ const StaticWidth = (props: BoxProps) => { const ref = useRef(null) useEffect(() => { - if (!ref.current) { + // Ignore this in storybook + if (!ref.current || process.env.STORYBOOK === "true") { return } diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx index 2a027ef7e9c41..9e65fca98cc3a 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx @@ -1,11 +1,8 @@ -import { ComponentMeta, Story } from "@storybook/react" +import { Meta, StoryObj } from "@storybook/react" import { mockApiError, MockDeploymentDAUResponse } from "testHelpers/entities" -import { - GeneralSettingsPageView, - GeneralSettingsPageViewProps, -} from "./GeneralSettingsPageView" +import { GeneralSettingsPageView } from "./GeneralSettingsPageView" -export default { +const meta: Meta = { title: "pages/GeneralSettingsPageView", component: GeneralSettingsPageView, args: { @@ -13,34 +10,58 @@ export default { { name: "Access URL", description: - "External URL to access your deployment. This must be accessible by all provisioned workspaces.", + "The URL that users will use to access the Coder deployment.", + flag: "access-url", + flag_shorthand: "", value: "https://dev.coder.com", + hidden: false, }, { name: "Wildcard Access URL", description: 'Specifies the wildcard hostname to use for workspace applications in the form "*.example.com".', + flag: "wildcard-access-url", + flag_shorthand: "", value: "*--apps.dev.coder.com", + hidden: false, + }, + { + name: "Experiments", + description: + "Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or enter '*' to opt-in to all available experiments.", + flag: "experiments", + value: [ + "*", + "moons", + "workspace_actions", + "single_tailnet", + "deployment_health_page", + "template_parameters_insights", + ], + flag_shorthand: "", + hidden: false, }, ], deploymentDAUs: MockDeploymentDAUResponse, }, -} as ComponentMeta +} + +export default meta +type Story = StoryObj -const Template: Story = (args) => ( - -) -export const Page = Template.bind({}) +export const Page: Story = {} -export const NoDAUs = Template.bind({}) -NoDAUs.args = { - deploymentDAUs: undefined, +export const NoDAUs: Story = { + args: { + deploymentDAUs: undefined, + }, } -export const DAUError = Template.bind({}) -DAUError.args = { - deploymentDAUs: undefined, - getDeploymentDAUsError: mockApiError({ - message: "Error fetching DAUs.", - }), +export const DAUError: Story = { + args: { + deploymentDAUs: undefined, + getDeploymentDAUsError: mockApiError({ + message: "Error fetching DAUs.", + }), + }, } diff --git a/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx b/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx index 7c062d23614aa..795c77bc3521b 100644 --- a/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx @@ -12,6 +12,11 @@ export const WorkspaceBuildLogsSection = ({ const scrollRef = useRef(null) useEffect(() => { + // Auto scrolling makes hard to snapshot test using Chromatic + if (process.env.STORYBOOK === "true") { + return + } + const scrollEl = scrollRef.current if (scrollEl) { scrollEl.scrollTop = scrollEl.scrollHeight From 370bdd6a03b3f04b918bbb551af943be40be878a Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Wed, 9 Aug 2023 19:17:57 -0500 Subject: [PATCH 073/277] fix(cli): only init `clistat.Client` when calling `coder stat` (#9013) --- cli/stat.go | 110 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 43 deletions(-) diff --git a/cli/stat.go b/cli/stat.go index 6311596ecea2a..6a49ffb8437b5 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -12,31 +12,43 @@ import ( "github.com/coder/coder/cli/cliui" ) -func (r *RootCmd) stat() *clibase.Cmd { - fs := afero.NewReadOnlyFs(afero.NewOsFs()) - defaultCols := []string{ - "host_cpu", - "host_memory", - "home_disk", - "container_cpu", - "container_memory", - } - formatter := cliui.NewOutputFormatter( - cliui.TableFormat([]statsRow{}, defaultCols), - cliui.JSONFormat(), - ) - st, err := clistat.New(clistat.WithFS(fs)) - if err != nil { - panic(xerrors.Errorf("initialize workspace stats collector: %w", err)) +func initStatterMW(tgt **clistat.Statter, fs afero.Fs) clibase.MiddlewareFunc { + return func(next clibase.HandlerFunc) clibase.HandlerFunc { + return func(i *clibase.Invocation) error { + var err error + stat, err := clistat.New(clistat.WithFS(fs)) + if err != nil { + return xerrors.Errorf("initialize workspace stats collector: %w", err) + } + *tgt = stat + return next(i) + } } +} +func (r *RootCmd) stat() *clibase.Cmd { + var ( + st *clistat.Statter + fs = afero.NewReadOnlyFs(afero.NewOsFs()) + formatter = cliui.NewOutputFormatter( + cliui.TableFormat([]statsRow{}, []string{ + "host_cpu", + "host_memory", + "home_disk", + "container_cpu", + "container_memory", + }), + cliui.JSONFormat(), + ) + ) cmd := &clibase.Cmd{ - Use: "stat", - Short: "Show resource usage for the current workspace.", + Use: "stat", + Short: "Show resource usage for the current workspace.", + Middleware: initStatterMW(&st, fs), Children: []*clibase.Cmd{ - r.statCPU(st, fs), - r.statMem(st, fs), - r.statDisk(st), + r.statCPU(fs), + r.statMem(fs), + r.statDisk(fs), }, Handler: func(inv *clibase.Invocation) error { var sr statsRow @@ -118,12 +130,16 @@ func (r *RootCmd) stat() *clibase.Cmd { return cmd } -func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { - var hostArg bool - formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) +func (*RootCmd) statCPU(fs afero.Fs) *clibase.Cmd { + var ( + hostArg bool + st *clistat.Statter + formatter = cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) + ) cmd := &clibase.Cmd{ - Use: "cpu", - Short: "Show CPU usage, in cores.", + Use: "cpu", + Short: "Show CPU usage, in cores.", + Middleware: initStatterMW(&st, fs), Options: clibase.OptionSet{ { Flag: "host", @@ -135,9 +151,9 @@ func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { var cs *clistat.Result var err error if ok, _ := clistat.IsContainerized(fs); ok && !hostArg { - cs, err = s.ContainerCPU() + cs, err = st.ContainerCPU() } else { - cs, err = s.HostCPU() + cs, err = st.HostCPU() } if err != nil { return err @@ -155,13 +171,17 @@ func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { return cmd } -func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { - var hostArg bool - var prefixArg string - formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) +func (*RootCmd) statMem(fs afero.Fs) *clibase.Cmd { + var ( + hostArg bool + prefixArg string + st *clistat.Statter + formatter = cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) + ) cmd := &clibase.Cmd{ - Use: "mem", - Short: "Show memory usage, in gigabytes.", + Use: "mem", + Short: "Show memory usage, in gigabytes.", + Middleware: initStatterMW(&st, fs), Options: clibase.OptionSet{ { Flag: "host", @@ -185,9 +205,9 @@ func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { var ms *clistat.Result var err error if ok, _ := clistat.IsContainerized(fs); ok && !hostArg { - ms, err = s.ContainerMemory(pfx) + ms, err = st.ContainerMemory(pfx) } else { - ms, err = s.HostMemory(pfx) + ms, err = st.HostMemory(pfx) } if err != nil { return err @@ -205,13 +225,17 @@ func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { return cmd } -func (*RootCmd) statDisk(s *clistat.Statter) *clibase.Cmd { - var pathArg string - var prefixArg string - formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) +func (*RootCmd) statDisk(fs afero.Fs) *clibase.Cmd { + var ( + pathArg string + prefixArg string + st *clistat.Statter + formatter = cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) + ) cmd := &clibase.Cmd{ - Use: "disk", - Short: "Show disk usage, in gigabytes.", + Use: "disk", + Short: "Show disk usage, in gigabytes.", + Middleware: initStatterMW(&st, fs), Options: clibase.OptionSet{ { Flag: "path", @@ -237,7 +261,7 @@ func (*RootCmd) statDisk(s *clistat.Statter) *clibase.Cmd { if len(inv.Args) > 0 { pathArg = inv.Args[0] } - ds, err := s.Disk(pfx, pathArg) + ds, err := st.Disk(pfx, pathArg) if err != nil { if os.IsNotExist(err) { //nolint:gocritic // fmt.Errorf produces a more concise error. From cdb089049e2d67daeb28a681ea886934319aa0ef Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 9 Aug 2023 22:43:44 -0500 Subject: [PATCH 074/277] chore: add docs for creating missing groups on oidc sync (#8983) --- docs/admin/auth.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/admin/auth.md b/docs/admin/auth.md index 16807c159fc37..794def7f67e9e 100644 --- a/docs/admin/auth.md +++ b/docs/admin/auth.md @@ -288,6 +288,28 @@ OIDC provider will be added to the `myCoderGroupName` group in Coder. Some common issues when enabling group sync. +#### User not being assigned / Group does not exist + +If you want Coder to create groups that do not exist, you can set the following environment variable. If you enable this, your OIDC provider might be sending over many unnecessary groups. Use filtering options on the OIDC provider to limit the groups sent over to prevent creating excess groups. + +```console +# as an environment variable +CODER_OIDC_GROUP_AUTO_CREATE=true + +# as a flag +--oidc-group-auto-create=true +``` + +A basic regex filtering option on the Coder side is available. This is applied **after** the group mapping (`CODER_OIDC_GROUP_MAPPING`), meaning if the group is remapped, the remapped value is tested in the regex. This is useful if you want to filter out groups that do not match a certain pattern. For example, if you want to only allow groups that start with `my-group-` to be created, you can set the following environment variable. + +```console +# as an environment variable +CODER_OIDC_GROUP_REGEX_FILTER="^my-group-.*$" + +# as a flag +--oidc-group-regex-filter="^my-group-.*$" +``` + #### Invalid Scope If you see an error like the following, you may have an invalid scope. From 21af02038603f3c9177ffc0de6806d4f232c18a1 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 10 Aug 2023 13:59:43 +0400 Subject: [PATCH 075/277] feat: add external provisioner daemon helm chart (#8939) * Refactor helm to extract common templates to libcoder Signed-off-by: Spike Curtis * Remove comment from libcoder Chart.yaml Signed-off-by: Spike Curtis * Add provisioner helm chart * Fix prettier, linting, docs Signed-off-by: Spike Curtis * Log at INFO when provisionerd connects to coderd Signed-off-by: Spike Curtis * remove unnecessary exports in helm tests Signed-off-by: Spike Curtis --------- Signed-off-by: Spike Curtis --- .prettierignore | 2 +- .prettierignore.include | 2 +- Makefile | 10 +- docs/admin/configure.md | 2 +- docs/admin/scale.md | 2 +- docs/install/kubernetes.md | 2 +- helm/Makefile | 11 +- helm/{ => coder}/.helmignore | 0 helm/coder/Chart.lock | 6 + helm/{ => coder}/Chart.yaml | 7 +- helm/{ => coder}/README.md | 0 helm/coder/charts/libcoder-0.1.0.tgz | Bin 0 -> 2999 bytes helm/{ => coder}/templates/NOTES.txt | 0 helm/coder/templates/_coder.tpl | 102 ++++++++ helm/coder/templates/coder.yaml | 5 + .../templates/extra-templates.yaml | 0 helm/{ => coder}/templates/ingress.yaml | 0 helm/coder/templates/rbac.yaml | 1 + helm/{ => coder}/templates/service.yaml | 0 helm/{ => coder}/tests/chart_test.go | 24 +- .../{ => coder}/tests/testdata/command.golden | 141 ++++++----- helm/{ => coder}/tests/testdata/command.yaml | 0 .../tests/testdata/command_args.golden | 143 ++++++------ .../tests/testdata/command_args.yaml | 0 .../tests/testdata/default_values.golden | 141 ++++++----- .../tests/testdata/default_values.yaml | 0 .../tests/testdata/labels_annotations.golden | 147 ++++++------ .../tests/testdata/labels_annotations.yaml | 0 .../tests/testdata/missing_values.yaml | 0 .../tests/testdata/provisionerd_psk.golden | 194 +++++++++++++++ .../tests/testdata/provisionerd_psk.yaml | 5 + helm/{ => coder}/tests/testdata/sa.golden | 144 ++++++------ helm/{ => coder}/tests/testdata/sa.yaml | 0 helm/coder/tests/testdata/tls.golden | 210 +++++++++++++++++ helm/{ => coder}/tests/testdata/tls.yaml | 0 .../tests/testdata/workspace_proxy.golden | 197 ++++++++++++++++ .../tests/testdata/workspace_proxy.yaml | 0 helm/{ => coder}/values.yaml | 10 + helm/libcoder/Chart.yaml | 13 ++ helm/libcoder/templates/_coder.yaml | 85 +++++++ helm/{ => libcoder}/templates/_helpers.tpl | 20 +- .../templates/_rbac.yaml} | 6 +- helm/libcoder/templates/_util.yaml | 13 ++ helm/provisioner/Chart.lock | 6 + helm/provisioner/Chart.yaml | 34 +++ helm/provisioner/charts/libcoder-0.1.0.tgz | Bin 0 -> 2994 bytes helm/provisioner/templates/_coder.tpl | 85 +++++++ helm/provisioner/templates/coder.yaml | 5 + helm/provisioner/templates/rbac.yaml | 1 + helm/provisioner/tests/chart_test.go | 172 ++++++++++++++ .../provisioner/tests/testdata/command.golden | 135 +++++++++++ helm/provisioner/tests/testdata/command.yaml | 5 + .../tests/testdata/command_args.golden | 135 +++++++++++ .../tests/testdata/command_args.yaml | 6 + .../tests/testdata/default_values.golden | 135 +++++++++++ .../tests/testdata/default_values.yaml | 3 + .../tests/testdata/labels_annotations.golden | 143 ++++++++++++ .../tests/testdata/labels_annotations.yaml | 15 ++ .../tests/testdata/missing_values.yaml | 0 .../tests/testdata/provisionerd_psk.golden | 137 +++++++++++ .../tests/testdata/provisionerd_psk.yaml | 8 + helm/provisioner/tests/testdata/sa.golden | 136 +++++++++++ helm/provisioner/tests/testdata/sa.yaml | 8 + helm/provisioner/values.yaml | 204 ++++++++++++++++ helm/templates/coder.yaml | 143 ------------ helm/tests/testdata/tls.golden | 220 ------------------ helm/tests/testdata/workspace_proxy.golden | 206 ---------------- provisionerd/provisionerd.go | 2 +- scripts/helm.sh | 4 +- site/.eslintignore | 2 +- site/.prettierignore | 2 +- 71 files changed, 2616 insertions(+), 981 deletions(-) rename helm/{ => coder}/.helmignore (100%) create mode 100644 helm/coder/Chart.lock rename helm/{ => coder}/Chart.yaml (85%) rename helm/{ => coder}/README.md (100%) create mode 100644 helm/coder/charts/libcoder-0.1.0.tgz rename helm/{ => coder}/templates/NOTES.txt (100%) create mode 100644 helm/coder/templates/_coder.tpl create mode 100644 helm/coder/templates/coder.yaml rename helm/{ => coder}/templates/extra-templates.yaml (100%) rename helm/{ => coder}/templates/ingress.yaml (100%) create mode 100644 helm/coder/templates/rbac.yaml rename helm/{ => coder}/templates/service.yaml (100%) rename helm/{ => coder}/tests/chart_test.go (91%) rename helm/{ => coder}/tests/testdata/command.golden (61%) rename helm/{ => coder}/tests/testdata/command.yaml (100%) rename helm/{ => coder}/tests/testdata/command_args.golden (61%) rename helm/{ => coder}/tests/testdata/command_args.yaml (100%) rename helm/{ => coder}/tests/testdata/default_values.golden (61%) rename helm/{ => coder}/tests/testdata/default_values.yaml (100%) rename helm/{ => coder}/tests/testdata/labels_annotations.golden (64%) rename helm/{ => coder}/tests/testdata/labels_annotations.yaml (100%) rename helm/{ => coder}/tests/testdata/missing_values.yaml (100%) create mode 100644 helm/coder/tests/testdata/provisionerd_psk.golden create mode 100644 helm/coder/tests/testdata/provisionerd_psk.yaml rename helm/{ => coder}/tests/testdata/sa.golden (60%) rename helm/{ => coder}/tests/testdata/sa.yaml (100%) create mode 100644 helm/coder/tests/testdata/tls.golden rename helm/{ => coder}/tests/testdata/tls.yaml (100%) create mode 100644 helm/coder/tests/testdata/workspace_proxy.golden rename helm/{ => coder}/tests/testdata/workspace_proxy.yaml (100%) rename helm/{ => coder}/values.yaml (96%) create mode 100644 helm/libcoder/Chart.yaml create mode 100644 helm/libcoder/templates/_coder.yaml rename helm/{ => libcoder}/templates/_helpers.tpl (93%) rename helm/{templates/rbac.yaml => libcoder/templates/_rbac.yaml} (85%) create mode 100644 helm/libcoder/templates/_util.yaml create mode 100644 helm/provisioner/Chart.lock create mode 100644 helm/provisioner/Chart.yaml create mode 100644 helm/provisioner/charts/libcoder-0.1.0.tgz create mode 100644 helm/provisioner/templates/_coder.tpl create mode 100644 helm/provisioner/templates/coder.yaml create mode 100644 helm/provisioner/templates/rbac.yaml create mode 100644 helm/provisioner/tests/chart_test.go create mode 100644 helm/provisioner/tests/testdata/command.golden create mode 100644 helm/provisioner/tests/testdata/command.yaml create mode 100644 helm/provisioner/tests/testdata/command_args.golden create mode 100644 helm/provisioner/tests/testdata/command_args.yaml create mode 100644 helm/provisioner/tests/testdata/default_values.golden create mode 100644 helm/provisioner/tests/testdata/default_values.yaml create mode 100644 helm/provisioner/tests/testdata/labels_annotations.golden create mode 100644 helm/provisioner/tests/testdata/labels_annotations.yaml create mode 100644 helm/provisioner/tests/testdata/missing_values.yaml create mode 100644 helm/provisioner/tests/testdata/provisionerd_psk.golden create mode 100644 helm/provisioner/tests/testdata/provisionerd_psk.yaml create mode 100644 helm/provisioner/tests/testdata/sa.golden create mode 100644 helm/provisioner/tests/testdata/sa.yaml create mode 100644 helm/provisioner/values.yaml delete mode 100644 helm/templates/coder.yaml delete mode 100644 helm/tests/testdata/tls.golden delete mode 100644 helm/tests/testdata/workspace_proxy.golden diff --git a/.prettierignore b/.prettierignore index 9296d15d8802e..d68357703d7ce 100644 --- a/.prettierignore +++ b/.prettierignore @@ -67,7 +67,7 @@ scaletest/terraform/secrets.tfvars # .prettierignore.include: # Helm templates contain variables that are invalid YAML and can't be formatted # by Prettier. -helm/templates/*.yaml +helm/**/templates/*.yaml # Terraform state files used in tests, these are automatically generated. # Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json diff --git a/.prettierignore.include b/.prettierignore.include index 1f60eda9c54a7..975c00ca21b84 100644 --- a/.prettierignore.include +++ b/.prettierignore.include @@ -1,6 +1,6 @@ # Helm templates contain variables that are invalid YAML and can't be formatted # by Prettier. -helm/templates/*.yaml +helm/**/templates/*.yaml # Terraform state files used in tests, these are automatically generated. # Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json diff --git a/Makefile b/Makefile index c9089a9d4e452..8bb681c9d4020 100644 --- a/Makefile +++ b/Makefile @@ -553,7 +553,7 @@ coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS) ./scripts/apidocgen/generate.sh pnpm run format:write:only ./docs/api ./docs/manifest.json ./coderd/apidoc/swagger.json -update-golden-files: cli/testdata/.gen-golden helm/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden enterprise/cli/testdata/.gen-golden +update-golden-files: cli/testdata/.gen-golden helm/coder/tests/testdata/.gen-golden helm/provisioner/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden enterprise/cli/testdata/.gen-golden .PHONY: update-golden-files cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go) @@ -564,8 +564,12 @@ enterprise/cli/testdata/.gen-golden: $(wildcard enterprise/cli/testdata/*.golden go test ./enterprise/cli -run="TestEnterpriseCommandHelp" -update touch "$@" -helm/tests/testdata/.gen-golden: $(wildcard helm/tests/testdata/*.yaml) $(wildcard helm/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/tests/*_test.go) - go test ./helm/tests -run=TestUpdateGoldenFiles -update +helm/coder/tests/testdata/.gen-golden: $(wildcard helm/coder/tests/testdata/*.yaml) $(wildcard helm/coder/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/coder/tests/*_test.go) + go test ./helm/coder/tests -run=TestUpdateGoldenFiles -update + touch "$@" + +helm/provisioner/tests/testdata/.gen-golden: $(wildcard helm/provisioner/tests/testdata/*.yaml) $(wildcard helm/provisioner/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/provisioner/tests/*_test.go) + go test ./helm/provisioner/tests -run=TestUpdateGoldenFiles -update touch "$@" scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go) diff --git a/docs/admin/configure.md b/docs/admin/configure.md index 25f6d87583763..2240ef4ed5d62 100644 --- a/docs/admin/configure.md +++ b/docs/admin/configure.md @@ -42,7 +42,7 @@ If you are providing TLS certificates directly to the Coder server, either 1. Use a single certificate and key for both the root and wildcard domains. 2. Configure multiple certificates and keys via - [`coder.tls.secretNames`](https://github.com/coder/coder/blob/main/helm/values.yaml) in the Helm Chart, or + [`coder.tls.secretNames`](https://github.com/coder/coder/blob/main/helm/coder/values.yaml) in the Helm Chart, or [`--tls-cert-file`](../cli/server.md#--tls-cert-file) and [`--tls-key-file`](../cli/server.md#--tls-key-file) command line options (these both take a comma separated list of files; list certificates and their respective keys in the same order). diff --git a/docs/admin/scale.md b/docs/admin/scale.md index 5b5e2369f54fd..998314061fd52 100644 --- a/docs/admin/scale.md +++ b/docs/admin/scale.md @@ -42,7 +42,7 @@ Users accessing workspaces via SSH will consume fewer resources, as SSH connecti Workspace builds are CPU-intensive, as it relies on Terraform. Various [Terraform providers](https://registry.terraform.io/browse/providers) have different resource requirements. When tested with our [kubernetes](https://github.com/coder/coder/tree/main/examples/templates/kubernetes) template, `coderd` will consume roughly 0.25 cores per concurrent workspace build. -For effective provisioning, our helm chart prefers to schedule [one coderd replica per-node](https://github.com/coder/coder/blob/main/helm/values.yaml#L188-L202). +For effective provisioning, our helm chart prefers to schedule [one coderd replica per-node](https://github.com/coder/coder/blob/main/helm/coder/values.yaml#L188-L202). We recommend: diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index ade4b058c2dcf..11e8138d7c6d6 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -105,7 +105,7 @@ to log in and manage templates. > You can view our > [Helm README](https://github.com/coder/coder/blob/main/helm#readme) for > details on the values that are available, or you can view the - > [values.yaml](https://github.com/coder/coder/blob/main/helm/values.yaml) + > [values.yaml](https://github.com/coder/coder/blob/main/helm/coder/values.yaml) > file directly. 1. Run the following command to install the chart in your cluster. diff --git a/helm/Makefile b/helm/Makefile index a3f689b1637af..4010cf42d64fb 100644 --- a/helm/Makefile +++ b/helm/Makefile @@ -13,6 +13,13 @@ all: lint lint: lint/helm .PHONY: lint -lint/helm: - helm lint --strict --set coder.image.tag=v0.0.1 . +lint/helm: lint/helm/coder lint/helm/provisioner .PHONY: lint/helm + +lint/helm/coder: + helm lint --strict --set coder.image.tag=v0.0.1 coder/ +.PHONY: lint/helm/coder + +lint/helm/provisioner: + helm lint --strict --set coder.image.tag=v0.0.1 provisioner/ +.PHONY: lint/helm/provisioner diff --git a/helm/.helmignore b/helm/coder/.helmignore similarity index 100% rename from helm/.helmignore rename to helm/coder/.helmignore diff --git a/helm/coder/Chart.lock b/helm/coder/Chart.lock new file mode 100644 index 0000000000000..9692722e192f1 --- /dev/null +++ b/helm/coder/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: libcoder + repository: file://../libcoder + version: 0.1.0 +digest: sha256:5c9a99109258073b590a9f98268490ef387fde24c0c7c7ade9c1a8c7ef5e6e10 +generated: "2023-08-08T07:27:19.677972411Z" diff --git a/helm/Chart.yaml b/helm/coder/Chart.yaml similarity index 85% rename from helm/Chart.yaml rename to helm/coder/Chart.yaml index a68aa330d8d49..99f6b710474c3 100644 --- a/helm/Chart.yaml +++ b/helm/coder/Chart.yaml @@ -21,9 +21,14 @@ keywords: - coder - terraform sources: - - https://github.com/coder/coder/tree/main/helm + - https://github.com/coder/coder/tree/main/helm/coder icon: https://helm.coder.com/coder_logo_black.png maintainers: - name: Coder Technologies, Inc. email: support@coder.com url: https://coder.com/contact + +dependencies: + - name: libcoder + version: 0.1.0 + repository: file://../libcoder diff --git a/helm/README.md b/helm/coder/README.md similarity index 100% rename from helm/README.md rename to helm/coder/README.md diff --git a/helm/coder/charts/libcoder-0.1.0.tgz b/helm/coder/charts/libcoder-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..b799de7d008196ae4c4a948df1db26d2c5cd4cf5 GIT binary patch literal 2999 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 1.19.0-0" + +keywords: + - coder + - terraform +sources: + - https://github.com/coder/coder/tree/main/helm/provisioner +icon: https://helm.coder.com/coder_logo_black.png +maintainers: + - name: Coder Technologies, Inc. + email: support@coder.com + url: https://coder.com/contact + +dependencies: + - name: libcoder + version: 0.1.0 + repository: file://../libcoder diff --git a/helm/provisioner/charts/libcoder-0.1.0.tgz b/helm/provisioner/charts/libcoder-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..638d50f976a7eb18335f26a15c12769a83e9f412 GIT binary patch literal 2994 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 Date: Thu, 10 Aug 2023 12:08:00 +0200 Subject: [PATCH 076/277] refactor(cli): adjust parameter resolver (#9019) --- cli/parameterresolver.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/cli/parameterresolver.go b/cli/parameterresolver.go index 9e803356b45bf..d2f551a17ff4f 100644 --- a/cli/parameterresolver.go +++ b/cli/parameterresolver.go @@ -77,11 +77,12 @@ func (pr *ParameterResolver) Resolve(inv *clibase.Invocation, action WorkspaceCL } func (pr *ParameterResolver) resolveWithParametersMapFile(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter { +next: for name, value := range pr.richParametersFile { for i, r := range resolved { if r.Name == name { resolved[i].Value = value - goto done + continue next } } @@ -89,34 +90,33 @@ func (pr *ParameterResolver) resolveWithParametersMapFile(resolved []codersdk.Wo Name: name, Value: value, }) - done: } return resolved } func (pr *ParameterResolver) resolveWithCommandLineOrEnv(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter { +nextRichParameter: for _, richParameter := range pr.richParameters { for i, r := range resolved { if r.Name == richParameter.Name { resolved[i].Value = richParameter.Value - goto richParameterDone + continue nextRichParameter } } resolved = append(resolved, richParameter) - richParameterDone: } +nextBuildOption: for _, buildOption := range pr.buildOptions { for i, r := range resolved { if r.Name == buildOption.Name { resolved[i].Value = buildOption.Value - goto buildOptionDone + continue nextBuildOption } } resolved = append(resolved, buildOption) - buildOptionDone: } return resolved } @@ -126,6 +126,7 @@ func (pr *ParameterResolver) resolveWithLastBuildParameters(resolved []codersdk. return resolved // don't pull parameters from last build } +next: for _, buildParameter := range pr.lastBuildParameters { tvp := findTemplateVersionParameter(buildParameter, templateVersionParameters) if tvp == nil { @@ -143,12 +144,11 @@ func (pr *ParameterResolver) resolveWithLastBuildParameters(resolved []codersdk. for i, r := range resolved { if r.Name == buildParameter.Name { resolved[i].Value = buildParameter.Value - goto done + continue next } } resolved = append(resolved, buildParameter) - done: } return resolved } @@ -160,7 +160,7 @@ func (pr *ParameterResolver) verifyConstraints(resolved []codersdk.WorkspaceBuil return xerrors.Errorf("parameter %q is not present in the template", r.Name) } - if tvp.Ephemeral && !pr.promptBuildOptions && len(pr.buildOptions) == 0 { + if tvp.Ephemeral && !pr.promptBuildOptions && findWorkspaceBuildParameter(tvp.Name, pr.buildOptions) == nil { return xerrors.Errorf("ephemeral parameter %q can be used only with --build-options or --build-option flag", r.Name) } @@ -173,12 +173,12 @@ func (pr *ParameterResolver) verifyConstraints(resolved []codersdk.WorkspaceBuil func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuildParameter, inv *clibase.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) { for _, tvp := range templateVersionParameters { - p := findWorkspaceBuildParameter(tvp, resolved) + p := findWorkspaceBuildParameter(tvp.Name, resolved) if p != nil { continue } - firstTimeUse := pr.isFirstTimeUse(tvp) + firstTimeUse := pr.isFirstTimeUse(tvp.Name) if (tvp.Ephemeral && pr.promptBuildOptions) || tvp.Required || @@ -201,8 +201,8 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild return resolved, nil } -func (pr *ParameterResolver) isFirstTimeUse(tvp codersdk.TemplateVersionParameter) bool { - return findWorkspaceBuildParameter(tvp, pr.lastBuildParameters) == nil +func (pr *ParameterResolver) isFirstTimeUse(parameterName string) bool { + return findWorkspaceBuildParameter(parameterName, pr.lastBuildParameters) == nil } func findTemplateVersionParameter(workspaceBuildParameter codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) *codersdk.TemplateVersionParameter { @@ -214,9 +214,9 @@ func findTemplateVersionParameter(workspaceBuildParameter codersdk.WorkspaceBuil return nil } -func findWorkspaceBuildParameter(tvp codersdk.TemplateVersionParameter, params []codersdk.WorkspaceBuildParameter) *codersdk.WorkspaceBuildParameter { +func findWorkspaceBuildParameter(parameterName string, params []codersdk.WorkspaceBuildParameter) *codersdk.WorkspaceBuildParameter { for _, p := range params { - if p.Name == tvp.Name { + if p.Name == parameterName { return &p } } From 834ce41013d2c26e7ee51479abc3a7236dbe6d69 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 10 Aug 2023 09:41:35 -0300 Subject: [PATCH 077/277] refactor(site): add default background color to html and body (#9009) --- site/index.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/site/index.html b/site/index.html index fb62af8b53a2d..29cafbb453a46 100644 --- a/site/index.html +++ b/site/index.html @@ -36,6 +36,12 @@ href="/favicons/favicon.svg" data-react-helmet="true" /> + From 091c00bd702156bc8a00ab88c177762e53bf8d49 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Thu, 10 Aug 2023 15:59:39 +0300 Subject: [PATCH 078/277] fix: make preferred region the first in list (#9014) --- site/src/components/Resources/AgentLatency.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/site/src/components/Resources/AgentLatency.tsx b/site/src/components/Resources/AgentLatency.tsx index 04f790980c977..9be53f106cc2f 100644 --- a/site/src/components/Resources/AgentLatency.tsx +++ b/site/src/components/Resources/AgentLatency.tsx @@ -69,14 +69,9 @@ export const AgentLatency: FC<{ agent: WorkspaceAgent }> = ({ agent }) => { - {Object.keys(agent.latency).map((regionName) => { - if (!agent.latency) { - throw new Error("No latency found on agent") - } - - const region = agent.latency[regionName] - - return ( + {Object.entries(agent.latency) + .sort(([, a], [, b]) => (a.preferred ? -1 : b.preferred ? 1 : 0)) + .map(([regionName, region]) => ( = ({ agent }) => { {regionName} {Math.round(region.latency_ms)}ms - ) - })} + ))} From 967a4b0c7ce88cff5eef34b2ac61572762d71d3e Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 10 Aug 2023 16:36:18 +0200 Subject: [PATCH 079/277] feat: add example template using rich parameters (#9020) --- examples/parameters/README.md | 19 ++ examples/parameters/build/Dockerfile | 18 ++ examples/parameters/main.tf | 281 +++++++++++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 examples/parameters/README.md create mode 100644 examples/parameters/build/Dockerfile create mode 100644 examples/parameters/main.tf diff --git a/examples/parameters/README.md b/examples/parameters/README.md new file mode 100644 index 0000000000000..8ebd3ee3c8b50 --- /dev/null +++ b/examples/parameters/README.md @@ -0,0 +1,19 @@ +--- +name: Sample Template with Parameters +description: Review the sample template and introduce parameters to your template +tags: [local, docker, parameters] +icon: /icon/docker.png +--- + +# Overview + +This Coder template presents various features of [rich parameters](https://coder.com/docs/v2/latest/templates/parameters), including types, validation constraints, +mutability, ephemeral (one-time) parameters, etc. + +## Development + +Update the template and push it using the following command: + +```bash +./scripts/coder-dev.sh templates push examples-parameters -d examples/parameters --create +``` diff --git a/examples/parameters/build/Dockerfile b/examples/parameters/build/Dockerfile new file mode 100644 index 0000000000000..a443b5d07100e --- /dev/null +++ b/examples/parameters/build/Dockerfile @@ -0,0 +1,18 @@ +FROM ubuntu + +RUN apt-get update \ + && apt-get install -y \ + curl \ + git \ + golang \ + sudo \ + vim \ + wget \ + && rm -rf /var/lib/apt/lists/* + +ARG USER=coder +RUN useradd --groups sudo --no-create-home --shell /bin/bash ${USER} \ + && echo "${USER} ALL=(ALL) NOPASSWD:ALL" >/etc/sudoers.d/${USER} \ + && chmod 0440 /etc/sudoers.d/${USER} +USER ${USER} +WORKDIR /home/${USER} diff --git a/examples/parameters/main.tf b/examples/parameters/main.tf new file mode 100644 index 0000000000000..0903a2d2e6475 --- /dev/null +++ b/examples/parameters/main.tf @@ -0,0 +1,281 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 0.11.1" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0.1" + } + } +} + +locals { + username = data.coder_workspace.me.owner +} + +data "coder_provisioner" "me" { +} + +provider "docker" { +} + +data "coder_workspace" "me" { +} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script_timeout = 180 + startup_script = <<-EOT + set -e + + # install and start code-server + curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/tmp/code-server --version 4.11.0 + /tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 & + EOT +} + +resource "coder_app" "code-server" { + agent_id = coder_agent.main.id + slug = "code-server" + display_name = "code-server" + url = "http://localhost:13337/?folder=/home/${local.username}" + icon = "/icon/code.svg" + subdomain = false + share = "owner" + + healthcheck { + url = "http://localhost:13337/healthz" + interval = 5 + threshold = 6 + } +} + +resource "docker_volume" "home_volume" { + name = "coder-${data.coder_workspace.me.id}-home" + # Protect the volume from being deleted due to changes in attributes. + lifecycle { + ignore_changes = all + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace.me.owner + } + labels { + label = "coder.owner_id" + value = data.coder_workspace.me.owner_id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + # This field becomes outdated if the workspace is renamed but can + # be useful for debugging or cleaning out dangling volumes. + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} + +resource "docker_image" "main" { + name = "coder-${data.coder_workspace.me.id}" + build { + context = "./build" + build_args = { + USER = local.username + } + } + triggers = { + dir_sha1 = sha1(join("", [for f in fileset(path.module, "build/*") : filesha1(f)])) + } +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = docker_image.main.name + # Uses lower() to avoid Docker restriction on container names. + name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" + # Hostname makes the shell more user friendly: coder@my-workspace:~$ + hostname = data.coder_workspace.me.name + # Use the docker gateway if the access URL is 127.0.0.1 + entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] + env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] + host { + host = "host.docker.internal" + ip = "host-gateway" + } + volumes { + container_path = "/home/${local.username}" + volume_name = docker_volume.home_volume.name + read_only = false + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace.me.owner + } + labels { + label = "coder.owner_id" + value = data.coder_workspace.me.owner_id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name + } +} + +// Rich parameters +// See: https://coder.com/docs/v2/latest/templates/parameters + +data "coder_parameter" "project_id" { + name = "project_id" + display_name = "My Project ID" + icon = "/emojis/1fab5.png" + description = "Specify the project ID to deploy in workspace." + default = "A1B2C3" + mutable = true + validation { + regex = "^[A-Z0-9]+$" + error = "Project ID is incorrect" + } + + order = 1 +} + +data "coder_parameter" "region" { + name = "region" + display_name = "Region" + icon = "/emojis/1f30e.png" + description = "Select the region in which you would like to deploy your workspace." + default = "eu-helsinki" + option { + icon = "/emojis/1f1fa-1f1f8.png" + name = "Pittsburgh" + description = "Pittsburgh is a city in the Commonwealth of Pennsylvania and the county seat of Allegheny County." + value = "us-pittsburgh" + } + option { + icon = "/emojis/1f1eb-1f1ee.png" + name = "Helsinki" + description = "Helsinki, the capital city of Finland, is renowned for its vibrant cultural scene, stunning waterfront architecture, and a harmonious blend of modernity and natural beauty." + value = "eu-helsinki" + } + option { + icon = "/emojis/1f1e6-1f1fa.png" + name = "Sydney" + description = "Sydney, the largest city in Australia, captivates with its iconic Sydney Opera House, picturesque harbor, and diverse neighborhoods, making it a captivating blend of urban sophistication and coastal charm." + value = "ap-sydney" + } + + order = 1 +} + +data "coder_parameter" "apps_dir" { + name = "apps_dir" + display_name = "Apps Directory" + icon = "/emojis/1f9ba.png" + type = "string" + description = "Specify the directory to install project applications and tools." + default = "/var/apps" + + order = 2 +} + +data "coder_parameter" "worker_instances" { + name = "worker_instances" + display_name = "Worker Instances" + icon = "/emojis/2697.png" + type = "number" + description = "Specify the number of worker instances to spawn." + default = "3" + mutable = true + validation { + min = 3 + max = 12 + monotonic = "increasing" + } + order = 2 +} + +data "coder_parameter" "security_groups" { + name = "security_groups" + display_name = "Security Groups" + icon = "/emojis/26f4.png" + type = "list(string)" + description = "Select relevant security groups." + mutable = true + default = jsonencode([ + "Web Server Security Group", + "Database Security Group", + "Backend Security Group" + ]) + order = 2 +} + +data "coder_parameter" "docker_image" { + name = "docker_image" + display_name = "Docker Image" + mutable = true + type = "string" + description = "Docker image for the development container" + default = "ghcr.io/coder/coder-preview:main" + + order = 3 +} + +data "coder_parameter" "command_line_args" { + name = "command_line_args" + display_name = "Extra command line args" + type = "string" + default = "" + description = "Provide extra command line args for the startup script." + mutable = true + order = 80 +} + +data "coder_parameter" "enable_monitoring" { + name = "enable_monitoring" + display_name = "Enable Workspace Monitoring" + type = "bool" + description = "This monitoring functionality empowers you to closely track the health and resource utilization of your instance in real-time." + mutable = true + order = 90 +} + +// Build options (ephemeral parameters) +// See: https://coder.com/docs/v2/latest/templates/parameters#ephemeral-parameters + +data "coder_parameter" "pause-startup" { + name = "pause-startup" + display_name = "Pause startup script" + type = "number" + description = "Pause the startup script (seconds)" + default = "1" + mutable = true + ephemeral = true + validation { + min = 0 + max = 300 + } + + order = 4 +} + +data "coder_parameter" "force-rebuild" { + name = "force-rebuild" + display_name = "Force rebuild project" + type = "bool" + description = "Rebuild the workspace project" + default = "false" + mutable = true + ephemeral = true + + order = 4 +} From 76ad116e12552ed76dcef9239347fabc37b99e81 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Thu, 10 Aug 2023 10:02:02 -0700 Subject: [PATCH 080/277] docs: write 2.0.2 changelog (#9025) * mention provisioner authentication * add changelog for 2.1.0 * rename to 2.0.2 --- docs/admin/provisioners.md | 10 +++++++--- docs/changelogs/v2.0.2.md | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 docs/changelogs/v2.0.2.md diff --git a/docs/admin/provisioners.md b/docs/admin/provisioners.md index 8f733fdccaa7d..6843a4e3efaad 100644 --- a/docs/admin/provisioners.md +++ b/docs/admin/provisioners.md @@ -16,10 +16,14 @@ By default, the Coder server runs [built-in provisioner daemons](../cli/server.m Each provisioner can run a single [concurrent workspace build](./scale.md#concurrent-workspace-builds). For example, running 30 provisioner containers will allow 30 users to start workspaces at the same time. -### Requirements +Provisioners are started with the [coder provisionerd start](../cli/provisionerd_start.md) command. -- The [Coder CLI](../cli.md) must installed on and authenticated as a user with the Owner or Template Admin role. -- Your environment must be [authenticated](../templates/authentication.md) against the cloud environments templates need to provision against. +### Authentication + +The provisioner server must authenticate with your Coder deployment. There are two authentication methods: + +- PSK: Set a [provisioner daemon PSK](../cli/server#--provisioner-daemon-psk) on the Coder server and start the provisioner with `coder provisionerd start --psk ` +- User token: [Authenticate](../cli.md#--token) the Coder CLI as a user with the Template Admin or Owner role. ### Types of provisioners diff --git a/docs/changelogs/v2.0.2.md b/docs/changelogs/v2.0.2.md new file mode 100644 index 0000000000000..01377772e2d48 --- /dev/null +++ b/docs/changelogs/v2.0.2.md @@ -0,0 +1,39 @@ +## Changelog + +### Features + +- [External provisioners](https://coder.com/docs/v2/latest/admin/provisioners) updates + - Added [PSK authentication](https://coder.com/docs/v2/latest/admin/provisioners#authentication) method (#8877) (@spikecurtis) + - Provisioner daemons can be deployed [via Helm](https://github.com/coder/coder/tree/main/helm/provisioner) (#8939) (@spikecurtis) +- Added login type (OIDC, GitHub, or built-in, or none) to users page (#8912) (@Emyrk) +- Groups can be [automatically created](https://coder.com/docs/v2/latest/admin/auth#user-not-being-assigned--group-does-not-exist) from OIDC group sync (#8884) (@Emyrk) +- Parameter values can be specified via the [command line](https://coder.com/docs/v2/latest/cli/create#--parameter) during workspace creation/updates (#8898) (@mtojek) +- Added date range picker for the template insights page (#8976) (@BrunoQuaresma) +- We now publish preview [container images](https://github.com/coder/coder/pkgs/container/coder-preview) on every commit to `main`. Only use these images for testing. They are automatically deleted after 7 days. +- Coder is [officially listed JetBrains Gateway](https://coder.com/blog/self-hosted-remote-development-in-jetbrains-ides-now-available-to-coder-users). + +### Bug fixes + +- Don't close other web terminal or `coder_app` sessions during a terminal close (#8917) +- Properly refresh OIDC tokens (#8950) (@Emyrk) +- Added backoff to validate fresh git auth tokens (#8956) (@kylecarbs) +- Make preferred region the first in list (#9014) (@matifali) +- `coder stat`: clistat: accept positional arg for stat disk cmd (#8911) +- Prompt for confirmation during `coder delete ` (#8579) +- Ensure SCIM create user can unsuspend (#8916) +- Set correct Prometheus port in Helm notes (#8888) +- Show user avatar on group page (#8997) (@BrunoQuaresma) +- Make deployment stats bar scrollable on smaller viewports (#8996) (@BrunoQuaresma) +- Add horizontal scroll to template viewer (#8998) (@BrunoQuaresma) +- Persist search parameters when user has to authenticate (#9005) (@BrunoQuaresma) +- Set default color and display error on appearance form (#9004) (@BrunoQuaresma) + +Compare: [`v2.0.1...v2.0.2`](https://github.com/coder/coder/compare/v2.0.1...v2.0.2) + +## Container image + +- `docker pull ghcr.io/coder/coder:v2.0.2` + +## Install/upgrade + +Refer to our docs to [install](https://coder.com/docs/v2/latest/install) or [upgrade](https://coder.com/docs/v2/latest/admin/upgrade) Coder, or use a release asset below. From 83061bef7e8ad2ff15fc85272e804e3f250b223d Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 10 Aug 2023 14:47:56 -0300 Subject: [PATCH 081/277] refactor(site): add minor improvements to the port button (#9028) --- .../components/Resources/PortForwardButton.tsx | 15 ++++++++------- site/src/theme/theme.ts | 4 ++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/site/src/components/Resources/PortForwardButton.tsx b/site/src/components/Resources/PortForwardButton.tsx index 2e9089649e808..0209f4bd9a9c8 100644 --- a/site/src/components/Resources/PortForwardButton.tsx +++ b/site/src/components/Resources/PortForwardButton.tsx @@ -58,9 +58,10 @@ export const PortForwardButton: React.FC = (props) => { sx={{ fontSize: 12, fontWeight: 500, - height: 16, - padding: (theme) => theme.spacing(0, 1), - borderRadius: 7, + height: 20, + minWidth: 20, + padding: (theme) => theme.spacing(0, 0.5), + borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", @@ -82,11 +83,11 @@ export const PortForwardButton: React.FC = (props) => { onClose={onClose} anchorOrigin={{ vertical: "bottom", - horizontal: "left", + horizontal: "right", }} transformOrigin={{ vertical: "top", - horizontal: "left", + horizontal: "right", }} > @@ -210,7 +211,7 @@ export const PortForwardPopoverView: React.FC< max={65535} required sx={{ - fontSize: 12, + fontSize: 14, height: 34, p: (theme) => theme.spacing(0, 1.5), background: "none", @@ -248,7 +249,7 @@ const useStyles = makeStyles((theme) => ({ padding: 0, width: theme.spacing(38), color: theme.palette.text.secondary, - marginTop: theme.spacing(0.25), + marginTop: theme.spacing(0.5), }, openUrlButton: { diff --git a/site/src/theme/theme.ts b/site/src/theme/theme.ts index 48adafe05408d..354ade445fee9 100644 --- a/site/src/theme/theme.ts +++ b/site/src/theme/theme.ts @@ -92,6 +92,10 @@ dark = createTheme(dark, { input:-webkit-autofill:active { -webkit-box-shadow: 0 0 0 100px ${dark.palette.background.default} inset !important; } + + ::placeholder { + color: ${dark.palette.text.disabled}; + } `, }, MuiAvatar: { From 175aed16858548287c3f965f3653f2bf457242b5 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 10 Aug 2023 15:23:31 -0300 Subject: [PATCH 082/277] feat(site): add tooltip showing the error in the failure badge (#9029) --- .../WorkspaceStatusBadge.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 2ace2d0b903b8..75a35f6839657 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -9,6 +9,10 @@ import { ImpendingDeletionText, } from "components/WorkspaceDeletion" import { getDisplayWorkspaceStatus } from "utils/workspace" +import Tooltip, { TooltipProps, tooltipClasses } from "@mui/material/Tooltip" +import { styled } from "@mui/material/styles" +import Box from "@mui/material/Box" +import ErrorOutline from "@mui/icons-material/ErrorOutline" export type WorkspaceStatusBadgeProps = { workspace: Workspace @@ -28,6 +32,27 @@ export const WorkspaceStatusBadge: FC< + + + theme.palette.error.light, + }} + /> + {workspace.latest_build.job.error} +

2#;s?62+-~X4|4QVN5zT4=msBKq9bH} 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 O=OXap9NF;NAO8f#png^W diff --git a/docs/images/gateway/plugin-select-ide.png b/docs/images/gateway/plugin-select-ide.png index b138b7b2a41d5baf82459d662f3e62dc64a9b251..bc4e6cb1ee03be03762de09317e3777f01487152 100644 GIT binary patch literal 87207 zcmd?Qg%|?(S|OSa5gu;O+r}LxS4`2yVeGxX<7YgWKS|NzS>y zdr!{$1K#&Nz8L;gIaARF0LXgE>5B1>|kzXYX$=&6Pc2Rq@lit-M_PZk$_173rnBREy@R50567; zM?oq$T-JeGZS z6h}SU4PTuYR;9xHl7*ISziO54zf-|a$A;!W& z{2Y4}&;ap(Ap|-AUsyfd9Y4)XORnot!e9qFMZMPk9D8bHdx>UtCIGXj^wWWzUzw{1 z|Bd-zH5{8iPmdM#qHqs=cT7Jw{5eod-TbK;`ZHpbWsW+rV7$!cYGE}_; zcQ2L~7I>**s#`xq_71!uo7^e5m!vy%$e@=~45UyTr1l`>VpmRL9LE0nAWYu>RDZzK zYx%;(c4Bg?GjTp{CUkM+CyHjxTQj-q*YXnGr`=gq$Z>>bMpXgpucP5xRkju?=#9Q+ zF`#TRx}9B5d}SI%k03JhQYv8AE?dmyAA-AiXCP@FGnvb>&3K76`E^5z*AScE?2`+} zSFwKM`xO5sDm9&scSM`qsoW}Fkb}jJMeQ!_oDuo>7h22uZ?rnz4Uta$jMbifg?$d! zXKeiGv_?SKj73K)gxw$ux8P!DTbh1s+lX~@egS!l<0oc_L=5{01x(jB3}WLSC_Jkl znaO&#B6ps*eO=9^&SXq0DBP)TdY2Zt;9ELw4+ol9olHe&vGL~+)Ms$ok z9Pd?xi*6qR3Cxg?ajaKgf(fY1Bhc3)95EeeU{J)V65(kGWyK33;L_un#N`sy3a}UA zrYSloq!XtGBnwEJ!d)r-28cH~qQtFUqs>T&$BwU*SjMK&5f&c}xLhUoip7 z*f*If60hkFs7NTYs25*dU@qfCN-%#xn204%P@pi!+`-bu=Eea>7DpWR*ssGxN=#BN zB(%z`(ZJHjYe)cPfYN-j&7%91el+C?=!0>aBFvBqVz4mmE<@ zv{_VNeil{~9TdD(uTu4X9jxJ2hAyg%e7Ag;^Md1||02FjL6cvz zy-da}(K^LCcprU6`#EZNOsCn;4=>*g5rz}Y5wOm@w%)dWw1%q-wbrzjp5dEDoKD|= zvA?#zu+KOHDKpdvC=#7~Glx8mY4y_jV4Uac#7GaD;^#*NN*6*Gx^t4+a7k00Z`T#i zwNPw?%VTE%rTKdmgyp=_wQ}c;=EZnQ!+Ozxl)}3A;JjJV%CU`$ey9G({z?LE?xVg& z4B$qcUtKS-7kCM5s1pW%bAxkbcbnaRyDu==3w{q)n#rzgk%VL&4WCw==4^y+ME8@E z1Q=q>)XqjvXBIbzA4e^JY!CELXjcgM`bhYYhWH+_9gz^x43X~n?ep}oZfg#rxAkx0*bJ=o9PS4~th?A`Iq5m9*!H+N-)7tL zt$d%oXeaEAF-f(r@2r?P2yP1X%DQnrmpZq=1LFBc!$rT34o`KJCm+^Hok?w|h|nS_ zS12FRnyXZ`!3Vp78NfDNC9&nPJ?6EFcko^C5R^%j85}-at9m_7qq{M<$Ki_QO6Q9IketVoCxnN|&C=a-!}9R8YlU;`mV^hY>-){=%?sc{ zpUy#R?@bljlv&NNa~fV6pvu0=d5&*Rh+DnEv_a9Hlmw41s5Z%^Gkf)Dr+whz^Bo0KhnI(iiK9Yz!ZXhEhWQ)UH{k(V zOM~&!4`Pm%C&1Mg?LvkFfHg>XV<;0cFR>h2ENbO*KWuZ-R~R-qF-kAeTkX%f>CM#( z)QP$&v&h?7?o~r1dnzIfi?NHP6e=@r-%e6G`eKD<{dTsZM6^;Z5x* zbnu9KNX^D>ARWki+l#fYl%J-#oykFPbcAkX+J zB+a)oqz7(d+`htt$GaE65LV^^n6*#b)1J5BIpW(IMldPp%-0N=@pHJFy*rh z>N&fWK1@y)9;fy)+ptWt>gmlth>v5`NtQ{TeTm!38E$xtcv3wx0ihP-5^MW1;^7)H zRI}2v-qffyo=qyh5Z1iE@YCEctDmVVZ>XTGN~~(CowYd3QM;TBnmlq#p5y#fJsdT> zT$$VSXx!v-GmAcr9)+HbYlN#vtjJxpa9zmWcIKX9sQK`!O_7nyZ=MP0YZ85&+FW_7 zD_`%w{A$77!)N>|%=&!)jI@XJhCk;hW@+J;V`nErzqv)u-EWC`X{m|Md2E5GAtR&B z+edruOI;MO-B0$e>UihSp{%wNWB|}KbcT8H`h~y~?M)g#k~l^^$vR0HzpcCH6xMd) zI%0v5y?B6$w~2+inEKsB!dK>zoDpgNx;r-r+gB6Ke$IG+kf@-l=lb1XQ1Y{4(_(`h zCH|!)_S&t^^lOfWQ6)i;JIlpl=y~AI*mq}LWrLGu$Ad5Zy9 z0UYnFko$hSrd%^RrLL3SioYb&elow*^((g7Ib|`JOQBO$CUZq=F}%;YyGzUID!wS* zY71-Q93#mk1*09bJo*C9bMR~MgIS{=Rh~?y7>BjPpDrHGuz*+8^Tv=I|9;BHiOnw zVZxc;a^kLz*u!|k!JPBKxB&+g1Z@Wfo<14{J@rt#K)yA>9qhtHYv~Zhd9d@9*}GUexB{wsp}%2gtu)@bzEf1-GjXtEF*0>9He>O$ zbNrPBM!=H~nzS=>HKOpev$c2O^Ax1|eFYyh{p&O<6~*sMTx|rY-YKe3h&wo&QE;)m zVR=I(gi1j{A>eFk&Zj0J^>=pYFF`6xS64?qR#p!W4;Bv&76)ewRyJNQ_bo_xIO*nt59N zwJCSSuvNK-&0YLleR?CiyoOFr6oXg5&JeR z^qvAol_|Kmv`zrks>eQ;iV#(wiu`=h6O2LdXYJ$3$w|}EgN65o!M5{_^cu^I4j%mO zX{v2k{6oT)BX3lseSKRAF=8TJ^o~O3hFRMwbQTuQ9+AjpjEpAQG`4iPC@Dp6A!8{~ zQF2XA8)4N;+#Je(H&}!p`{ZSw--huPs|$yjM(xS>Lq!=m%zGayW}U~?eP;S9hua#H zu{*!3!zGHR4aP!thK4<~ z*%1lwwI+uodpkLGbs_VZOFX>HNBCy;z?<{!!B2z91qY*~H*4|!w#_xBr&NT(_E~zZ zuChY+yCyCN>BYH8qR)_q7F-Z&Zl{#}TC!3z7)<(OxOTW57J#J)mQRa%gnX_Mdw10) z{eE{Z_af=Y0u;i~Ng3`iNIuzt8p4^m%r7hl2V}HI(+W#UWM94?9qlSKGjrY?P_2Y~ zJa_B?o%oxZnj(`878Mmq>ak453=R&~7~gcWdyZ3_=fQ22*;t#NZ!1Q zikegXJ$7ivj?ILsF$Ey(-w4U2wG zeGC$cjdLN};`%kBUG3ns0S26;yOlY^8o2Q?;$hF(a-ShR$Be-D+`7F4Tg2(Nl^Ibk z(QIvBCF^il48>SY0Sl#1V5oZgrLV$`*F#Q=9*h`*?BHxN?H60=(f1J)kC$|}$1)kk zGy8jU;xBM=4wbb4sh7lDa-yQ51-(>5;X=aa)5(2Y^9H)QjMwFvpFY1HCO3G6nVLw$ zikJAwgzWDp0wz$4Y}~@T`1_f!cRo=m)l07x=E@5`QogI77yDW{W8<>@eFKYpqRSp0 zbo_Dk%N1Em!1D#ZG;cAa2#pb|PoM0tN=yN$o-x1rnU63qI*o;>^J5DU{7&7vaUg+Y z&@~B<<3|lmP5Gd(P^#<>N7dWy&4Dc5TF%9q4ir!*#C~|z&RM#x#K7vTK$hM6;`QL( zlr#ea<5tjYwHt_wS_{*@`NN|Cp@8Q%toOZhfZ@U9;?423Za9psZU$paMBl^r)iDG) ztZr^@H9lhodmwIUNH}p|*u8E;CkXf&1?7}scDtN{h^b9O#jwR)@kNRBS(dzKXE(pe zt*Z%3YcRm?lCee<0jN20Y!R=$5)_B9aJGpPFE4yZj(shn*ZxrJAwEOO=PSYGut3%3 zeVOFow*lVvjJvw&>Ww~IM;nt@Q%icunEd$cR=(1kc*XD7FI~_kb#n(j`7Le*LA1+$ z<#M}8I3aabaPisIA;^5ix%XMb+EBGz|JM8|P7QSajEsy_&3r|~>S=CXq_N*L92z?0 z*%CHa@7oWVXESkSx+S{fvIo*gcL7LT-my+bqdG9n3eWk7n^QDhu9(oZcQS*GkLD z*-~tr9&NKOm~_)H3F>VVJ~`2r?jHkg@k)2mk6=nLHO9OIi`_0z@^PToDgD^voz0NBe-hb+S(dC2B^6o<%^-p|*S(J_bQW@D#oD5`` z^?VY&bOL@`TU$xx#xPG5{_=8iH0r&SYEs_KgULxrRMGg%Z@)B2g=*KJvPIA$?*#>& zpIw_MUWyhhswF;CjrI4tIHhxz6_2uG&(XJgh60Cg&_%?IW2*d}@|$S==bL(Ol+=)Ad`X5La(CbyH}|N1kA9suLHv5CoMWItJoE2+4i_;q+VR(!=f7RmRc(FlD$ zg{Yz(;O86RNVJ|JaBMGL$=rB(!6}Jzz9hoXl#?HZ1GRgdr{+17*d&KNha6>_i;bvi z4_;#XzDfMOB)Y=FRl@J?_7r_9kg<)YIo^F_-tj}6A(ma{n`<4*yWK}Z$ll`pH5u%B z!?ToWqrU+Aawf zmpkG>Yj*A@0$%5W1wOy+7_6+%)VwZzuGG$}OTpI??;*YTedku)T>UYG%%JHF^Qji$ zfU$^5ZA1S-5?~1$iIM*r*w9DOyR>9z=x35mTa@@^r+-AIv{uENWl$ou)OXB09tqwQV`XL}iSXI(C1L{7dEb_>-mX@_MuAu8}_nf9eSlZSo+ z$n^r+g2;u(n|d=F>}SFe2A!qJ*+m3|3dugu%?X zdjVkm3-5@`QH;xdlrnM8_NpRPj9hJXOSG9;jXGGksv4UeR{qs7o+9w8TU~lGo@{p| z34tf`bw2SiYNzwk2$FcMrWpwi5n=Vfi(P8QgQA-dd{j!nsHvd3`sf}*sgN3^ z{L*(RTuGZ1<;?XW0w!2=0+=E)Z+NOyL~kF%$wM=QE7!{Vd`hH$pye2aG)QHP?-dt=fQk5<_H8J?geg*bqhJ$k1@yWqWxv z3>YbJqZolyQr3NI6^EWUX5KqDZo#&OCiAt>(0-rA#_A#zfj6S4*2Guc94}7Ob93>V zXO(mKa8KWTMSt~s4GTn)&r5+3)KRz5YL4w;YE7lp>DZbBRby<8>L@xer zby;i^Qxe3pmp#kBN&0;!I~Qf-%ZSeMFj?_+2;`z1&|dZsm(0xY606y|m#ZhEw#u=d zhUPn7Y;W&e>f$1;F*gMHpeDAinFGjEOXx=xHZ8%B|M4Ng=Ik*vsdU+LaPX z32FavHPiAlhlvtZ#kHEx@{t;j?GzVsDdqe6Wm<`-V^dk%3B6kw!nWHuV!jk@hin#A zB5hWfxhDN_6A%@1lx-5K@7O`;%XG+0o(9zf&$mByWro4CVc9v)Hkb|?Z!C4|iG+MP z0tNi=RLyovP2ptDfXCP`2edN13wf>P^P@sb@Eb3~bM0L{DNhs1)kM_js_(m*xy+X2 z>aQtJC_V}6)bW{;ZP#_7MKoo8!x$WeMK^P{6^WsweCbgYhXG$M`@(3!mEv9*x!%iV zC!?vFPB{k^_XnKRQOHdp=0GVCV)RX|=aWSZ(8f;4bNAb<3%4t>f!n4V9e4C0zdNFV z1p!@zQVaW=`454wGEeYHqxIJ~xw&rYVr|gRjVmDpXsW#A6!T~IG`~^iZY%sz73GTU z29g=BGaua8HsMHc>)IqVp4hyY(c){+tmeo-^6D!hq!wyDa&_R5$h4SPLRfE* z9hSa1eJzE2Rn{ULV`#0w)Ex-N{oru~#ImlRNMq4?ib75g&^N%srPKH{UdVqQH4N~hjPsl60$H2UP0hQzoLGMv- zW{w>;U5mE%=(}&;yz#k~-T`!EBtPy@I*|Y+{GyF*#f&Gw1hdW1krOzR8NHtYp=mGC zUIte9D(?V8W~31kbtM45;PtxCW|eZlHaW4EyeE6~rCZF$tP zv_tnh@)X&j!>n~2RJ&`SHMKKDA4B!Ve+|EXY}iKId)v>NG~M7KzS<%}sq(anj^4sx zQy4B-?l2x+AbJcJDx&yg5YvR{8i`T1_%q0CSC=evr?+PD-VoXRkW=R6K&qGc9%5-b}pJ`%5RB$w6A{ zI|-NUn;GyN*Da6vPs{Yua%7Vf0|A(uiTD6xfiM1Ur#yc*nR=C8#K=78F*cyygVl?M zS@sM&L*O&Qt)%H;wzZd`U~Zl2W1B*R%_j>BtzlHZ894(b^$6w8OP-qp$E4N{I$3LQ zX1j(&jErJg7NeS=;tCoNP^ytt)sXOASW0d6#`d9&YP~Ey@i&hA4JENY_FoeN0!9cB zFb;1oiFuxCUC^;ADSrjT5y;o5;}LQk|3e12!oYfJS6kc1dJ6$|?;Lp{wa@ZuQGPkO z5d*$Ud9)QHwnpyvO7Qy`iU@<*{zf*f8JzwLw?bo2uE2p;`tFB9livTm=x;(GP)Xz? z+;^PkaEgDQ_+7%UbYRXe3tbHRoy?z{L-t1)1WP&s_>6zC5rTnGi!y=P)&I5i_Z|ES zj9{UJgVG!1w}Joh!hbB@a8OImo1p{hKe-QAIJna-k0Vz&4okQ{Z3!YCbRrm*9^^(> zzXR;Qn)=s&K9pZ|v17jd3%UQNqF=7yhgyz=x(>+yEYB4i?qy!KDTO8Czbqb5%aUKn zg7BZ-+ogejEy6!2GXDHumLsU8@PWEK4X*3k1vj>3`eu zE1j2&nFn)7^GuZEUzQdesO5nk; z`u{rnuSB4d7_`fipTm#*YcL!sK`kxuZ|Rsvj-!jKibxe6zGnV2PFzrjnbH* zftao6D`8e;?Bt})i*yb(L+!csEFX*UAA#)9XPc1Awxocjcp!yQd-G?L%ljQlk6Etc zcmg&(|5M$Sw_7VutKtjwe$tuXGX~_l{#Zg2B~q0Zw9#B{P@4}VH9SqowBF-*fiVpY zZNyzmtB6VG`+%)sqo?ZCq40$6UXw}w&-NZl<>|xk zF7mo;B}$Vg_Vx8?!MV;J@Upuf5a1dkoOP4mkI$Vf)S3^Tk5zt}pKnp^^z!O9JzC^t zhjKPvkey>$KfgAIn^9*fHcHj)v0RIvr4E+JDC15;Kga-8r6?%l6F_dCR#!zGi#^1{ ze|3>N-8c)JYtKNU6p25?XC?V!c`r#=z>r8IhD^v+M$7N4x}fMX$w)jLrOOa3(3*!s z7n7DUCScZMVP{vFB^)3sH!Nq?bF!9{m`KTCp^!#|p5ph^9SLyw+;9q#2fV-sW3 zu8>IQi(M>wmUb3#M2y6cYj|bYX3tY9Ss9r(F~U$1Y@x}fVPW*=&jg33Ay#>E^g~hr z)eODJ4wUIhrc+FUg;ET`-bB$+RRfRDkl@!Uxq5qh2d9*UD5<65R9d_<1&}me`V@wu z;SChX;>1{1s8#EFHmN|l111Yjc!E8MKZIXOg#6)5esFN_t5H|?W>+ZM*+YQC-c0BZ zDbK@`d*8v4LwsxNlZ4LvL(yPFRNG)_50R(G?d#Y~?pVM1$O4B%VDRna)YKLj(DACY zO|7u3c_?IS{~Xrqwv^6I4-tH`tmeKG_YxOxP?|h|YEE8Gr9v`S-RCk%^{aIH#xA6p zm1|&YIBmpV&bdb5v}YseWLv@S!$+^WIe=lB@|^E0i_siTuak{@+Gw-;Yo|$JZteaG z)hGb|aFL?!=1C%TzQ9@^ECx;20%=(c@gLJjcGM3+v~m9jXS2wmgVtFBq#*DWTOIR7mF4ybLAlFuiNzkWUCF-ugna&x$%0YW6o92y>ubqw)5 zDE@N8`n2y5!)#!;+S7xAQ;-|CApCINVY$MvRR{2wl;Vz7&k8T*=j7CEzljA53*QGk z4M$9DVX!7kYccijsdRCkrDz+b&NrBBuI6`}>vhL=H?*I6ziSakcl=}B$WzB^q=Q9; zJ&$$p6@OEFTI4;F%A(Kk^(_fxCE(;4ZwZ-T=Fm(_*hNB)VVie~psf()_^Q5mQ1Ns& zg-rIBm+RXT(pm(kUUye^UUwG9M3h%&(X2A8l{=^XWVpyGOt;UfBj6J+ZkpW~-k6x( zoS89ae7h9&72|ojh~?IdF&l#N10%=-;c3h^)d?6q8_8xX4q}EpI^3ejp&D-w9hj|OgsldBkqPQL0t+c z)5f*D;{R~Fccr%grSy#aE`gl9PU{5~b4ZA*kDo_Tl;q^zVqmx*0cnRb@IN%?V_+^r zr?%z+jhfbzdl#va(dqi?57>R2mLysMw^kbN{Jc(?|3n0aAQ~(pET8Kx zW-?Mg<%P1sz8z{QwBY6MzsWWK}7Pr*qm4G%_H=o!^8xC!^b7 zr+vEoGL@K^nC{_jXCG`__WokYf#gda2>9bSLGs_lAaGQa!Wx!O9RauzFKn+>C+M`^ zc(Bx>-4-=Wt8PelwiIwzgL*CJ>8Ez>Fxw6sbGA_x}Oq$G$}X0 z&tiPzLP;5RLX_r+VxXNF<(8AL=)O#Kg=LS5QKA^i$30U-5b7pmt&QXExY!1_?4V&v**2!S*i7wVojwm{~a2!;UcxZFGuind1uOp zL>&ghTIkrCj20`7I`O$)e_JI_x+E|G{c;Jh8`~dP$dwFE6>VIH!+d}cS4~(IP^?I} z=h61LwT4rqtF&Ou5jiib(>E+GOkS?Gvom>i zw;|IF%{x%XUHCX0FHgKz2A5GHWo8vFKG?Uv1z=k(hO)z^Jd>UkguM1uyGoCQn3asO4Wh%z zAC-C6Xc;s5p!X$OhBAMwUpXs54qee7%9L*t_~&(S@!q9;JvU~vM_zNk60{`wQVpsf z{C^oaT<HMA^13Gm_|p1m)o5-bLHAmIxvIY7?hTjHKT@>WjL3{W}2Ro;poH+Xxk&b z`6|w{X|zOp^2%x=Jj<)eZQ=>xT&SvEim-^iSL&FJ~@eu{~Hmq?w z9bU8O^#S?Q1UXTl#SX#IeD&03u=XW%W26~*lwiZ|eq`mE(`6BrvUa1)VX`3djP7X< zLlKldvQ}6xlqozp#z?dueUrmgL0~*yG*vt{aA7s|ljSz-CRZl$YQ!{d6=@&T5YQ2$ zrVNhRn^NF8;ZPQ)CFZjKR6T?shFw0_rG;!ybHeL-ePpBR_MxXYhMX=kO9tkff{v?s3fjacz%=294PkZzBYtz9or9JY9zDi(yp*L`mOriS2@ovctx(&&|RlG0uHhD8{)Zrew4O(&>N5U*S7+K>!LPTxaIwYR; zxZ5wx8H8nwfU=I$Rs62oDgX+&iD6BK+waaHrv0P(YGr8KA`+qld48=8E>Nz~mdood zJ9ki4zX17?+UUhvSZt=)&S6-sP%8({I*wU=`R50b6?q`lO_P(ZsWq_4^Hgnj*9o_q zOZ1btCsk*yjo{aL4gZPndPg) zrkZu0ZUXKAQW{+w(5=?=bR)9oBFN1+t;_FhkzIJRgMir>cb=C@;^HQ&o*~5H6fJGQ zO}2lAv4Kd+6#JNqac>qzBV!6GWh7|fRcUU<4OCS<6Nw`A4-@IsGLH+HTituXGmHjq z%gt^cPOl+eKZo1&-t7t&dRm`I?!zGt{j5?xqh|q#AUmmGZLF^E*@V|YTinuun&LqIF8fi5Kv>DZe-FXS^oHH*rgz6|Xj3-uyLaJ55;P1CIs2KxEB;C` zivqf(jY{fb-+0bHR_Epe)fyzFrxR+Pe*gY9ozuj7o;`6JO;^<=>~7*o24p|xt6xXH zprgZXx4#)dR`t$c6b$yiY-t1A9fN0UcI9&I2*LGNth~HB$LScuXwXofF4t2p$8Vb+ zx-n0;zt3Z@O3i_Agw$~Lm~E>&_Jwh)eVc!M88^l7|_#8Ioi)R%0h9rpbn%L5h^LE zI1Q7EddtYD{(_vB!D1{s`ul+3&hXe+3mu?akoX5`^Yg$jP^x4JB9w4@dmCXlI<|9( z%T}TEshZhjN$VUMY*WYHu7pFQ*W>$x9cO50rqgPfPmvK43C|bx@h)R*Y^RO!{gr)~ z|JHHJVOuJsmN97qaw~W!3M7+#`Wy}~v)YLr5nq2ZJ*{D7X_=tgV4wMAE-HHK+znjV z+zdR3hsqUB5~w7$Cbz_(`*d%NWs#w@ep217R7REHtw!Bs!V&e+CDn0ZCn#_`r~I(Ai0|e~@j>RuB*HD!A&IZhWoZ6WF&% zW7jPK1jv1fr=OU~?XZ7*MNnE^K4oK(3zaIB_P*;c!@OwWY6V7YzOV6g%6AbP0&Neq2AjTRXxa z^S_P7wf#o_-hK+IzD-cj}Uog*$HB_rz=cT_dx{KH+WkOLk+Qh1zBy zcoyV`^>Ztd9ek%AjJqCPvwN3!)Mo52F%riI@?$6F!5n#LnU!1A;f#U)eoIZ3oAOIqt5-A~5InrixJ7cl}$f>%1kW zMn*cV25!+aH{GpDZ9&!4Gy_>Xhwed-iLKt3WxgkC{c1XzR`7JAYo&`&EcQ#Pa@HxG zOJEdEOs)3x*u?HA5^zq7@r8$2bKsOzu|$oHHT% zG**5jQAeq~(@M1#k1H{~)%7L96XBAZi%Y8J(+xB3ws64Hvxg*onv)s|uZ2Kj&LLBC z^MM;Q{LMBDA$G4rj2K1&Ds&Pal}Ybn!Z2Qju9I*??mNdT&zV?OLiseNR1-QSB0&5K z55ko+`TN!UzB1)GsET7&)?oQfp#JWY0($Z?0E7MD@{QM|#LDAcogLvSj!8&;M*Z>G z?a78_5@zm)DleCsoAA5aE~5_coP%E0<;`WOWX{Kx;Ks(5tFO+xn?X|e`PK0)mMDEUoAZui_)F@fJNa*R9>=Tl|BUG4ZlF3Y+1 zw|l0$Y5gO!@v(-Wqq9*Vpq;=^$ZgB6w#E2yNF`40(Xo~Pp#;#wVo8+in(geit+f(k zvD*7ZZFUxzSne+GyKQvw>9qaM@skV+Jl3F8K|5E@ao8PCNx89_mrL?k=b1O^u{y<`BE)yJ7 z*}1kAqD`&}m&Rhq1$pK|!q3Y~ zjK+jR$bBQ+*s@*w`qv=&A_%xkHWW@=DUvy(0FV=Bom&(4chh!sM8DqAgboo272SUh zj~-5}qh@68ZGQ-xF#kA)kN(-6dfc^Nzk3{agY1uZk!i}tcQVh`Ne zn+~jY_Q{8ZVP1v7C#sh%kLGEVQnKK`&6+F%jpVnXGlwxuaElbcr`1FFK#)d+IubYK994_ zNa$V+s|6aW0@kz*s54OO%5z%(3mjJ0X-JsFMt zxg@Q!?*P;3@v_DlbUdA+oVL$#-mclMIAkdbwWVea8ZwU8cf_mqV}NX#yQ2!$ zfiMcGqm|*j*7`O_6EatFaniLT@p@8Usk0XR*&O@phrz)J5X~wLly7*MQ!TB{gdd_w zRaihHU(p_gaE`w^)2*IgJ{J``{}I9_i(sR|pnVMAYI3!rn2m<}DJmL344}`6T9@K- z-b4=kAsW4W_N&WLk0^P2`JC!z0Y#ZefW3s6I|?c``+R>yE_!pn zFI0}kVkE>UkT)-NHVn39E$J8tR~b_sg0Z?NNi=7%O&?8EYA-gh%r(mEvh-{?&{1%k~f3WfTa!|)%oae)`T1Fi4Hr_g?Y}1I*jOd(Q zfgAsMQ$w(=^G@BJf5(&mTp+WBF!h?%dDtBkmfQnRhU49(z~Ur}#C8l*U~($goKfcb zwWaGe{l*VDr!k1tXhht?Alnv$Ts!)Ws(uIGpxZ#cu4O~2dt&UKWgt+?KKt^oez(1u zACo!O=Molrusv<5zdy3_hcH;^`nyqKd~sPtaCy~H-=&{9%6Cw2D17MUi@4=fgT5bL zE3{KzD-hRm2Zne|=!CoQLjgznSli*8Qh}exEWW8YCN<>POvM0X(8va9ihG8d--;}82<1X0J(f=)TrN$n zqvJ(*79?fv&-gOY_MuY(J>&M8n5*c?v8}BpW}YR#vu4-qxjEhkKg*PZOR$R{ z&-vXYebs_Xe5>dN6k9;m*O^|x-D6U-pqH*o8P7+AgZ8EHxG{9OfY7DGP9Hj2S{`Ho zE)_z=a&d|~v9EU@u}13P10%t$a@43SdaVT3?#au=-!SJ~7<0KGu#0H*_A5qWTbL|6 z9)@T{4KjD+>|)$h%o}4&tL-?B0b%Gy$E-pfqCi$+-H57H`zmdLRh`olcCX_Hj6$g+rMlCWfOUtU zmE+H~f*|R|SNFFI(&Rm$%`~K)Y}Ej*`-QF!H-1}rhGnh?f(^u^~DZ=CC& zsm8E+%*vaOEhL~Lo6!jKPjGLB=Ap^`nmMk@Sd&@}9zs6^Yh$66(}2?qSeFy056#pY zO$@F=7&nBD$%eL$O|;5za_w^bH=6lD*>4!_^aJm7vHn)QVN3PkFssANG9 zKf+&+`Qjx%1f+(zMNOES=g(tz0*agFV!b-z110OyxO-g+s+lL=FU1s$*(0u9eQomK z1)tUS#;fCG&O)p_+1idYN9z~ejA2OxJbT{~12*ThrcQT{l>HxE>%qSu;Sz=gsR5{7 zpf%V3;qtC!SCc#r;X&y-rPHayo$9cq_K1S`i_uG20|1-D*ov%02i3w=4PBD+sDS-; z;zhs1ea3;+gU??7>=M_3m;tmSlv@M(Qc@1U5cXtXa^9xJ(lAR`nOV-f`C{f#c6^QpQH^S zJd%^-tf!I9(7FVV{2x3TV)>3Zn*e~k+xr9O)KjoJSYR1L?%rQGs0t(lcHee!;9fYi zOXdg6`O`oj7Cjn)CA0ghhbr_AuGTYZfMFNo*OM{Ezunsk{MHXtLp1yC-ubIwA?mu3 z=r$;&vxyg}Z{QdIDSgFnaRJ)l2S3RN-QPl3Ed2cIubr%or*46e7HwXT^IYT}C9>Cr zyuTeMY5dXS=A5zx$lq(xcIm-!cCF5C80m8E`1p&q9}nII|C%;p{{t}c`|)UVux&(` zDveiLU#nmMUG_tiDQGxHW{CH^FkX+XT^bO4`U50`P&hF}?)D-gh z|I2GqBGb{BScL!N`G2#O-$S9&7TuES=l`;aOhbvnuTp(~l7Ub{(e9V-2G5#F{9iUq zPN?{(B(eIROz1Cpj)*Q)6jUEdH2kMl2L?q#@gv+@Yg49wNv&wc@u0dSyZk=(KUqu> zI8~@BD3*v2?cc@yf4)g}czJtY$rB5NsDYE_@vI!13jqlR!4|HlFWL|l)3Ta6nEcHB z_0WBS1K0T zyBtYdl?Q=87Lz_b*wkf1 zX!XN&c26{yDcri3GqbAhw`e0z34;OPT_iZ-n&LJFw$nj1f1~1;evdno?+mGB7j7mU z@=>e(O$zh3<31TK+O>woqPyi>=`G!3K;Nk$-k4L)WNljUME7|pqPZANDO*o=wgU^x z&CIZl>`}B}bA+Rcd9nuF->JDhd>UK{*dvc9K{_A851@QT{?I||wo6hgyqH^*kr#y|sne%weq%nWNmOq1;C+bcSAomnG>G24VcV1E5mu$1XUTai@ zuH2+_JMRqdPdx0Z@p7sAVv-Y0`Wtxaw}U-&4_u(6BUCE)e@C7Trlkig5l${mFxgW; zL3^1-l+c|+(&ubC-r4|F$yM|w^=~7{sWnsJLfa`sS@I@@Tjp!GCKvnKx6@iy@2u+! zUSyVw?HU%oM|drP8+SQsSORw#AS?F1Djo1n-}!xaz>2kEBd_qbw!y;5+@!p?er<8b zD^70Kft1StS`@C_mEfoa}P~c#mB9NxK_4r&Fnz4IT@hP_J^PZ zm!j)wk`@2iRJ`;G!j0Td>xWNNZXQ*om=9skeK&>WJ2O|Nw~65nqatHD>gDvrN(LJ@ zUCnS!uH@YV4A+yqGcom$FZYvb^L^c^*=#;z&8O_|xfXYEJ}r>2ZxH;TP~GL|#E>Od?{+%uu8}o#S9^7+x?DEXlku zbE|b`)a>A-3BrTbT)HzUPcLjmAE4>yZs|Xsb7no&d;3lFD?zZcU+e6e`CxTWLsNX7 zlm=Y@4v)M~#q!$wTw2D*q;^(KwL}JzJ@*8l3$wk+S0X>ikBsD8Cq5I{lL~)-rC#x^ za|3<-_iu!XNB{rmVE?nY4y|F9)!visA+t<*4B3W?Sr<{r^%n$eNAA3-mWIfW9BoL; zXr(y|(8nKTcNOKx31q@Q=jn*XQ@~f0&rvgLdNgIT#^a}^)R++*YpJ_Ii1H529|t48 zHMyZqq7R*oi>mM5LqpX=Krq0pUGh%cb{*Qk1 zKWolr9O{hr_lbpu{MW>5bMm@MxW*r`=wk6zr1lkiGd_rMK6yxzMc*8JjvUlj-O5gk zn~i?0vfTC2o^-pjZh?TvbqRRa2)Rk1clp!NqNQ%&1$IuseLn0= z>@P~Fzb`Up0^-N6`nD=6Gh)d z|DekAw%-1UC>uyk`$qFT--(`R`o%_Hr^UY)OOHaarNW(JY|d8A7&dFE&1B5X@uERz zgz%~R=2`z&PK>perz?${H}0P|bP`O^0oGM!mcx~6hPWf#3;1PkXd0R)v2iLEjhprljKSyKmd;T179GFgd;XjZgH0t3x zEVt&Taq$~#b0oUay)~h-?gWltW_Lm*Vh6|uW#ays7`}!-fo1WBCnI|>vlF9q7&&LL zmC|v=rCNX+Kx87jkVOpe{wh%1_!t$h8hWC8L_Rj)K59KeOeF|4KaXF3q&eYp1-Uk5 z!b8#sln>;u?#vJVHeW;2G;Asn3Y*G!2lHUmI3w(Mo6w%8c(pe&ZVGZ+WZ3mq_9{l2 zD}C6T7yLTDR91(^cH-k67C`RWsq|SNp_eZazyk&5H=Be=sJ_oq&-z7`h+~tqRvEl; zC%0+wSDk7DHDcUwdwd}`%`rBIK1Nl#;`jF*Nq_~rJKLa4OpGVtR%umeK6fV+|Hsd! z=wF$N!(zicl}uT&$i|o4tiISdQ5KS*tTFJFh()VhAAWm6$A>W8#yP`Q1pe_CMOBMw z0+3=Uxd9=6{GrLU=yGcT-Oxq24RM^NZV=i}PbJ#{8j~TOYz{6ge)fmc9uF|<<9!C% z;{V4BKqm)futL>8B3f0Q><`-XrXVUj@7|tZ%oP5=9`o*zJzU;c%i9U>m)k`BQR@e; zZt~pKp$F7Tv;K_W4ukdIOoytPx>=nZbEq@d><)%SZed< z%l`lGbtoF>K@J^dwA$0IGjjiary&v4Q@GIP^e1!k{KsT$YR*}ik1AqY@Qr~gWC`Dz z?bgbhX2*9q(pU=iCs%7N#>n2fKN?>ku&GD{#m$BPeY;Vd|KO27T~0yXaepU12=jGY zCrLAo;|5Ttb9v>-D0PKF!HV_oeh@x;Eqq9HT_u_=R^+8!_Vd9zjjw(3e>|fF2E}ev z(%_~ae&5W~5}EtNfLQvU1N`sL#wb8na{_j1e>OCQ$0jg5BnAWD{9m~9Km9i&oFew1#XBr7{MWPYz7#xG75n zzZljTFV_?(+5*&LPjd(B$$4!;NYW*P%!daARL+_LK$SQ5j6u7jD2)on|HxtZ?>)Ex zce(h+U6x!jXD9ij ztBsA#CqSEziHR8&6GIctq#d^5%A++(397+}6>Ckzfl5eIpU^oRXi~QF=@54_~GGO%z_HiCF+V z)S!+Q+4#yXK)@XBxX=wW;#(&EB++%z^vo(@lCmtVy;{e8nlIkbCNztf;^b9jXQ zjd-CpebnG2G@4!&8nZpHm0(rW9iG#*R$siSHQkho#yOCg(2oR$mb3-exrNjxAqiPeo>fNj5) zWmRtxP>w7^G)XY?c{-sc0@_08HIKu3VBlD&6^oGTi8QI-qu4^2H#+4KayVDHgPpuk z$g(!!f^YUk`8a1?Bj%)VM&F!F)jwvSK;*qNq7YJ{Cr^`?icEGmy8*31f}mUAz5cz) zN$-sn7iU@7AgvlLnq>E4o$c+NHvofN$7Su!HJPJ|uh|PLQPH%YZ1edJP3gpN{+XT0 z${YyBa!)QDeo&3!EKY){4NlE+OQv)Q1ojPV5BO>r98iRBs6v+GOPu#MY!wF`-kXlT z9I7DZv|hT`-P0FAuNv2;sUQ%bEYly)$3-JOWe4W{p;@sX+CN+IF?rWKF@Se5V!A>< z>q#geC0NDBZmxIUi*q6k2>Qvf;s7{Clo;&`svlqQew6zdm6hZ)Hx?cl`9}IV(>vX{ zMb{0ZPxc6Zh6x@h9?u7MqdpdSp}yc7VRLTLu)!pH7Qci`JSqBQh*Vfu*a>D2?{v8l zG!T%@kbRou9uUaeIH;DW@2)+Ck6#qDt_>6P$_fB>A)ZCXb$&*OnR46BET6`MgNtH1r>);67W0g%Fc80$iFM!IW2A^3Mn2 z>G?qWti;)?9kS^*fC3}_ly{}m?dsjKUXJm|YyFB&Vn{B+>0R<7V zn0I`C>^FAow7RJnt{>yfRjSPLia@*iYm(rXOD&Q6g8U(GIBPR-H+@Qs76rp`7rHi7 z1*r}SCw7#ueh5CcRCb7TJ?W+k@4Ru67SBN_tW1T9cm<(9e>``o7KlOFBm{xU(lXGo zZCEYT(Eda|Lrqez>RP<*mKRH!T-dfLe`U8B+U$9KCUn(xg5tx@#%3_$jrp4_p;IZQ za#7o8ec^c?7q`_W3&IH}I-FWQmcI()jFkIJ#bJhFnLaD+%QjctA6*oDVG&;BKzIFC z>n=J3dreak*8Vt*|-`34a7!TNd8QLErw0{=Hb<8B8|*>e55ZPuBYt&a0=^2Pq;Q zl!ojViRAfluK||Kg!_5%VT?|rPTHaky7Wzvp6R5;=&9!Z4gc!BI)@!VHU9R+p}LY1 zeNfQXG!zRy(!=R*_x%-=LH>%Y>v9*e`s(;3lPaYlB$|wx1??2W@bxGLLF)u{`6JmYsZ@unc`|yeV162o8vQNpo z`Jwn-{iupD=VVsLD_63KN4~V4=SHKXewNK3Hkn_xZyS%@kOuMQ2lg}G`xlkt%Z9U# z63Jg@=Dp-Bo~9@On^^*$=mFiH%?}Ry!jz>tb#IK7{afUKRc~tza`)`^8XdQ0QL#KZ z=eh*bj^>y3GKIzPfzDN{lK(U>`7_E=G=MtBll4~Y7}tC4RGF(jF=2PX?~O*-%FC6mf)@iH28X~wkq>@(gA3rtvCa!`6C{xfxb6l?-czviHhdzGXV*=q z`wiilULrbQt}>q-p8~~?yXMm0ngbRckD!6?EZkkb4Ga;1b9;}^fH{{S7l*U>E65Vb#H;$umS(ivbk@%b}?WB=v~ zxdSD#3dgpySM{1KG#|Z)(dT$cXlNm&3MCe`-)p?O;g6GF#On)FYk#zF?%maP=?35Q{vtyrVSWmAzf?58;LzJ6J@$;-3Sc(G}-=5_Vq z$4m-xPU zC$Dr;4bgF4d^wj(U|A>&AXl?T|B7HuWcNR|-f=%hKuu)M^;7wOnR}N~-L-Dnp24^} z5>}B{8uTlCx_;_;_Y^zsKSEq#vj>;BWKS91d;7d!=5O&7lNUVw5jfP&QtY&q3255R z4h*HA_kO>*HjI#~Z9O|lORN1AP+%5xh0(wz*2kn<)5eWa{JT?C7d*|t=3PNgM`ySd zCccgc#LE~oeZSgvD9d{vNx@Dv${^uA*=_&XG#vc|CDH5$W~Ca<=CYx5P=UgtRl{{K zW+j3hQZHARSeVxj7<|Yyj71v$`ZgG6cr(p*aBw}Ydn?awtTH@nHw@wQf5>r}0qFI) ziie?f=V^793zy#r)XrEhGnVQavQD}?$=Dw})yvD9$mbBnr!3L`OjD><8Pb$Olg=-O9%`+$~gQ~T4u$~b~`e{l34)KBhI>_SSZ@K#4GyeSp7ki8NXWDqr z4BwcFx379bm4FM7ljHco#!3ht}k1Cg!RF>b4B zpB^0##_nH;*DUSGX%Cg<=4vd)9ab;7B{Of=N*!Joe&D$GTvC)b)vURR$PZy5c;ytC zFiJBJ{DE~8DCIL+NFBUf-$I=%;WK1hu$cbcqvfN()$oF=f6^||d?At5!aqS(-ar%o zd0(@Co+F%O+lEZccm;ZM#N3Te?ryG9*zT=IGO%rqpCrp5r{Ai-$8&O@O!4ZnrajL7BSiQ52=75#``cDJC zE*v4U?8*Iv@W|3@^m%fjTHe+d^SHAKyFfARf5V1(pC%UV$Hx0NUP<$@?@T=Bs@57q zLyagyyim|jCij;Rsay7NhN0)mH7PlZXHK9S<1XhI%((9Vvi*(>;<_1gT!^{LnPeR^iH?B4wZ z!!^r&k+aUh!jv2IM6uhMJo&R$PA+%&KbiFJB~n;q)GTu3KGr4EW##oiskNUpZWWngT5p+Jh+v#khgHdyJciQeZ+1ZVN@<7Y~w$6V(42c7UsE3cA z@ynpwe?6>!>vWxfs5;v(hx`|O;ZX==nt4l(KEcBhe4&Ll^euY) z(UDL6B&;)^B<1N*2C3Vqr^MsoTO16-oE=yH^)j&-ZEZgQuA&9S_cQQzJbSv-4!CE2N!qjDIN8LWM)#0@cEFzvRUKLT!SnfGhv6yUmaV zsxhFr1PJ_&7tJywkjJFF8A^zB_L)`mZ2D=_%OL`bii+d#f9%xXQPU`g*3gV?0WoyR zl^%GxI`YnZuD5iM|Bc(_`uoCVeJ7LlN+O4An?>)1r(r)M`QP(Pp`I9^6|}D=lvf&y{7-b0v8vn$?PD- z14$#Yy4tN|d)}pZjMuzVRl8C#&8tKgA!6AG`J`qqZn;pGqt7C-y}fO4mgHu8_-T~; zTw6$vgcoJe*uuExhDv{Fm%j2}Ew!YB!SBSiETEP=PpwHuM~AvH^cJHfvDT5~Fg7xh zmi@PUIFKOqqLaIQZF_}^4S;ijzMX&3kv}&Th5;r30G;|+AXs4rAX9fU5#*3VH5iGA zwj=_-Z5_rEv(>uadMJmDN`qdQAYgGu=O(J*zMo4{{4 z5|9e2FO3d1q#TKGULab#j{(B7ZQPd%ubG`iA=QUCn^t76T^lUxQ?5|~J|k#O9XxB% z9|7p*;)?ygz|$WPJ0|m~e(|Q6!6=!Y86rBbX!ren_-L@{G(7oV3(OE1YE9X_pn#xo zU*CtiIM>M2$grI^w$()+a`W=;Yz-OL{ZzJ!0XMz}R)Be#D1qSgZzd?L`Wh+q^{eM& zg^-=8;q)L$hpy1(u)KxQJ8mr1qy0rdycTrNkz`Gkj8n&|;>lVFxzHBVb~SmSRYhpB zo5-4%`OyygOy1{9`DC8Fg^h)yp)m1Ocfd&Eb#}e2+xL4=#N2dg|M~7zu{7Y9)4pG| zMxPmW zLyIr;gbl?rwW_R#<0vYv<~bK+sDWbdoJw$>iaTAaFHYq01^5B(QSP`|+dj8u7|6|ZWR}M4dEUFo&VOm z8sk0yjjOqgO*A`1L`Gu1->j=H4Z;vV1{@GW0m%u`ps~%N13Nh@AAK)|&oAe~_w|t$ z9|8AGEPi=aC_yh~Vmbz{_NU{zkvke=)-%yB!M^7Jt~vfr5#Ym`+}sENPmHhrEotAJkV$MyOhTA-thnC=@bYBvHy%AQ*dC*O zs|5lWgbi!On*&E{y)vzU^tpV!9BDsEIL4(}m9gsUG>v`;S0!ud9egW<1DKH(RH>63 zU@GoRE)>jG8Uh63b!FazhD0IvW`4Erpy?g%=N@sKh9#esRkqS(3HPT}^3z^Q1@}|L z7hunFXq}53l<#Ri9G>}p!wa<&dPvDM_b=$&!;u1y-J*k`Qx)!*eRyB_S452QIy^FR z-o)@ee>mO;4vzAI0dbdm4ylYkG>uG6shXWWCU!25zb~;YQt=8?b^CSF zKiKdawqWiF%s9!=J#NA=oixC=6!psPEfkjB=^OZJ|GH>=X33~@)b}@kLFXdW++}Ll zoE%Z!{%}_Ek9|lIZ_1}z3Xcl>%@I4{*#(QJKV^qNjp#0>AeBwn`>)6W z7pTL`h@mIV|7*71)trLwVv6CmX|n$cQ0_`Z0`^POe^gZezjPbC#mLLVQ7%beToSs& z!(R^Rg@tkhNsSbf0Z`DI<*4JI-Ln~n`bx<%vCOR14?Db96)I)u;PC$W@7Hf~zK}}X zCw{{CzxK%g^Oqi{e=V3W7_7yA5K!mcA;4nTq6CJXBXiYu2AiRj1tood*JY-+&%L-q z<7edoSDHpfz|Zz)&4mjsiG%)==jTDXx5`995H0WO*r0!fOCM75o^Fmjc=z(;yowv_ z+HMQBpJwyG3wkX86k7l{q)ip|XhyeeSx>04_?|rl6Q|18lSb>U>d3(^dgl76SDG1y zn$P^LJM-$T#r+3wvh*>?DV2F^{er` z$yKYH(|xKEE_y!cu&3do;B;CNx@Yaawe>XqZ1n#gcfwrMLl$V83W(ewQUNI-Ixrlq zu-Fp}3?O6N0eJ6D*BgbH7!9R}9Q7&5_W+{5%(^-)Ao5`VHi2yu zFI^octve$ip_^`!Pk_&?Ofsi(lJgNJcqcLWqewIO!t3_jsXwcskvl>MU85UAi$+*!)7IRuh(sXTQE-px_UT7$_8vbuRT>qji7 zhX}p`iW_TyjO(QkMD(p(3s)|ZI*jt{g>ywq3q$IKlOHloPX}u3nYs4`u^L^6?5;>F}o(yulS9&++ zSnMx`0mas=%-zf}Q5^wX{yi1u(GEJ9j^ zGA2VWTc#Yv2~C}M_A1wGm)D*f>gy}Y=LU|4!p4&ee+AJY0i>Mju4Yhis$z2uB!jz$Rzfhn)QKAG`phl?;BtFC$yh z(j3tf6B1tI)pyes(s7iXWm(k`nd+ZpD~@EzS6t148ks9C7sFmzxQ}K3VzZokF!73_EmDBx>;Q^UJk?@P~VyZZ06a7Rowqs9R7~1Jme%(G%<#9 zbLn_87P@Hhsk?fplS0RI>Q|k+v8U})K7JOLCQVWTVC-fSeD5*9xw4l5Q8Vy4B za{Wmrn8@zpuFo|9JWlUE@X)e_Nf<9$diP?9RO()`0F~a0gLHWr?qWtWAusb{aEqHL z&Fp8Os!9|~^o_9PiCzid{Ipb{2|Au(6G_IH2V6?9f6A}cI~J%dX}1fNp|c9P&Ad~> z9-#Em_u_{*!e0Kvi}0?PD!f58#E42$1m|;7@ZHuDW8g&{E`#)b1Ejt`;?DPGE5%ki zpUcV0av9Sy83Aq)qoU0=0*)M;>#N1*= zEgNV2ez6X0*Y{EhHf*oFZX~TZ9Qiw;08y6ra9{h!?D@G9`|X(X2g$$}@Wg5G*su;D z$$k5h2^nz^i(Ay7y_ZPJ~)6na6CMMiANcXlu3-3O=K*i^VGij8#~~ zv*cnK+>bilU)znbtE;KK7FR8dId*VBcpj-aZTf5^N2iMU#T_kPBrb-j>JC>&6h33x z2Rz@#Aak6QMLG4K)&QS>+i?owift<_w&)%Hkp?=0;ulZa4BlY|pd|{p$R&-dR=dIw z&~B?Mz;}+BnYri@$b|-%Y_LF!w$&G#!_#?SSKZSm!u-Lk9U9uVUl|&1P6y+vy5sF( zOyYgR6RrOab%9eBWGO70nnZ3h?cz7B=J60tI*nki*JYyhrjiZ(Z@DayE@XBkCH8tr zzpH=faOnR^7Mz}D(-a>8))Rw{6O$Ti`TRTB)K;SX&4>fS7Key!D#6`p`jhs&!}r+A z9|R^(Ed4{3FUINEo0SdaVkxNqiG|a)1S_ia7B_n|G=A(ccN1}v6`6h z&Lk{$)?fTPad|(i2ju}PU_?+zpgSoZDu+>*f+2D$1wUm$Crp~^R(jhFC@1r($j#0h z5vP0|oMXjT%Z9eQQ<$xlAJDF^F6{bW#!S3D$#N-Iu?O`Ku(6qHl-D0+#pkAzdE2Oi-gDmib+Qi*^3+QK;J zl7B#iA#!_1x+T;2$a$Q=Kw3*H;fvuC9|-P5fDrH&hBz5Xw=gY+0Edv^LR(YzdF?~ zgftq<02iwDE zn-hymKcCa}yz2?<6W_ZxrO*CY8;9S2f3#_ADm&rx@37}PsxPaDV(B9|%Qu@)|Y!I}VW1iT)n|{-4)jF?}|apU*%`)3raIrml_| z%j)I~)sMY7=%wf5)BF}0Z4|fnLn`{CHv30*JcX+nGm0ZQYTVkJ3Yk*~?*(>e)pdci z`>3wm+e0U{;2C0kZndqnM8uu~9sDhK4%=ddL%#q*sp(Yr&6LexRe5 z^rI{GiF7+tAZ2+ygH9mtBHY?{h!{yHs8}tBIJyK0$8bb8kgaWG^uTSvHHIX)hoL!j z5C^--R%RXDPIL{XTK?L`sZPnGn;)vecDA;wQSp`$@@^|S)yKiJYrrw0VK%THnfTSU z_3mwD`iB{AS4VWTOi_l?(H|I|lZ4vBZ&%$usc6vQx^Clt()jcW3LYE!}YU>qzGCBX&kRiXJ)|nowo>C>MX0 zh`n{%rhDXNoiO2wi+cgFHja+ho@_)vAovlDmkG7xK9Qq|gKJ2EA)s*}L??lc)!Ta! zJC@AM7l~DH#%B-s;~A@Mi!VQ)?WdT9x(&?ZRA|BN=3~$7I{#|7=^8`35I_4PQLWkq z5fk>vNoIMFz|{)E!8K*Aq_^}kz|HmbeuxvA{dEn;NX~7N)3yotoU0KyAP3*Jf1KnE zF=s-H8a`2kR z71_>!{bWazo#3Y*xS$?|?lo&?)DSROp0DN@lXoMSFV3BiFeFl&9P%2VSrJ8zzwExY zV)4K^bCj-xudNB_JG`r?)^4<)e*Dc9Bt%?ji)4MV*|6f!ST{Fl3~}f@JUU9U@mkb5 zf>o);@YwnC@LSA$hK`z$+Et2y!6Cr=n;7zU{hBYS?i8zOkB&YY3;Ne z<^XkNIUT1l=E9WJdxs=qu%qKG)l%@;LbTH>O8t(&CtJJkqFRGUCVC@i!!uJGL@LbZ z-JtDDKS9%%wVzC6Rom;Vhu9VWsgHuI#q*gqClPMQW~er#*K0*V~d#_w6O191Zeb< z-UK{n3e}3lw6m)B9V)Cn`GM z>+5B{D5(>G;_RKHf|w0kY6B3^&yk9GRQH{i{psp2X=|=<(>3Zvjr7} z1INTG2PLzk1I(Lo@bTwzx<@rasO^=#XJ3N}3%wlr#og+A+T2Dkak7dK8aXOk4TbSl z54oG&>bqBKLI_7i0_zPxJ)IK)%?^_~^JZ6s&!3-~qElscvipwW?m9a3Jm@W&yag@H z+jJvd>OU;0Nk+edPquG2bA(Q!pIw(gjmqNUh!eJ@yfwQKeUH-MN3(85BXO;Lb*D1EX#Yb_S(8%Umi6RRa@wf&)C} zi8p2;c2G0C7fx$FFv#_rM9vaJV#`Z0{kJbOG@>J-Vn~Q0gsD@87hHENntiY-Qy+Cs zml~}dQYVG2M}0d^@qjt8Xf~L~)@9<#$_pIlD}J(GrkjAhu;xQ7UmsE@b3V%1`>L4X zzlWIAp>jFhk{9)2NuF5VxXT8=6D2vSsYAnA=nqrHt>lOhmEiI?RX z2jmvmNfz))%P#6{f!q^c6dVkIHWlUB@}QNRY0L?)Ni0I!9lto_mI4d;C8*)`#h!jh z-d9_VgNH=TQOCob$tQcQ3g5nM_ZQR4j?rM2=XNR`_Le8Dt~)$yT{YpVI`a$)z{FoY%$n~)p4->UR?08WfX2h<*9s~Z;SB@4M>CqW^T63h+b-C}h7z??l)%_ue7e_JJ z3drMKi1*=s6uV3;9D)XJ(Yn=zAUFnhsuvOc`{%-^1Vy%s%$Lp*SfL9e$wx8ka7712u0o26G7ryPFz3uVe3461PVW+2#i^%DV3)b%> zJ)HgM&jr+QLAlD)q*px-op!fyW^z6uwO{nipXoM6M{?(y$CC(w(4Z0dN}jpenm=1W zZYu~~3WZ6>5XrW>)GUT%`w|v7+2F9g%kz4Wrs=`yvSe*$LBYIQDJw3wP@|bg!M8o3 z%|Q#(;gdXEq&nTWULDHYtzOJAY`6KmMls>Wnk*{U8dzT|W%e)*}!0{;;4So60k5#(h{ppxdpGl;ImdIOxQKAC}g;|*Uh zA0-0{bwepO-6&L-(jsO@XgwdSJ$PMc1?ePJPb?q|C~`vsSM5i;38W*&qjZi^UP57z zIaCsbU#jeY$cVaq~X_9bu z{8zIjpb2;GURzzR^zk89k*i{u9G}EmC}UPHQJYu(WUIzVANz2uJ14rdefFKVoEy)Q zcM7uUk&w1-cH3vx@8Ya9mTw(E2G$i_t-m<>xa~%GG;0UAeHVv6OulfKoTlw;E1lt& zaXClKQV2R;1lK_{`IwGQ!pFNHhc&CKYi)Zrmi7ZD=1)3*Zmix1M$TEr1dkkwy<1B! zZ&d*~uWOqOPf@Qm;I}G{zy_QwdpbxQYX#-B%t!Z>;AS96qd~Re+o zwb3OszhIm1e8%WqZl;pAGL4Y&jjo69#JXWW;w4lGmT@v1aqmAy$xoqs@jDQ~W$;Q& z*TF~Tl>5xgz3q6LnHx@U)!~0_PEsK_;3UoiIl5RIS9K@a*Soe}EBV8Iz1KZxR>4aX>o}0LS7gtMJOiHdfq0>dTL;C* zf`I*8EY{D;<|Q`CSu(=c)K#-s6HlO=pcSK%c7Acdmw~hi-%e_Y*Jh^E@QneH*Fg3~s>6mj!l9|0!Jmoq@>rTGz??5eW{ zQ=2-6^!<{YI{ZZA zb~W;?uO4T#|&0RuR$96UmWClG(sdXQ_pz;OlZ# z>CfaT#A7W8WK_Y^QFLO|x?jM7S+#S{z5OM-xo>Rq)6!I*Fs1vdKYj>qiX_CHU}+Uo zKl!xnY-n&jm5Zo$ZhZ(|x{;4N0#2egIF90yc^tE^=g?|otiYjGn-W3gG(MP6b)HO) zBI7zRjuzvXLvOHr_&`Y&_H=Goe;%8T@HjVWTa;Vv#b%WmW#r9K@o`6n6T?0GR>b5M zicwqL3n%SMFVfx9I6y)}9sd4Rq1N8#qHABQDju=>?fiHVJ^^nRs#e-XR59X}fU#+y2%n8N&gJ}FUA`5>uc^s#hHyD56N zah8YxbxtiV!@0G{Y~gX$@}uCPyn$Q;)VopmWX!7dsCW(jv}of4Z^?{JaTo|x*ix%jb#ndrW zRZ;mkgqZkfR^fI+TNm0YT^es$(`-*n!{Z@x+$t-WEmUpjc!c$JeeyX(pV8b^Xg#!> zECdDI5A1h(9tH9EaXnC-AB&UR;xGB85YlHgA(!u-Q$OZwV0J8puUr~@d1Kv6RM7`- zv8V1sEQTf|a(?kY^Py7 z^E(7ih-r5h|Gfn-E{bix9DZYWnkBA9Cog{F6#mHDfl}-=KE{#6v10`_qB)=~xn#Cx zo+NA>-Uuur#;7)lnsW@NkW<%VP>z$x6$ew?;oQU6%AkJvgi>E80IGES*~F*HzcS` zC_Fm+wrJB{j^A29*z@pacS*I;9yWEj=hkRTN$r{be#&I-a_h)uV}4x4MtaLGdgv1L z;%&Xs<9X&fZ662H@Cmz<^$mEmPFRLgvi(XL1D9Ft6%Z-#@A@$ka=G%q=P5LTyCy7l z;o_bM_&3GM?8Aqi~1gys*8dcC4uE6l>|aIAU`4^|q;csAx| znIw1%D1vqhbhY)s;)~uTWSkkZ5_&Ng-5b}QHb7Z<=P4TXfUjjyaI5!O@zUb znX})zvV=;!4VT_zO|ttcF}uXW5sOek$Rt-Itl}~J+Wyj?ul|^ClZY|A!v4s?>N>_g z(q@aJV_rw+0^uNJn}u2P5G}Fv7&D9>Uq|nf+2DI1XwEU9;pLPWe|8ZIF8_Vj`aB`Z zz{hasEKSwMQ+ZW5i~buKx4AWB_eONIuDea;uiAuP2QF=z!lYf+O7`xMRvyB#cAxeg zO7hN8@QX)uD3A2LYW5afM}xjaeo)IQ`eaqpT+HT_c^p0tg*cMwt^C~stu#q;g8ym# zWpd6-C0)t&=9#Z#dB;n;ES-eZSUmIn$CCg5 zvvrL&&A@Shg&p!-d+{omFR;F1>y!Y>Z_V&m%bw78hI~{diq_Ge+glqT+#13DJBH*D zu{CU=H@{`1>587Us#g|x!Sn)))u3f8O;A9}eA(@^wQH#mRO29iRRrsol(FLmmUAU? z5Yb&Uur{t+H`Ap(@Zns*lp%%cKBqz3Ea}-j650}6pM+KRXA$ZTF`e+2&X3oh+S_Na zlWZNKmtzlNGu%Io^jPE3vh#|>@qgNS@P>tWCi@IEb-CAh?1RJ_dlhXrbqwxcSksrM zOXt$b@DE0uBWv=F?J=*E9_-S`hLNamWxL|so)TkQyhpP?KVz5ruj9bdJpt%Fj|hhB zZzE#=-3uJX(r2U%0Lhw0e%}c35ydCvszqzZyvcA@hG(Ape$HqNwzfRm`){O&if?*| z+9S?ff*T=>?F z!Z@w=#Y2G8^>#GUEE|R^e);(APU?F5(ccJs3x3K2(_)QsTc3-Cw^0n8m8(&V+Fw;r zO4A-AEj{E;bu#JKozXM}p~rf`%Oq*QQHvak$F|1?h+KlC?vVna7&mw-dOw|)qi^!q zVCxq{AqH5`@c?H+b&QYd+b@l-_u$2f)X+p$pns z9$j`6M=xX^p2obyP)BDENi|MxHV}w0kKy`@P9c1Y5g4azed(7{*yV&vQ5#Sd3G4JL{a-TJS+UuRj^7#0=*4x`mdg>k;X57wuHm%->or%rKBHp#1SxFOCuPR=&BZ;ryzG#SD z8@n8Ft@~NMqok8moe!^Lid=@-WeB}Q_a6}s)Rl}{Rv?#rZrO^z6!Un{pY?BQs06de z6X^GWBxR<6=Zs^OTh+OJ3DJ924Hkldf-a}P30u}4tGN!3Ix8()Mj23aG&JbURH0!H zuiy2wVZ!>WD;L44UlL#ZjsHcAggw=UAY<}X(YCe%76mk_zBDkz2h7V~i&C5@}!vwQ+-{>KYI zv8*_3q62%!uqUD$nG^GHy2K%VY`b$cVEn=E7J6WJ$T4qVPgzpLLOf@Xzd^(iInl2@dypKbZ zb>o*`iZ*CKmP@eLN{frTU$#mP!hU)O60u8M2}bL^a=tRM1N9wN*znOp$9!)+8?jy6(o;Vt>PqddvtHUu2Pu&4*kbDQt2>jMx85?ICGzvG2rSw@Chp@wZ{9pZF$9!y0JDq7>SJ<0aYA@3<~HH+6J?X>g*D;F|^WY--(HKP@)My3$q=z>1)?%4sQVy}QWob3?`P7W01@0C>l zt>k7wVgwpvZK9Tc(aFw#H}U>me;%~nQp5PBuKE)=b%ch7y9j{XrmG-{Jbt$azh=@+ zm)~=Pw<9!Ydixp{-D6rY=;`LN@IuMSB9M>zkzX7RhV<|Yv?3`Pwpl(d&cJY^rcFkF zOR>d{;oh8f)qwUP>b2jLzf2T0JRBcn;$oo^h?U6<3{;5w5}8=&pUU?x*A^{cVP-sU zErM;T;hNFJ$_gZjg}6*|fwSSp9ID9(P9ahg1VC~dAJXG5g#d$Q zFvIFJtDp7es>56%aj)VaPo6Dyy2|$Gw3pDlzR=}GC&a<#sBL0jBft(~v87D5GJF4e z%dH+~N&o-h?Ja}qTDGp?Kp|WigW%ih3jJdEQc=EnW5|;j|S!W(JKGwf{-J)|_C;iK!3Qx0x z2X-87WZDUm%LK5NPS08=GjV}h+8wW3#+CHG#?$h7?5m^+(OqIlS~|u(nD*9JUoiTw zT2t@h1NskCIW_l_R~v|~bc+Tm*Fj-&DdrTH8kbYGyvO}P9ajw$T ze9Wbc`^dq`k*=LbzACEdPfS)cI>_!x;|A8}JP$UGT{k@Kj_v z^+5GC$)IM!o?L$zm2F}1W+RwW!3B=T?{gfF%g5<>9i8@N44~oTBvPrBZ|8Pb_dmDz zK{J(OGEHMMK&hBxTbBEo%kSb?-`)+)?l+?;p|xPUaBXWfZ?f;EmoK^4zU|11DL%fO z6yLUN`_Iy;x0@Y3#yn6}?Kn$KOWkagd2Rb-Zefm7Jvu{|fdj|Lx$&-l>AEJ{qRnfp z@`!ZYxB75kUDbt+ldN6W?dZn4EW@+$FLNFA3@G<+H6PS?)kyT)zI&oA{+cP%D4~s$ zgtpFby^lbFvGW{DpHnDFoRsP@b9(;n*`7d^IQYV6EomO%$Pve5%=TU7`cwgjwnUh5 zK88Be%$iZ_dbQ{_SL8sR<7rfi9MaGqRn}-Wp2EE#zTR>u`>4PHQ@fhGIn?lK7EWzu zYb=GR<@!LG>fAUl0(6vzGe19HpiwTL92ey7%enlNmuam!Wo;3X=|XkxoO$%vBN~;= z{v~b-2mSs?4t%Jchva5!2St=8*Fv;=T=n*0eq^jhCxMqdM@?=}USQkYXQ5Gl$yZGz zz>wQvtIRG%T}pFYihy zlOOw#@zJn;11fTkem5pb*Q;Ac_F-=$sgkCn4NnP;GjpYmb?NMYt2){OS|=BOB$IpC z(hdi%=SOscZq~^#6ZPv3AX7VAOgD$PukmUOZ_fy(!_hhYNo(I@bHm}89~*G2EV?dj z(d`(BzXXI4Rba-yxFHeNkpIDrYb1D-Tsrrs_fV8KEBR>zDsAS#NPU;ac8ORO9b;-A z^$6S_FWH(k6?AW|;M=^NpLdoSZoAviki={^{0W2I(qL_+!Jq9Ej#?j8^7I7Wkp0^1 z9DP^O!nS1FCO@X(*uJ@MW63l$=dCPe)4cnp_32p0w`Xu8wmd8dhLoD;V?A&)OXh8M z+*R&_Mn|C0Ms6-$Y6I;x0wu`LQ$Aq#YSEBuA631Ww)`L7({N=~ZdKzJ-qS|Ln;!s; zj)WCHy=gt(?nPP}g6o;hDS0w~(H*|jQ6120Q#FC~Nz$YXFK7m5`wOjQpj#He(ugxA z02|k_dIbIFZDwp6A8M-Z3&>ZrZ}jmtASox6FR^d; z))VYxvb^3$_USkLnjOsgcg7spz+y21Vgvn2BD&|h3omXsaI|iF{lk^Foc4ihankI6 zmkZM7>sgzHrBD4mwihi|cYlipibdsM<0`^Eo{x==nnN%;>JPk~j$_B~anr{h5pNss zeYJV6|9jDZ@%vx};gtG{jk@71)IqzyhssgL8(_KWgFM##Vp0X&Sa$hp~E`4BSoIwY`X&OQbO!2^NoTec1nb z?L<|~<9$1Sws6nyRK1a-qf_=*6y%*n1>f`Icz&ORZ0PL&Hii(!0ANyRFWw)0=<&wfAaoc!9~0QXltT^R5gfyQt9X+UmgJ>Qs~1!bcBG5l^*l^Y)B4F zjTomth;I0PA6^~-)|5Ex-w1>sz`*-45mxs4UytzWgP=Hws=t{FBbaJ5qNn^xQWV2igf!FS%kV#g4^P*& z{cfi41EycpU;Kw=LjbUA)SQ2@rO(qcv*%3HPTe%SMryCH>wTv z^CwB)&7UL92Z`}v!1Y6Jgajy8Ul^Z_O*N55leE@kJa)ahSEPO~)tudNr|Mv_Nij7o zEhM4!Q}91OThH(#Ix;jfa_GS2thR|*+u2^}SV3WhOzdFHW8`G|fu)(5 z70Sub_;5n&BDOd&aH^`{GId_*|6l~a_owWSIkaO;f&;D zF#9X$Z^cz|2_x8{y}6T9i-g;y;DU;FeoV}?d~uqrYPAva9Ch{32%4NGfXmqVh&14F z+(uSuwY*qf_B^WEPPji?9%lvi6!7D9KS+M51bugjWjHH}CsC6{_ZB>s6}Q+5IjFda znRB+te!H4+IJr25{sw8|k6qy{5#*s2+GVDg`!IX~8fPSYG#;A{?%jb2w5AIzK=4aD zI(FqUrVh0o?tKz_Tjk2i%5qsFMLI2^uC5LG^wU!Ltv=i+F|}s163Vj*fR$Ke4Ss;J ziz)2LQ9zkEOw-*PdvpJ}?+*^^$teLnIlQUcE%oh)3GOE~zDLM00>pJh5#N@*?{-Fv zTe(h-^h4-nT+`@a3CHSY6cb$qAuH{+YW|+dH1SSTp2hFI;85Wj4s5vAg|p@n}1Mf)!rT-%)qPbMNj1!b0xbTLY^KLJ?%mVH1ORg zq{NB1qH!_i)D$Nd%O7fP+Yg9}h>O-sT|8Ig&u4V@M-R~IHHM*_m_C25T)ugzH(C%u zguCQ%^^6-N7@x3%aUnCz7rs9zU+40DfoRZlISJd2>RI`7_0ns>zHNl$(4u_9UX?ti zR}sNE;Y5SWB!c!NKCoN64V@^%*_j%yY;QGe-&?}u94~jzQpdtU*C2Iu@S$4w)n__{ z1JJ;GyBN}?)$2JFGQ5uS2kLOaFPi_@bkMz^-WVYkqmh!cT5tAIE%V`6SeqpcrOu@W5=YIUEx48iMCSt?DuIJBs@Gy50=|R!QfJ9R+8S{8q*&F;>0zPx$di-eo{4TX2rh75m9je*aP*{ zKAM6nSVJu8Xe(N6c^Z&xY=t{0gxH~w zWB&74>@)(7`3puqlbAo}+ld|V*Ahq$!_rl6Y^)%sKc%IBP!wR(e+z-W{`1WAg?bYM zoWcBo!)5snwLourGN%qTP z3!|%{{QJ+VpeV;P;IQgcxtdpXzRN2sPb0kbc{wL#d^ac@oTbDets${xtqC8bAyYoC zx(=n|D8_d3eE_Dj4%9y{`f%Q)~~_+Np3 z^Ko(g5oEoP@a0}e_@BhL{zoYQG2kG_$IS%7EOZ(ucm#OE+sT5w0**2mxVIdBxw(Yu zZT6elNj;EadY_R8hK6DRd7_xJ^OfzVXvUb%FQhM{ChAzu^u8T{9G`X&I_N5`y|tI! z?=z0DpDv=G#dJI#-vMe$*=vR)U_78cHf~pN8UcWJlF*&$HLly;+%_#c&wH4@C>*L; ztu}t=)@vO-XErUL41iH67p0mG8_^#}4D3FOUfVcZ1h;G*Uf(qHZ_l@ea2l293u%zgxL>}1d1fmX;KavN+5?edRFZVx;^zK zNo8~|6Lt4t{m3Tgf-`&>12NkVp~=a!+75Luyg7hay^_H==>)!Ka{*?UKb)oeny}mL zv?>WNov!!sc~;!<Om`e?Phln0w9j?WeG<|hXizxaV+q@%gf^cTa3N>8CM!? zF2@^W1D%b_pVvMT@CXQ-!TS{np&v=bA`7>ZTcL1}-ZDYLAmYYuZ5h85>};(C6$$0Z z0}7Ak_TO^{yqVUGG5}6w^l)Q$7HQ+H34j5tI7~mk8E5SMs8xMom!~j*niuUi-kmMs zJq%2_j_xVHa6Dqu(x^%=EOF%opLXPYT_to0@pG`f~qYIRho4w)$yf!C91Ivy@7H}#zYQn@`6 zn<)AX6t68A>2A&`dmXDj4gmTAu7vYxx+{SZFA zhVx8^y@?E{+JHTkaBNjpRvvU|zkXdZ(J;-nk3i&df>rr^yB~5%(5!>EsQXbZ;WO#d z?;DRs=sE%{G}VuY)X>^ol0i3^_m;N<~slEUK8Q;-enM=yFPdO*UFe)91#<2zf~f zFTpl9rb9J$i%Y9hfR1t@t&x{Lg)lGWB^l|l7{L$8hLj)RPyhP=F7*l3_$8F+=j>3A8_0T{79TBlRO4-h2yC!e3cAem2&QjnLPA!FM5Dn*n{ODN>QiB;Q(Tt4TjY86 zr~D0I8ts4OoUhw>@h{;RR432bSekx%IGlEa;dFjOmpGY`%t=fy0SgrwK)(Ttu6Jv< z&Y`JhfAF4JC&QJ$=CfTRiQk&Q>cuXT`E6w)Rb^v$>qH=yl^mXP-<#k4!xOp>XR0Dy z>AW_X56j1%Dq+(C<{`z)c^sVX)*51Xb*Ow|1qw?);+iX&Z|83(LWz6?PAH7}Ed;u@T+}fJe_2la&bs>M zLXFI&i`JBFvz3URPY~CL)ZPBVM62t8kFBk(^x_yHP$KXA{Lo?G>VB6gx;VcO6ah~U zAVs{}OK#fQ?av{yz4bPbx($942^rlG5-63+c;ug4>a4_V)qSO}=AO8F_c|H!Y#=Wh zqm*K+Yi=#SLid#{WSNC;)4~b$=gM#=hOeEbM82|>TsNIXhCo`h&>VE>Amp0z8i^HZ z8`tlV{u=pvX=y2&gJ_WyAQGRi7KUZIk`j6nh0Ke1rEM$7YFb1);=9_)5{y6-FI^V# zMv`=yb^M20mhRJJmSkKrJO=_;E7ofq!H|3UeB?ym0L;LOo_N*_VLRqxSSarpLid3cEsS!bg3Xmk~&likZh~d*q zYVJ44!S9!5J#g(d;1|0t;Jwd1#sBR2`^)KBiZV}a%X~5jKK%WWTh;Qa{=;BV<}T0i z@-8>!lF4lvR4^Sx*_@wnE6;i7V;}k}Hs=VEvM=GQi$rJV=fCr!uWoqm=T57>TY%JwUc*&Mvn<%o}fdK1j|!d)_3A|uSuwq`BZAVZ9R>LEs>)=*GTn6xolZI;(o zjI-lutd7P|BnLc8Ft)n_iEW$UE?l^bI1sn?$dsxiE`2=jIT@WT<+C;hY+Bd-URSgx zBgB_bC$+o-Vgdqsr46`!)NkNx=?OMo2Hdl0&LSPO&j$E@F=F^YcTFPuqNx-meF_-h ze>v411KO^aedQ#zCtUs-9f9fUYo;GmFqYqtgEp>lM$EEe#$9Y2>O?zifm=K85LuXi zP{NQ&>I8<@)Q15_Y-#zYW+xt&))qW&V5cZ%aMc>3c1w?ItK6?v6V zsHaw&9Oa+UEPyj{ynsB~aWw*e#iwXTYjA^%4f|rN*QDQ62-|Z%rJ) znR=ZuzQ|U+9VabRBBsB9`kWb@$u5cA2K*pmvruC!DC@gnwv$pTglzk9z!gQyQ#ABn zz!=bEH}ZfQgft)^sWPJfmmSO3PIAKc9@pU*)4{Zj|35-OQW8`)C9!mw?vjaY>9I3n zTB*5kCE~xuw3#*c*5@YQ4=47y0`)X0zXq@y*;BU2nQyR95`0k9(o$d~EtRh^IE)SJ zEhr}@@K{2{YD73NG#gdFZH=4=x3 z$?==GB&GGbVUdEd2Y#iJxe^=;!3+gWHMUvkwuD5&tP(M4N#j=RLA+SeR>)h7KVKi9 zx+U==kmmz|wNSxl>`mn|eRXUS`l1SFN^{g-zKM(1h##iPE0Yp|zlu!Fm)zx3CYGD8 zJ>dKJb75-}MnV@LN0TbgNr@$Bh@`E9@`de*f6USLR!a<;*~CU|#ZzWqkmDw{MF{+< zWcEdVLBm>zNE9_Jj5H+6BgE^&gh0sqO2cH9*?s0Id-Gsq%DyF)=?O!HTlwu?5~}+g!IJl zrWG2L!F(=7c;8qEt9bOi8ob zUME$vfpzA$UHB#nr=~;Stnx9E!$8U7?*)_1d$PiPG!tCNv0n@&RWt*HoVxOow?_X3 zQV`qT!o6*V1Y1l?gi@w&C=oDQ%z^#YOT*Tp3MJeMcGGF8Kg9{j)PIUc5d#z~@nesc z*4Byh%?PoUHcWx`N*FX$)L$pBz9>yIi#uqh<#SYN$*YQyXy%`X!I0#Je-hp>0w<9c z5G_|&H-iflAe`v!B>#!Z^FbyhbY@d!J77{7fi-weZ&AoPmsm^AIYG_-a)XVGDe^kB zs@ioz0MkT5+Ntj4p0@0HE?rUqW)uh1kqInVtYU;8k{VXLR4HZBzg}I@CTR_}&T^6_0WM=U$PD z;}3#gbZhntIf)|-!XU%J*wXy6Z%S7Ob3{cl8Iu&*azrfx-u!;lI?YP5<&v4Hlrgw{o?0o3nj0s2 zeH{QDam>Sm=it!L3|-^Sfz19-8u?LPQEks0UuU6ts_75H8Vn z%~=6QA{k9ARe3YEJS8$4o&x{wVRP~OPh*LRsAlGq!oiLoa-E&3odnVVE6aRZ+CV+3 ztQ#CfXlCBI!Yo&_geakqZ)6+=+?Whmc3roAuyMmRMt3*ZDLnkz9`~>-52bErhjin| zL@KkpO^RnDug}7of((uqHl>@?u9#_js=B)|ZbhO>jnUq!d>BcBdXwJSTGC{{(#N>s znKVunzo3#da4^`E^nI7=kOa;ZI0z{Gu#(V*Jyz67dZF7^C~O|@>~8}W$O=d zZZGOKeoAaaVLMyC6H-5_0Rp!>Nqk(JM3mvDl6#xMb(_KbP|f?aa`CK$;y8?-Z12Q$ zMgpNk82y%AO8JWiD_X@FtMfF%94HIO3o07#GRDO6bvQQUNOPW@clH_>GS25NlO|AX z*331vw5ZF$@r4Q$DU3!EN&7VI@4y{75e84iAL(J3D^}nlMdBtaDjLf7mecm8c;h-g zmijE%@OUdY<5|V4u2k+a{3!-k3T|@VKNf*&ao%91pXNs>w%b-N# zGR3~Y$&GGWm+g{d_f|EQvJVJro^Qy6?f&w8R za%H7$Qdd_O!O(7VuNz5W5(PUdYpL<;lhg^CDA8n^-&#rwMU>j4?G?)La4OB=6rXHg z%fy6OC+mHhsGYVtTXovloG3A-$V$!|au8ac*Cg|K91S>2^HHM?a-#_&0p)tMEYL;=_rRCK7k#1|qzOEIF_6?hJ z;oh%DcA3u009?Yk-b3gqhvB(q;XxY{3L}R9rSo{1OPuA&%>r2Q{UHZ-lwFw8aulf;`I+3&A3X4eFu18=$*n2 zk8vRciTh_y@505ZGBx|!yqagB+K0miB>28Nr02?@8e8|w_Rk;ZyvoGe&qiM>EOj0u zz}Rg!tqEsWo^FZV52&K0dGrR%MklFr&_1v4-;{V!_?FP?RfaKtj5+n^kr)! zUW@A$!#aWq@@knVS?V0}tIcA0qBGgx0q07d6t2^EESb#WPNSaeI(BhkpJL2_N_!^1 zmPA^?U||Kjvp2Q6DtCSwat*rsG5H7|)YAaiTW_fQJ^+t!Fhy&I2!* zWYI_fdw3wxm}s}rHwcKrN4M3@&1viS43w7=g``q1E2X!+3mtcJ;wiHlZQm*l%&-kZ zH!+;s?j4M0#8TmbjgZZ0wLQBP(^{*TyD@$w}0;@ zOw#~T;(r7-iQzY1uFs{iGAxWP#aB(&u6fPRSB>Ov2KNUTeKa!Vd9Jf|9vShg6aaV^ z4eqo3g>L%P@d-4u&y_#WBIG_)0SVw=~)vN z8SLUapT~K2<@ZP_)t{hPa_BPTB%Eq7gj$s%Af-={Q-bPuxb91^|}-+F_TJ7N|xgZ=*!KZHMP8rBydsp z22Ie0+!NEsLuJ#8A#DwSH6|d4gUbS42ZTo59GV-$rYXRNlNfE-_$BfIAu8q z;&3cRIYS0mLu7p9B|Og+H4NS{Z~UQ=QB}fIBmWp&1P*0zYM{YPvstu3!egfv1~~?4 za%aWG%J1&co`@|D?Q^) zogDeO-%he24!E>4u8?O@ZKLf8?lClJZ>M)bUw3$4f2F{aAS@%3TRu@?mreN2l`-bF z503R7Zg4gB8@wMpMEkwp!L7e_Bt<#Os8Ud{ClO?ybMZ86L-~8<*|S2c6rsKhV3u#j zHE+f7R3G|3RO+e@Jm1B!`$NaPPj}=$Yj3NRdAeA+y4LhIJ~<_ssGyT>2Z!O5g+4#n zD&VRdLqx!h2=Zvpncp$(Sh^QUct6k3Ze9$jyB`URH*Go~K+Rx6eQy%gz4*MA;dN=w zeW*S{O-^&ib$6ezdAo&_I4jI_{(IQ`5YT`RBxK<*si=|{y?ufHZXz3`ZCt!;7D{>e zK|iAKnCrc3Z?%dUezUzfv3o+kCDqesP?X-al zR;Vd^iBdh!we!+`a7`w4I20|+OP+lj45@+3Nw7gUP1xt3H0f6eCKw4ePYHlVtf=F3 zX)g+(Um;Js@mlMK&ij)2*{=HlZ&J2aucKMx+%5=lv`iX}B}}hC+v)UBE)FlsDOEr# z2IjN_Di5#sZFBAr9Rd<`)MM33DWB0J2ftJ2voViygkf?iCD&? zE}16R6F#x-S(Vpi0%J`xR~4Yc5?rSxb~Iuekb^7$qCOM(F~d2U`#5T;ud(>LEy=93 z{!*5XLMIzbH-AfVuQhaMCxG%32L`jbz&D&LUV;p&AaXyMuefPPcCZLFHJ$=Yf(iY&jw8~Pa~R|;K*Wa zdbMd00;Z;Z|JN1tZ^LUmaBoTcu3fyu!}sHR!`taf3}+?p#e}l-S-v%(r5-k&4?9AP zDu76W4BVc&6)yd$7`IP!g|b|%Y`8q|wy8=h4QlW0q%tXxUARdRguzdS>hv5Y78jVMmWxMUuL=7s_uL^Z^Y9NNwu?I*+EO! zb`c9C7Z*G;dv$PTQJVzM59kq)^qsUlF_eaPC(92^1#4CcC2$t6p6-v6hFeSAmd>YE z;am2_W?!F;z_Ir|(rf!w4G~ZaS2YvrRbX~ol}bhqug*TX);~NRkHnWW+tczK8jO`( zZgqG$2bWEu@aLR#v~|cJ?)R{FbMFs9nX%I1T6k~pk}dHKEM!o)h;<6TLMYUWz}K^8 zh@U8`S4~A@j++j9R?p~Q&-pw&G91YGrK%xUyh?z1O#f}ybBm*-SYazp<3)CX&tNcF?dWqQ!Kxe^{a8uUE(q8lWAvc{{)P0%NDX z+VacfYLh3Goi|vt!@?vhEz+*gU@jU88lsAvW~@+|cxbJioZ{k_F7#vzF=&~lll z0qlZC-8d&K+P*tj(V1V_?^xXra~aNhTZ)olmbRzM-?O=FLueEnRTQ?_;IM9=o^V@f z)zcd1SBzjj9mc??NyCdYraToFe58tyHWAL(H!)kVR;!ipA!)m`>O78*()9sMDF9~~PGc)B$kmIjJ)0&4K z!p2x0(`03TR!Zt~o|;zB*Y>)$sc$ywTK^VTh46F0z_9hefs z{I3w;rO}`3rP5F8DU-2&U(7kx=q=M-@T>6N&#K^uL@gGLr^zc5x(q5yw&(Q27aZmW zeJe5ZZ(gdHx#3m(L6kIdYN=>pMww6mVl5QG@P2b_|BY_AmFqv$%$lqpckzUibC<6Y zHBJwCHV6gm@>NKC#|HMwT#Srnc+?X=Q+UWW46Jm_;HM$Zg1x8FDYLT0)Z-Gx263Lr z)oZOMVCLfU=QS>yvnflSq{}!k;~nM3*JzkkemFvwB~dBKd#Y|1vR8zRv3Jm*zU>Zc z48P#aP-8)J*h<{%ackpw+tWJ%H{h|GF5tG=CaE27hv?we%0mz^&AsNeUzaRLdd6{P zD+$RDbz;>`w_~8L`aF%O?jjsx`7??2$oEN=heCD9e1EBEBphYr=fi5%b$F>HvKq{Z zLLVK?kXANmlm@Af02Kw;n$~1iIWo4h%71EOt7;8k8Mn?|`lFy!9J|JZzGp@~bmRvC zO9>cxmcP!d9zQnkAha>ZOf&kDa&DKg=`<`QsE(hkC#!y{Rt;I zgfP`zLN%6YnW)?9I793uyPenc`s_-9=L6DqYrJf_!=hF7F=EQV{#ZbVcxU*lE$RmC zbk0MStMr>4pK077vatm^-Siv%27WX7!Xz#wo}Njg)?w++eGNQdJnTU<5S&nv!kju2 zzs|4(b&T91`CG7$;yP{dSAmC!OF9ePNWS0Ad`=N#9>@*ijSqIeyeLTtF$Hqe)dciA zsB$5OT&eNw_A;!_Dq>5a*T2ygbYr*t0~hW4r5QcOsJ*DBHW{&g7-<4u1@-kR0C^7I zru8&fJ#b*K-y#iA^?q79WvTOBCO-r>I)7u4WKK|0U6Dy~#GuL6sX0Uc2LR$Yin7nx z{`C!9gb-xU3}jfHDT63ei8V|S2kGFhq0%-*W`YV6KWBkW;A(8Pvm|*o>FRw(sz~&P znu+KacGWN8;}tuLvf6Y%<6(3?{s}CiXF}bSad0gJDZFF#<9F$*;|=nU&Nn1c?ou*h zrivlyAHz%N&vKQjaj22vZqc&6Y?YCa0uNg|DCXT#OX|Mo;GEhkfj61OTeM{%Qx>cK z_2FL>&##>)`1CvQ#oD8Rw0fUpOOP?gW6omj^T=6QnI$A6n}@sIOX&RdU=AmuHJ!gZ zBD<)xN2G8mbAarWwR6;*)!B0NFvE*9L^OGF*D{VV&6<;!`qI8qjH-U__oc$sq`wCc6zp~c3f^ifR#=m^q_itpM!)a!; z76J%Lu>)gCWomxRE{0~<nf=k~)2R2e>3zw*1{QObKDp_X_uB)xjQ{Dptl0I){&1d9EWJNYj zryk`5g&HNdgDJX(Z$g)X9eXMZ6%{R3?qxK{vnz1o=8N%TDAIKeVnV9zqY6PxIrpbZ zY2Pnhq&|XBwEj7RULZohHGVy@PZPzI33g5qz3_F)9M83^bEHnET93ntscMN+eaH$n zWvQ6*PJPK5EDqDIa7J~4s*P3_m8!i=qUD**sn$i_zg07AI5{y%a_7wj9M4^&E z?qPGXoYwXi(kaMPx&31Enh)A$h)~hk>PZGu=ji2R#4!8QmKy zSO}u`TCU38DtzqLa;n4l(PDO`oga;KZ{jxNhS^joL6PN)go)O(pl@yonSr>rq1baS z$4cD46Hagw>wo70VqQ#8 z#mp#%{zYp77%M0fAC`ST{k`1Ye`sDJ@IV5OsGxxSZ>0(U!dWA1UlMe#uN6H1Yzdv> zg@qlTn4A7*Mu5KiC3UykC%~)_-iAFTLXn26|u& z8#Vav6xAdVA2c(!PQ>4;ko~vKuLLi7e18z*pW}h=cL6ZXq@&S5e{x6xR@)0<>i^3w zy`&pr#E7{=-_T!eQhU5A?JE1T6M9PTzaTl&aBy;x0Pe7W1E08#cjk8k21P^4gxJ1d z5q6(7|J%rO*qf!98RN;{TU!#|I+?_Z;w4}(ay|v%dkR?1?ut2y5o0E}a*@ud&&9^2 z5uK`O0UwxOT`dj}zUXLZXh>xNCcvMsWGFty0%IL5qML|vd)zCwMXTD9-`~DZm{$i(kST-5?kK_RGPjpDZTwf88 ze4Y1}q_s>`ek{e{*kB?+OOjwL8Cp+gH*bm2e&YjGS=& zXyXAQ()akkXWsu>Hm_c`si1<|k`i&6L^#mW!OqV62mR%(x61Szr;Y5(6^XLsNw(EA zbXjKg$xQH@4kO77=F<_$=gBZ(>^X9L4E@Za+sS=XL-Whaq;*bClZA;&BSimeL*#jB zX>>+7U~4qZf*Ppx9j=r@O-qsZOIc`X2pa zyh;yRi#*!Fsbbr)&RL*Is$?=n$%0I!E-0wBpgzq<;lGU>N~N zTF`0W=BNHDSotPe#&3F0o=!cZjPRz)K%$P@S?a|d)9twJdK6N)cdswpezlnsaM&Ns zvvrkIp}=R``&#Ls|B~Vk4(?3u7Emv+eu4!#b=3frF+}MuvqXd86yr($Xr6QBt&-Sa|=?JWFXvo@sn2)E)6(x`g`(BVoBB z)pM$xbB3Nkz?~p9C_rTwwe;81?;_rW*#=8_mqp9Gh;FBPz{_>neV+!j`<=3Z$x+9I z@BQnoQtlHPG_sHuZL?2R0o^uEb`@0HUJk+9qVc}9=Hg1NHv0cTx~y(;Tfwt zd-Hld>h{uE#MVVjnXW{K&x6qF!L^n>k<**8F#8k_hpO$=n)R*vg$1AOAC^>~=A~4d zvaf$tt<7j!vxY-=pOgQjciQ{Zv{A3Q`A++ufc&kHI{)oCHk_fvtH*05B%bX380uJy zie*!->#M^Ro0=X>Qu=Hq)mg*TN8kK_oK4zWP59BP}QhnL~D}8ri_MhtXYS$(Bqe zIXiJ`5S%|#oOXzD{!T7`STWpOssmuxW<&4>XP<6$Z@pHdl}ml3C`+#epgge_5Vb>E z#^(A1uFyuZ$VfPZjmf*^5?MZ7>SN>ubOPqMk=3gW(cHAp$IlOuL)XpI>7Is)T3oX(47&I={ht>G6~Ij#X=<26&$)8_2GjAoNU zT?@;K8b>*TuPq)I0VO{5Z1{VXUGAcD>8ynd{}`Mzol={DM4h_t8iL2*#(S|^=pN_Lpf}b5R%xamWz5#>zL*{jub|>FbuYFGh7mP>G^Ey<`*Waly_Bfw-eej zs_pUfTC|UWw-4Ydvsq%28TT5djT>WiuSG-K=Ty9y=#G0`^y0qnE81)+=H*l}11NZ| z{bX|3n1>U-2cd49`vRl3hD`<0XT7B8_peiUB2hW-6Y8$vCiu&@T~20m%BmO$$HSHF zq{m0UFp_cK(yMy=x3Au3J+I7uFO`;h^I#+N(f5+K_?SK6p1X|~6d9~((=M(WpG7Xq zd!KGOagV!bc77Q|dsG_D{gi*IVn6wL0jr`#C2@ay$`;AR1J~`cbmewYFe!d>dORep z!Cz6=T9nRjauT`D*4dFa&kJyk2I!7Rl5NXrPbUZ+Hg|_s4a)|oy`BJJ?)SV!1*;k1fx=p$59DLc#^AlZ;mHRpOV6H{gf6R}%tioc-p=ux3eroZ4 zbtL!Wo?tu=AfjTWmVt>awUr6e3Tk zdds@Et%8WlMeK2Pplo#Lwc(8rvX??43+CRZcb$31{2VqM>=wwW`629qkQ^%ytq5+xip6+uoxn{nCm-U{T_ zrikbGLC@)S?Alu|UY30Ed~1v$QDa2+5}r(nA_y8ioYiR*D?`7m2ncVsKDTEzt0tU% zNo}rNZtN<1@Hn`!(`IOlaIkLmJ^NKfOfRULzS?(}m-Qg!{glK$ScKXtLrpz9+)b;uVn3p4ab1QeeD+#)fclzh-MgIQn+bW=T_IBY2UoSN``o#A0 z$79LFcJyw9d_b7>n=$5s*VRxD+u99O${u3TS>CW{_(_m=)1i< zlwFml;|f|)b*KO3vg-TL-_Twz)wb~6u=`nr8RFtPyM0^fsGs+-NnPCgCjuSj=Ew7u z2|zt>RqLtPkJVo1HJj1E`J<%)J#VMdHRo*U_*o8bBD{J0_L2%nYCX1c5&@~l_aA%> zLeMgN&UByTbm4hSTYuTM^;I7~pNJYqtOk+9%eD%&$)Erq>o(d--OILn+;&e(WG2-i zie!X3`{46<$2QVT;@pc3K9a&9 zB&H62VH{*@^3iD(GsCOaQpz<#te3xwr@-!Cz~&0<=0+JJvyPSJevFV`Jz-D4tmEKY z3m3^dyZI@|c-8RelyvAz*9?M|{NQVcOfV6k zagonmb2^AOEAf-V))9^bZx9*dR0Rf*sKSh8cXpp1oig-+6U?SWUQ#0>%Cv> z;2B{qC}w8n)U?{RfeG9U-YDhjN5~5C6GA@vmG>&LugM#AAZAmGvX+REcGK~-;$G?n ze0+xlb;Nk8@OBoCt91iuHjMqw*IP=LW3BnppNC72pdPTb=~bh$d&l3|#8z+3w5`XV znun?ym}1U;IgC_uFu`69o1E$EsOOjDy}8MBX{x)MHtK|MYCC#q9+#MS{&^I*5#C&K zw-R3=FgbC*Y5mmg^=_@)WqF$2M4L|akohJZ^UQ9&trirrn45?R)lqF-)ZeblyYf3~ zV1<}|0O|yT*R@M;DQXV zf802l11RbaB5b?03ugtAk(aYL_ zJmeP=nZ_rZBtylzgQsF~8gz!b_05v?6lQB@Ydt^=LqCWWM$~@13pnz0#eM<(a)DIo zFaH@*SPeQe+-rm!46T>2m}Va|w@Si5dq*g`cV8dz`4-wL2b-vqrJ_AY2kip~K43>K zqC!3Z8`X_~)}oRTzfYd^@q+)3sTuY|*|%|?hLQyuM7yz!4!y~Bz;(j?{NxK3tM!BJ ztzhPH={~LX7;IGy;i@zNBeAt;IOf^I4Qv0lG}45)UT5Y$U+p8Yvpb zV{w&$KSNKN+SBG2+|8Tnj~bWv-HmV7Dm}_P;^8dbLoU|6=b1_pNk~vO#@o&IBy`+S z>mzrrpi!IZW)HdJQ24yvcD~;vLnwRS9J&JAwd+8=Z$K0!V?G>%ZOxkS(#LYXSz9rV^AbK3Fbtf%@e&^UxbS;2*N^?sptNN3MT9;*cQinv+9gfXcD+f#}^E?EXjHg)|N#CF7(vN-WXSlj}*r$DL z3cE}vdrn!fsoWg~s~JlnjNBc;pvO`>m`*>p1> zRr0XSF9;dbnhOU{im4wHN&ZQJTw`=p8V#zau3iPF--wfV*5oJ=h7>2(W}Js`WpaFF zzo-QY6L(PUE7{z{xWfs=>@o`8{H<8X~muF)JX7gO~dKuIgsn3O0Sk&Lh z7%|v|KEeBNjkmy?oDU1hW}p^Ew#Vn9rKpZ#-kKWsAPA}Y3SvYlavO6-GM=Us1l^!g zmM9Zp$1ggff4Jjk#4M{l!a&4oI6px0^$A;~a^(HD{TRvoW2J(Vxx z;WZWhWI!n=b&4AD5OU-2(d20%dR7#J{Z~`7?nu-j%E^#@0}IJg4Bn9aocEnhxc-BK z8jf(_SVgobXf`yU>IaJMjT4TQ3#(_@N`79W8y5p?_BnC}HagpzV~8odViUP-jUH}} z`-2>&H}AAzx3Kwe-cY?=Pp{YrAD*87NI^nEVtnPM6>?D|oiXn%UbaoiRX)a3bojv` z0LuN#D~<;|?-K|REFW^QE;eEjMnkk0SA*#=|3pOq8p7K*CnH^-cWTZYbfC#y5VpqI zX1inOb1(g-jSqGf;mYsjLO!9Qq~u`3z9vD>s62pAG31&Vh_+py+AW|eV%M;e3D<_QpkG_kvu${PyQL<3$Nq)TdQU8$}j2vwDb+MWv@d6R*n$dTq^={~g z?bi0Ex9gwDab$+XyJ(LeQGK&iji!cgvoM zw?CG`=KE6S=dr0#Ii~3SH`|)CFP3mvEkj^eJFIu;ls(L@EIMD?Mypa!Ebdi12b;e& z#TS8T&u>sW?~`apdWZ}MyuP}4>A34#x;t?`9qT>QFT*i3f^PPFQ`Tual4e@sYD3nZ zf~qG1*K;~urM)|3La4_6^C#j)Q>Xx~e3=j)xM5>IWC%JGc(bSJK1F@9Ak>`7<(lIJKgwb$0GnNtmuvr%U^w8Hi|>Zk z`LiHLKtTWYm6rbX{R$bh1ESpfL|2U_v5=R6+g~hIfeW5yP#)}&Vf{oiTdjf~l(9@m zcAbscyp*u@48biEPaGev-8S&FE6YmfVN>1GQI)0hS4hokSRh>()J~4pp>-{$wHwad zb>_$z)BK4s;mixGQT+(F6M#?YNPNU>=>6?gz-k>EOhlq~ z4p-y9azOh_6FU%UNiVw)7#J8~RwCs2laedb>Ci>K?tK{;@$vXq2vnka90+6vo~mw{G@(8H5JA%mM>;%H9Lc4F z$hcBn-S1&311^AobwTQC!>(G_4_z;Nx`D4fE+LM~9{i3{>-r$F`cj$O4Rk zxkm7DHr5TQdu8W8Q4;3PZN4U^h1u`Ob_#?sEMD%k-AqbpAPZF7WxE<5^JpD z!pKG4P*NF0GIx`@a4~X9HeHP@5l;J}#^PZdN2LTHPL(gr6jmbfe2pT4mYON1Ya7It zf!+kTm)RW!L)L4toBOaS^~wEc!P$dLQ`3f~>&F0M2lk+A;fK_l+Q&uf-_J3B$)@=P zP()Q>D9U2)XNt@%!l@DmOZWsXaI?|HRcQhAZ&X?Tv*m42Utq&Nrd+1F>Vdm zMTj6M7K?>m`J!^S0D4nDKD}U`o%DMV$`B2j1S~-|3?YEDzGBvpCY6-KS5D6{<+yx^ zeqPmE7cYPhDgR$YnFQV!Kl2C0T)DvK05lZzk#s|~&8g}f_~Q~cyrA!Vj}I?&?YF<#2Nfa^V-;&NK4~Wv ze@5JYNgo2T300k?{AFh~kn*yUQ+Yqwe>Oof!3#A3-st^$+GhUuiTTM1oK0JiF|fb6 ztt@X~NQ=DQ&Z{hc@3bc!IF2S_W52&o7_c88Um>%T^DO0WvTIWspp2HInfU)6zCl`7C-$ZJLxJ zx}S(uXO^S(FED+tWG8K?o&9cJh9u&dj9-bITKT}`M@w9hSb&8f`f~kA=-3SEzgsqF zd{=9IIDoPtqvE<a*Z@{=xRQA(cplzgG(%Fl2up%f2%9#Jqw4RH5j&>k+yYkOXDZ zG`C8Z*&B3;3NXjx0;x{NNi?QG#Il56SyC2X@h2=(jbzI6bC%rnOmK!u9v9sdE*%<{ zLEO@1a7UFkXR31t1FVCe?mv~3H8@dvUqHttCXy>urqGZ}h;OfFcVCDn97J`Y2@w)= zYmrG}e#x|YIu2?1vI+}zZ3wECoXqHSjJa^TaNFU5Gvdxm9<_Zju>Q+GzphL%C1otr zp9E>bz}Og2Z*KubKat>Dc2;t6wlwY{9Weqiks6RLL# zdjgE;5)?@-dWxG3mdkLGX-$OB*peLESD`BhsN)whB#!f$Ba+QdO5X(MiFG%{SGzQu zZBO9}{0cx*nmSSJ6aIkJ)Yk4KfU_xpNF8Sa3E+C5t{*=Qv2|WJKHL$!eGAG{H8&0Q zIBQ*to}aIX|M&^Pk>is@B29w7o9E=kRysV2tDe>A2Q`hbUXFJ2_vT|>8$M{5NJkNQtq6VHQ$B?^Igk@Z0f=KE zP}@xKc?f`m)b`_$la=kafvB&6EdggL3gChaEo7f-2-lyp)zlxW*P32Lof2z@i}5f? z|7so)+Fo2ELy_5j@6KfUA1k3Rb+}Kmr%dU$^tvya0zs4ZORmX9_~7j93+V_pSSo!? z<--GH{`V1$uK`?>tGR z*E(DfVR4y6m3i8y7l-D=XAhw2U3^OiH=msR{rgIK&2bOIpe#XVAUZdoMjhuCVcB~P z+*HV&Jc}nUijNBl7Bh<2%bQPJTsqlc^7e5H<_nOG;qP%mjgY$Mv@k`wn1Eve_*|@0 z$nERsm~KXjr!S`tXMwg!{lK6y)fUAP^AbaYU!vviW(F^TQhjbA*~ED1f;nuXt#TE! zZeJ96(UV#8RF>RAfkfQedb|+3z?yHLeLKY0pHHX0t^|}?RQ_>XHXbN!fC&Z!zO?Lu zX-Yw)hHKPDb~$uCc4GWw(n*9cv$K`e)pDzzZyyJTMq)V6nnrtkG5F2QN>IZnCLoUG zxQ$}jA2++cCdEF2b~j&a^#}KFvBDkKVvBfswnK5&=>ACKQv0rLv9HwX`YPpDx2_DS zvMtl#Wrn)CWUXcBme2dVua5!`cPUhI{Tm!$dF+MuKTD@I!@YV|g&kXdk-q7_FY3TNS@OP@rK-f3U zV6X{F=ujyk!+c6|plLlSemGIfrnK|rn9^f0 z=>i!2_21bqO5%M?p&$wlWh-Uy>vGz&0ev951;lbGryas}UzUNQmcp6$t+GE%p$8zWt`23tEK5R zfCi0dd|OmEbiVFfh}R;PS(T^>gm%ZTJMlUvymKFiZV2< zYS|-@l9DRbFCiq9uPz))^2Ivqz4Obs?{-)zmVD6QWgXl6*g=*vPgYu%b;8DpLyJ)W zr+t5!lKRU$W7CqVM}wIN+z;U;=V63X29gzS56jPfd zaWiVYe_mL+eSI)>6%$wuqAVjJJO0OV&w|tBO3jkJ=_aS6I^!l{jJ+oXo5x=U4Z>DW+>sw02!zF=u_8;u%FlZSS{D=umqPmgAfmQiimg+DH zDj!$3O|<3g#V{r>iUiMm1Wp(d9)m~I@t@|}7kTCw>om^!WBn2G?H?^O85BH}4-c*d z7+5>jkFzQg&9{mgpcvo6X8W!mF|UN*7y1v%QV#37%b0;~>#qIu@D73MwF?4R>CXB^ zX^0&nRnTI@J$R8T*|=|g!^S5`dJwfcFl(z1QzyOvHKm0 zX0(U*mOVuu$+W)J6YI619Kl0AT86s8=6g{s!so**@S~UY3$2&-$3U3+jm&_VA@DwK z!kN)2oMu2c>o}g-E2ltGp1T|R#XasmmeE0)DDigW5KPeQx~h1}@QyV&_af{o6?|x+ zL21*lQ_vh(xP8V(->17!Ah2u@^JZM6_w^T`UIEOXUs7VZ4|$qQHbBMZf&<}kIUH7R zc8sY;NGM%wH+C*>ThC1Wn+Dqq&h38X31Y4VR)3I{PCV*z4vX&oMqLHzd9x8Dxs2%^ zl3@i6bqx;c;^genp>W##M0Y~lkZkNRdSo7+-v{f(b4DhhELoNeQ~5D-ED$b{TtXHd zMv?L*@$4;@6}GJp4`UTF2?&;U^t`Cx98H=?Nl{P7-Bd!c@Rd%AiAf|4{jwkZ1S*}u9b3*>1L$9(Aw1&gXyX%87y1+()2Z3qB8fBRq1 zvmo#9Aa@4`_zUlX;SP_3Rrjz$oJAke!m9Hmj}G%N{LZDGNO&UPv?+l$VAbxXUCABw zX#Mf~tkM{FZ66EkK%_b%;b*o2n}-m?S2M=7D@QtWs*J`=$ZDPYHO0)qH6FT}Ep75E z9c3xQ^zorsn=Zumu6X|An#QT5R`@V=E(3Ny*m(D^~ql<=ci_{~{iRy}%{$OsyF{|#+ker~~91A?OeLkHXGuSw)5Y(p6AqLFFKPu#wh=HcKX4u`Aa zgP2L5!?pu&RHw+pJTe`8mpC>kQE!v;y3a_Gpq{>grZAuS0o|M+84;P)a_mdEjL)xWpiE=a=Rx&MDXRUmc#Vz&G#7=t zLakH=wBb&D&;%#Ak$dv4AHf z_3VrCJb14i4aZeEuVvMJjqtvDX8w0;<+bm4&SvH~ZwE4X6#T|DU$MnWeOzi`db}D0 z%1io3bx(Zyuw?URGg%ZhIc%e~=3)LD?Bklp7hi?XGr2qO_3^A^Dk%^CqI^?^fa+8s zQ@m7N0H=VrXp7$wk4LL^+|w>K@7PmLJxGfI|tp#dOs+rD%@Xa4pk4xyll$jcS@Z zPcz{Sk{-?aaOf(7tlX=4{dXZpBluc4iiNkcj)EuUH;3{XRIFN`==a4iCaXFmX;911 zXKL`=!d227Nyz|Ccu75xFew@CZyb>jq<{Uhptl1hW?6L-U1ZCQlK^Ohfoa->4^>w9 z6o*&!{R}vp;hULbFCGmoN@582A1Ydugins%Z zZPI_wN}@p5(qaQi^w^0#B`{YMG#L@Q*cah#%H*geu`^mcm~k!q>$w5HyBFs?`T%UvPz*%H0ME1#&C=@|NVQrV3u{+$3;$y zB&xqr*C;+v#3tsT6|e#N+W%=j`mYU4u)Slyshv3X|KPZu5ddDya~I3d{a^q7pBwnU z`lGL{DJLN83tDhPHh+(U57aY>Pv6rr(QeHUysoCCMFNTu@!#WrBjLkXetl@Ys`wi% zCp-mE>^?kpl)pV7zOO)ro5|britGH}J4JK`Q0%RUG^oEh4nAN%;C27Mg6qe=wOFQ( zl0E9ANhjtN8V;Oq5R;J70xzBkqC!H+%xsOKgoshP^hu%%M%w>kq#XQ&dZ@CkD`cjo zri>Q*Hp%o4W>~y8WP=1O{J8khEtj?O9_!o-jaT}VTu!QEX~l?@^Gm@?TmQ}77%b4m zsy~+|!AQR7=M{Sg;H_4zK_g+X?;w$6s#iKJCWFG5i^xC)3cRbealYcbtKnP^O|zF> zNZ42faxpKV$gL*{r;`WWj`5HZ(v71A4<^z-7fM$z6!YACN~j-efA1Ig)*KbIiMTk_ zWS|#V)BQvgXhu9(_aP)a+pyHN<5~f zH=%A@;-z0G=4CDi>QT5Zw;v%)Zy;cKQ%C6*4y2RmXDHdEuZMr6$pek^GJ|`Z&bK5w zegE9SBoo5#%SL55NII8mjATZw{Oe1;53X`(VG(haLV4r8%j|GtHai6fcfxj!SgzHR z(wn5+k|H~txS?gKhwM>&dC)g}ZANM3@)jwlvjqU|8sJWn0srU3@oggQ*Bh#m0Yb#y zSxUR}Z?SnzLV>VVsaQ6iC4vzIyHRJ}3<|IPmIpNsMlsK4pBN2-y~vxtskiYDO52NS zCupUl0N&X#;lV)y>W>hxqFgO0xovG+&4#~58TFo34wYh^C=q@jAXD})mVL| zrk1d3dTlAPHOMO*PFtwW$v7(ZU$K?pj%VwW08RhgW!%IR=OUGZB6{g!Ff$Mxla>}2 zr8YV?7IRe5PWJh#Pcpiz7xFp;G-OA)FAR@7NCv#;2&PD)To7M1i7d2$d~oGTe!Ea- zF#3qh?OVHL??#=NRf1rLyK&(2+MSu+^p=iJ0vTJ8dquZi>~uWsrlXn#{>U~1$Srs`DB(NM9Ssg{#L3CI z*cC%p1W-8S>y3`3hV0ZT+4NB5>WP% zCnsL|d>PqUoIvoBt!xx-Kb-5GPPzybj zuW}#PemKbGop%gOm6D#+9UmN&KG$S&CqWec0Ao`6hX{3IVq>DYs#M>q@9H7aSe*gD zO2r7n_&VGww7gg9FNaFax7WO$Q68=&z6_ToGl9*?4&()+81P9)EbI~h`=~(Lk7vjt z4n)8Z5wF0VuFjNJB4X&MZTMV7*Tl-Mu$377Ae}6~9s`0UJvfzYQ_Ty6kf&%kY8Gj0 zXkp0qG7;kA5rkB=Mt~c1P-Q_9z#eu@;v--Gl*(Y9%=1ll)+2pb&yCz6P7ijjP?t3t zOQKViFZeGc{LPh}|LnTFRCx%1pnGyF@S3auAh)IAfT7uKQBlofSb$wlv&=jjUg@Yp z(CI%Bct+bE@u#+LO@aWH_F?El8379mi+t7&J2qG&*HBEIt{P1o8LOz>)Rru06UoLNjRIs?#U+nG6THytBF^oI8{ z0WZtOJ^2NsW?7pWdxXd9d`C0guDy9cw{w}D>wLP7?3b*`-AiH-7{hwjV}ho)j;kT; zsgT<=VfVT2>35+En=i;mI|u1#pSZX*M!8(tyM7GqIJa_%j@$U4QTtYl17s{lm#TG7 z_O}98qoTm#O6l~3MCHna!$a)ktn_5+1iE;jxTc&}*bGluf6)VgtA4t|2d^@p2{${9 z9XH&0MHiR0X;;!{JA{rX8)DuN3?T=AJyQ8EZYgs-#e7x(7Kss>wd4v zetT%eHno5n8t+IUZPZGo`N)KKNuV>DpkyDjGjEQwy>o}NgAG`^C*^JZGSC<6RWSgUXz8u%At z=EbbG%@*I3wbb1MWM(RtWWo+VByhX?0XgpP0Fr<@!7?BQ9%9tO20u>}KgL> zijf9#{ckg>QlSFmxvIWH81fZpiBr8?4rZf6l?c1N_xzfI>_ysvt#Mp3 zt}zo5We@OEQdgeyvRb-5srlyq<8x73H#y3p_j(qPD=KQ$Vq4*c+r)CpwH5EvXDvI; zTlBfiZ{+==wrnm(lIo%FE=j68$Z*c%tmTdMJpO0|<@|#DvlTQYH`a|$PV>pDmP$9CVHRz=o}*lx4bHQkjkH{lt}K zA^K=D+j5pwN4*^%n!E$9YrR~R_1gp8rE=4NbsR5sCS+kHE%D@(p#JAYdY_8b*c*7? zBMgH`Cs`$BWuf-@&CS@$V{31qc^E^(?LI?S!pQ^{5TgSy5Z>kYU*#E0<=OU2wJi z{vW;|S<6%$PZw2l&N+J{t!@DP`UDb3T-Uf>4A2kL@ASh!TxmrMA4u;6l`1zazo%hi zx;-89j`R^CLWlkB&y~fC`XN`UuIJpPanq_6I324**Gr=xd)(BAtw1kkFGw zt^l8Afz#T#c<)+;9*T?j9H)e?b<3Am+K%t zn;qV9xPG`sP0a4>8C6WC!F)@0I}4j>{kMOh!aMUUA_WdF(FdTmLk%ONB(9G6HA+_76HoD2=vNIzYk4=$&= z_n>7@n2buwUSp*ncC3CqKkTYE9X9N9w%u=w)dCD3in_{1yN;@&L7)yHY~1?yavTOW zkauEZx-!FPbAteRKNQSOSR<7Gw^8Fkk3jZdfOOoad`07~m8y0za#ZAppe3fA7f;&D z=Y-nJ;m+ZN-}05mw6}v?T6R0!N&wjF!D#vezKv%+R)uHchL;I<>JSFIbbnp5J-(I! zxno43Dy@qJeTcMfTg%-EhK$)a9iqa|g>v2SgEOS$A;_91GxFp_0N*6gn%fvA2Lpw@ zR20>{Vm~T`_Z%GW^oidk2@E-dFIL@>K6SmV+1}B@u{0rh;)lk5)`P_p{KKY}bBZCU zZnK9N`KoUg%{#Yn3RvB0t(CTo3jKL)QUidd12Mfpn%Q7qc}EKp>=Q)Vls%{^pH`(5 z<@+Q^8hIi*-ZN&7&~?Di5jim&&a7PNIx1=O{-!8|eA;%m|FSr12zHjhQd!CUMaukB zh1z+I%XaIV?y7`HTrP{4P~BHuwC+|KP3aOp9i6o-aldgFvvo}o*PrM-Ty$3biCstL zZ$!?^PNiIzcrPzE*}^ZEi#U53@exBB26Ww--aQMG_Bi--%FW9F6{5RxM({jMY?Ys+j6tyed{yNCq{ZKR zMjsG5?LmiaM$Q97;{u4ZRM#V~xyn(^TL}ZauPp-owh)rtR7G&vpRC1Ll*b#L>jy3u z#4bZ;-<9Rj{p(b_(El?F;EhLw>gZ)rgNjivN#k{%pd_3Hm1)1h?IV!)8u)zTr~c6U zc6YW*ibT6I%+2aLMS_Rwm229D4!9)rxjrG_lf0G3QvD!0A zT2;NfdqdNX@sL-gHMGJJsAS7xgJhlyQl@}h6zE3cObu$%;M-_9-<}>HhEVk%&A>*+cEw!KeVC1 zBj&UeW`5d~Ep|DY@AWWk-+92WgF>$-MXz6a9RS+ME4g}cL9VeBFQfVKXs9<%M$?&c zh`i!Va6-pb|7F_;S;&_>!`S+6=@l|M- zNxemRF+H4^aqJY5fL0p+$P3_qvc}>X&(pr~yj5o`y)S)+%sVK0x%rOl5^2pS@LAC_Qsne>P#DdQYFwRR5{7W`gHN|lkL2@uWppNfA(XP6>z zNSnQ@;Zz3h``TxuQU+?04|PnbZ{H0(8%YJxSp}1is-R84>m`sV z$WEIi6ejSuqMZa$2h`IFImd=el^I9k)A953)p_^w-$WtM0^kR^FDe234@d~;fw=(D zI`?=I<-cwQ3g9muu>V5Hn?Q_|i6%W0(!abB@B@hdkNv<{1qpUw@YGB;@NZ=}P<+@8 zhJ9q-8uvs;ofWS%t0f-g`|s!<$w6Ay|F5X(f%E-x<-Di$cS;oBGQbCgbJ~&p(~3iVUU4_mpXLcB&gX`e_&!GB7_FZI- zJgqY>C-Qr<{!}2X3Tal6c?J2!o2Cj!#<|RFY@zcf!&OYLG;&YNpm3(igc&>fci_pbPX^fMGRZ@4APF?#bq*=!_HkYJkyKAT2*>DMJ>rqgE@jXG0lw2Z{l zYfUS2jmi=YR8d4K1=;Z-U@?sCQn6vRq(b{Q7V9fMc}ag8+je0^@5XL!>&EbEsN z*+s>NADmV(EjtCA{)~J;FDZw#LsQEU(JH@~SMkL`|5>7Zcz<4OQdu(f-z(GpJlJLq zV20Hj{83-a7vD#de$tTB`#DpQT%tMRh^rLZxK^E;VQ#um+_ZJ7#8+>=u8}fFdPR)& zv3vrYx*k*~(@0jE=bC2)J%T$@uH077B=^AEA zrpF<1C7_`~YEupt6pGT3eU@|Z`B-(-U$Opb<@Nz{i{sNolkHrErb#DH#kDJ&q?9td zD4Tz&fG$hdFy|634zLSl$V&UzSjvnQ@%K&UmcTbJ8L2gb0vj7%;_&-1_#&g8$9+k% zF&na4*~DaYVxlGc4+2J7?a`;TN0AjrOEYfAfdiw&>YRqIo4KYnVKCUpywIiYAh_5{ z?&l~?7FT%;eO-D0(my4Oich8l!v2rx7mtr=nqOPYIk_?i$ZKk9wgFAK#5N71H3?#T z7x1k=!^b5G3MKqO;!^}RyL?P+EHaI8_HmY1>*=C7GNbhfDwJl}`!Q04Rk?ll`y6%* zi`l;rRTCVB4X)s6a`MO^UcgS!0*-6{=Va54z4u@uKrIXz+H;zYm3zL-+?SO<+fqT+ zwuO_FnpzwwjX|!xu%E1Qg8mFQ>1Qqnl_bXieHVYyj`yziR^OHER( zRB`+-K}`2n8o|BCL2>ev;^=$ya?#zY->N%I2#YqD;lst^6=dR!lW5@&J!%P`WU)pQbolX}mxzMM|eo_hAw!(y83IKnzqYBY%v*`#NI}lIFw#;!+~}FO{dnK^U2(r$JzPq z3eL_f^jM_J#l_BnY{8)Ldi&?5>dTf>9p_ONGMdY$0q55*uBXo4Q6WR(`XI^d_JMhY z`C`qiPV?n!6usm!L$lKbDGuU5?U4h-@C8b*Um_{MD}egi>i0wbKU|RVHxKLO=LAZO zfj7;1_22cZETlrx={&TR6|P!lX6B>izc$(J`y6aJ((UT1=yy*d@L9x_X*7sBJvQeT zerq^NfWVv~D@Yv115R)AJSpwrr z%rAe4!9})tEawHluYs&e6N!eGn5fc8l`D)sP(vyKq%6t-eS|yxZx_=rmrIlFzz*es zP(rd!k7pIKk>(?LDdRs!=JU5EL4Uxpc6@mx*1zwJwp0goc)Wp+-!$)?{;o0k4`wI0 z#rinuKY5*c9!=u^^i%xW;i`enZj%!w%m9Oc9YE^jB_t&+l&k@YcoTU$&U0qimdkgNaG0W`kx30~o6iN?El&VNLI)+P5NDrY&B#Jvq)i=Dn(>j3P z#Uchsi|gSE`er@Fx?68=)Hq8nboo2<3-EN0?_GpXFSit!@urYKMcyw3t81;>6I!zV zJZWvweF-Iz33poS_MuSlNmf)aQQ)A59oM;QM;W1;2s(W#O%PKv?EF4S_i=td?S9&G zh&)GFPew`#t+C~@S+ioI!AmfPOU!6(L-uaH;YfA^1CRH!l+-Arvp*R28-u2wbFuL7&iNq!+G1wSnK6|W#&voepVMZ z9;0xkbJ)R;nn&P6*i4Xc8ux4;VM~gnqUr}* zoOA6dZH>)q5sE@i00ne;>JFreWqOFO`GTm?zZIMp0pF{5$Ukqwf9=!VkR_IXMCy7dMrIFLx`%Y-Uezj;ImbFUR(~ zE1gy^5Wbhe5%H-?AfD#KoR@#LWQveTCQd7ks?50yhiJl}{=<9GI0AStk~Drg!k@Ow znz30d(sa6Ankr<{OpZy%;W^~Ivo;y`qY5#}$w&yel0Z_&558xbtel~2Xw-qIRZMx@ zY{|fgppIDGZhkfHn=@epU7Bjrykq+ez;~Y|+WJd#tpi^(Qu!o+nG4YpPPR z4EnGjeUTNep5+Im_<#r;mVif+amEjzV6`uf+O1w3QeCV0HoT5boFzN-Kz#k6sShVU z3zCqME_gr`llI1F9pZz+Pywq0Hi#-KtC>*9p7-=FE#uZR8`#iHJ5+^CRQaJ} z)jJ&n3*arbhdEPn+UuY&k%5C8512zl96u4Q z^Z59`u?zX*5yZ__@8infE%)`2xxV=$NWFGU%=!K}|4A+szZG%H!Ufj@l~{?FdBz+x znN%3nm5;c;k0s)IDEb`$W9b1f7W*{A5`U}yTfvititbOjCCwF`)N)$8`Dz3DqA8JN}NRS8A z&_ba527Z@NP$oNJc45Z&J5Ydq0Tcv}eO0;d-1D_r)a^gtQHW8ZZO{ADX*qx*nq@FqW;=vvbFjA z#O_>4Cc8wCXX!W>K(NOO<-=DyN9)34_X(CBkNGQvAwoLQsJaLVsjFl=QhD3PVg!k@ z8Y}`zspgoxGrz-TR(F2GXgCj7Sv_7UtlIqff0&te26CC!y=rb0u6*|v8#^B0lI>=d zFetLlBYXif$czfc46S-6{{-DP_WBG*g|lz$dnV=@bMy15HuQ?RuhEj}H!sh1WBKNE z+W;so2sT#V>2~ zOr0E8{rYztZn3WszmJsci)Ff9M;+Ch+|PN+ipmpzZ;PRXXYy#85`*ud(`pjhr253w z-!KYpfCViu8JB!lBL^DF%@7t#USB8|r&-SWn)@uzzuDS8H51_}KwvQ%RHZ2;Y627F z2#_CJYC&DS#ei3Wz5r{h@apPHa{LR2YeA`Dou+%zXCSVJ(L}a`Jt)}{pef8Ewh(B3 z5O_X|J@r#D&4Jq_2kd||ZT+Uj(1uKKh{H%*190~iweZP|^T`^0;{nj%t^EaI{}1wI zhG=xxq9_&J@0@kRxkP zOH)j%&Za6@z`@}x&<-J+iCXdtw3`DFLfoc$Em$JO zgO+F^8#9u-3%bk^o^4Y%>PfLL!^vz3VGjRl`txYj~K*-FZmww@{1#S^4fH|?t*iOnT%&5IC3n58z%#`Q7lUa*+=Aq5z2c@x^%@v z$(9yt^3&Hc^#1XNv#2ktj)iKI1Rc*cZ{UmGf(yxh)tkFd7>@Ugv2pz z9JQ9R(k!icLpbUjq^KhMDHCfG(PPh!M@bVji8>H1vneGK)oB)X;<;+Zbxhfu&TwQ% zYAr6+Sl#t%?z!|&v|zDCy26@m-)MYWC#kfE5B(!~5}emOWw;hU1T4{ji66$5lHq`( zgd9wdzs`*qnFiE%*uryLB&_GN60)gKIeuu{5im^O0@8VeKvfU_<|nxB_HP1e&?s%b z^yCS^!BunWSBc)S*c%OdAAA!@FxZ!zHG%A6L9(lEBP_RmzERmxur%-}+TqaDwG=4C z+Vm$J$Kqi4R`&#jfYA)hXm@3=qsetwio|DM6R;|XLWOdc4Smipw_zfEC@1d|7Z?<- zL?;(1j1&7my8H5QDBHJxL|L*_)~uye)*{&%3E9`NjjfQKvhO01z3dET5Ha>`>{OK9 z5Mwuz7z|m*I`ZB<@ArLr^t`|S-oJj1Ip!G5ao^W@p4WBV*L8lb&*ubSin64fjR1fH z+W3NK(&?A{kgav69RKUIXj1dDboJ;AZ}&9l0YDh>sC~h(l?an@n2g>at++NSfxFy6 zq2}l#o~fp(bd3FiTzKi3$B;66jZTOp7N5gF8HyaN_%Kkw%0zXOpQu9rBiP_XEnMX0 z{1=ma6^2g;gQiTdqg|efV2kr;??(2?25X4hif((68iP?h#_86sI`}s%L(r&U!iDpq(CDVYKg-)yOI{>{{#= z<*AsbQoj|s4QLa!Dg%_8mqeyZkMLn0Z%gJH;sK;0CSUq-6L-iIKYvo%S3c9L^D)Ux z^->=ga5b6)Iu_b*VUGIsTqyP#t$oioQ3nH21b=(vWAjOiSA{;CnM?=}T zF1Jep03^?%aj<&xmox1S=I&gqXCtYQ{^&}wIT3?$f|H@G7i$Q`V4)1EFlXX6Ta+YaJK%8?)1{@$F-Cs zQM@}lpC}?gQ?x6Y|KZ)Jy3vC>Go?{#sa7hw(YE0tToNlTP~0XCAEYuIm?q77?3Gvl zF*Ku#r1yz(%lpVo*Tq=ULy?e&@nJvvLiaR(C)`ujxHJXpv9{GU6N2S1%{MA6=IPL3 z{&_UJx9Zt43e{@f#8QM0uo|_5k~ruwL6o8RyT$^AeyTIxij=HoTaUn1Y)((r6mj_n z955L7|B}btxf`<22VaT6)i%7n_WD|!0qi?2>MDnS^?A)LwG@;4)uytwUXvad$)M@` zj6@Y335Mt5AII2MyBHRe;oml{-QS!x!{%&F3~N!$|Mu2@eCEPMXGlhG1-esgm~cbo ziw92+y#EHo!Ktcc@>ZVivF__{iJ{iz=k>j(^|K1e6R95^UNw5as=q9qXC)Kukur5j zBj-+@jpUOW&Hq(-1NJMaqrUhC*ccfb?^~-@n}DbwtGXCC6=76VrD%ZH0C99iMo?v# z#l_4ENPZkUL(nS|aFKieVO1I7=&x zyZ(q;1InAekndGgUiNyW5nk!ih%Su017kj-_+X?BRF%QAs@1)}`oAO|mmt5gbdw|J z#@5^@=!f*pzA`0z^D2Ur3AT`27g^FSwwopCn*NhSKt$mY_sc$*P)%Xk)S5wY!GKS3 z^s)hX>>nyn2Je=O4cK(0S>9Q-0`zNdkvSR-Q)ljq`HHwQ%RrwEBi@Eqn8*%)L$4mHgJY zf5|55-wxNI0L0UFLiL>y+mlXAY;k^E%yblI+clSwQi{_8c;RnfpRni?o{cvQ7NC-^ z-sqez>A}`sZB3yX*#1NJ4fqwbPWP&f#KHU3SMIqeoA3Qo9R)q_`Z?*q@~B8DV^c%` zBb(8UgBx{bbT4O@-y~YOrsz2woCgh2n=hsK3ktjZ8=2`y1QCGZ^W@;tBxx|)u%oIz zMXe`1bh4u?%1V3H&98$^0xJ8kWBt-I#FTS2FD&BTL-X!k^4`LS@8^=fJ&XRX{{fGv zLA(TaM(F+*`Hro|;f=Fk{bqJ(xujsC&uxOGZLb)Z0lAxWU7h)u!W3e}cu< z1eulW8A+aVfbBeNQ!O13o7_pSi9rtM<8% ziIYG5aCX5J+>7PumfMVc)=y1KyRf}a3YJx#rc>qWajKsibBF$!lz#~KChyQ9{`49( z-uSL@sPX$qSA!CVK7SC^0tirfcRhLx9@3Bas&OWzP4XRY14e?J9fqbsbMtQE8O zc8f*+Rnwn-dhJ8%XGToD((xeqz!2!AkbZTfHc!@1MwW(?{y|;73w;DcNj7u2OP%E0 z>;0CTXRr%nPgDQpAFfUErN`+l{EgCTo)~e8Yx$A;0pDC?_{h$U6>lA+vy8H2OTnZR zQ%#IZ)Jkg_W-^RpKb9{wto?(DlC2i@YnC#-7*f)wo-X6Kc>QH$v-p3=C|&JOg+wGI+>edPS7Yq$3MIK@>*a-9_E~Wv0UBmIzu%%jQ78MKr;HHSJ;6#DTsbRebo$HU?m{2R9^Od0-Yo1@vH>TT%vPvS#H$OV|dj*msC|Hd;lD) zPDmBgW}L0k)})8g8(B`f)CB<97M44Y!_KoxJ^*rZHu#LO?;iHc3pOb^DuDPPyyX3% zj-DPjnS*o3-sgVTZUA1;aKR!JFP7C7-MZ!WHyZ@vIB*&Pn3?hIT>COO$OTB<-g7}# zKk0kj$r1c9?09$fxU_lWTd%{xPFK`I@AuUB6IG7Gt>b?t{v(Yrn z?@sj{5wF()TEAUToLHJS#eSxYzX71BarM)uPr5;iF@5!i4(vr=6M?E6ogMa9=Q%Id zlZQkni}Cq<-)`IO_q=*kw2YnYW{=fVQIJOe8u&F+#2 z(z?Wan@bs*L450$giXREAV)>me94q=j!P&HTzIrSEeL6R5PqJ6|35GR>OA^39{soS z68EJV5%*7D@^#JhO7We5_=|)3)YFrYGj~u|hMS5 znkP(6y9OT}Ve^q?nxn4M*X-yN1|>+zHX>)GZvkW;oWG(i$qe%>VYHIWoZ)^baLv_*z zBO(c~R99K;?9En?tMp4!Ni{A5U#fWSt%G>G#vlNa=^BLLS{RIIqrr5o^xf0tjsZt#@ z|D-fNJE5;Hrhdcn>b1d3q<*s`h)WdLMZp$NT`K}EFSutKfcmMKGS^Gb07^y937C@P zYkrknN4uQ24KC`}*nR!@IJEmAmjX|Ij>OCuKT=yvN zJcsnda76xmvZ2ML7}y8=Er;VAK+r;!D5W{pW^Ur$W1ZjD#d-QGa0^cp`NDA*t zxi0KE&LHb8ifG(8Ae07>E`aPgi_oib*&TQQ;y_89{IU>`zaKszRUBDxTW-4AK23#m zHB_^IV-he3x3?2_{0N$uDWl{kmBBt_#cCD`OFIB*KtS_m;L?9+6%~ix`alRfcvFE8 z&dpWI=t?~snh;Tyf^Vp(sH9X*8rR5f-T#pYqP=A2?3_F+eH;UC5mQQ*{|B`K>z^Mx zV#^RtUW4O;In5MsJmZ^mf91Bf*huZ5gD=Lr=k;aE&KPL|ittz^$4m|ZD!X$4v%>kq zGsMzWi$bfC8tqPgJuQ~M^71X^&y{i*pVr4tl^SEK7fN_Nyq>+X2*^DX$PR=a*D>^Uo+dW;Y~Eur744G|X|Cjsc=2z7FpE?`pseh~%3 z%3Kz##0DJ>SETLowO#tidNTBeyX`1XLi&+m1$Ktx*UGgwr;(`kiJLO*;W($wlVDUL=v6p}5@UP<)KG z_^J4i?d>`E%D$R0s<7s0w)oM{EK%OM#-=S9EwrC!mJ-(Qi|tDQkXgi{V6@Q_$iOCB z3oYNz?)sCBWo2R}oaN-_8A6H;RpOkUIqDS2VYeSiNw*hQAdgaI+Uh$qC+Fjz_zw4& z5FYdMa*PPfG}{yhch9g3r#PH}=k8|m*81;;2|+K%wes_FSMO*0mwcEaE9?pXTpYzt z76LT`S;T?YOciC+#l%F9Jl(`=qB*b=sxt>*snn+CnfYgJ?Ipky*>b)!o(guW*tauR z@lkUS+un0Mv6s0gJvR{}z6E;DPm&^wN=vEJmGKTOy)QnfD|+YSBU+mhYFZZ;pB(MM zq?t&**|tz4TH$t3^gUA+O`F?!tXhcNa>On|XEPcbY;(O;%c>b`MoH3gK^$wL&rnh{ zlU7D$Cg$gqCd`A_eV5?Tl3#u!=x1?!=v&7-vKC03@bdH+VbilHqp6JDtPT-0`(^s# zN++N{dB;dN{LTn;pnSJD+rzW7VrO2~wKigt zv!5Wb@5f5aaKTBPFM$HFCuEg2YO(!WLcNC%@J6{@}Nq83JWeajA;21%jP+mha9EEBsCCX zy_IrvcjF-r4y&vfiZs=o2s>3SDkI5_EZDa^Jp0a-Yu!~fB-Pv(E!A=udc5IEFx_&05FBA1aFDDS{T5g19?ExS zbK9a;xF0VL3OjDD z$2+(&B)>(~1mHP0`dtHL?k<0+g=^m0Tbpu))(vcJT2Ms~QeUMcQ2@ig{_tn)Y@PII zV;$O-=vDTBT^al`Ds!k+1k@VR3qRmYo)B$AR^p2qw>E2Q85kimYw_cAx1vj1?CNbL z)tshtV()p5@v>r)R*rSo?rUi__tNzFf5}kel_3Yi4e2cUMB8B&c-pZTT=@k*c%VlF ze<1scadk!E?Hw2BPnPuy^%JD@mNNK`OS^f&$6~|nHE1hKzadWdnZihtPy1MexzYDA zy%BTapA)gn28=4;K>W)&B}N&HqKxS~rsE*HT_^C)&9ohqZ#+ngY$O8o#?~=+kt?J8 z#uy~vU>O^X{Cm8hYRcYV8dM8rDKB1gfs7h?HoU`^VOp@{EQ0L%w$FGhQ>5KER=2#W*pPh~yS=?Mt0_6t9ZK-DJf+;P2T#X-4!H zisV!azUtxT_7sBcW;1*BA(cSS#Rc}C}7yD4%lPX_}10{Cr1{N!*_d$*e=3WiFUwu`f`Dg z2Kwc8NoT1i=VZ-{*$Y&02WIGgJ0q=xloN_zyINJ1?|fL5#wx#rUb7}ACQTsK?>9KP zMb0&NLSpKA?Amvk?jh4SzWauGzqGr>nF(1n3!3!{UCJTyYhe%5QD@vas{K60N8YR) z;78k`_o1kSg5q?P`UaR9;yj9+g;ds2iQ3!;uJ@dXEXyC%bGncIfxTC}oL z&2^A+KSW-$vg;nw6Av;(0&7!O3>jeZuAoWo@mM}GqH>E?-_{Ab{Iu;7Qbca zpZz8?%ljgu=q8af9xDRYs(e=KD6H{8{GYJ~5rxRpG1n96e|YDIC}p;pcbr8Q`3=~b zKvB`$H+(k^Zg_c((9`Nes@)$@gPPPAaD~h)p{ThCZwR=(KoGWd_H=kHH%QM45()6{ z@W9CK`A+rTvtOSw7?~WgDM!r+_d77jI;W1v)--*s?#-+eYY#WJpI2e6n-eHToQ(Gf zvPB1Was8uX16};RB_;iIAnLIe>uxi>kMe!q1#TsL08hb#(}C_{4f(uX)M@LrQ#C&p z;!k6T(XQtHJwpEa6nKG)Xf68k_8)=UF?mi@GiKd)+S6QvOvEIEt%Y}JPqznDPIfp= zbY_FP-n7a=@1MbE3Pnw7gAW|C|`ezvmJW+X-?o zASq7Wv`Yc7O>)c-Ty_y;e~Za zcU=f!n1PQwr*1$*;(PPy>e3tYHE{WvK};Fk5Ypc9=QsF34AadrHLJ|0ErI;9#8Z0w z@@Y$4e_66SSY>tUG9VWUz>?8TOUKh8Xl4LHFs0BN_eTh>10jHPz9>CCZEhI=A#kM? zkP1H?f)pSGF6^|((*vao0)*iIpyBijql*=yzs(U#d|&aX-DxYS`(AF<$Ghrx99>=O zwwVM?0?h)M2dqOOJPeid(fwfp?-;E*6DuKnf|qmX*w>(>~j z6Rer#=tyKFloOFJi0-O{Z~kuNu^=4r)>^jQCPf9w+g)$w_&F_vVd)2(#^hy^!D}p^ zxlfP9X>Gt)tUcWyPle~#)0G^QXw4jd%HLd1708yz_QKt&ZvSqY>4p7iTPDb}XO(ha zzK0qsM+EvU#)#f!LyjVmxXF%knWLD%{Y{>!x|8>w0=Bnpvw>U|pq10Ds;au$5t)dz z8Y}BiZtbXZhwlasg_3!F8&Q$++ex#p2bOJ4O1Ony1H)NMTU1uGt3j%LF6|^ie9UQz zqh+mbZ0gBCP1AZ~d(d%{+|HEbH>$ESL{%rJ@A$9t*;6gHT7rw7pO>0b$ncQ&|^{%M2paKQk~lz#Ie>Qu7~AUu@bF z@tz_Rt6wFa`AD|y{IgkN_lOBtTvV^2V#oKA_k?IWAEPtsE*jRZwq8g2I%<%EL`{v1 z>`F_W4NP^N+9@eN_J@sOe11npmH0t$WOGS?N)1Fv%;yY z@eZn*`T^ORpzE-+S~sn^sh`EBW4w%-ajJ3V2q-ei>nXMBoIHkEES96QeI08R@Ob1d zQ20gOxw#K2)tTa}qyu(KuI$ANyLFJW$~Jez!Ni9eieRf{)a^R8qx-EE4h~zEp7EuS zwjB3EJFY2@P1Lubc!2n!kuH6U{~IcJBak-K&_8+d(C*DltAoGS>a-1$kZH}kwvD9z z^gw7}v#(m+cD~kYk5xWi-c^Xj2?G6N##ZE=);6f2g9eSBXbLukA$r!RGK78I$e$M% ze;y~BjXMkv-}&U-8lUpc-F3Ib*ucQxa@EFqbJIW^yWFE8ETH;R!_q1XzGMi-JgX}h zXsbk{N56deh7)%xo;6`b#M5%hPPgur%GHXxwpjrO(%0R}3OLJuO44LWjYj~3O%{HK z>i4|xgK8=I5Rj)*IC>uy>KQnYF@Z9bc2kpHi2DCqQvBQdhV-C$%dRu4d#?a;KO0p)b$yDSYNtx0F0gapL0c?m05i$g+}y{Iwns0{ zSs9+0>#Ds`eop45wD-^ESUfAKPJ_*k9ptvF%g#ME$2Evwx7RB8dP7M`UjdMd4xX+U z@^JrFy@$DkI9yqLV88s-e+MXM7lMIu(5-A2BD@d%cFfT)YnxwgV_%+k(bKCIXx$Up zO{<$Dte+exVzGLJ>FNCs%3dow%Y%Y@-JP6NVp1Nh$wvq8cKc{c1KwkdS_<4bzs`>X3i# zf&hot(#Ilg+Cg{lLGaLm>F#77`hY}044t`^>G;G@}31PB-#*~39m~TouQ*QLGnCV#J{3i|ycyntCd?!^$ue~qtZbg=$t8r+>NA_h^S((+!+R&JINY-9;n|JsQkQNmCiiEm=^`nq5rJ=~CF?!|+mC>MR%cXGtSjdyl~zvGWCL)a&p#k@tvVHZ=E$bVQb^>w4guD!D*f@ zH|b(sWMj6Re7$V|OeHJv0Xp0>+lIFX`L0}n{cO=>ISJUagFg9#EX|PD&SElAjhKmg z^xu!J+t#Ez*Uc>m=;he(8=7gB!z~$$Z@874S4!VWw$%9Fvrp2h9^u4aVDRJ}UUCNX z``l*^_wFw)e>i6_F7K*UNL15~i|5!Kqh_70E0^A`k=b10&Y5TSWK6^@G}l4(%j@zn zECWZFMeX=P9ntBY3&}{&Ddgal*CbgHhGiA_*5FSV1NVcfe6JpVbMR4nVIjidi^qBi zBo(cN(_aL4Do(u5mQDp{;nnvL6+J|nX}1R!`_tzqPzuOE?0{Y3{Rr6%9$x1)=ehKp zp`#_ysmk~Q6+f^oh;o|KwDRL<%bvM-+sFm5_d?uDs{5XcY8t8c&nY?BmaKeru*fv` z_}RTAj~Y4IY>CY|EagbPWTQDhOuO^!Re4i+J1IOc*-G|z*fSXitGOARmfNC2d(P5w zjrw)oU*mXyD4m(du*CGZph6Ko!yR;%%|90%hTSi=ZS|#o6elZP_XT;?dW2~{-Qn*Q zlEuH3+4_2Bw+B~T7L=K%(DT*)1TUW98i1duNOoSiGVY%OW>qBnUW_rd;!6p}wC*n0 z-uIm6HfQjiPsW7kHK8wrqTVjk!)>zP0y&TJZx? z$benW?=RDoFIf(x!&RpwcBb%DYT;;&; zj*)UycZX$nJ!Y#lQg_mYQ(v$II0QFI?WU`Ln@^a0`uSZXEnnBV#zv9v@NtsFD7VQ# zmelO^{l2@-0W-_Jk+Q5Y^B|(j%iB?Z54c}z#Ze%l5@e zrG!E)-E$f=cc<6yXAswOkS0~3pG&OVnH?pD+FB7{#g5KW91kY@=0^<*audos_1q;%%HDh zW`1!-@UP86Uj59Un+3ZKtcqra^aL*-A7LW%yAo;HY5|G*pF4-nifjLLC482QlCqD$ zdHu!n?Bu0*zJ8;pyh#9-Q;&_}X>(YT;$M5~X;5rFq#l^=ZK^ESE%UY`EG-(lW#6mI&9-dVGIu!&xo(7b{dQ;*OQm{>I$T z+r#dolarD@koJtd^aZ)$|nvhw=+dW5QzfWRPOQE>-Hr`p;&H4UxM(6GkF#-^sGug+$;fDKBrI>Y5sTL!;ln ze_L2tC#R$)Bql{gN1It#nwVJ>my{M36*V_EE30XI`SK+%zd+BxP)zx(6z3Fkn--6_Q}FNU?(SBJUUT5%@e=`Y@Jnjz>Ib|d z=PK10TRNZLyh`F{7c+|sD%{|8s6RNlQ*f)+)Ya1!5Uf>I*NZcANnZs?Xti+C&;kj6 z_rIv^KU52v)>n`bcPKAXk*dXv_ z*0i`+dXupw!o=?<1`EsBr6Yc7Z#B?td5LkdmZluPw4(OYmBd0J=TA4fD_X>92gdKw zHcu=8gMsF-sYwM1p;k+lE=NN>6Q@oYL3WUEjuFSAwz`6UM9-%H`YPd-wzh|L7CEC1 zF0dr8x_GRj^jKV26@>UsQh2G6h(tWh7>IALR>du?Jd%_W>%oJa*iUT}IGppwT$}T7 zeSM?9f7!)o_drwBsRPO#{W_w_J1c8Yz@xj@Jg4T<$^O0w8yio6^ggROsT71Tu)Ik$ zJw-BUk=fSAhA~<`Khi*}B!@CrrZi8wltRGQ($Xw6!fL9wmnMGREn}*`vn#)PpsS-J z>{z80{z#xXO6xcyAfV&@`9nm=$Ra>MAVrW77g6_E{`vLZfoLK7^l|RpJd$i3t6G8> z*rQ9W1Fge|%1J(?U{4ib9#O$#XNwra>~F#^q0DF99KkGO*xf_k6wo4)`${H;ZoC(r zBQy$y5tn8KeHx)%!Ci%;eWkHG=sMLT;L-T3!>>Dv9p8|0`CNjF>hM0GYBOuaXLroU zV`5DC75^5$h>%<25-#Uj=Vwr+s7e!rn`TtG+k0?`Y$G>xBu#E!1vYCrPxG>n4%t}|;s9dUOZ zw&xQ)UFUwfst~%r?rd!JdpcU^dRp}}@wC|to3wtI?1k;mZhv}23g)w(p^e%4SU`|wke`^d=m-ZkRK(`fbK=l4X&r**Xc zytXRS(F{Z_eFu`ZK4QA*u5N~=?45Jfo=P`jgT^UXA}Rc`?VIw^ zNPXmTB>z>N*^)jko!u)A23vpGl{fOp1r19}W$);YqQ1HH>e&!|U`iT7ah)zKe>G)n@C|+XuC{S?9O}I5%rQY=;v+!5q-oz)STLmr1k1}ha!;v#~&ZA}n zkZ=1TZ+WkzEyF0USf$_R{t1+&C4^*({`wwrDFDdnnpQB`}`H`;sz~c8PosR)1;wu?Ya0M~q{dV$c9&*v?UFr;N`*D{fy^&Id76 zEXXJffy?3*YI|gxCcL!uw8s_e3_Iw0tmlHr^b2TM1?I?6xY~J=AyQ>PnR#@{?8Hvi zpxBSsBWDDoTqU|^fec|JvuurZZnZeS8mbj>yjsbA*Vg(D)nOtC9qQOLVj#+a zjKfHTd??0vD}ufCUsBCM1$mNhhU)DE`s?jV60?mP7@~5+TWn7Q9~5h+&=F8aH9v5U zQmZlO<$<+znp8~~%I2r00Dvrz!*+97soX5p_SNT-68%m?JqW@)nE*aV3wZ6l|t0G3@Ox=@^Tt>dw=wF=M4R zw8|7?7%(^La2*CJ#*6Z0s^vJ}Tq+lUIb@e`zux_704&qZ>WB%anlBY`yy|(yn4K8_ zGD=aHyzf%QlQp~CDnt-znjS}CrvHu*-;PA`Y@IP+A`Jb5y`e84f5F~oaMHHzgu(ff zd2dwIT_?FSkPQ_&+56 zQEIk3%=&o>*ijmZqd*XgP>%%DcAd-(NtK!lrnP0Wa>jV?86G#8_Xe=<@;o$sX1|1h zI&a3D>s5IC%Xf(Ii&eHg+umOs+!EoV4X}9)f}?S^%+q+tR6@&fO(Z~ZQeqG7^;=ZJ_+d0umi7l#gS$R#zKQ2KD+j6)h}e zny?ebvoYAan@1W0ycYRMIM^^uF~Rzd(fjuKYHvkC$p$;B?Rl^r_xQl4l+yTfvQ)ie z((DYZ<1Acvfra3)#fa>`((nO^2Tk&W_PJR$R$l5B0u*svc8y&Eo0weV zOL7_3u>j3E+3*^PA&fqbtk8)!ZN)fH<58SldZw?mJ?gGKCF_H7iT6P!*IP@nLj--Z zFCz5N(vyy5Ln_53&4Q1y`zU2-<=E83%i{EvIKM7{-fK8al)SVq1ZC0d(A}VSc zEY618P1iQ~=IwNhztgt5 zJ9_r?i}PC8>AQSzm^C)8X7^( z_zBD}`66358I3GG$Ho9;!S0qo^r_X*$b@W45R$_>ZSNleo@+qi0;e#dm z+Vl&y=ynbHglioG-)6zZRr)HD7t^$uWPxH{R%Fm(OK%zS(on06cA4-hC4hwvT{5*e z&!S!xg;a>FCHjkgU^I@394wFV{6gvlPRdd6O_|Ps&&i)VZ}uK6pCntX3TDkw6S~4t zJP%%Ma8Wn5vlq<})D=*ntSjR%DzKJVvFZZSioV2fR>&Q9q2|{*V%$Xj+-QxP&rZ zh8iKj{cX|~(h)n-qiqs=s#($=qG*jw7zqqw;pHWH2zAB`0H|O&w|nAbCu=9 zdrpO_Db%8*sq%?Z!qRto&CiQD0_})E@AyCU0Rr=Y2HMS7v1ox{(ibBEr0-GTIWPWB z{2v+rDE&L}-_`%O(tla~zZj}dgb~cSKQa9bzzHQJPCx1B6j;Lz8nHM0{bU2=SeH4f zim*#mxNZjMb$l7uYp+mv-{S!9?NQ9iQLtBuKv|)iJ15Ers}5K4oX|xTH>>=(<E7#?ZR`U&O?%&(U2HQ4eYJ?Cg1XGz z*Nhx4vx$-B7ycOj$RHSfVI2qjUbK^bR^Pdz?*ipgxPQ|e13cXJ3J0&ER2-&Hb)7}q zn}Y0D46dbqEU|w0w5BL7Wv}7WKE!Gd8e-W-B`o}|zEZ)>I-f*a$RS`FaX^@1EYKaT zDUieZEsl*7nlMI|T~2orcpgbz%8r5O?y{_UAX&hkve%+-;y`&&;P;7NaG0QQ%ja|nGus}7QPyzVU=zNdH*9qtn! z20Z!3AG&0od^~bEuB_lo=EFoS__Q`~c@(*74c3}E$evKYZ>Y(IW@NALs(+3(Sh4x8 z`zr%~(uMT~=ir{nT%osx-^k{q#B0#EDotAvhow=V6YISJMqwSRag_1C|7-<#Sd3}8 zB4O{G9<;ee`}a`q`Jy+)k6Pq;w`)TNlFN$XN3pJC ztnxjBms|@A+y^Q1oF%^?`h_dZ*(TXL_qwKk&v6BKXl;PyQ#-kP26rof`_iI7 zX68lJ{xYRUq%lTJGKr<;AgC6&p2CpmUxQS@VAst_OdL$Up5G%|Xc^_5rY$Vw+rE6K z-c&TJ!(k37DvDv1ZIC6c2l9r3%=Xg7XFOo4VEqK($eUN7yDd$w)h@Ro(xWcifTZb& z4vH|~x3)L}3nqzFweRo8udZU+id|#MzZPCPmm0OX&i*yX+6un9a?F1WviiRUS<2mJ z8J18rLPjD1s`EzJOQ+s-slZwt4RaR6OfVgJW3giwwM{MHyeIvmQTF(2IBjjSg?S0F zH(Q6C zUnB5|{D~^g6)cWbel}8U)u~=iBtiQ6ooi^D)3sfTpMrfI-WSOJ zpCRI!J4j1e;qz6Yrc;fId}&?pRB3ALWHslLjZy4!iE{nbQp343(blHcy0Y1{er%G~ zX%Nq+uuqrWMmrv$==bX?QNLk^NWd~|HD*dIT3t)Bxy;-+VZ?gXK!TROtRrrh4fpW@0JdWgFh+XL=F|D)_wm9Go4M z3pz5BJlwiWvVm8^{JpW(x85xndJ29LrsL%OoB|Y>v=;WAn-ShER(IDQ1e)1@jrfW> zFVsPJRPJ0`8m<4kJ1-2Qu&{aY=9>Tyi$~vK-=4T*)DTv5HM&e|h4Y8%=GK@`4V`uN zCza@VdX)`>NV+SH72Jo_uI=PfQ|01#yM}9fgQin zTBB9vc)N--#(Fa?@jLypROG(;p1X#69&zq`2?di$hL?u6KHu z{#5b4{CWMu_Zn&&tu9$zyeDSMhEDV2)BU}rUvY}pr_B})n~tZg>)7A23ChH}v$$Oa zqah)R$c;Kwjc-N*?2te?dOS}PeABbL#oOo(UE)d}`(bQE35LuaBP^CipX@i`-M%VH znyngm&q?UTd3M+9Y(uL7nP@yXdJaSJ*A><6Y|U%BO8EKK+yMv^ZVCX_FlF(3f;WHG z81l6`CXAc`UvGgzJr$C-L7|{qrsVCp#A(_m`i!F40;;5pXcv3tj*66J(m}q1q5KpN zSCP&gY-RQr`Es+?kJ1~I$V$T zWcet-uac9Nvxa(xa3-$rbh$HmRrcFpetXjBc!?BH&kZl{ZHlKTF;IgI^ zWJ=kG`5n7i3zz}^lVuLcHPH4r%T6`a*IpC{v_fm3l&vZ1qSeC;9d6*w%8UzoUM_~L z$EPR^=Z7M%`7ST8@g{U-4flJt0nhC++@iZKXQVx;SW01^9J8yRy(-w{Q`Ndw;mKvP zs#dy@>p{npmml9rbhQJzNK^8^ywU&7J^i2l=0Ba+|5^ICpZfncG-&aF8Hj;@D-I-1 zCs<*lmXvH32f%aiBp~q-y*Z49EUa)NvC%-ieUvXqFyVKEpl`qIXieb_g5iyf1b|83 z4~iueQX4durV^C!vg5I_VW<%kzLzR@qq$eP82#-aH{^#U;G&cqC#3@r8^QI;13NO^ zvD=yuR1e`t_(3+akYKg#Q1j0PPXywT$-jTRDfRffz7|9j((mE@sAy0|b0PQ?n!CO|# z=1pSl>ZZANN#N2v$9g}%LU2AbYFs^;+fX8~pU_S7(%W$7fYrHRPUm#vGKG9V6XEe~qIvd!*X`^WK_DSDID6y_M;0@hKS_WqT03-82QLn-ux z0h4mp3i^PeJF(H$Y@J;6Ob%vskNXQ}$87<4WtR)ojIl^=;o!~T_%y;;Rdo3ybHR#( zNmaR_jjAZCtn3U|1Y3GW+JYm)N4?M~lh<`tHA9g-hz|4p{0u>ka%=|>b1TVc@7(*> zPp5<^Mz7oWp(B-4-hDeKxut85@%V~K;1rz<>mbdEBp_Q7kH#f1(K-=L*Fx7(_P3?9 zTw$G4hJA`|udwq-!8hTQJ)7^ht7=ks1tBzSHq&{sNRj!#_JfR|++v{>E_&;qOQxH@ zDpUJpsjU#{hkq_5maiuQd8f(^GG3RM(dz$_3B3UFx|DUN6$HV`PWP?%!{QzD<(aq~ zHVmS;&I(G7r)GyF_J7mux{o=2j7gYNE9kx@o-ZiVfdxX6?TUAOG!G|LD}07=w&?z= zby)y$d1cXx+0F~CNWIo2OfYcs;Y^vUwI8;n$<#_SJonQ5F?f|6d+VJVwRT};?&b@! zVDl79RJh6^^JkgK1aNF;><3Fn^2U5STF_p7yWd6KDSzIh!C6rnZ1GKDOh%=!oM}a7 zzaT!CBcnr&H#eDAwLICpeb4BC60?jWPuszCh=O@AhfV1P{#pagET!k~cuermbn9iA z>N=X)#ud3lZ&7~M)yF|8$e%(IZHkNMw_)*P^2G6~B}Jo0`<^jE`SDM-92vy} zIhFQkF_O+Itp@Gt+CR|1#I3W_HV*7abeIgGKgmJAenwbAVMR`-4Kc&1un?C_o2xCc z2RjYRgCBF>ekMLq42i{?-us?9E`MocG9NKdg6A-${?Q5fd57HMko*+hh}e@2Ha!GB zNA9WF5sCXaw$6wG6P|?dIl?!qIL0Sl<$o)R|IiKpPPioijYQ)NUc$A2YbI3EA*O#+ zGP@qmcSTt{&QpsTL|qaAk9mG|4q7Ih`f-#(!NP5{*yShQz)C+eCQ&hGRh{006#SC_ z^vpG<1{I~T!SDB_u=R!m-(~6dqXmNN6YjUyC(OYM?LGIDbC-hej)dJJfw9j$b9RGb z;m&uL)59@zu`3tAeHZ^2@&r_vK}F!lLg*|(!NRGhfU;P4MpQb*0ndkhxxwO4!lfr) zq%6=^x-h}rezD-6$e_3OhCOQp?+5c*=Z>IpN0TQc`=e;n0b(ggJ|g_$Lx8wG;V&CS ze*6vu41?cV=c=IoFC=~y`E^@=6G_J2Qh-;PXxZ7zYDBnZloIZmz)3>#EP}OMSigSt zOU_N)iZe@-EH)505_{iC8s-U8rKmRqbdXBD1A&f@HJQc;=k!_+q1}3|Lxt3X27<`Q zlk`}KF&B^E4KZOenh_`4cR3>r1wh@ZU*u=jUzXF12Z!P*lSVc~(+loK5|+3SzuSCSCNx&^s}9gx zZ33r>H>4dL^=`WFI6^(r<7BG1EY?`T3~#8126|^Lz%2$VR2y%v$d}eZrp}}GlcD|c zO89^-_AsijF3RC}4*bN6IrA=p#ur&~6<4iM5&IuOd(82HxQ5)T2ViCWY8kLJ-&Qle zn%H`fV)t>GTTa6WBDdUad2l+ttyapu;0xcvrsd1sN4%A?FxxGhswTI1H{)h@OiO25t4sO2HM>Ng#C-inSX|#>s2;@|Qvpd>oV)m;8>TlF zFJft+E`3)4@=tbMkm)z0GVrp?dK(nW#+LF9T-_1lcMHSu}$H1n&+wF+MUr|(d`^6u?$^OKEfF)ra8?-zqFK z!}eU>>^3Ef0cwcs6((&;`z1@tlK<{~8=~dc9S`mgN5QnC#N}XJB^kN#JnN<-%E5TF zDmPAxLRlBy#9wt>b19}jG{CRM`ZGqXwfff^s?w>~;&!ppG2J2rb*~y++w}oTGaC zMej*W?Lo$CWV@>GL<1hlGnyecF1=lk&{{CyW013Ts_WXsY{?NsV5tdsUmd?qp1xos zR?ve(&t)nAwqo2V;A{BnF2@Ir!ax;Vrt>3w1y|u$WG5m@-38?|8*glPa9wnZIn$pv zeR+hY)r4oIr4<+~9*y1R4VkZif3{0Pm=2{zZ;L;A7CJe4a2mGHc2TJPSqplocIqTf zV?@oCSSQ1kbnt8Bscg2ny!eBZhE5nZY@EZMcWirk5C#6d-Vsf^-=pT({$uHrP=T<` zN}Z*^(d6)rs|)Z-@UDDd27vx&@cmt-eb%M9(2p!i` z_a}a8TaVX5ch4GU_D}*T25r24>)$N=_z8P73ag`mZy>$8kK?hFRK84Pn4~1cebwU1 z0RK#qPZy85zwf>haXY-@bGz7yx%Pd$xSj$a!w~#UyL^8aUfcg#I$xLKX%JPePAt0r zcPs$Fi}7ha)Oc&ro8(h~5@P%s(_r1TJ%-KRH+-HtQMbn-R*$Dw0IYW)-KT?W36o0; zu8!q*@;VDt47|kyE=qR=wt6HxnC-O&;nCJ7Wvj|}*Md25^lW1iy(1&Sd^T=BHV$Cw zzrB*R|6Iq@a&_Nt-NTNUxRXOE$X52*a;qoIlJXM4aew7wQ#(2qMFHPnBXxB#0z=IB zON(-m~YVR;;wpTMhuC7Z@W zB78Y|t!ozT?WZi{Z5NYDVR>F&7#@d(WT@ig8AGcsWacTNoL$RK38l}{^VTNtyO+jNb+Nbb>SU9(dtD93xbkEzQd^6Bwdra< z#*ZWLWQsbz>>kaeA4%6}4sa&CJf<*ZKt7TfHLJ@u%1OF-%b+fYupX%@pn+PJYEA3d zHZJqkbcvb&18BAz5js9{CT59GXW>k>8eRdEuruM=!saI&w~QK9PE&9F-xg-rU?n zd8>4Nq@87nt0`EXyI`}`xGojuAZ%ikLg`D*=RP8 z5wfZnPr>$18$-MV=>jn=Hy2$A_1Q(dzpu|}X@wW8Oe~19alkH09crB%VTKn>fLX+# zRPwpxrA{cFOfL=_9|P#zcrsElmxOd8XaS3*PlTEPqwVFu3#04B1Y&sEX)?5<8VGg` zpR=(qPl`<%<4k4TIE6 zYME!aZs;D&21{^`VIs9%qB zG7BM-Q5T*trnyzdT!gpb)sldwdEGp~G?U zHF>+-NcYkZ02~MvBn)nx3~!use|%MnKGk`}DPKKIv#y0Qc_fXd z`_S7TjF1_<$C&lDC231-yOfXeB(NCSwBQTT)Ttyye?3<^E5Es~>K4rkP1Eu4!@%*2 zMmcPuZKTXRCe417y$+|X@EiAUYoEjUMT7SXEF07K+(zTY^vFdaBb&^TJqW_l69wJ0 z*c2XPMFh_!0BS)rmFXiYbjy#+#=|`@WI^N)?C3fa{u0<0trH&`-!wWWpl!S!J%qk! zmS9R%u0T?9J&iQ4O^+InK&6$cNXIwXSKP=Rmpuz=L$A`V+i!BCc$)*1vnR8ymOg15 z<}_x+LHMpqEKhFDB*On4wHA$XDo8WbAAyiRlz?87ONg+QUJli1DS z|IOx2DpVw#*W#zMjS8_>X_(vIG%HnPEilN<*RfUl2WLu!3=Pk+z3a!B@|2g%f_rJ` z-`2b5#@+6emqIi=JaE>0O*jkH)@C_&=JGBtArh+?6l>4@4Rx);oOxHj0-aB)?ib01 zNCX~CKHO`Nl>rb@rsVMP`hNz0j5fVBq#G!BoJ)-qc~pGi(_V*rXn>LKeS7obQT;e3 zhx@^ld;R|QzN>TQJMn@0Aamv+;?n-==%5tj)8)9s^bg`LG7-Pjhs+=SN|hAv!^{S2 zU`Fp!eQ}iU6og%nL_kUDbKWP{Pk`I+Kg77Plf>@!jK#6IeII?V4`17V5Q6)euBKC| zlM0Bd9!gK^pJSSg8Z*i5|Pz zLQkh*gMJUWzfV8@R7QryJsotflK6Q%+{FIA5bApT@%s$x$6b%m)3#6naHu+U`_Bb8 zv3~s5+rOXues7a>UR9O)3VR-m{tyMltuYMp2_=QTq#N*iI9pK~_R)b;Ah!l!!s{P* z=c&1O=W0SXVI;nKbI)MH%8*juNy7Izq9E*~LMh0**YJ=Fm@qu?_jsE0ArAR)UFWP8 zYIR$YnR*8K`2G!H;&0#6f!lQr&MOf%bK@r$Df%9iEiu+6F>AvpVH%n$(w@z%{ReVcb%kU) zI0au$n6p;MSKG7_)?!!0-}HzqAAGKF`Ob89zH`Us-#~kV4rpSmCxjvE{X*wx=oN7Q z(>+(Ni>||#24|strb2Ld{s%}aN62>J_#2f#-^}(+Ru4__V+?RDZ!g75H>H_6jMB1o zeAxIY+r=PyR0hewrwRd^`9^6)ZDU);8fW@=BZIG+iF73D2w^YM!~}Kd%rt!)*I9^v zeKRa+`^0Y5KY)V~c&2ew35f~_fF6!g(!qElxXGay{X67WRx^dsIG4rA?B2WClrLSk z(#YzP(mgQyn@o)6oq9hnpN6+(y%Qg1Cu$<fOzJ<{p%H2TKw+KMqph;^fOp@5CrxUJtC@m`66hB&YH}?*{5Q310Bpeeuiz7KKsE2 z4TJJ|BBfK^ntgj-c1<`1ib+tqcr+!9W51X@EOkT#b}j>l?H6|abgArI7byO$3H~@+ zsOB05tN2qu|KvnPT7MTDQc^>XHvQ@d7kR-c8+{`-Z8{k&fhptRlxb(TSesp9W8dt=yb>SK6eoxok@FY$yC-tRQ z!rxO-3RVk4*zyctL40P*Xub(5oJlsw0d|imc98lVlq)a|&5(1`WB9ude=HcjjYw8&{YP&>`4kL_eVy z$x249f69mF%>d1x5aD*@Gj_pRv;z|fJO2qH8c4XG<$BffPt9s$wLGp-v8?dB-Wq+9xJM{J}KP&HZe* z;Aj4%H$7s13w3duLuclH1U=Xyw8Aszj?I@7D-eA7seEOXB=+~s;9=M75iR^M5X2if zWP~b9bsOgJN#|Bj`A^m>u@8z*6RW=;xRRlxLN{=Pb@&6X%Fk7vPaRuFeP8U1l=d?I(_J+_fWwpEPFV?ZRQ3Zk#y2s^qCxo1?f_vv^CUVCewQg$mQ)_% zzR8|8@%qH8pmN@H&t!*BZo14z*Sxok2nO=GZ;HEf6^E0>61(%)(UrQT00H&}qA@dp zmd~ii59JgyiaW;7E_4N`N-VK5UtjP!E#Fgc5b*vWd}O{LmJmG+Eei#_S;nV(oWBe z9buvbJL}o$`A#I*V(#OHKFZhoAp&#}?GxulOas$=i0D%8(3KhToJF*&gC65%ZHccZ{_ z!JMQwWJiGN*6o951s#h3|LXVDZvMbsrYvO(H5%XB_6sHb$BBs1-`B|wSvL_Jqr6U= ztKTpQ!nK`1GOrH$H~*lpDQs;0_K038!=q%>o&ZlYs)vi?JU>2xJgfov$cF+C36(h( zNZEFvG~|f~5ACdFvo7(Jo#SwN4W=$u*i|n>+mH#q*&p6`Kq3Ssbti9%pgj{_3mI;z zqcXFa7q8PD@(Nw(d*e+zW$26`>_6U|{WgyxEQmn!Q6xN`p0YoK&Tg~UCf}$bn)}lEA0hKNpeP^s|Y2?3`^kEgOrhfqu*cslYqQMMR6XX z8Qr83%(e7ru6f3Ps6<)sqE4@moVM$t4$uRT=7hBnlh)3wzR6+dHCTcTBaZz<04cgK zb=CN8WBGZ&Mz(1TZ~I6L^9zUFi@xiui9+5Q7xFvt$W67ZVO0{$!2{@|Uv&U^A~8R) zy|B*5v98c+pjE_&IKg8bB$=0U~!00;8A)V7S3&CH+ z5%_IgSt5#BkazPbb{17_S>($}Y+xO? zWAUv$_6SquALv>h2q4TpC@EpBK`AaJCgARqc1O%heY3ou-|u<70Mt_Y3J!^M+T!~G za=IEx#+&|Lq116cl3Wo~4RQlQ%rSgCD*L_g#dhp-owK^Uwt8DH@b_LF&|J=h?iPdQ z=g@b*e+6Il-^%%u#$v!v>mDNL@h1#;rI^|G*2TPw6CHcRK@4HyRN zf#bRKC~F{TJ2ns=mS3vz?^xM?0B5@*1aze2 zcUk!9-uJRF*XAsCi2S`2{-e_M)v20*8)YDP=(3z_KR_TN59qRZGu4Ut@B$SJ8Rp-O z!2Rp!UIhUzJRgDgP}>4b;Xwu6jH`>yCsK=<@L_G2-rJ)YCR4QQ0v$X)Eq}r#hf|Z`h=p#0`z0H2&+S+&1fFzO~A!%vJ^aQNE_^JFAg4CgUku z#*!*`HEZvqUNjMGW9>U)i1Kh^2@B?MQk?U6;h27Wr#Y05T#bPx`yRoDBk`lD+|XoYPD^OufT3*T0`O8G z=s&|859ol`@{==&UzvRQjg07=I@#z4Qghxa@_w|*@BtGpBvz5^*}v#wEjH3#_=0#n znrL)i90dtVmDS}f;ugQhBLX|nEU+i9LOJ@>HI{KH9$sc7GjWk@we}<{2*hCani&7+1pywFdX@)dAm;rmQVdRhkTDg(%7kfO!FUpZ=u zqE3^pk{A6zG5-oJAl75F=%b8wD7n-hCRkLe9-25&`4&|&b(9lP$HBVzRihh}MXoR+ zNosU>*SI+$^~UF2g}&<_dI$@4iSG2)OnA4a(}h)XB#BVP1HaKj`N0Lqa4Gm-g6=qA zJO<(W9bNWj)RNg=z@idt@-U<7tPMaU;R`ybh(Dl~>9 z#u^}1w5tsLcnWbaQ?peg?mQ92I<~bIUh8eXd_}gRj~_Vgon3S9>@@6hB?Y4=nGy>6$NWDbx6>Uc$~^|5NT8i<)wy8{81+?EKe)1!Kf1-m1vi?d4mxKt4kk8q-<;@x2T zk29FXAiNPtgYjN3>P}7sP=;wST#q2~Dt=(Dt}tWA&pgjo`-6Y7fed000H?s=waDKx z?q3g{4aZ+HE}f2zzL@baquKcDJK0=)ym?&2)5^IX%7h)<{tE^i4ETG01=$Y5k#X6i zVZVMU4RL$sHF(_rbQICGf@=1DJ5>f4@Rsxw=A^jazUk94TVt*bF6GjE>3;1Y+s=fD zQuTx4xv9g3tfTDvH7m__ohTOhXY9Ol3JRu%n6gTIEcT)C$IpbB85?KBBJhL+>o+xz z*-!-eHBG)okp+?Pi^nl0APLn&;3DU!BAh67QAd&LQKI_xJPQPGXkEZ1P2X~&K)ZP0 zjcXpf*oZO)UX$hpw%H-{&pTUieGvd>M*nz?3m(+={}M%`pdLGNKfDjCnmZRz4g7Gu z{{Vu|ewCJDHt*GFtkBJKSkQOD`vGR(v*xacm9EEkzo_BJcC$_bui$#HH`Pbt=lIwV zE7bC|Z`pOTvE3Kucl+kAIv(fAYBkCr$h)sTk9Su}LJt>kCG_t0OJA4@T=?{mcnd0f zm-~3TUz+QCN3SIGxS&+?NB+zoa=P7{`u;>$eztk_z4|xo&ioJKqXbny-Y(Nj*Pf-u z*7*JEK_&6=hX=rgF$}`K3*KI6<-m=Q81i}Do)41vJ+S`am`bwv+hJJ0VSkKY8VTJ) z&o}$Rgl;y1V$U?00>R240Lf!?+gY*_mJy%N&FN;W-&vH=pUAqiNEf11#m{h)iG8@K zLVq;cpb@8|JJ|Q)fbG8T)x&N+J~Uv0uj%a=HL#lXPsLRx%Wa=fiD^SCvb@UW|?zyJ5Oh-MQnd;;yCe#%j{M6&d zw3Y}>Z!u3Jst=8{R?wG2)P9@!dJO6w4V1$2JJ*td9EM?4Tzp0)M}36=TT}@l<5wA3X8EkA0el~MUmph>M zK~e2s(*ZL@Ezt$vm_;{V&AwRhWL@!pOHzd!^%ublo27u5cPTR>deA2h1h?pis1xTI zrd%vG4Ot*$DUk6z-++xLA5nz81qx3ZHkPr9(v1C^yn)mFR3-4Y!PCO{M)U~%=ub1h zUfp=8>IOIRwd_DD(8JG7_z>C((iTO)`cKO7+jlgX(LfY(P5r+PDAgmhzvE^{X?Kr@ z75{vF503}b#0;i)-coOtT+No1|5D3Y3OOB zi1*!P3DZ>6ightmQjkM+SrGibhqf;Wm%{(;j@im0E*pn!fi|O=Kp*;|2{YBQE#PjK zYUw|oNJ8Sb_sVUj!#?Lcja=C-aJBBR#IXrWK`4P_4a+7?5_Eq786K;D7y(8j^^Qj_H%rCsN#eNx=5dgi3ObYWU4h0mVj1_!T+PCNclI7cXvxlD?S<%_7P zXgR@G5Fkrpcs#T(fdT~^jFg6(Xo8%#=MSc`epWaN%aF!Le6}Uw)n?AKUrAGxXn=r# zfB)zI0#slcL9E+xgy}!`(1PEy(t&4rSklx4bpXs~xBPwWpI&e1_>spK7wT@3ck`DP zf)~LEXZ-y~2mxPG>QA$#_D7hM03R&EhR)`{2>Cs=@>krAgr&yXpJ$2j&e;aXoT~#w zc4?@~aJDp=WuzxyZ(<^WYj0h*u5dKt`*b3|9X*A;9}NClJWfR&fuA*i&=@&D2?ZTJh(Lh&8`qH8>e-TzsL zEQN1EcQhkcH#2^Z&}DPajh4AVsUkIlzs?;0oGj}46VSTbGiIQj1Y&|YJbE+Afo*iG zgwDY&GgvBr5weKk0;7#RJ_eFQ`PP5XxD{c@U+QA^#@+|AlsN(s%!b$WTx*(C6^pp@CDf>NKM<*ci1a1~2n6W} zNEHaZ6F{PryU}ycJ>Nagz0b+Nnb|X$$==!PUGG{mOEp;00f3yz{d5!UkE!5Tbdh!S zDx9TocvPH`-qFaOnmwHmlVSa)0o1QWv$3Z?*C-1W3p{LT=G$pG;Y)x-C2%SFU~_O; zJPv~EkQcEkK}#}QGAM5guwTI+ipB*)t#Jzn1`wBL^BDcaqJn)KIjP=(lI>@fSE)Xsyr}Y zZ~P$uKlZ}d{cc#f<>~gtGepS_T-aU82?^fY1^(6I4^QyJOCfc^#FVws-#DZa5_QDX zq~o(%xHpA}66bCXByJZeeJ}6g1Ga&s8kO%6qH4CqnE?;B^7PNa8vtuU{I*A)4gEJ1 z1`6LIkj4fUDMTUSsN~~OS5;+S zE+-{UrCc_+sCK6t6ScCpI{u9nez>_d_L34O;iuv|zg=ew!uB5T0`=KJ2NALr;tZ+t zgE1|?8h*@#oaS-hUct^AR2TU@@ z`m)(ldCFg=Ltb0kbWrxjIRfKt2?30zvt^YIqpDce_jnIG8H63~eGFmhK8U$vPf7^&^&OOeEJ>gr};L z+h_pw(tof*Ge2GNk_jCC5|6Gd%<%AQ?`kdE zALo!ED#f2)wO84OCG4Z4hS{w|yAIWKE3r$_J-D1si7iH2%F+UN)Nw+Gu#cCzpE-qy9O$zw9l4L%)uBiEMTIX4o^$((Vi#X2-83D`YOLE;j6FSI3L;-mb)7k6eoU z!%#u=Rs{C~isjB(o=B4r`Z-6Ok&mvW)cRklL&;`0n2Q5h{SDNr*ZUh^R*!(L?5 zk2kj#{+iE<=TJ|g_mAmQRlP2eC!HuSrJl3J`1tSZ;JMEj# znrivizX;d}JQhUW@dn+~mCpwas)dI751hH1Q~^!Y#_&0aiE~#|p5i-tA#o%h`O!fK z!IM{gL*v7y+RrPq1k<64cZ$#c;c>Dt!RyQXi^u8uf9!GM!LmM***{|S9%OPYE6AGf zYu;X`5kK!glEhMH7Ix^M-nF_&N?|jC9@cUpV>s?d-_|W1Yk%C51r`R2+^xbv#TLw{ z*5VmF&P3CJ!m}s&T!r!FK0asiw7~5PQw9d=}Z2q|4rltBap_<@V6V>33R7 z8i(}8EXE($B*O_zWV^9^;58%31w|J2B$+?aitvVZIwm+o&NdrV?oqM;lDRZsA-5cZ z-Y?47>aZ1RedUyIhGfHVKz|eK4h=e|U{dp^%#-q_6*oyY7~A>|KV2z|W3C{P_sEpv zJIUg~a(F15cC<{rn&|Icb1Z^{Z{EM;;Mm;;e!(|;-iqfWm+kyj4_=5DA`zpB{Lbqx zG$llDi+&}KR90gOZ~HBwz6REK#=~_l#u+T5Z?_R@M2~k!)YPM(ja5q0o_8di4;>WiGel&GvYE4BArAG8E5=STPk zT=kmXAcr=k{OZVQHl2CC3-SCU4dY1@%zSs_c{F@%^Gz(@ntl%5H4MsHusuNQH@o+) zsX?Av)~EbBJ+A!RP;NLKL}(bCDwdtObl9udG=$Oz(c?F{Nj2#@Pd82tlFFp6?(*+K z*Y)j20mpjC=@EyeY$1dbA>j$c5J_Z+qh@S87ZhIGUI3%F8|@@XI$ce=on}1X{54~l z0wCw1bs@xZ6!(k?@mI}G01%E?AvRAvmX1^>s87zV;+H)G zJnNE&*?zuI!qf5oMYV9!$W=Lxwl{O_E@FoD{202+wee;1x$jbCOWG9rWMLbY(PC3i z!UF~+k|9BzBv4Q&1f)z0`dtB(L3j`d3ECb)=TU`@CZ^+GgBm0Mtr_&sUQw7)8k&c; zVA9Tt%!sY6sb5^apw5bGZbwVg>+9>+)oPs)3KnlANZ8$a^SA|wjvW9k)NC!whL#G_SmA*Tj$ zd?GRiOn+!L<}$V^q5wD(kDzyY5T(ByS1x7^BkfdS&9*=)oVwYp#>>uRT0Ohfyn%=+ zQY*fuqmEQf))6&SY;e%-Dm}f0i+FetTiy#ByDB-#Zh-o7gGD}D;q;NP9HX3F_s)*b z{hNG^cmcHvw<4!wakWFcsw!M;nYzE%+Wu{pVi_+VQJqqn(h`yl(|Gr zXky>ee1!0&l%2@TL`RlvMgQHSR>Au}Z)Tv(kVo@z=yml}!&pOJaJQDRWl`yJ39zd_ z*k{3Xq`E!fbtAQeon%JO7}9RJ>IkoAZ)&fDYk~V8yp%o4Iu@OHLhA0(>-PZQYXMPO zc>QDswfpB{*cHn0PKE~3LotwReJDt7?y{z2F^$WY2ZqM?kql}Dk;%e~E3w*YYGP$J z+sT$+^v7D-B4{>r*tx@hL}0>a93 zf@U05dOVh9hax$Ar{zafWPLrw!TQS2uL@0%j*o~QHz#{Htl{+J(yT1!gf0`z4fWli zr3zhQZ=*^36q5bpU$CMN-*<`__h%}=-^9|}55NY<;UD5IKNiX_gY~66mJk#kHs*(7 zUVmWka;}E>j!QAV6|OXTFLVbfAyH&-8*Sg-8%;S-7{o4{^;wcz*U5%hy~+!#nDzC4 zKe08ve&IPsge%eiiaQMTrRqq4}cPHXHX1#`b; z6#v$4Sj=MfQsPDFUFTRm^>k@A>kvLtw6qS*gtM@gJhwxW_MDnQ%))SLZd@`P7kwvn zj{2?9t9TcI98CS`6Pu87MkX(Vo0?ul+uH-h94GLRwndzH5irx^}-D zu6Vl4XlaIem9DMk&u`;YWRDRxV?K}8TWw6WBTZ$_moI-S1%Iszf40QTF1>f1JB7)& zsl@c!my+>%?G~Z{`kZiy!*KstPO7!v^0U{1%we(Y(k5@up~+J;HH4`RjTw--kwwKZ zgU=dkORq*Xoz!0(GBPmmWJic5Wgb{GjQphRPH*yhPrS>!)|nLVc!h8&y-W;t1>V`g zp^FkZb`P5|M+VBu`t7goXI-j~g=nDl^bNj8;CG?m3mlQ1f;tfiR1)U!)JP&*9N%zP z9<}&~R_3oJCbk)3EOFFF^$<_9eq&zMB7wq+bNRW&@<@F%*BdbWIhDsRp_p4SVA%WaY8AoI zIxH*ATS(KW$Y!7|03I;ArlVN4C`n_N|B>J%tm*t{$I`{?hlyR2q^!cE8m?cpz7^m3 zrB5m7C(-Y;`7vR!#(RdV@t4h4m@#JadnArVx~D)#M0a?nEb^Bz+EVMq{f)w>EXNXS zqj|IO+PpaldNQvdse@Vxn_s?2aFt;zO{%hcWcEr~kCW_knWSjN#lS{+%N^odHc9|4 z$Q9RzI662uARG{jyj)%6iFuIj__VaNn+fzXAiU}4yj%{tEn8O7P6LTz;m8Z;i$M57 zydK@wA|W8-g&{hn$LFD+)-a22n?4{J0&JaCy{Iz#~$EaSocjQ zVYXS?!#!_36!Q9Bbz)W5Y%cZ%P!T>x3ROPF7Z!u@phS!sT_*`oR2)|#8E}B<`mM2V zmmqKz6t&_5Jmsd`xOdqe;$fV{%Z(L5#jLE8TOw26fY1DR!^B+{3;$|9kI@mCPLfqo zF!pidWPdvvZ<-79f%+Z>=n)Ux{ic5kHyjP9+#IOS__*o8xt}$zW|~`7HC5&JE^zTaq3sKtduGUM&8s1k$&Ocvxu<(1V#*Syequ5Ywa zQ~2jI-gA6frO)_bYX~H9X|mdcRIP}jljOy2M^w*#$4Fi=BRo zPb?|?BVFWu=ZO6US%}|$_ck9zr@U0 zQiQZJ?e^e;sSq-8rvi>R{dB-01{l+u70(7YuMJi;Cq-j~RXaN=o%aJ2vN64#rCXFs z+>Q)Rc6843y^M2rXc23hd4i3wjyhdc^IAVD=pk(i<{j{hz&}7l`k-u2u(jb6a^<7l zRTm#7WQ|w1tY(JCr*z<=Oa%3wz%0@r?$6Gz^WXgD!kJG+EP7X95sh|q@9VR?g(07G z0UjNj-krQFc(=Q~i})Ik$y!e82*d-^wXenB**FG2<{;*Ou2a=zxy&Che)u8Vy|6qR zfLmDJCPw{YS)la9QbEmcE%B&9$4xtE>Ea+~tJQbL#=&u%oN|L7FcVchtYbc}2swM} zYaWEa_ana`L3vSqTw^P1a`WGYxFPHU&0IH4CZ~BW?LjVybxHVkD#?udpf1yL%G-!m zm-yjpC*=pZ{mWw^jtcz8?%5b^+8TBqu<4DmA5gj8A?aW*ZtMlmU`B;mLVW2lZQTsU zE6;YR^RJ7y5HntLgquRHPr&NioMrg}U~D`s=ajDL)A{L2mWi)7k%`st&89G6Wa4)! z#A)`8e+(=RWmy`YZ*KwXHuqPWJPB{N2nB2YxVSM)vc@-9*DZZ-%X4xg?U1ZAJ9x9- zMcVD#o(z!r+bV>^29B!Ma z0#4?jPIH|ezgyu<-O@`oGJ`Uiasu6yaZj(^bsZ!5Bq`HWd)b_C;H6A6Xwgog;1wKzm8k(pv=13E0<*wR-wrc6RM8 diff --git a/docs/images/gateway/plugin-session-token.png b/docs/images/gateway/plugin-session-token.png index c233fd0c277598e31237caee0e85131691b55107..b13d2fe12828e6ae74d7e91dbf2f48509df0ef70 100644 GIT binary patch literal 135600 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ⅈ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�!sMLs|vCZJpNe`O4l zF^6OgMpjy<5ElPHm5(hem%7=uiglta90Wr;_OR}Vs;$=_pnN0wqG{71*^V>)I&H** zN(@*tHEq@&c^NjlX?msRUvqQ)_F)gx@Dixu!&@#;;VwmMmd>%${-z!NP~O

+ } + placement="top" + > +
+ +
+ +
@@ -66,6 +91,17 @@ export const WorkspaceStatusText: FC< ) } +const FailureTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: theme.palette.background.paperLight, + border: `1px solid ${theme.palette.divider}`, + fontSize: 12, + padding: theme.spacing(1, 1.25), + }, +})) + const useStyles = makeStyles((theme) => ({ root: { fontWeight: 600 }, "type-error": { From 25c6832772142e1f440a98b2faf6bb02cd3d4f12 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 10 Aug 2023 11:26:28 -0700 Subject: [PATCH 083/277] chore: update tailscale (#9027) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4ac487adf127c..be7e6ee88f9d9 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ replace github.com/dlclark/regexp2 => github.com/dlclark/regexp2 v1.7.0 // There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here: // https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main -replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20230809183309-7a9c6c71e16c +replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20230810172417-9321478a5fbe // This is replaced to include a fix that causes a deadlock when closing the // wireguard network. diff --git a/go.sum b/go.sum index 3fc732489b5e3..46703cf471c41 100644 --- a/go.sum +++ b/go.sum @@ -220,8 +220,8 @@ github.com/coder/retry v1.4.0 h1:g0fojHFxcdgM3sBULqgjFDxw1UIvaCqk4ngUDu0EWag= github.com/coder/retry v1.4.0/go.mod h1:blHMk9vs6LkoRT9ZHyuZo360cufXEhrxqvEzeMtRGoY= github.com/coder/ssh v0.0.0-20230621095435-9a7e23486f1c h1:TI7TzdFI0UvQmwgyQhtI1HeyYNRxAQpr8Tw/rjT8VSA= github.com/coder/ssh v0.0.0-20230621095435-9a7e23486f1c/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ= -github.com/coder/tailscale v1.1.1-0.20230809183309-7a9c6c71e16c h1:4NR1TCdxl6Dw2iQ37KDyvsLZgYYWvBSKCnsotofZrFg= -github.com/coder/tailscale v1.1.1-0.20230809183309-7a9c6c71e16c/go.mod h1:L8tPrwSi31RAMEMV8rjb0vYTGs7rXt8rAHbqY/p41j4= +github.com/coder/tailscale v1.1.1-0.20230810172417-9321478a5fbe h1:1Sbdy2U+ruuiL9hYQcJwhDlgjRKBBJbkabQ6r+BNXd4= +github.com/coder/tailscale v1.1.1-0.20230810172417-9321478a5fbe/go.mod h1:L8tPrwSi31RAMEMV8rjb0vYTGs7rXt8rAHbqY/p41j4= github.com/coder/terraform-provider-coder v0.11.1 h1:1sXcHfQrX8XhmLbtKxBED2lZ5jk3/ezBtaw6uVhpJZ4= github.com/coder/terraform-provider-coder v0.11.1/go.mod h1:UIfU3bYNeSzJJvHyJ30tEKjD6Z9utloI+HUM/7n94CY= github.com/coder/wgtunnel v0.1.5 h1:WP3sCj/3iJ34eKvpMQEp1oJHvm24RYh0NHbj1kfUKfs= From d2f22b063a3cf260e4d6a60870d57a97dbd4a673 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 10 Aug 2023 12:04:17 -0700 Subject: [PATCH 084/277] fix: move STUN servers into their own regions (#9030) --- cli/netcheck_test.go | 2 +- cli/testdata/coder_server_--help.golden | 15 +- cli/testdata/server-config.yaml.golden | 15 +- codersdk/deployment.go | 6 +- docs/cli/server.md | 36 +---- .../cli/testdata/coder_server_--help.golden | 15 +- enterprise/coderd/coderd.go | 48 ++---- enterprise/wsproxy/wsproxy_test.go | 146 ++---------------- tailnet/derpmap.go | 57 ++++--- tailnet/derpmap_test.go | 4 +- 10 files changed, 101 insertions(+), 243 deletions(-) diff --git a/cli/netcheck_test.go b/cli/netcheck_test.go index 890260c1a704e..be0b50e1a5bac 100644 --- a/cli/netcheck_test.go +++ b/cli/netcheck_test.go @@ -31,7 +31,7 @@ func TestNetcheck(t *testing.T) { require.NoError(t, json.Unmarshal(b, &report)) assert.True(t, report.Healthy) - require.Len(t, report.Regions, 1) + require.Len(t, report.Regions, 1+5) // 1 built-in region + 5 STUN regions by default for _, v := range report.Regions { require.Len(t, v.NodeReports, len(v.Region.Nodes)) } diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 4e1274bb7ad20..cd8fd309f1154 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -175,18 +175,15 @@ backed by Tailscale and WireGuard. --derp-server-enable bool, $CODER_DERP_SERVER_ENABLE (default: true) Whether to enable or disable the embedded DERP relay server. - --derp-server-region-code string, $CODER_DERP_SERVER_REGION_CODE (default: coder) - Region code to use for the embedded DERP server. - - --derp-server-region-id int, $CODER_DERP_SERVER_REGION_ID (default: 999) - Region ID to use for the embedded DERP server. - --derp-server-region-name string, $CODER_DERP_SERVER_REGION_NAME (default: Coder Embedded Relay) Region name that for the embedded DERP server. - --derp-server-stun-addresses string-array, $CODER_DERP_SERVER_STUN_ADDRESSES (default: stun.l.google.com:19302) - Addresses for STUN servers to establish P2P connections. Use special - value 'disable' to turn off STUN. + --derp-server-stun-addresses string-array, $CODER_DERP_SERVER_STUN_ADDRESSES (default: stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302) + Addresses for STUN servers to establish P2P connections. It's + recommended to have at least two STUN servers to give users the best + chance of connecting P2P to workspaces. Each STUN server will get it's + own DERP region, with region IDs starting at `--derp-server-region-id + + 1`. Use special value 'disable' to turn off STUN completely. Networking / HTTP Options --disable-password-auth bool, $CODER_DISABLE_PASSWORD_AUTH diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index f3149b31eec7f..27aeb41a61b6a 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -111,11 +111,20 @@ networking: # Region name that for the embedded DERP server. # (default: Coder Embedded Relay, type: string) regionName: Coder Embedded Relay - # Addresses for STUN servers to establish P2P connections. Use special value - # 'disable' to turn off STUN. - # (default: stun.l.google.com:19302, type: string-array) + # Addresses for STUN servers to establish P2P connections. It's recommended to + # have at least two STUN servers to give users the best chance of connecting P2P + # to workspaces. Each STUN server will get it's own DERP region, with region IDs + # starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn + # off STUN completely. + # (default: + # stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302, + # type: string-array) stunAddresses: - stun.l.google.com:19302 + - stun1.l.google.com:19302 + - stun2.l.google.com:19302 + - stun3.l.google.com:19302 + - stun4.l.google.com:19302 # An HTTP URL that is accessible by other replicas to relay DERP traffic. Required # for high availability. # (default: , type: url) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 96cb9d19516f3..a7dd2a5af50a0 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -733,6 +733,7 @@ when required by your organization's security policy.`, Value: &c.DERP.Server.RegionID, Group: &deploymentGroupNetworkingDERP, YAML: "regionID", + Hidden: true, // Does not apply to external proxies as this value is generated. }, { @@ -744,6 +745,7 @@ when required by your organization's security policy.`, Value: &c.DERP.Server.RegionCode, Group: &deploymentGroupNetworkingDERP, YAML: "regionCode", + Hidden: true, // Does not apply to external proxies as we use the proxy name. }, { @@ -759,10 +761,10 @@ when required by your organization's security policy.`, }, { Name: "DERP Server STUN Addresses", - Description: "Addresses for STUN servers to establish P2P connections. Use special value 'disable' to turn off STUN.", + Description: "Addresses for STUN servers to establish P2P connections. It's recommended to have at least two STUN servers to give users the best chance of connecting P2P to workspaces. Each STUN server will get it's own DERP region, with region IDs starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn off STUN completely.", Flag: "derp-server-stun-addresses", Env: "CODER_DERP_SERVER_STUN_ADDRESSES", - Default: "stun.l.google.com:19302", + Default: "stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302", Value: &c.DERP.Server.STUNAddresses, Group: &deploymentGroupNetworkingDERP, YAML: "stunAddresses", diff --git a/docs/cli/server.md b/docs/cli/server.md index 27658ee0d16e8..9c7a90893ac9e 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -129,28 +129,6 @@ URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custo Whether to enable or disable the embedded DERP relay server. -### --derp-server-region-code - -| | | -| ----------- | ------------------------------------------- | -| Type | string | -| Environment | $CODER_DERP_SERVER_REGION_CODE | -| YAML | networking.derp.regionCode | -| Default | coder | - -Region code to use for the embedded DERP server. - -### --derp-server-region-id - -| | | -| ----------- | ----------------------------------------- | -| Type | int | -| Environment | $CODER_DERP_SERVER_REGION_ID | -| YAML | networking.derp.regionID | -| Default | 999 | - -Region ID to use for the embedded DERP server. - ### --derp-server-region-name | | | @@ -174,14 +152,14 @@ An HTTP URL that is accessible by other replicas to relay DERP traffic. Required ### --derp-server-stun-addresses -| | | -| ----------- | ---------------------------------------------- | -| Type | string-array | -| Environment | $CODER_DERP_SERVER_STUN_ADDRESSES | -| YAML | networking.derp.stunAddresses | -| Default | stun.l.google.com:19302 | +| | | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Type | string-array | +| Environment | $CODER_DERP_SERVER_STUN_ADDRESSES | +| YAML | networking.derp.stunAddresses | +| Default | stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302 | -Addresses for STUN servers to establish P2P connections. Use special value 'disable' to turn off STUN. +Addresses for STUN servers to establish P2P connections. It's recommended to have at least two STUN servers to give users the best chance of connecting P2P to workspaces. Each STUN server will get it's own DERP region, with region IDs starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn off STUN completely. ### --default-quiet-hours-schedule diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 4e1274bb7ad20..cd8fd309f1154 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -175,18 +175,15 @@ backed by Tailscale and WireGuard. --derp-server-enable bool, $CODER_DERP_SERVER_ENABLE (default: true) Whether to enable or disable the embedded DERP relay server. - --derp-server-region-code string, $CODER_DERP_SERVER_REGION_CODE (default: coder) - Region code to use for the embedded DERP server. - - --derp-server-region-id int, $CODER_DERP_SERVER_REGION_ID (default: 999) - Region ID to use for the embedded DERP server. - --derp-server-region-name string, $CODER_DERP_SERVER_REGION_NAME (default: Coder Embedded Relay) Region name that for the embedded DERP server. - --derp-server-stun-addresses string-array, $CODER_DERP_SERVER_STUN_ADDRESSES (default: stun.l.google.com:19302) - Addresses for STUN servers to establish P2P connections. Use special - value 'disable' to turn off STUN. + --derp-server-stun-addresses string-array, $CODER_DERP_SERVER_STUN_ADDRESSES (default: stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302) + Addresses for STUN servers to establish P2P connections. It's + recommended to have at least two STUN servers to give users the best + chance of connecting P2P to workspaces. Each STUN server will get it's + own DERP region, with region IDs starting at `--derp-server-region-id + + 1`. Use special value 'disable' to turn off STUN completely. Networking / HTTP Options --disable-password-auth bool, $CODER_DISABLE_PASSWORD_AUTH diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 1679165750b16..de385209e44f4 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -594,7 +594,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { if initial, changed, enabled := featureChanged(codersdk.FeatureWorkspaceProxy); shouldUpdate(initial, changed, enabled) { if enabled { - fn := derpMapper(api.Logger, api.DeploymentValues, api.ProxyHealth) + fn := derpMapper(api.Logger, api.ProxyHealth) api.AGPL.DERPMapper.Store(&fn) } else { api.AGPL.DERPMapper.Store(nil) @@ -659,7 +659,7 @@ var ( lastDerpConflictLog time.Time ) -func derpMapper(logger slog.Logger, _ *codersdk.DeploymentValues, proxyHealth *proxyhealth.ProxyHealth) func(*tailcfg.DERPMap) *tailcfg.DERPMap { +func derpMapper(logger slog.Logger, proxyHealth *proxyhealth.ProxyHealth) func(*tailcfg.DERPMap) *tailcfg.DERPMap { return func(derpMap *tailcfg.DERPMap) *tailcfg.DERPMap { derpMap = derpMap.Clone() @@ -753,46 +753,22 @@ func derpMapper(logger slog.Logger, _ *codersdk.DeploymentValues, proxyHealth *p } } - var stunNodes []*tailcfg.DERPNode - // TODO(@dean): potentially re-enable this depending on impact - /* - if !cfg.DERP.Config.BlockDirect.Value() { - stunNodes, err = agpltailnet.STUNNodes(regionID, cfg.DERP.Server.STUNAddresses) - if err != nil { - // Log a warning if we haven't logged one in the last - // minute. - lastDerpConflictMutex.Lock() - shouldLog := lastDerpConflictLog.IsZero() || time.Since(lastDerpConflictLog) > time.Minute - if shouldLog { - lastDerpConflictLog = time.Now() - } - lastDerpConflictMutex.Unlock() - if shouldLog { - logger.Error(context.Background(), "failed to calculate STUN nodes", slog.Error(err)) - } - - // No continue because we can keep going. - stunNodes = []*tailcfg.DERPNode{} - } - } - */ - - nodes := append(stunNodes, &tailcfg.DERPNode{ - Name: fmt.Sprintf("%da", regionID), - RegionID: regionID, - HostName: u.Hostname(), - DERPPort: portInt, - STUNPort: -1, - ForceHTTP: u.Scheme == "http", - }) - derpMap.Regions[regionID] = &tailcfg.DERPRegion{ // EmbeddedRelay ONLY applies to the primary. EmbeddedRelay: false, RegionID: regionID, RegionCode: regionCode, RegionName: regionName, - Nodes: nodes, + Nodes: []*tailcfg.DERPNode{ + { + Name: fmt.Sprintf("%da", regionID), + RegionID: regionID, + HostName: u.Hostname(), + DERPPort: portInt, + STUNPort: -1, + ForceHTTP: u.Scheme == "http", + }, + }, } } diff --git a/enterprise/wsproxy/wsproxy_test.go b/enterprise/wsproxy/wsproxy_test.go index d9d4e1a96bc8d..3ba8463331d6a 100644 --- a/enterprise/wsproxy/wsproxy_test.go +++ b/enterprise/wsproxy/wsproxy_test.go @@ -27,7 +27,6 @@ import ( "github.com/coder/coder/enterprise/coderd/coderdenttest" "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/provisioner/echo" - "github.com/coder/coder/tailnet" "github.com/coder/coder/testutil" ) @@ -203,10 +202,10 @@ resourceLoop: connInfo, err := client.WorkspaceAgentConnectionInfo(ctx, agentID) require.NoError(t, err) - // There should be three DERP servers in the map: the primary, and each - // of the two running proxies. + // There should be three DERP regions in the map: the primary, and each + // of the two running proxies. Also the STUN-only regions. require.NotNil(t, connInfo.DERPMap) - require.Len(t, connInfo.DERPMap.Regions, 3) + require.Len(t, connInfo.DERPMap.Regions, 3+len(api.DeploymentValues.DERP.Server.STUNAddresses.Value())) var ( primaryRegion *tailcfg.DERPRegion @@ -230,6 +229,11 @@ resourceLoop: // The last region is never started, which means it's never healthy, // which means it's never added to the DERP map. + if len(r.Nodes) == 1 && r.Nodes[0].STUNOnly { + // Skip STUN-only regions. + continue + } + t.Fatalf("unexpected region: %+v", r) } @@ -270,11 +274,15 @@ resourceLoop: connInfo, err := client.WorkspaceAgentConnectionInfo(testutil.Context(t, testutil.WaitLong), agentID) require.NoError(t, err) require.NotNil(t, connInfo.DERPMap) - require.Len(t, connInfo.DERPMap.Regions, 3) + require.Len(t, connInfo.DERPMap.Regions, 3+len(api.DeploymentValues.DERP.Server.STUNAddresses.Value())) // Connect to each region. for _, r := range connInfo.DERPMap.Regions { r := r + if len(r.Nodes) == 1 && r.Nodes[0].STUNOnly { + // Skip STUN-only regions. + continue + } t.Run(r.RegionName, func(t *testing.T) { t.Parallel() @@ -311,134 +319,6 @@ resourceLoop: }) } -func TestDERPMapStunNodes(t *testing.T) { - t.Parallel() - // See: enterprise/coderd/coderd.go - t.Skip("STUN nodes are removed from proxy regions in the DERP map for now") - - deploymentValues := coderdtest.DeploymentValues(t) - deploymentValues.Experiments = []string{ - string(codersdk.ExperimentMoons), - "*", - } - stunAddresses := []string{ - "stun.l.google.com:19302", - "stun1.l.google.com:19302", - "stun2.l.google.com:19302", - "stun3.l.google.com:19302", - "stun4.l.google.com:19302", - } - deploymentValues.DERP.Server.STUNAddresses = stunAddresses - - client, closer, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - DeploymentValues: deploymentValues, - AppHostname: "*.primary.test.coder.com", - IncludeProvisionerDaemon: true, - RealIPConfig: &httpmw.RealIPConfig{ - TrustedOrigins: []*net.IPNet{{ - IP: net.ParseIP("127.0.0.1"), - Mask: net.CIDRMask(8, 32), - }}, - TrustedHeaders: []string{ - "CF-Connecting-IP", - }, - }, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureWorkspaceProxy: 1, - }, - }, - }) - t.Cleanup(func() { - _ = closer.Close() - }) - - // Create a running external proxy. - _ = coderdenttest.NewWorkspaceProxy(t, api, client, &coderdenttest.ProxyOptions{ - Name: "cool-proxy", - }) - - // Wait for both running proxies to become healthy. - require.Eventually(t, func() bool { - healthCtx := testutil.Context(t, testutil.WaitLong) - err := api.ProxyHealth.ForceUpdate(healthCtx) - if !assert.NoError(t, err) { - return false - } - - regions, err := client.Regions(healthCtx) - if !assert.NoError(t, err) { - return false - } - if !assert.Len(t, regions, 2) { - return false - } - - // All regions should be healthy. - for _, r := range regions { - if !r.Healthy { - return false - } - } - return true - }, testutil.WaitLong, testutil.IntervalMedium) - - // Get the DERP map and ensure that the built-in region and the proxy region - // both have the STUN nodes. - ctx := testutil.Context(t, testutil.WaitLong) - connInfo, err := client.WorkspaceAgentConnectionInfoGeneric(ctx) - require.NoError(t, err) - - // There should be two DERP servers in the map: the primary and the - // proxy. - require.NotNil(t, connInfo.DERPMap) - require.Len(t, connInfo.DERPMap.Regions, 2) - - var ( - primaryRegion *tailcfg.DERPRegion - proxyRegion *tailcfg.DERPRegion - ) - for _, r := range connInfo.DERPMap.Regions { - if r.EmbeddedRelay { - primaryRegion = r - continue - } - if r.RegionName == "cool-proxy" { - proxyRegion = r - continue - } - - t.Fatalf("unexpected region: %+v", r) - } - - // The primary region: - require.Equal(t, "Coder Embedded Relay", primaryRegion.RegionName) - require.Equal(t, "coder", primaryRegion.RegionCode) - require.Equal(t, 999, primaryRegion.RegionID) - require.True(t, primaryRegion.EmbeddedRelay) - require.Len(t, primaryRegion.Nodes, len(stunAddresses)+1) - - // The proxy region: - require.Equal(t, "cool-proxy", proxyRegion.RegionName) - require.Equal(t, "coder_cool-proxy", proxyRegion.RegionCode) - require.Equal(t, 10001, proxyRegion.RegionID) - require.False(t, proxyRegion.EmbeddedRelay) - require.Len(t, proxyRegion.Nodes, len(stunAddresses)+1) - - for _, region := range []*tailcfg.DERPRegion{primaryRegion, proxyRegion} { - stunNodes, err := tailnet.STUNNodes(region.RegionID, stunAddresses) - require.NoError(t, err) - require.Len(t, stunNodes, len(stunAddresses)) - - require.Equal(t, stunNodes, region.Nodes[:len(stunNodes)]) - - // The last node should be the Coder server. - require.NotZero(t, region.Nodes[len(region.Nodes)-1].DERPPort) - } -} - func TestDERPEndToEnd(t *testing.T) { t.Parallel() diff --git a/tailnet/derpmap.go b/tailnet/derpmap.go index aa79300801d71..f35e48156b768 100644 --- a/tailnet/derpmap.go +++ b/tailnet/derpmap.go @@ -13,11 +13,11 @@ import ( "tailscale.com/tailcfg" ) -func STUNNodes(regionID int, stunAddrs []string) ([]*tailcfg.DERPNode, error) { - nodes := []*tailcfg.DERPNode{} +func STUNRegions(baseRegionID int, stunAddrs []string) ([]*tailcfg.DERPRegion, error) { + regions := make([]*tailcfg.DERPRegion, 0, len(stunAddrs)) for index, stunAddr := range stunAddrs { if stunAddr == "disable" { - return []*tailcfg.DERPNode{}, nil + return []*tailcfg.DERPRegion{}, nil } host, rawPort, err := net.SplitHostPort(stunAddr) @@ -28,16 +28,24 @@ func STUNNodes(regionID int, stunAddrs []string) ([]*tailcfg.DERPNode, error) { if err != nil { return nil, xerrors.Errorf("parse port for %q: %w", stunAddr, err) } - nodes = append([]*tailcfg.DERPNode{{ - Name: fmt.Sprintf("%dstun%d", regionID, index), - RegionID: regionID, - HostName: host, - STUNOnly: true, - STUNPort: port, - }}, nodes...) + + regionID := baseRegionID + index + 1 + regions = append(regions, &tailcfg.DERPRegion{ + EmbeddedRelay: false, + RegionID: regionID, + RegionCode: fmt.Sprintf("coder_stun_%d", regionID), + RegionName: fmt.Sprintf("Coder STUN %d", regionID), + Nodes: []*tailcfg.DERPNode{{ + Name: fmt.Sprintf("%dstun0", regionID), + RegionID: regionID, + HostName: host, + STUNOnly: true, + STUNPort: port, + }}, + }) } - return nodes, nil + return regions, nil } // NewDERPMap constructs a DERPMap from a set of STUN addresses and optionally a remote @@ -52,13 +60,17 @@ func NewDERPMap(ctx context.Context, region *tailcfg.DERPRegion, stunAddrs []str stunAddrs = nil } + // stunAddrs only applies when a default region is set. Each STUN node gets + // it's own region ID because netcheck will only try a single STUN server in + // each region before canceling the region's STUN check. + addRegions := []*tailcfg.DERPRegion{} if region != nil { - stunNodes, err := STUNNodes(region.RegionID, stunAddrs) + addRegions = append(addRegions, region) + stunRegions, err := STUNRegions(region.RegionID, stunAddrs) if err != nil { - return nil, xerrors.Errorf("construct stun nodes: %w", err) + return nil, xerrors.Errorf("create stun regions: %w", err) } - - region.Nodes = append(stunNodes, region.Nodes...) + addRegions = append(addRegions, stunRegions...) } derpMap := &tailcfg.DERPMap{ @@ -89,13 +101,18 @@ func NewDERPMap(ctx context.Context, region *tailcfg.DERPRegion, stunAddrs []str return nil, xerrors.Errorf("unmarshal derpmap: %w", err) } } - if region != nil { - _, conflicts := derpMap.Regions[region.RegionID] - if conflicts { - return nil, xerrors.Errorf("the default region ID conflicts with a remote region from %q", remoteURL) + + // Add our custom regions to the DERP map. + if len(addRegions) > 0 { + for _, region := range addRegions { + _, conflicts := derpMap.Regions[region.RegionID] + if conflicts { + return nil, xerrors.Errorf("a default region ID %d (%s - %q) conflicts with a remote region from %q", region.RegionID, region.RegionCode, region.RegionName, remoteURL) + } + derpMap.Regions[region.RegionID] = region } - derpMap.Regions[region.RegionID] = region } + // Remove all STUNPorts from DERPy nodes, and fully remove all STUNOnly // nodes. if disableSTUN { diff --git a/tailnet/derpmap_test.go b/tailnet/derpmap_test.go index bc5205cc45cf4..07d1b11255c93 100644 --- a/tailnet/derpmap_test.go +++ b/tailnet/derpmap_test.go @@ -24,7 +24,9 @@ func TestNewDERPMap(t *testing.T) { Nodes: []*tailcfg.DERPNode{{}}, }, []string{"stun.google.com:2345"}, "", "", false) require.NoError(t, err) - require.Len(t, derpMap.Regions[1].Nodes, 2) + require.Len(t, derpMap.Regions, 2) + require.Len(t, derpMap.Regions[1].Nodes, 1) + require.Len(t, derpMap.Regions[2].Nodes, 1) }) t.Run("RemoteURL", func(t *testing.T) { t.Parallel() From a2d64c08c15d582a893772691fea554bf0c4649e Mon Sep 17 00:00:00 2001 From: timquinlan Date: Thu, 10 Aug 2023 17:06:10 -0400 Subject: [PATCH 085/277] docs: update helm values.yaml code snippet, put quote around boolean value (#9026) * updated helm values.yaml code snippet, put quote around boolean values and added comments showing that CODER_OAUTH2_GITHUB_ALLOW_EVERYONE and CODER_OAUTH2_GITHUB_ALLOW_EVERYONE are mutually exclusive * Update auth.md spotted and fixed minor typo --- docs/admin/auth.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/admin/auth.md b/docs/admin/auth.md index 794def7f67e9e..5d7b135f12a46 100644 --- a/docs/admin/auth.md +++ b/docs/admin/auth.md @@ -59,15 +59,17 @@ If deploying Coder via Helm, you can set the above environment variables in the coder: env: - name: CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS - value: true - - name: CODER_OAUTH2_GITHUB_ALLOWED_ORGS - value: "your-org" + value: "true" - name: CODER_OAUTH2_GITHUB_CLIENT_ID value: "533...des" - name: CODER_OAUTH2_GITHUB_CLIENT_SECRET value: "G0CSP...7qSM" - - name: CODER_OAUTH2_GITHUB_ALLOW_EVERYONE - value: true + # If setting allowed orgs, comment out CODER_OAUTH2_GITHUB_ALLOW_EVERYONE and its value + - name: CODER_OAUTH2_GITHUB_ALLOWED_ORGS + value: "your-org" + # If allowing everyone, comment out CODER_OAUTH2_GITHUB_ALLOWED_ORGS and it's value + #- name: CODER_OAUTH2_GITHUB_ALLOW_EVERYONE + # value: "true" ``` To upgrade Coder, run: From 6fd5344d0add5148396726b1a83abfa7828f38d3 Mon Sep 17 00:00:00 2001 From: ffais <42377700+ffais@users.noreply.github.com> Date: Fri, 11 Aug 2023 02:57:59 +0200 Subject: [PATCH 086/277] feat: add support for NodePort service type in Helm chart (#8993) * add support for NodePort service type in Helm chart * fix nodeport values * formatting & make update-golden-files * update-golden-files --------- Co-authored-by: Eric --- helm/coder/templates/service.yaml | 6 ++++++ helm/coder/tests/testdata/command.golden | 1 + helm/coder/tests/testdata/command_args.golden | 1 + helm/coder/tests/testdata/default_values.golden | 1 + helm/coder/tests/testdata/labels_annotations.golden | 1 + helm/coder/tests/testdata/provisionerd_psk.golden | 1 + helm/coder/tests/testdata/sa.golden | 1 + helm/coder/tests/testdata/tls.golden | 2 ++ helm/coder/tests/testdata/workspace_proxy.golden | 1 + helm/coder/values.yaml | 6 ++++++ 10 files changed, 21 insertions(+) diff --git a/helm/coder/templates/service.yaml b/helm/coder/templates/service.yaml index 60dd5fe931dfa..1881f992a695e 100644 --- a/helm/coder/templates/service.yaml +++ b/helm/coder/templates/service.yaml @@ -16,11 +16,17 @@ spec: port: 80 targetPort: "http" protocol: TCP + {{ if eq .Values.coder.service.type "NodePort" }} + nodePort: {{ .Values.coder.service.httpNodePort }} + {{ end }} {{- if eq (include "coder.tlsEnabled" .) "true" }} - name: "https" port: 443 targetPort: "https" protocol: TCP + {{ if eq .Values.coder.service.type "NodePort" }} + nodePort: {{ .Values.coder.service.httpsNodePort }} + {{ end }} {{- end }} {{- if eq "LoadBalancer" .Values.coder.service.type }} {{- with .Values.coder.service.loadBalancerIP }} diff --git a/helm/coder/tests/testdata/command.golden b/helm/coder/tests/testdata/command.golden index 852ee36330ed2..4e88c36d4641d 100644 --- a/helm/coder/tests/testdata/command.golden +++ b/helm/coder/tests/testdata/command.golden @@ -90,6 +90,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder diff --git a/helm/coder/tests/testdata/command_args.golden b/helm/coder/tests/testdata/command_args.golden index 98bce5214c48e..9e7a9a01ee27a 100644 --- a/helm/coder/tests/testdata/command_args.golden +++ b/helm/coder/tests/testdata/command_args.golden @@ -90,6 +90,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder diff --git a/helm/coder/tests/testdata/default_values.golden b/helm/coder/tests/testdata/default_values.golden index 36d9fa171b63e..ed02773c6f7bb 100644 --- a/helm/coder/tests/testdata/default_values.golden +++ b/helm/coder/tests/testdata/default_values.golden @@ -90,6 +90,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder diff --git a/helm/coder/tests/testdata/labels_annotations.golden b/helm/coder/tests/testdata/labels_annotations.golden index b0edb4346f191..38812ffeab832 100644 --- a/helm/coder/tests/testdata/labels_annotations.golden +++ b/helm/coder/tests/testdata/labels_annotations.golden @@ -90,6 +90,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder diff --git a/helm/coder/tests/testdata/provisionerd_psk.golden b/helm/coder/tests/testdata/provisionerd_psk.golden index f8cfe550eefff..4dcde1eabe0fc 100644 --- a/helm/coder/tests/testdata/provisionerd_psk.golden +++ b/helm/coder/tests/testdata/provisionerd_psk.golden @@ -90,6 +90,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder diff --git a/helm/coder/tests/testdata/sa.golden b/helm/coder/tests/testdata/sa.golden index 940b761dd3f79..cf3b2df693835 100644 --- a/helm/coder/tests/testdata/sa.golden +++ b/helm/coder/tests/testdata/sa.golden @@ -91,6 +91,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder diff --git a/helm/coder/tests/testdata/tls.golden b/helm/coder/tests/testdata/tls.golden index 75f0794a7945d..fccbbec0a2aa2 100644 --- a/helm/coder/tests/testdata/tls.golden +++ b/helm/coder/tests/testdata/tls.golden @@ -90,10 +90,12 @@ spec: port: 80 targetPort: "http" protocol: TCP + - name: "https" port: 443 targetPort: "https" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder diff --git a/helm/coder/tests/testdata/workspace_proxy.golden b/helm/coder/tests/testdata/workspace_proxy.golden index 6d03e49ff794e..096b40978aac0 100644 --- a/helm/coder/tests/testdata/workspace_proxy.golden +++ b/helm/coder/tests/testdata/workspace_proxy.golden @@ -90,6 +90,7 @@ spec: port: 80 targetPort: "http" protocol: TCP + externalTrafficPolicy: "Cluster" selector: app.kubernetes.io/name: coder diff --git a/helm/coder/values.yaml b/helm/coder/values.yaml index f6b43e4ee4dd0..2b85b54e67127 100644 --- a/helm/coder/values.yaml +++ b/helm/coder/values.yaml @@ -241,6 +241,12 @@ coder: # coder.service.annotations -- The service annotations. See: # https://kubernetes.io/docs/concepts/services-networking/service/#internal-load-balancer annotations: {} + # coder.service.httpNodePort -- Enabled if coder.service.type is set to NodePort. + # If not set, Kubernetes will allocate a port from the default range, 30000-32767. + httpNodePort: "" + # coder.service.httpsNodePort -- Enabled if coder.service.type is set to NodePort. + # If not set, Kubernetes will allocate a port from the default range, 30000-32767. + httpsNodePort: "" # coder.ingress -- The Ingress object to expose for Coder. ingress: From 40f3fc3a1c7ef613537be2b4064d513c4cc28925 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 10 Aug 2023 20:04:35 -0500 Subject: [PATCH 087/277] feat: allow creating manual oidc/github based users (#9000) * feat: allow creating manual oidc/github based users * Add unit test for oidc and no login type create --- cli/testdata/coder_users_create_--help.golden | 12 +-- cli/usercreate.go | 44 +++++++-- coderd/apidoc/docs.go | 12 ++- coderd/apidoc/swagger.json | 13 ++- coderd/coderdtest/coderdtest.go | 16 ++-- coderd/userauth_test.go | 18 +++- coderd/users.go | 40 ++++++--- coderd/users_test.go | 66 ++++++++++++++ codersdk/apikey.go | 1 + codersdk/users.go | 5 +- docs/api/audit.md | 2 +- docs/api/authorization.md | 4 +- docs/api/enterprise.md | 23 ++--- docs/api/schemas.md | 41 +++++---- docs/api/users.md | 21 ++--- docs/cli/users_create.md | 14 +-- scripts/develop.sh | 2 +- site/src/api/typesGenerated.ts | 4 +- .../CreateUserForm/CreateUserForm.tsx | 89 ++++++++++++++++++- .../CreateUserPage/CreateUserPage.test.tsx | 11 ++- .../CreateUserPage/CreateUserPage.tsx | 10 +++ 21 files changed, 355 insertions(+), 93 deletions(-) diff --git a/cli/testdata/coder_users_create_--help.golden b/cli/testdata/coder_users_create_--help.golden index bb94cac633bc0..275e89803d4c6 100644 --- a/cli/testdata/coder_users_create_--help.golden +++ b/cli/testdata/coder_users_create_--help.golden @@ -1,15 +1,15 @@ Usage: coder users create [flags] Options - --disable-login bool - Disabling login for a user prevents the user from authenticating via - password or IdP login. Authentication requires an API key/token - generated by an admin. Be careful when using this flag as it can lock - the user out of their account. - -e, --email string Specifies an email address for the new user. + --login-type string + Optionally specify the login type for the user. Valid values are: + password, none, github, oidc. Using 'none' prevents the user from + authenticating and requires an API key/token to be generated by an + admin. + -p, --password string Specifies a password for the new user. diff --git a/cli/usercreate.go b/cli/usercreate.go index b38bbb2d6401f..80118d7fced0e 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "golang.org/x/xerrors" @@ -18,6 +19,7 @@ func (r *RootCmd) userCreate() *clibase.Cmd { username string password string disableLogin bool + loginType string ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -54,7 +56,18 @@ func (r *RootCmd) userCreate() *clibase.Cmd { return err } } - if password == "" && !disableLogin { + userLoginType := codersdk.LoginTypePassword + if disableLogin && loginType != "" { + return xerrors.New("You cannot specify both --disable-login and --login-type") + } + if disableLogin { + userLoginType = codersdk.LoginTypeNone + } else if loginType != "" { + userLoginType = codersdk.LoginType(loginType) + } + + if password == "" && userLoginType == codersdk.LoginTypePassword { + // Generate a random password password, err = cryptorand.StringCharset(cryptorand.Human, 20) if err != nil { return err @@ -66,14 +79,22 @@ func (r *RootCmd) userCreate() *clibase.Cmd { Username: username, Password: password, OrganizationID: organization.ID, - DisableLogin: disableLogin, + UserLoginType: userLoginType, }) if err != nil { return err } - authenticationMethod := `Your password is: ` + cliui.DefaultStyles.Field.Render(password) - if disableLogin { + + authenticationMethod := "" + switch codersdk.LoginType(strings.ToLower(string(userLoginType))) { + case codersdk.LoginTypePassword: + authenticationMethod = `Your password is: ` + cliui.DefaultStyles.Field.Render(password) + case codersdk.LoginTypeNone: authenticationMethod = "Login has been disabled for this user. Contact your administrator to authenticate." + case codersdk.LoginTypeGithub: + authenticationMethod = `Login is authenticated through GitHub.` + case codersdk.LoginTypeOIDC: + authenticationMethod = `Login is authenticated through the configured OIDC provider.` } _, _ = fmt.Fprintln(inv.Stderr, `A new user has been created! @@ -111,11 +132,22 @@ Create a workspace `+cliui.DefaultStyles.Code.Render("coder create")+`!`) Value: clibase.StringOf(&password), }, { - Flag: "disable-login", - Description: "Disabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. " + + Flag: "disable-login", + Hidden: true, + Description: "Deprecated: Use '--login-type=none'. \nDisabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. " + "Be careful when using this flag as it can lock the user out of their account.", Value: clibase.BoolOf(&disableLogin), }, + { + Flag: "login-type", + Description: fmt.Sprintf("Optionally specify the login type for the user. Valid values are: %s. "+ + "Using 'none' prevents the user from authenticating and requires an API key/token to be generated by an admin.", + strings.Join([]string{ + string(codersdk.LoginTypePassword), string(codersdk.LoginTypeNone), string(codersdk.LoginTypeGithub), string(codersdk.LoginTypeOIDC), + }, ", ", + )), + Value: clibase.StringOf(&loginType), + }, } return cmd } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6d590b02d4904..2f04b8be2a3d2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7536,13 +7536,21 @@ const docTemplate = `{ ], "properties": { "disable_login": { - "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.", + "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.\nDeprecated: Set UserLoginType=LoginTypeDisabled instead.", "type": "boolean" }, "email": { "type": "string", "format": "email" }, + "login_type": { + "description": "UserLoginType defaults to LoginTypePassword.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.LoginType" + } + ] + }, "organization_id": { "type": "string", "format": "uuid" @@ -8449,6 +8457,7 @@ const docTemplate = `{ "codersdk.LoginType": { "type": "string", "enum": [ + "", "password", "github", "oidc", @@ -8456,6 +8465,7 @@ const docTemplate = `{ "none" ], "x-enum-varnames": [ + "LoginTypeUnknown", "LoginTypePassword", "LoginTypeGithub", "LoginTypeOIDC", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 09aef2e0872b4..997bdca3ade64 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6715,13 +6715,21 @@ "required": ["email", "username"], "properties": { "disable_login": { - "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.", + "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.\nDeprecated: Set UserLoginType=LoginTypeDisabled instead.", "type": "boolean" }, "email": { "type": "string", "format": "email" }, + "login_type": { + "description": "UserLoginType defaults to LoginTypePassword.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.LoginType" + } + ] + }, "organization_id": { "type": "string", "format": "uuid" @@ -7576,8 +7584,9 @@ }, "codersdk.LoginType": { "type": "string", - "enum": ["password", "github", "oidc", "token", "none"], + "enum": ["", "password", "github", "oidc", "token", "none"], "x-enum-varnames": [ + "LoginTypeUnknown", "LoginTypePassword", "LoginTypeGithub", "LoginTypeOIDC", diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 5e2e55d5c032f..04470509682e2 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -588,14 +588,7 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI require.NoError(t, err) var sessionToken string - if !req.DisableLogin { - login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ - Email: req.Email, - Password: req.Password, - }) - require.NoError(t, err) - sessionToken = login.SessionToken - } else { + if req.DisableLogin || req.UserLoginType == codersdk.LoginTypeNone { // Cannot log in with a disabled login user. So make it an api key from // the client making this user. token, err := client.CreateToken(context.Background(), user.ID.String(), codersdk.CreateTokenRequest{ @@ -605,6 +598,13 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI }) require.NoError(t, err) sessionToken = token.Key + } else { + login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ + Email: req.Email, + Password: req.Password, + }) + require.NoError(t, err) + sessionToken = login.SessionToken } if user.Status == codersdk.UserStatusDormant { diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index efa7673890863..8910bce286818 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -145,7 +145,7 @@ func TestUserLogin(t *testing.T) { require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) }) // Password auth should fail if the user is made without password login. - t.Run("LoginTypeNone", func(t *testing.T) { + t.Run("DisableLoginDeprecatedField", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) @@ -160,6 +160,22 @@ func TestUserLogin(t *testing.T) { }) require.Error(t, err) }) + + t.Run("LoginTypeNone", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, client, user.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + r.Password = "" + r.UserLoginType = codersdk.LoginTypeNone + }) + + _, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ + Email: anotherUser.Email, + Password: "SomeSecurePassword!", + }) + require.Error(t, err) + }) } func TestUserAuthMethods(t *testing.T) { diff --git a/coderd/users.go b/coderd/users.go index b34b447b8c456..29a6d56015369 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -287,11 +287,27 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } + if req.UserLoginType == "" && req.DisableLogin { + // Handle the deprecated field + req.UserLoginType = codersdk.LoginTypeNone + } + if req.UserLoginType == "" { + // Default to password auth + req.UserLoginType = codersdk.LoginTypePassword + } + + if req.UserLoginType != codersdk.LoginTypePassword && req.Password != "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Password cannot be set for non-password (%q) authentication.", req.UserLoginType), + }) + return + } + // If password auth is disabled, don't allow new users to be // created with a password! - if api.DeploymentValues.DisablePasswordAuth { + if api.DeploymentValues.DisablePasswordAuth && req.UserLoginType == codersdk.LoginTypePassword { httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "You cannot manually provision new users with password authentication disabled!", + Message: "Password based authentication is disabled! Unable to provision new users with password authentication.", }) return } @@ -353,17 +369,11 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { } } - if req.DisableLogin && req.Password != "" { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Cannot set password when disabling login.", - }) - return - } - var loginType database.LoginType - if req.DisableLogin { + switch req.UserLoginType { + case codersdk.LoginTypeNone: loginType = database.LoginTypeNone - } else { + case codersdk.LoginTypePassword: err = userpassword.Validate(req.Password) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -376,6 +386,14 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } loginType = database.LoginTypePassword + case codersdk.LoginTypeOIDC: + loginType = database.LoginTypeOIDC + case codersdk.LoginTypeGithub: + loginType = database.LoginTypeGithub + default: + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Unsupported login type %q for manually creating new users.", req.UserLoginType), + }) } user, _, err := api.CreateUser(ctx, api.Database, CreateUserRequest{ diff --git a/coderd/users_test.go b/coderd/users_test.go index 9b130133cd58a..c2564e2ff8c29 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/golang-jwt/jwt" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -566,6 +567,71 @@ func TestPostUsers(t *testing.T) { } } }) + + t.Run("CreateNoneLoginType", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: first.OrganizationID, + Email: "another@user.org", + Username: "someone-else", + Password: "", + UserLoginType: codersdk.LoginTypeNone, + }) + require.NoError(t, err) + + found, err := client.User(ctx, user.ID.String()) + require.NoError(t, err) + require.Equal(t, found.LoginType, codersdk.LoginTypeNone) + }) + + t.Run("CreateOIDCLoginType", func(t *testing.T) { + t.Parallel() + email := "another@user.org" + conf := coderdtest.NewOIDCConfig(t, "") + config := conf.OIDCConfig(t, jwt.MapClaims{ + "email": email, + }) + config.AllowSignups = false + config.IgnoreUserInfo = true + + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: config, + }) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + OrganizationID: first.OrganizationID, + Email: email, + Username: "someone-else", + Password: "", + UserLoginType: codersdk.LoginTypeOIDC, + }) + require.NoError(t, err) + + // Try to log in with OIDC. + userClient := codersdk.New(client.URL) + resp := oidcCallback(t, userClient, conf.EncodeClaims(t, jwt.MapClaims{ + "email": email, + })) + require.Equal(t, resp.StatusCode, http.StatusTemporaryRedirect) + // Set the client to use this OIDC context + authCookie := authCookieValue(resp.Cookies()) + userClient.SetSessionToken(authCookie) + _ = resp.Body.Close() + + found, err := userClient.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, found.LoginType, codersdk.LoginTypeOIDC) + }) } func TestUpdateUserProfile(t *testing.T) { diff --git a/codersdk/apikey.go b/codersdk/apikey.go index 514b519f5ffda..32c97cf538417 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -28,6 +28,7 @@ type APIKey struct { type LoginType string const ( + LoginTypeUnknown LoginType = "" LoginTypePassword LoginType = "password" LoginTypeGithub LoginType = "github" LoginTypeOIDC LoginType = "oidc" diff --git a/codersdk/users.go b/codersdk/users.go index daeefee5f12bf..c11846ebdac2b 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -78,9 +78,12 @@ type CreateFirstUserResponse struct { type CreateUserRequest struct { Email string `json:"email" validate:"required,email" format:"email"` Username string `json:"username" validate:"required,username"` - Password string `json:"password" validate:"required_if=DisableLogin false"` + Password string `json:"password"` + // UserLoginType defaults to LoginTypePassword. + UserLoginType LoginType `json:"login_type"` // DisableLogin sets the user's login type to 'none'. This prevents the user // from being able to use a password or any other authentication method to login. + // Deprecated: Set UserLoginType=LoginTypeDisabled instead. DisableLogin bool `json:"disable_login"` OrganizationID uuid.UUID `json:"organization_id" validate:"" format:"uuid"` } diff --git a/docs/api/audit.md b/docs/api/audit.md index d5aeb78665d31..5efe1f3410809 100644 --- a/docs/api/audit.md +++ b/docs/api/audit.md @@ -63,7 +63,7 @@ curl -X GET http://coder-server:8080/api/v2/audit?q=string \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { diff --git a/docs/api/authorization.md b/docs/api/authorization.md index d57a5e7542c35..17fc2e81d2299 100644 --- a/docs/api/authorization.md +++ b/docs/api/authorization.md @@ -129,7 +129,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/convert-login \ ```json { "password": "string", - "to_type": "password" + "to_type": "" } ``` @@ -148,7 +148,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/convert-login \ { "expires_at": "2019-08-24T14:15:22Z", "state_string": "string", - "to_type": "password", + "to_type": "", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } ``` diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index fc887cd12b6e3..15ba8c12b4ea3 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -183,7 +183,7 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -245,7 +245,7 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -307,7 +307,7 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -444,7 +444,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -502,6 +502,7 @@ Status Code **200** | Property | Value | | ------------ | ----------- | +| `login_type` | `` | | `login_type` | `password` | | `login_type` | `github` | | `login_type` | `oidc` | @@ -562,7 +563,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/groups "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -625,7 +626,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -988,7 +989,7 @@ curl -X PATCH http://coder-server:8080/api/v2/scim/v2/Users/{id} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1040,7 +1041,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "role": "admin", "roles": [ @@ -1086,6 +1087,7 @@ Status Code **200** | Property | Value | | ------------ | ----------- | +| `login_type` | `` | | `login_type` | `password` | | `login_type` | `github` | | `login_type` | `oidc` | @@ -1197,7 +1199,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1222,7 +1224,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1278,6 +1280,7 @@ Status Code **200** | Property | Value | | ------------ | ----------- | +| `login_type` | `` | | `login_type` | `password` | | `login_type` | `github` | | `login_type` | `oidc` | diff --git a/docs/api/schemas.md b/docs/api/schemas.md index cfcf28701ac1f..11aad4ba5f814 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -792,7 +792,7 @@ _None_ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -817,7 +817,7 @@ _None_ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1086,7 +1086,7 @@ _None_ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1163,7 +1163,7 @@ _None_ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1388,7 +1388,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "password": "string", - "to_type": "password" + "to_type": "" } ``` @@ -1665,6 +1665,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "disable_login": true, "email": "user@example.com", + "login_type": "", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "password": "string", "username": "string" @@ -1673,13 +1674,14 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------- | ------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `disable_login` | boolean | false | | Disable login sets the user's login type to 'none'. This prevents the user from being able to use a password or any other authentication method to login. | -| `email` | string | true | | | -| `organization_id` | string | false | | | -| `password` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ----------------- | ---------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `disable_login` | boolean | false | | Disable login sets the user's login type to 'none'. This prevents the user from being able to use a password or any other authentication method to login. Deprecated: Set UserLoginType=LoginTypeDisabled instead. | +| `email` | string | true | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | Login type defaults to LoginTypePassword. | +| `organization_id` | string | false | | | +| `password` | string | false | | | +| `username` | string | true | | | ## codersdk.CreateWorkspaceBuildRequest @@ -2752,7 +2754,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -2970,7 +2972,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -3188,7 +3190,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ## codersdk.LoginType ```json -"password" +"" ``` ### Properties @@ -3197,6 +3199,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Value | | ---------- | +| `` | | `password` | | `github` | | `oidc` | @@ -3305,7 +3308,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "expires_at": "2019-08-24T14:15:22Z", "state_string": "string", - "to_type": "password", + "to_type": "", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } ``` @@ -4563,7 +4566,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "role": "admin", "roles": [ @@ -5071,7 +5074,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -5196,7 +5199,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { - "login_type": "password" + "login_type": "" } ``` diff --git a/docs/api/users.md b/docs/api/users.md index 3c583e15787db..fdeed691da48f 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -36,7 +36,7 @@ curl -X GET http://coder-server:8080/api/v2/users \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -79,6 +79,7 @@ curl -X POST http://coder-server:8080/api/v2/users \ { "disable_login": true, "email": "user@example.com", + "login_type": "", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "password": "string", "username": "string" @@ -102,7 +103,7 @@ curl -X POST http://coder-server:8080/api/v2/users \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -360,7 +361,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -411,7 +412,7 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user} \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -821,7 +822,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/login-type \ ```json { - "login_type": "password" + "login_type": "" } ``` @@ -1005,7 +1006,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/profile \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1056,7 +1057,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/roles \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1117,7 +1118,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/roles \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1168,7 +1169,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/activate \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { @@ -1219,7 +1220,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/status/suspend \ "email": "user@example.com", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "password", + "login_type": "", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "roles": [ { diff --git a/docs/cli/users_create.md b/docs/cli/users_create.md index 2eb78318ffa0a..b89ff2aeb6d45 100644 --- a/docs/cli/users_create.md +++ b/docs/cli/users_create.md @@ -10,21 +10,21 @@ coder users create [flags] ## Options -### --disable-login +### -e, --email -| | | -| ---- | ----------------- | -| Type | bool | +| | | +| ---- | ------------------- | +| Type | string | -Disabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. Be careful when using this flag as it can lock the user out of their account. +Specifies an email address for the new user. -### -e, --email +### --login-type | | | | ---- | ------------------- | | Type | string | -Specifies an email address for the new user. +Optionally specify the login type for the user. Valid values are: password, none, github, oidc. Using 'none' prevents the user from authenticating and requires an API key/token to be generated by an admin. ### -p, --password diff --git a/scripts/develop.sh b/scripts/develop.sh index 671c46a0bd5cc..cc1ab23f0554e 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -131,7 +131,7 @@ fatal() { trap 'fatal "Script encountered an error"' ERR cdroot - start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "http://127.0.0.1:3000" --dangerous-allow-cors-requests=true --experiments "*,moons" "$@" + start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "http://127.0.0.1:3000" --dangerous-allow-cors-requests=true "$@" echo '== Waiting for Coder to become ready' # Start the timeout in the background so interrupting this script diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c1aa96d872994..019fc0f60beb0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -247,6 +247,7 @@ export interface CreateUserRequest { readonly email: string readonly username: string readonly password: string + readonly login_type: LoginType readonly disable_login: boolean readonly organization_id: string } @@ -1674,8 +1675,9 @@ export type LogSource = "provisioner" | "provisioner_daemon" export const LogSources: LogSource[] = ["provisioner", "provisioner_daemon"] // From codersdk/apikey.go -export type LoginType = "github" | "none" | "oidc" | "password" | "token" +export type LoginType = "" | "github" | "none" | "oidc" | "password" | "token" export const LoginTypes: LoginType[] = [ + "", "github", "none", "oidc", diff --git a/site/src/components/CreateUserForm/CreateUserForm.tsx b/site/src/components/CreateUserForm/CreateUserForm.tsx index c2f03155e7c62..6270f0ca88799 100644 --- a/site/src/components/CreateUserForm/CreateUserForm.tsx +++ b/site/src/components/CreateUserForm/CreateUserForm.tsx @@ -13,6 +13,7 @@ import { FullPageForm } from "../FullPageForm/FullPageForm" import { Stack } from "../Stack/Stack" import { ErrorAlert } from "components/Alert/ErrorAlert" import { hasApiFieldErrors, isApiError } from "api/errors" +import MenuItem from "@mui/material/MenuItem" export const Language = { emailLabel: "Email", @@ -31,6 +32,7 @@ export interface CreateUserFormProps { error?: unknown isLoading: boolean myOrgId: string + authMethods?: TypesGen.AuthMethods } const validationSchema = Yup.object({ @@ -38,13 +40,31 @@ const validationSchema = Yup.object({ .trim() .email(Language.emailInvalid) .required(Language.emailRequired), - password: Yup.string().required(Language.passwordRequired), + password: Yup.string().when("login_type", { + is: "password", + then: (schema) => schema.required(Language.passwordRequired), + otherwise: (schema) => schema, + }), username: nameValidator(Language.usernameLabel), }) +const authMethodSelect = ( + title: string, + value: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- future will use this + description: string, +) => { + return ( + + {title} + {/* TODO: Add description */} + + ) +} + export const CreateUserForm: FC< React.PropsWithChildren -> = ({ onSubmit, onCancel, error, isLoading, myOrgId }) => { +> = ({ onSubmit, onCancel, error, isLoading, myOrgId, authMethods }) => { const form: FormikContextType = useFormik({ initialValues: { @@ -53,6 +73,7 @@ export const CreateUserForm: FC< username: "", organization_id: myOrgId, disable_login: false, + login_type: "password", }, validationSchema, onSubmit, @@ -62,6 +83,42 @@ export const CreateUserForm: FC< error, ) + const methods = [] + if (authMethods?.password.enabled) { + methods.push( + authMethodSelect( + "Password", + "password", + "User can provide their email and password to login.", + ), + ) + } + if (authMethods?.oidc.enabled) { + methods.push( + authMethodSelect( + "OpenID Connect", + "oidc", + "Uses an OpenID connect provider to authenticate the user.", + ), + ) + } + if (authMethods?.github.enabled) { + methods.push( + authMethodSelect( + "Github", + "github", + "Uses github oauth to authenticate the user.", + ), + ) + } + methods.push( + authMethodSelect( + "None", + "none", + "User authentication is disabled. This user an only be used if an api token is created for them.", + ), + ) + return ( {isApiError(error) && !hasApiFieldErrors(error) && ( @@ -85,13 +142,39 @@ export const CreateUserForm: FC< label={Language.emailLabel} /> + { + if (e.target.value !== "password") { + await form.setFieldValue("password", "") + } + await form.setFieldValue("login_type", e.target.value) + }} + > + {methods} + diff --git a/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.test.tsx b/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.test.tsx index d342ace9bdf75..ceeb30528d4f3 100644 --- a/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.test.tsx +++ b/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.test.tsx @@ -21,7 +21,7 @@ const renderCreateUserPage = async () => { const fillForm = async ({ username = "someuser", email = "someone@coder.com", - password = "password", + password = "SomeSecurePassword!", }: { username?: string email?: string @@ -29,10 +29,15 @@ const fillForm = async ({ }) => { const usernameField = screen.getByLabelText(FormLanguage.usernameLabel) const emailField = screen.getByLabelText(FormLanguage.emailLabel) - const passwordField = screen.getByLabelText(FormLanguage.passwordLabel) + const passwordField = screen + .getByTestId("password-input") + .querySelector("input") + + const loginTypeField = screen.getByTestId("login-type-input") await userEvent.type(usernameField, username) await userEvent.type(emailField, email) - await userEvent.type(passwordField, password) + await userEvent.type(loginTypeField, "password") + await userEvent.type(passwordField as HTMLElement, password) const submitButton = await screen.findByText( FooterLanguage.defaultSubmitLabel, ) diff --git a/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx b/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx index cd905c39d88ae..cd92b6bc8141e 100644 --- a/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx +++ b/site/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx @@ -8,6 +8,8 @@ import * as TypesGen from "../../../api/typesGenerated" import { CreateUserForm } from "../../../components/CreateUserForm/CreateUserForm" import { Margins } from "../../../components/Margins/Margins" import { pageTitle } from "../../../utils/page" +import { getAuthMethods } from "api/api" +import { useQuery } from "@tanstack/react-query" export const Language = { unknownError: "Oops, an unknown error occurred.", @@ -25,6 +27,13 @@ export const CreateUserPage: FC = () => { }) const { error } = createUserState.context + // TODO: We should probably place this somewhere else to reduce the number of calls. + // This would be called each time this page is loaded. + const { data: authMethods } = useQuery({ + queryKey: ["authMethods"], + queryFn: getAuthMethods, + }) + return ( @@ -33,6 +42,7 @@ export const CreateUserPage: FC = () => { createUserSend({ type: "CREATE", user }) } From 4d8152d54324ef5374cfd2737723bb04e47d6876 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 11 Aug 2023 13:48:24 +0300 Subject: [PATCH 088/277] ci: run tests intelligently based on changes between consecutive commits (#9017) --- .github/workflows/ci.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8220492528b9a..8c3a3d441e2f1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,10 +2,6 @@ name: ci on: push: - branches: - - main - - pull_request: workflow_dispatch: permissions: @@ -47,6 +43,7 @@ jobs: uses: dorny/paths-filter@v2 id: filter with: + base: ${{ github.ref }} filters: | all: - "**" From 114ad4624e2408c19f87bdb7afd4210a785b966a Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 11 Aug 2023 13:49:23 +0300 Subject: [PATCH 089/277] ci: upgrade pr deployments workflow (#8924) --- .github/pr-deployments/certificate.yaml | 13 + .github/pr-deployments/rbac.yaml | 31 +++ .github/pr-deployments/template/main.tf | 313 +++++++++++++++++++++ .github/pr-deployments/values.yaml | 38 +++ .github/workflows/pr-deploy.yaml | 345 +++++++++++------------- docs/CONTRIBUTING.md | 5 +- docs/images/deploy-pr-manually.png | Bin 0 -> 13583 bytes docs/images/pr-deploy-manual.png | Bin 27155 -> 0 bytes scripts/deploy-pr.sh | 35 ++- 9 files changed, 574 insertions(+), 206 deletions(-) create mode 100644 .github/pr-deployments/certificate.yaml create mode 100644 .github/pr-deployments/rbac.yaml create mode 100644 .github/pr-deployments/template/main.tf create mode 100644 .github/pr-deployments/values.yaml create mode 100644 docs/images/deploy-pr-manually.png delete mode 100644 docs/images/pr-deploy-manual.png diff --git a/.github/pr-deployments/certificate.yaml b/.github/pr-deployments/certificate.yaml new file mode 100644 index 0000000000000..cf441a98bbc88 --- /dev/null +++ b/.github/pr-deployments/certificate.yaml @@ -0,0 +1,13 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: pr${PR_NUMBER}-tls + namespace: pr-deployment-certs +spec: + secretName: pr${PR_NUMBER}-tls + issuerRef: + name: letsencrypt + kind: ClusterIssuer + dnsNames: + - "${PR_HOSTNAME}" + - "*.${PR_HOSTNAME}" diff --git a/.github/pr-deployments/rbac.yaml b/.github/pr-deployments/rbac.yaml new file mode 100644 index 0000000000000..0d37cae7daebe --- /dev/null +++ b/.github/pr-deployments/rbac.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: coder-workspace-pr${PR_NUMBER} + namespace: pr${PR_NUMBER} + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: coder-workspace-pr${PR_NUMBER} + namespace: pr${PR_NUMBER} +rules: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["*"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: coder-workspace-pr${PR_NUMBER} + namespace: pr${PR_NUMBER} +subjects: + - kind: ServiceAccount + name: coder-workspace-pr${PR_NUMBER} + namespace: pr${PR_NUMBER} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: coder-workspace-pr${PR_NUMBER} diff --git a/.github/pr-deployments/template/main.tf b/.github/pr-deployments/template/main.tf new file mode 100644 index 0000000000000..bef767547b2a0 --- /dev/null +++ b/.github/pr-deployments/template/main.tf @@ -0,0 +1,313 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 0.11.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.22" + } + } +} + +provider "coder" { +} + +variable "namespace" { + type = string + description = "The Kubernetes namespace to create workspaces in (must exist prior to creating workspaces)" +} + +data "coder_parameter" "cpu" { + name = "cpu" + display_name = "CPU" + description = "The number of CPU cores" + default = "2" + icon = "/icon/memory.svg" + mutable = true + option { + name = "2 Cores" + value = "2" + } + option { + name = "4 Cores" + value = "4" + } + option { + name = "6 Cores" + value = "6" + } + option { + name = "8 Cores" + value = "8" + } +} + +data "coder_parameter" "memory" { + name = "memory" + display_name = "Memory" + description = "The amount of memory in GB" + default = "2" + icon = "/icon/memory.svg" + mutable = true + option { + name = "2 GB" + value = "2" + } + option { + name = "4 GB" + value = "4" + } + option { + name = "6 GB" + value = "6" + } + option { + name = "8 GB" + value = "8" + } +} + +data "coder_parameter" "home_disk_size" { + name = "home_disk_size" + display_name = "Home disk size" + description = "The size of the home disk in GB" + default = "10" + type = "number" + icon = "/emojis/1f4be.png" + mutable = false + validation { + min = 1 + max = 99999 + } +} + +provider "kubernetes" { + config_path = null +} + +data "coder_workspace" "me" {} + +resource "coder_agent" "main" { + os = "linux" + arch = "amd64" + startup_script_timeout = 180 + startup_script = <<-EOT + set -e + + # install and start code-server + curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/tmp/code-server + /tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 & + + EOT + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + # For basic resources, you can use the `coder stat` command. + # If you need more control, you can write your own script. + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "4_cpu_usage_host" + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Memory Usage (Host)" + key = "5_mem_usage_host" + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Load Average (Host)" + key = "6_load_host" + # get load avg scaled by number of cores + script = <> $GITHUB_OUTPUT + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + get_info: - if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' + needs: check_pr + if: ${{ needs.check_pr.outputs.PR_OPEN == 'true' }} outputs: PR_NUMBER: ${{ steps.pr_info.outputs.PR_NUMBER }} PR_TITLE: ${{ steps.pr_info.outputs.PR_TITLE }} PR_URL: ${{ steps.pr_info.outputs.PR_URL }} - PR_BRANCH: ${{ steps.pr_info.outputs.PR_BRANCH }} CODER_BASE_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_BASE_IMAGE_TAG }} CODER_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_IMAGE_TAG }} - NEW: ${{ steps.check_deployment.outputs.new }} - BUILD: ${{ steps.filter.outputs.all_count > steps.filter.outputs.ignored_count || steps.check_deployment.outputs.new }} + NEW: ${{ steps.check_deployment.outputs.NEW }} + BUILD: ${{ steps.filter.outputs.all_count > steps.filter.outputs.ignored_count || steps.check_deployment.outputs.NEW }} runs-on: "ubuntu-latest" steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Get PR number, title, and branch name id: pr_info run: | - set -euxo pipefail - PR_NUMBER=${{ github.event.inputs.pr_number || github.event.pull_request.number }} - PR_TITLE=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/coder/coder/pulls/$PR_NUMBER | jq -r '.title') - PR_BRANCH=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/coder/coder/pulls/$PR_NUMBER | jq -r '.head.ref') - echo "PR_URL=https://github.com/coder/coder/pull/$PR_NUMBER" >> $GITHUB_OUTPUT + set -euo pipefail + PR_NUMBER=$(gh pr view --json number | jq -r '.number') + PR_TITLE=$(gh pr view --json title | jq -r '.title') + PR_URL=$(gh pr view --json url | jq -r '.url') + echo "PR_URL=$PR_URL" >> $GITHUB_OUTPUT echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_OUTPUT echo "PR_TITLE=$PR_TITLE" >> $GITHUB_OUTPUT - echo "PR_BRANCH=$PR_BRANCH" >> $GITHUB_OUTPUT + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set required tags id: set_tags run: | - set -euxo pipefail + set -euo pipefail echo "CODER_BASE_IMAGE_TAG=$CODER_BASE_IMAGE_TAG" >> $GITHUB_OUTPUT echo "CODER_IMAGE_TAG=$CODER_IMAGE_TAG" >> $GITHUB_OUTPUT env: @@ -74,7 +105,7 @@ jobs: - name: Set up kubeconfig run: | - set -euxo pipefail + set -euo pipefail mkdir -p ~/.kube echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config export KUBECONFIG=~/.kube/config @@ -82,53 +113,21 @@ jobs: - name: Check if the helm deployment already exists id: check_deployment run: | - set -euxo pipefail + set -euo pipefail if helm status "pr${{ steps.pr_info.outputs.PR_NUMBER }}" --namespace "pr${{ steps.pr_info.outputs.PR_NUMBER }}" > /dev/null 2>&1; then echo "Deployment already exists. Skipping deployment." - new=false + NEW=false else echo "Deployment doesn't exist." - new=true + NEW=true fi - echo "new=$new" >> $GITHUB_OUTPUT - - - name: Find Comment - uses: peter-evans/find-comment@v2 - if: github.event_name == 'workflow_dispatch' || steps.check_deployment.outputs.NEW == 'false' - id: fc - with: - issue-number: ${{ steps.pr_info.outputs.PR_NUMBER }} - comment-author: "github-actions[bot]" - body-includes: ":rocket:" - direction: last - - - name: Comment on PR - id: comment_id - if: github.event_name == 'workflow_dispatch' || steps.check_deployment.outputs.NEW == 'false' - uses: peter-evans/create-or-update-comment@v3 - with: - comment-id: ${{ steps.fc.outputs.comment-id }} - issue-number: ${{ steps.pr_info.outputs.PR_NUMBER }} - edit-mode: replace - body: | - --- - :rocket: Deploying PR ${{ steps.pr_info.outputs.PR_NUMBER }} ... - --- - reactions: eyes - reactions-edit-mode: replace - - - name: Checkout - if: github.event_name == 'workflow_dispatch' || steps.check_deployment.outputs.NEW == 'false' - uses: actions/checkout@v3 - with: - ref: ${{ steps.pr_info.outputs.PR_BRANCH }} - fetch-depth: 0 + echo "NEW=$NEW" >> $GITHUB_OUTPUT - name: Check changed files - if: github.event_name == 'workflow_dispatch' || steps.check_deployment.outputs.NEW == 'false' uses: dorny/paths-filter@v2 id: filter with: + base: ${{ github.ref }} filters: | all: - "**" @@ -149,47 +148,76 @@ jobs: - "scripts/**/*[^D][^o][^c][^k][^e][^r][^f][^i][^l][^e][.][b][^a][^s][^e]*" - name: Print number of changed files - if: github.event_name == 'workflow_dispatch' || steps.check_deployment.outputs.NEW == 'false' run: | - set -euxo pipefail + set -euo pipefail echo "Total number of changed files: ${{ steps.filter.outputs.all_count }}" echo "Number of ignored files: ${{ steps.filter.outputs.ignored_count }}" + - name: Print job outputs + run: | + set -euo pipefail + # Print all outputs of this job + echo "PR_NUMBER=${{ steps.pr_info.outputs.PR_NUMBER }}" + echo "PR_TITLE=${{ steps.pr_info.outputs.PR_TITLE }}" + echo "PR_URL=${{ steps.pr_info.outputs.PR_URL }}" + echo "CODER_BASE_IMAGE_TAG=${{ steps.set_tags.outputs.CODER_BASE_IMAGE_TAG }}" + echo "CODER_IMAGE_TAG=${{ steps.set_tags.outputs.CODER_IMAGE_TAG }}" + echo "NEW=${{ steps.check_deployment.outputs.NEW }}" + echo "BUILD=${{ steps.filter.outputs.all_count > steps.filter.outputs.ignored_count || steps.check_deployment.outputs.NEW || github.event.inputs.build == 'true' }}" + echo "GITHUB_REF=${{ github.ref }}" + + comment-pr: + needs: [check_pr, get_info] + if: ${{ needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true' || github.event.inputs.build == 'true' }} + runs-on: "ubuntu-latest" + steps: + - name: Find Comment + uses: peter-evans/find-comment@v2 + id: fc + with: + issue-number: ${{ needs.get_info.outputs.PR_NUMBER }} + comment-author: "github-actions[bot]" + body-includes: ":rocket:" + direction: last + + - name: Comment on PR + id: comment_id + uses: peter-evans/create-or-update-comment@v3 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + issue-number: ${{ needs.get_info.outputs.PR_NUMBER }} + edit-mode: replace + body: | + --- + :rocket: Deploying PR ${{ needs.get_info.outputs.PR_NUMBER }} ... + --- + reactions: eyes + reactions-edit-mode: replace + build: needs: get_info - # Skips the build job if the workflow was triggered by a workflow_dispatch event and the skip_build input is set to true - # or if the workflow was triggered by an issue_comment event and the comment body contains --skip-build - # always run the build job if a pull_request event triggered the workflow - if: | - (github.event_name == 'workflow_dispatch' && github.event.inputs.skip_build == 'false') || - (github.event_name == 'pull_request' && needs.get_info.result == 'success' && needs.get_info.outputs.NEW == 'false') + # Run build job only if there are changes in the files that we care about or if the workflow is manually triggered with --build flag + if: needs.get_info.outputs.BUILD == 'true' runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }} env: DOCKER_CLI_EXPERIMENTAL: "enabled" CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }} - PR_NUMBER: ${{ needs.get_info.outputs.PR_NUMBER }} - PR_BRANCH: ${{ needs.get_info.outputs.PR_BRANCH }} steps: - name: Checkout uses: actions/checkout@v3 with: - ref: ${{ env.PR_BRANCH }} fetch-depth: 0 - name: Setup Node - if: needs.get_info.outputs.BUILD == 'true' uses: ./.github/actions/setup-node - name: Setup Go - if: needs.get_info.outputs.BUILD == 'true' uses: ./.github/actions/setup-go - name: Setup sqlc - if: needs.get_info.outputs.BUILD == 'true' uses: ./.github/actions/setup-sqlc - name: GHCR Login - if: needs.get_info.outputs.BUILD == 'true' uses: docker/login-action@v2 with: registry: ghcr.io @@ -197,9 +225,8 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Linux amd64 Docker image - if: needs.get_info.outputs.BUILD == 'true' run: | - set -euxo pipefail + set -euo pipefail go mod download make gen/mark-fresh export DOCKER_IMAGE_NO_PREREQUISITES=true @@ -217,35 +244,42 @@ jobs: needs: [build, get_info] # Run deploy job only if build job was successful or skipped if: | - always() && (needs.build.result == 'success' || needs.build.result == 'skipped') && - (github.event_name == 'workflow_dispatch' || needs.get_info.outputs.NEW == 'false') + always() && (needs.build.result == 'success' || needs.build.result == 'skipped') && + (needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true') runs-on: "ubuntu-latest" env: CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }} PR_NUMBER: ${{ needs.get_info.outputs.PR_NUMBER }} PR_TITLE: ${{ needs.get_info.outputs.PR_TITLE }} PR_URL: ${{ needs.get_info.outputs.PR_URL }} - PR_BRANCH: ${{ needs.get_info.outputs.PR_BRANCH }} - PR_DEPLOYMENT_ACCESS_URL: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}" + PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}" steps: - name: Set up kubeconfig run: | - set -euxo pipefail + set -euo pipefail mkdir -p ~/.kube echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config export KUBECONFIG=~/.kube/config - name: Check if image exists - if: needs.get_info.outputs.NEW == 'true' run: | - set -euxo pipefail - foundTag=$(curl -fsSL https://github.com/coder/coder/pkgs/container/coder-preview | grep -o ${{ env.CODER_IMAGE_TAG }} | head -n 1) + set -euo pipefail + foundTag=$( + gh api /orgs/coder/packages/container/coder-preview/versions | + jq -r --arg tag "pr${{ env.PR_NUMBER }}" '.[] | + select(.metadata.container.tags == [$tag]) | + .metadata.container.tags[0]' + ) if [ -z "$foundTag" ]; then echo "Image not found" echo "${{ env.CODER_IMAGE_TAG }} not found in ghcr.io/coder/coder-preview" - echo "Please remove --skip-build from the comment and try again" exit 1 + else + echo "Image found" + echo "$foundTag tag found in ghcr.io/coder/coder-preview" fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Add DNS record to Cloudflare if: needs.get_info.outputs.NEW == 'true' @@ -253,43 +287,27 @@ jobs: curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.PR_DEPLOYMENTS_ZONE_ID }}/dns_records" \ -H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \ -H "Content-Type:application/json" \ - --data '{"type":"CNAME","name":"*.${{ env.PR_DEPLOYMENT_ACCESS_URL }}","content":"${{ env.PR_DEPLOYMENT_ACCESS_URL }}","ttl":1,"proxied":false}' - - - name: Checkout - uses: actions/checkout@v3 - with: - ref: ${{ env.PR_BRANCH }} + --data '{"type":"CNAME","name":"*.${{ env.PR_HOSTNAME }}","content":"${{ env.PR_HOSTNAME }}","ttl":1,"proxied":false}' - name: Create PR namespace - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | - set -euxo pipefail + set -euo pipefail # try to delete the namespace, but don't fail if it doesn't exist kubectl delete namespace "pr${{ env.PR_NUMBER }}" || true kubectl create namespace "pr${{ env.PR_NUMBER }}" + - name: Checkout + uses: actions/checkout@v3 + - name: Check and Create Certificate - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | # Using kubectl to check if a Certificate resource already exists # we are doing this to avoid letsenrypt rate limits if ! kubectl get certificate pr${{ env.PR_NUMBER }}-tls -n pr-deployment-certs > /dev/null 2>&1; then echo "Certificate doesn't exist. Creating a new one." - cat < pr-deploy-values.yaml - coder: - image: - repo: ${{ env.REPO }} - tag: pr${{ env.PR_NUMBER }} - pullPolicy: Always - service: - type: ClusterIP - ingress: - enable: true - className: traefik - host: ${{ env.PR_DEPLOYMENT_ACCESS_URL }} - wildcardHost: "*.${{ env.PR_DEPLOYMENT_ACCESS_URL }}" - tls: - enable: true - secretName: pr${{ env.PR_NUMBER }}-tls - wildcardSecretName: pr${{ env.PR_NUMBER }}-tls - env: - - name: "CODER_ACCESS_URL" - value: "https://${{ env.PR_DEPLOYMENT_ACCESS_URL }}" - - name: "CODER_WILDCARD_ACCESS_URL" - value: "*.${{ env.PR_DEPLOYMENT_ACCESS_URL }}" - - name: "CODER_EXPERIMENTS" - value: "${{ github.event.inputs.experiments }}" - - name: CODER_PG_CONNECTION_URL - valueFrom: - secretKeyRef: - name: coder-db-url - key: url - - name: "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS" - value: "true" - - name: "CODER_OAUTH2_GITHUB_CLIENT_ID" - value: "${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID }}" - - name: "CODER_OAUTH2_GITHUB_CLIENT_SECRET" - value: "${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET }}" - - name: "CODER_OAUTH2_GITHUB_ALLOWED_ORGS" - value: "coder" - EOF + set -euo pipefail + envsubst < ./.github/pr-deployments/values.yaml > ./pr-deploy-values.yaml - name: Install/Upgrade Helm chart run: | - set -euxo pipefail - if [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then - helm upgrade --install "pr${{ env.PR_NUMBER }}" ./helm \ - --namespace "pr${{ env.PR_NUMBER }}" \ - --values ./pr-deploy-values.yaml \ - --force - else - if [[ ${{ needs.get_info.outputs.BUILD }} == "true" ]]; then - helm upgrade --install "pr${{ env.PR_NUMBER }}" ./helm \ - --namespace "pr${{ env.PR_NUMBER }}" \ - --reuse-values \ - --force - else - echo "Skipping helm upgrade, as there is no new image to deploy" - fi - fi + set -euo pipefail + helm upgrade --install "pr${{ env.PR_NUMBER }}" ./helm/coder \ + --namespace "pr${{ env.PR_NUMBER }}" \ + --values ./pr-deploy-values.yaml \ + --force - name: Install coder-logstream-kube - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | helm repo add coder-logstream-kube https://helm.coder.com/logstream-kube helm upgrade --install coder-logstream-kube coder-logstream-kube/coder-logstream-kube \ --namespace "pr${{ env.PR_NUMBER }}" \ - --set url="https://pr${{ env.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}" + --set url="https://${{ env.PR_HOSTNAME }}" - name: Get Coder binary - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | - set -euxo pipefail + set -euo pipefail DEST="${HOME}/coder" - URL="https://${{ env.PR_DEPLOYMENT_ACCESS_URL }}/bin/coder-linux-amd64" + URL="https://${{ env.PR_HOSTNAME }}/bin/coder-linux-amd64" mkdir -p "$(dirname ${DEST})" @@ -414,10 +395,10 @@ jobs: mv "${DEST}" /usr/local/bin/coder - name: Create first user, template and workspace - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' id: setup_deployment run: | - set -euxo pipefail + set -euo pipefail # Create first user @@ -429,28 +410,24 @@ jobs: echo "password=$password" >> $GITHUB_OUTPUT coder login \ - --first-user-username test \ + --first-user-username coder \ --first-user-email pr${{ env.PR_NUMBER }}@coder.com \ --first-user-password $password \ --first-user-trial \ --use-token-as-session \ - https://${{ env.PR_DEPLOYMENT_ACCESS_URL }} + https://${{ env.PR_HOSTNAME }} # Create template - coder templates init --id kubernetes && cd ./kubernetes/ && coder templates create -y --variable namespace=pr${{ env.PR_NUMBER }} + cd ./.github/pr-deployments/template + terraform init + coder templates create -y --variable namespace=pr${{ env.PR_NUMBER }} kubernetes # Create workspace - cat < workspace.yaml - cpu: "2" - memory: "4" - home_disk_size: "2" - EOF - - coder create --template="kubernetes" test --rich-parameter-file ./workspace.yaml -y - coder stop test -y + coder create --template="kubernetes" kube --parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y + coder stop kube -y - name: Send Slack notification - if: needs.get_info.outputs.NEW == 'true' + if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true' run: | curl -s -o /dev/null -X POST -H 'Content-type: application/json' \ -d \ @@ -458,7 +435,7 @@ jobs: "pr_number": "'"${{ env.PR_NUMBER }}"'", "pr_url": "'"${{ env.PR_URL }}"'", "pr_title": "'"${{ env.PR_TITLE }}"'", - "pr_access_url": "'"https://${{ env.PR_DEPLOYMENT_ACCESS_URL }}"'", + "pr_access_url": "'"https://${{ env.PR_HOSTNAME }}"'", "pr_username": "'"test"'", "pr_email": "'"pr${{ env.PR_NUMBER }}@coder.com"'", "pr_password": "'"${{ steps.setup_deployment.outputs.password }}"'", diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 611faf48400c9..291f4e1444e4b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -84,11 +84,12 @@ You can test your changes by creating a PR deployment. There are two ways to do 1. By running `./scripts/deploy-pr.sh` 2. By manually triggering the [`pr-deploy.yaml`](https://github.com/coder/coder/actions/workflows/pr-deploy.yaml) GitHub Action workflow - ![Deploy PR manually](./images/pr-deploy-manual.png) + ![Deploy PR manually](./images/deploy-pr-manually.png) #### Available options -- `-s` or `--skip-build`, force prevents the build of the Docker image.(generally not needed as we are intelligently checking if the image needs to be built) +- `-d` or `--deploy`, force deploys the PR by deleting the existing deployment. +- `-b` or `--build`, force builds the Docker image. (generally not needed as we are intelligently checking if the image needs to be built) - `-e EXPERIMENT1,EXPERIMENT2` or `--experiments EXPERIMENT1,EXPERIMENT2`, will enable the specified experiments. (defaults to `*`) - `-n` or `--dry-run` will display the context without deployment. e.g., branch name and PR number, etc. - `-y` or `--yes`, will skip the CLI confirmation prompt. diff --git a/docs/images/deploy-pr-manually.png b/docs/images/deploy-pr-manually.png new file mode 100644 index 0000000000000000000000000000000000000000..718d00c65d1cced5f5defe15e4d07aefc4b6f16c GIT binary patch literal 13583 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 literal 0 HcmV?d00001 diff --git a/docs/images/pr-deploy-manual.png b/docs/images/pr-deploy-manual.png deleted file mode 100644 index eab92cc2249e75e08119bc1d1e4897545f6f75e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27155 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] [--build -b] [--deploy -d] # deploys the current branch to a PR environment and posts login credentials to # [#pr-deployments](https://codercom.slack.com/archives/C05DNE982E8) Slack channel set -euo pipefail # default settings -skipBuild=false dryRun=false confirm=true +build=false +deploy=false experiments="" # parse arguments while (("$#")); do case "$1" in - -s | --skip-build) - skipBuild=true + -b | --build) + build=true + shift + ;; + -d | --deploy) + deploy=true shift ;; -n | --dry-run) @@ -63,30 +68,20 @@ fi branchName=$(gh pr view --json headRefName | jq -r .headRefName) prNumber=$(gh pr view --json number | jq -r .number) -if $skipBuild; then - #check if the image exists - foundTag=$(curl -fsSL https://github.com/coder/coder/pkgs/container/coder-preview | grep -o "$prNumber" | head -n 1) || true - echo "foundTag is: '${foundTag}'" - if [[ -z "${foundTag}" ]]; then - echo "Image not found" - echo "${prNumber} tag not found in ghcr.io/coder/coder-preview" - echo "Please remove --skip-build and try again" - exit 1 - fi -fi - -if $dryRun; then +if [[ "$dryRun" = true ]]; then echo "dry run" echo "branchName: ${branchName}" echo "prNumber: ${prNumber}" - echo "skipBuild: ${skipBuild}" echo "experiments: ${experiments}" + echo "build: ${build}" + echo "deploy: ${deploy}" exit 0 fi echo "branchName: ${branchName}" echo "prNumber: ${prNumber}" -echo "skipBuild: ${skipBuild}" echo "experiments: ${experiments}" +echo "build: ${build}" +echo "deploy: ${deploy}" -gh workflow run pr-deploy.yaml --ref "${branchName}" -f "pr_number=${prNumber}" -f "skip_build=${skipBuild}" -f "experiments=${experiments}" +gh workflow run pr-deploy.yaml --ref "${branchName}" -f "pr_number=${prNumber}" -f "experiments=${experiments}" -f "build=${build}" -f "deploy=${deploy}" From 7fb9197860b72aaa4004ef6a06ef8c4d51499e94 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 11 Aug 2023 13:54:55 +0300 Subject: [PATCH 090/277] ci: do not run deploy-pr on main (#9046) --- .github/workflows/pr-deploy.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml index 9cae6f7f3520e..42146da3a21b9 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -5,6 +5,8 @@ name: Deploy PR on: push: + branches-ignore: + - main workflow_dispatch: inputs: pr_number: From a13c8c88d5276dc582dc752f1f9500e1a4c05d0a Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 11 Aug 2023 15:32:42 +0300 Subject: [PATCH 091/277] fix: prevent unrequested PR deployments (#9049) --- .github/workflows/pr-deploy.yaml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml index 42146da3a21b9..8b830ee73e361 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -73,7 +73,14 @@ jobs: CODER_BASE_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_BASE_IMAGE_TAG }} CODER_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_IMAGE_TAG }} NEW: ${{ steps.check_deployment.outputs.NEW }} - BUILD: ${{ steps.filter.outputs.all_count > steps.filter.outputs.ignored_count || steps.check_deployment.outputs.NEW }} + BUILD: | + ${{ + (steps.filter.outputs.all_count > steps.filter.outputs.ignored_count) && + ( + (github.event_name == 'push' && steps.check_deployment.outputs.NEW == 'false') || + github.event.inputs.build == 'true' + ) + }} runs-on: "ubuntu-latest" steps: @@ -165,12 +172,12 @@ jobs: echo "CODER_BASE_IMAGE_TAG=${{ steps.set_tags.outputs.CODER_BASE_IMAGE_TAG }}" echo "CODER_IMAGE_TAG=${{ steps.set_tags.outputs.CODER_IMAGE_TAG }}" echo "NEW=${{ steps.check_deployment.outputs.NEW }}" - echo "BUILD=${{ steps.filter.outputs.all_count > steps.filter.outputs.ignored_count || steps.check_deployment.outputs.NEW || github.event.inputs.build == 'true' }}" + echo "BUILD=${{ (steps.filter.outputs.all_count > steps.filter.outputs.ignored_count) && ((github.event_name == 'push' && steps.check_deployment.outputs.NEW == 'false') || github.event.inputs.build == 'true') }}" echo "GITHUB_REF=${{ github.ref }}" comment-pr: needs: [check_pr, get_info] - if: ${{ needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true' || github.event.inputs.build == 'true' }} + if: needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true' runs-on: "ubuntu-latest" steps: - name: Find Comment From 59fd4e86c9f2ddee4a313aad176c85b0e8f1babb Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 11 Aug 2023 15:43:37 +0300 Subject: [PATCH 092/277] ci: remove deleting comments section from pr-cleanup.yaml (#9047) --- .github/workflows/pr-cleanup.yaml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/pr-cleanup.yaml b/.github/workflows/pr-cleanup.yaml index bb4f7146c7025..510c8f4299361 100644 --- a/.github/workflows/pr-cleanup.yaml +++ b/.github/workflows/pr-cleanup.yaml @@ -71,10 +71,3 @@ jobs: run: | set -euxo pipefail kubectl delete certificate "pr${{ steps.pr_number.outputs.PR_NUMBER }}-tls" -n pr-deployment-certs || echo "certificate not found" - - - name: Delete PR Comments - uses: izhangzhihao/delete-comment@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - delete_user_name: github-actions[bot] - issue_number: ${{ github.event.number }} From 1c7bd57da8f592a25dbd76dc5eef984a5e7d568a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 11 Aug 2023 08:26:01 -0500 Subject: [PATCH 093/277] chore: clarify region selection behavior (#9021) * chore: clarify region selection behavior * Update site/src/components/Navbar/NavbarView.tsx Co-authored-by: Kyle Carberry --------- Co-authored-by: Kyle Carberry --- site/src/components/Navbar/NavbarView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/components/Navbar/NavbarView.tsx b/site/src/components/Navbar/NavbarView.tsx index 6bc5777b95064..e90c23e7654e0 100644 --- a/site/src/components/Navbar/NavbarView.tsx +++ b/site/src/components/Navbar/NavbarView.tsx @@ -307,7 +307,9 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({ }} > Workspace proxies improve terminal and web app connections to - workspaces. This does not apply to SSH connections. + workspaces. This does not apply to CLI connections. A region must be + manually selected, otherwise the default primary region will be + used. theme.palette.divider }} /> From 47ca84be47f13a86abeca1f220895c467d969a6b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 11 Aug 2023 09:02:19 -0500 Subject: [PATCH 094/277] chore: return queried user on failure to help debug (#9051) --- coderd/httpmw/userparam.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/coderd/httpmw/userparam.go b/coderd/httpmw/userparam.go index f565687e00bdd..b895d17aafb6c 100644 --- a/coderd/httpmw/userparam.go +++ b/coderd/httpmw/userparam.go @@ -2,6 +2,7 @@ package httpmw import ( "context" + "fmt" "net/http" "github.com/go-chi/chi/v5" @@ -85,6 +86,7 @@ func ExtractUserParam(db database.Store, redirectToLoginOnMe bool) func(http.Han if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: userErrorMessage, + Detail: fmt.Sprintf("queried user=%q", userQuery), }) return } @@ -96,6 +98,7 @@ func ExtractUserParam(db database.Store, redirectToLoginOnMe bool) func(http.Han if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: userErrorMessage, + Detail: fmt.Sprintf("queried user=%q", userQuery), }) return } From 320de18be7116cccafe1036e485df9b25a24439a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 11 Aug 2023 09:25:05 -0500 Subject: [PATCH 095/277] fix: correct github oauth2 callback url (#9052) * fix: correct github oauth2 callback url --- .../SecurityPage/SecurityPage.test.tsx | 1 + .../SecurityPage/SingleSignOnSection.tsx | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx index d5ef50fad2ba5..5b5fdd49ba4f2 100644 --- a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx +++ b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.test.tsx @@ -136,6 +136,7 @@ test("change login type to OIDC", async () => { jest.spyOn(SSO, "redirectToOIDCAuth").mockImplementation(() => { // Does a noop + return "" }) const ssoSection = screen.getByTestId("sso-section") diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SingleSignOnSection.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SingleSignOnSection.tsx index 6d793d2e2546d..66830b793e38d 100644 --- a/site/src/pages/UserSettingsPage/SecurityPage/SingleSignOnSection.tsx +++ b/site/src/pages/UserSettingsPage/SecurityPage/SingleSignOnSection.tsx @@ -30,7 +30,16 @@ export const redirectToOIDCAuth = ( stateString: string, redirectTo: string, ) => { - window.location.href = `/api/v2/users/${toType}/callback?oidc_merge_state=${stateString}&redirect=${redirectTo}` + switch (toType) { + case "github": + window.location.href = `/api/v2/users/oauth2/github/callback?oidc_merge_state=${stateString}&redirect=${redirectTo}` + break + case "oidc": + window.location.href = `/api/v2/users/oidc/callback?oidc_merge_state=${stateString}&redirect=${redirectTo}` + break + default: + throw new Error(`Unknown login type ${toType}`) + } } export const useSingleSignOnSection = () => { From d2a9049fd71afd874ec96333fe8e599227961a65 Mon Sep 17 00:00:00 2001 From: Eric Paulsen Date: Fri, 11 Aug 2023 15:56:35 -0400 Subject: [PATCH 096/277] docs: add offline docs for JetBrains Gateway (#9039) --- docs/ides/gateway.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/ides/gateway.md b/docs/ides/gateway.md index b0550c378b668..45c9539c80456 100644 --- a/docs/ides/gateway.md +++ b/docs/ides/gateway.md @@ -179,3 +179,9 @@ cd /opt/idea/bin [Here is the JetBrains article](https://www.jetbrains.com/help/idea/remote-development-troubleshooting.html#setup:~:text=Can%20I%20point%20Remote%20Development%20to%20an%20existing%20IDE%20on%20my%20remote%20server%3F%20Is%20it%20possible%20to%20install%20IDE%20manually%3F) explaining this IDE specification. + +## JetBrains Gateway in an offline environment + +In networks that restrict access to the internet, you will need to leverage the +JetBrains Client Installer to download and save the IDE clients locally. Please +see the [JetBrains documentation for more information](https://www.jetbrains.com/help/idea/fully-offline-mode.html). From 6af6e85fe39f0f5b3e4214fd3479fa0482d76b2f Mon Sep 17 00:00:00 2001 From: Eric Paulsen Date: Fri, 11 Aug 2023 16:55:55 -0400 Subject: [PATCH 097/277] docs: add coder login to CI docs (#9038) * docs: add coder login to CI docs * add CODER_URL * add --url flag to login cmd --- docs/templates/change-management.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/templates/change-management.md b/docs/templates/change-management.md index a1add78ec64f8..f2781d9ee0711 100644 --- a/docs/templates/change-management.md +++ b/docs/templates/change-management.md @@ -20,6 +20,7 @@ export CODER_TEMPLATE_DIR=.coder/templates/kubernetes export CODER_TEMPLATE_VERSION=$(git rev-parse --short HEAD) # Push the new template version to Coder +coder login --url $CODER_URL --token $CODER_SESSION_TOKEN coder templates push --yes $CODER_TEMPLATE_NAME \ --directory $CODER_TEMPLATE_DIR \ --name=$CODER_TEMPLATE_VERSION # Version name is optional From 984f7ce045b4f2160690b9acf5c5a175a71cc292 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Sun, 13 Aug 2023 11:18:17 +0300 Subject: [PATCH 098/277] fix: update BUILD condition in pr-deploy.yaml (#9064) This makes the build condition more understandable and fixes an issue where we could not deploy a new PR as the build condition was constantly evaluating false. --- .github/workflows/pr-deploy.yaml | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/.github/workflows/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml index 8b830ee73e361..4dd2446dcc9aa 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -73,14 +73,7 @@ jobs: CODER_BASE_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_BASE_IMAGE_TAG }} CODER_IMAGE_TAG: ${{ steps.set_tags.outputs.CODER_IMAGE_TAG }} NEW: ${{ steps.check_deployment.outputs.NEW }} - BUILD: | - ${{ - (steps.filter.outputs.all_count > steps.filter.outputs.ignored_count) && - ( - (github.event_name == 'push' && steps.check_deployment.outputs.NEW == 'false') || - github.event.inputs.build == 'true' - ) - }} + BUILD: ${{ steps.build_conditionals.outputs.first_or_force_build || steps.build_conditionals.outputs.automatic_rebuild }} runs-on: "ubuntu-latest" steps: @@ -162,18 +155,14 @@ jobs: echo "Total number of changed files: ${{ steps.filter.outputs.all_count }}" echo "Number of ignored files: ${{ steps.filter.outputs.ignored_count }}" - - name: Print job outputs + - name: Build conditionals + id: build_conditionals run: | set -euo pipefail - # Print all outputs of this job - echo "PR_NUMBER=${{ steps.pr_info.outputs.PR_NUMBER }}" - echo "PR_TITLE=${{ steps.pr_info.outputs.PR_TITLE }}" - echo "PR_URL=${{ steps.pr_info.outputs.PR_URL }}" - echo "CODER_BASE_IMAGE_TAG=${{ steps.set_tags.outputs.CODER_BASE_IMAGE_TAG }}" - echo "CODER_IMAGE_TAG=${{ steps.set_tags.outputs.CODER_IMAGE_TAG }}" - echo "NEW=${{ steps.check_deployment.outputs.NEW }}" - echo "BUILD=${{ (steps.filter.outputs.all_count > steps.filter.outputs.ignored_count) && ((github.event_name == 'push' && steps.check_deployment.outputs.NEW == 'false') || github.event.inputs.build == 'true') }}" - echo "GITHUB_REF=${{ github.ref }}" + # build if the workflow is manually triggered and the deployment doesn't exist (first build or force rebuild) + echo "first_or_force_build=${{ (github.event_name == 'workflow_dispatch' && steps.check_deployment.outputs.NEW == 'true') || github.event.inputs.build == 'true' }}" >> $GITHUB_OUTPUT + # build if the deployment alreday exist and there are changes in the files that we care about (automatic updates) + echo "automatic_rebuild=${{ steps.check_deployment.outputs.NEW == 'false' && steps.filter.outputs.all_count > steps.filter.outputs.ignored_count }}" >> $GITHUB_OUTPUT comment-pr: needs: [check_pr, get_info] From abe17b1164fa76e45c813b58036f532aad126089 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 13 Aug 2023 10:10:58 -0500 Subject: [PATCH 099/277] chore: update speakeasy to fix stty path bug on nixos (#9022) Prompts failed on NixOS due to /bin/stty being hardcoded for turning off echo in the terminal prompt. See: https://github.com/bgentry/speakeasy/commit/760eaf8b681647364e7a400b856e0921248728a5 --- go.mod | 2 +- go.sum | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index be7e6ee88f9d9..f67e1b88d2771 100644 --- a/go.mod +++ b/go.mod @@ -78,7 +78,7 @@ require ( github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/bep/debounce v1.2.1 - github.com/bgentry/speakeasy v0.1.0 + github.com/bgentry/speakeasy v0.1.1-0.20220910012023-760eaf8b6816 github.com/bramvdbogaerde/go-scp v1.2.1-0.20221219230748-977ee74ac37b github.com/briandowns/spinner v1.18.1 github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 diff --git a/go.sum b/go.sum index 46703cf471c41..b21e1008f30e7 100644 --- a/go.sum +++ b/go.sum @@ -69,6 +69,8 @@ github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8 github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756 h1:L6S7kR7SlhQKplIBpkra3s6yhcZV51lhRnXmYc4HohI= github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= @@ -152,6 +154,8 @@ github.com/bep/golibsass v1.1.1 h1:xkaet75ygImMYjM+FnHIT3xJn7H0xBA9UxSOJjk8Khw= github.com/bep/golibsass v1.1.1/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bgentry/speakeasy v0.1.1-0.20220910012023-760eaf8b6816 h1:41iFGWnSlI2gVpmOtVTJZNodLdLQLn/KsJqFvXwnd/s= +github.com/bgentry/speakeasy v0.1.1-0.20220910012023-760eaf8b6816/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= github.com/bramvdbogaerde/go-scp v1.2.1-0.20221219230748-977ee74ac37b h1:UJeNthMS3NHVtMFKMhzZNxdaXpYqQlbLrDRtVXorT7w= github.com/bramvdbogaerde/go-scp v1.2.1-0.20221219230748-977ee74ac37b/go.mod h1:s4ZldBoRAOgUg8IrRP2Urmq5qqd2yPXQTPshACY8vQ0= @@ -281,6 +285,7 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= @@ -323,6 +328,7 @@ github.com/go-chi/httprate v0.7.1 h1:d5kXARdms2PREQfU4pHvq44S6hJ1hPu4OXLeBKmCKWs github.com/go-chi/httprate v0.7.1/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A= github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8= github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -330,6 +336,7 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -365,6 +372,7 @@ github.com/go-playground/validator/v10 v10.15.0 h1:nDU5XeOKtB3GEa+uB7GNYwhVKsgjA github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -504,6 +512,7 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUKaCaGKZ/dR2roBXv0vKbSCnssIldfQdI= @@ -550,6 +559,7 @@ github.com/hdevalence/ed25519consensus v0.1.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3s github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= github.com/iancoleman/orderedmap v0.2.0 h1:sq1N/TFpYH++aViPcaKjys3bDClUEU7s5B+z6jq8pNA= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -559,6 +569,7 @@ github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16 h1:+aAGyK41KRn8jbF2Q7PLL0Sxwg6dShGcQSeCC7nZQ8E= github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16/go.mod h1:IKrnDWs3/Mqq5n0lI+RxA2sB7MvN/vbMBP3ehXg65UI= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty/v6 v6.4.0 h1:YlI/2zYDrweA4MThiYMKtGRfT+2qZOO65ulej8GTcVI= github.com/jedib0t/go-pretty/v6 v6.4.0/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -575,14 +586,17 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jsimonetti/rtnetlink v1.3.2 h1:dcn0uWkfxycEEyNy0IGfx3GrhQ38LH7odjxAghimsVI= github.com/jsimonetti/rtnetlink v1.3.2/go.mod h1:BBu4jZCpTjP6Gk0/wfrO8qcqymnN3g0hoFqObRmUo6U= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/juju/errors v1.0.0 h1:yiq7kjCLll1BiaRuNY53MGI0+EQ3rF6GB+wvboZDefM= github.com/juju/errors v1.0.0/go.mod h1:B5x9thDqx0wIMH3+aLIMP9HjItInYWObRovoCFM5Qe8= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -658,10 +672,12 @@ github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= @@ -675,6 +691,7 @@ github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= @@ -694,8 +711,10 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= @@ -711,6 +730,7 @@ github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4Y github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek= github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o= @@ -782,6 +802,7 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM= github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4= github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= @@ -789,6 +810,7 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= @@ -907,6 +929,7 @@ github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGj github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= github.com/zclconf/go-cty v1.13.2 h1:4GvrUxe/QUDYuJKAav4EYqdM47/kZa672LwmXFmEKT0= github.com/zclconf/go-cty v1.13.2/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/errs v1.3.0 h1:hmiaKqgYZzcVgRL1Vkc1Mn2914BbzB0IBxs+ebeutGs= github.com/zeebo/errs v1.3.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= @@ -1365,6 +1388,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From 1629a2a4ee3df9d9a96b757c5c88336fc2e1db40 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Sun, 13 Aug 2023 18:52:14 +0300 Subject: [PATCH 100/277] chore: sort DERP regions by latencies on workspace page (#9063) --- site/src/components/Resources/AgentLatency.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/components/Resources/AgentLatency.tsx b/site/src/components/Resources/AgentLatency.tsx index 9be53f106cc2f..6f3ef29ad729b 100644 --- a/site/src/components/Resources/AgentLatency.tsx +++ b/site/src/components/Resources/AgentLatency.tsx @@ -66,11 +66,10 @@ export const AgentLatency: FC<{ agent: WorkspaceAgent }> = ({ agent }) => { This is the latency overhead on non peer to peer connections. The first row is the preferred relay. - {Object.entries(agent.latency) - .sort(([, a], [, b]) => (a.preferred ? -1 : b.preferred ? 1 : 0)) + .sort(([, a], [, b]) => a.latency_ms - b.latency_ms) .map(([regionName, region]) => ( Date: Sun, 13 Aug 2023 11:47:44 -0500 Subject: [PATCH 101/277] fix: remove duplication from language of query param error (#9069) --- coderd/httpapi/queryparams.go | 2 +- coderd/searchquery/search_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/coderd/httpapi/queryparams.go b/coderd/httpapi/queryparams.go index 3f16565e1dd20..b3ae12c7de11c 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -45,7 +45,7 @@ func (p *QueryParamParser) ErrorExcessParams(values url.Values) { if _, ok := p.Parsed[k]; !ok { p.Errors = append(p.Errors, codersdk.ValidationError{ Field: k, - Detail: fmt.Sprintf("Query param %q is not a valid query param", k), + Detail: fmt.Sprintf("%q is not a valid query param", k), }) } } diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 4a7f61331a5f2..f30cc44d9498a 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -142,7 +142,7 @@ func TestSearchWorkspace(t *testing.T) { { Name: "ExtraKeys", Query: `foo:bar`, - ExpectedErrorContains: `Query param "foo" is not a valid query param`, + ExpectedErrorContains: `"foo" is not a valid query param`, }, } @@ -239,7 +239,7 @@ func TestSearchAudit(t *testing.T) { { Name: "ExtraKeys", Query: `foo:bar`, - ExpectedErrorContains: `Query param "foo" is not a valid query param`, + ExpectedErrorContains: `"foo" is not a valid query param`, }, { Name: "Dates", @@ -370,7 +370,7 @@ func TestSearchUsers(t *testing.T) { { Name: "ExtraKeys", Query: `foo:bar`, - ExpectedErrorContains: `Query param "foo" is not a valid query param`, + ExpectedErrorContains: `"foo" is not a valid query param`, }, } From 0d01d022f7ae20910d726d8d895ad8f0a58f60c0 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 13 Aug 2023 11:48:11 -0500 Subject: [PATCH 102/277] fix: remove unnecessary newlines from the end of cli output (#9068) `Infof` already adds a newline, so we don't need to as well! --- cli/exp_scaletest.go | 8 ++++---- cli/gitaskpass.go | 6 +++--- cli/publickey.go | 10 +++++----- cli/server.go | 4 ++-- cli/server_createadminuser.go | 2 +- cli/speedtest.go | 6 +++--- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go index d2ee36c1819eb..e947aa1260100 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -427,7 +427,7 @@ func (r *RootCmd) scaletestCleanup() *clibase.Cmd { cliui.Errorf(inv.Stderr, "Found %d scaletest workspaces\n", len(workspaces)) if len(workspaces) != 0 { - cliui.Infof(inv.Stdout, "Deleting scaletest workspaces..."+"\n") + cliui.Infof(inv.Stdout, "Deleting scaletest workspaces...") harness := harness.NewTestHarness(cleanupStrategy.toStrategy(), harness.ConcurrentExecutionStrategy{}) for i, w := range workspaces { @@ -443,7 +443,7 @@ func (r *RootCmd) scaletestCleanup() *clibase.Cmd { return xerrors.Errorf("run test harness to delete workspaces (harness failure, not a test failure): %w", err) } - cliui.Infof(inv.Stdout, "Done deleting scaletest workspaces:"+"\n") + cliui.Infof(inv.Stdout, "Done deleting scaletest workspaces:") res := harness.Results() res.PrintText(inv.Stderr) @@ -460,7 +460,7 @@ func (r *RootCmd) scaletestCleanup() *clibase.Cmd { cliui.Errorf(inv.Stderr, "Found %d scaletest users\n", len(users)) if len(users) != 0 { - cliui.Infof(inv.Stdout, "Deleting scaletest users..."+"\n") + cliui.Infof(inv.Stdout, "Deleting scaletest users...") harness := harness.NewTestHarness(cleanupStrategy.toStrategy(), harness.ConcurrentExecutionStrategy{}) for i, u := range users { @@ -479,7 +479,7 @@ func (r *RootCmd) scaletestCleanup() *clibase.Cmd { return xerrors.Errorf("run test harness to delete users (harness failure, not a test failure): %w", err) } - cliui.Infof(inv.Stdout, "Done deleting scaletest users:"+"\n") + cliui.Infof(inv.Stdout, "Done deleting scaletest users:") res := harness.Results() res.PrintText(inv.Stderr) diff --git a/cli/gitaskpass.go b/cli/gitaskpass.go index 5bb67adf82416..3d2654507ce00 100644 --- a/cli/gitaskpass.go +++ b/cli/gitaskpass.go @@ -51,9 +51,9 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd { } if token.URL != "" { if err := openURL(inv, token.URL); err == nil { - cliui.Infof(inv.Stderr, "Your browser has been opened to authenticate with Git:\n\n%s\n", token.URL) + cliui.Infof(inv.Stderr, "Your browser has been opened to authenticate with Git:\n%s", token.URL) } else { - cliui.Infof(inv.Stderr, "Open the following URL to authenticate with Git:\n\n%s\n", token.URL) + cliui.Infof(inv.Stderr, "Open the following URL to authenticate with Git:\n%s", token.URL) } for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); { @@ -61,7 +61,7 @@ func (r *RootCmd) gitAskpass() *clibase.Cmd { if err != nil { continue } - cliui.Infof(inv.Stderr, "You've been authenticated with Git!\n") + cliui.Infof(inv.Stderr, "You've been authenticated with Git!") break } } diff --git a/cli/publickey.go b/cli/publickey.go index 43537eec428a1..cbe67fca23c1a 100644 --- a/cli/publickey.go +++ b/cli/publickey.go @@ -45,12 +45,12 @@ func (r *RootCmd) publickey() *clibase.Cmd { cliui.Infof(inv.Stdout, "This is your public key for using "+cliui.DefaultStyles.Field.Render("git")+" in "+ - "Coder. All clones with SSH will be authenticated automatically 🪄.\n\n", + "Coder. All clones with SSH will be authenticated automatically 🪄.", ) - cliui.Infof(inv.Stdout, cliui.DefaultStyles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n\n") - cliui.Infof(inv.Stdout, "Add to GitHub and GitLab:"+"\n") - cliui.Infof(inv.Stdout, cliui.DefaultStyles.Prompt.String()+"https://github.com/settings/ssh/new"+"\n") - cliui.Infof(inv.Stdout, cliui.DefaultStyles.Prompt.String()+"https://gitlab.com/-/profile/keys"+"\n") + cliui.Infof(inv.Stdout, cliui.DefaultStyles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n") + cliui.Infof(inv.Stdout, "Add to GitHub and GitLab:") + cliui.Infof(inv.Stdout, cliui.DefaultStyles.Prompt.String()+"https://github.com/settings/ssh/new") + cliui.Infof(inv.Stdout, cliui.DefaultStyles.Prompt.String()+"https://gitlab.com/-/profile/keys") return nil }, diff --git a/cli/server.go b/cli/server.go index 55a4db844723f..e4557e7d218ee 100644 --- a/cli/server.go +++ b/cli/server.go @@ -1030,7 +1030,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. defer wg.Done() if ok, _ := inv.ParsedFlags().GetBool(varVerbose); ok { - cliui.Infof(inv.Stdout, "Shutting down provisioner daemon %d...\n", id) + cliui.Infof(inv.Stdout, "Shutting down provisioner daemon %d...", id) } err := shutdownWithTimeout(provisionerDaemon.Shutdown, 5*time.Second) if err != nil { @@ -1043,7 +1043,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return } if ok, _ := inv.ParsedFlags().GetBool(varVerbose); ok { - cliui.Infof(inv.Stdout, "Gracefully shut down provisioner daemon %d\n", id) + cliui.Infof(inv.Stdout, "Gracefully shut down provisioner daemon %d", id) } }() } diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index fbdfed6b8016e..4eb16343318e2 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -51,7 +51,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd { defer cancel() if newUserDBURL == "" { - cliui.Infof(inv.Stdout, "Using built-in PostgreSQL (%s)\n", cfg.PostgresPath()) + cliui.Infof(inv.Stdout, "Using built-in PostgreSQL (%s)", cfg.PostgresPath()) url, closePg, err := startBuiltinPostgres(ctx, cfg, logger) if err != nil { return err diff --git a/cli/speedtest.go b/cli/speedtest.go index 150605b3330ce..e38581404ecf0 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -85,14 +85,14 @@ func (r *RootCmd) speedtest() *clibase.Cmd { } peer := status.Peer[status.Peers()[0]] if !p2p && direct { - cliui.Infof(inv.Stdout, "Waiting for a direct connection... (%dms via %s)\n", dur.Milliseconds(), peer.Relay) + cliui.Infof(inv.Stdout, "Waiting for a direct connection... (%dms via %s)", dur.Milliseconds(), peer.Relay) continue } via := peer.Relay if via == "" { via = "direct" } - cliui.Infof(inv.Stdout, "%dms via %s\n", dur.Milliseconds(), via) + cliui.Infof(inv.Stdout, "%dms via %s", dur.Milliseconds(), via) break } } else { @@ -107,7 +107,7 @@ func (r *RootCmd) speedtest() *clibase.Cmd { default: return xerrors.Errorf("invalid direction: %q", direction) } - cliui.Infof(inv.Stdout, "Starting a %ds %s test...\n", int(duration.Seconds()), tsDir) + cliui.Infof(inv.Stdout, "Starting a %ds %s test...", int(duration.Seconds()), tsDir) results, err := conn.Speedtest(ctx, tsDir, duration) if err != nil { return err From 594b9797dda57c8dfc5977e54267ba05403093a1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 13 Aug 2023 11:58:04 -0500 Subject: [PATCH 103/277] fix: change dashboard route `/settings/deployment` to `/deployment` (#9070) It felt unnecessary to nest this. --- site/src/AppRouter.tsx | 5 +---- site/src/components/Navbar/NavbarView.test.tsx | 2 +- site/src/components/Navbar/NavbarView.tsx | 2 +- .../LicensesSettingsPage/AddNewLicensePage.tsx | 4 ++-- .../LicensesSettingsPage/AddNewLicensePageView.tsx | 2 +- .../LicensesSettingsPage/LicensesSettingsPageView.tsx | 2 +- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 449d4925febb3..c1f222d90c334 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -272,10 +272,7 @@ export const AppRouter: FC = () => { } /> - } - > + }> } /> } /> } /> diff --git a/site/src/components/Navbar/NavbarView.test.tsx b/site/src/components/Navbar/NavbarView.test.tsx index 307670708cc72..2ba0abc8e0ab4 100644 --- a/site/src/components/Navbar/NavbarView.test.tsx +++ b/site/src/components/Navbar/NavbarView.test.tsx @@ -154,7 +154,7 @@ describe("NavbarView", () => { ) const auditLink = await screen.findByText(navLanguage.deployment) expect((auditLink as HTMLAnchorElement).href).toContain( - "/settings/deployment/general", + "/deployment/general", ) }) diff --git a/site/src/components/Navbar/NavbarView.tsx b/site/src/components/Navbar/NavbarView.tsx index e90c23e7654e0..780cbd98deb81 100644 --- a/site/src/components/Navbar/NavbarView.tsx +++ b/site/src/components/Navbar/NavbarView.tsx @@ -93,7 +93,7 @@ const NavItems: React.FC< )} {canViewDeployment && ( - + {Language.deployment} diff --git a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage.tsx b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage.tsx index 7c75dca3713c0..3d60005a5e87f 100644 --- a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage.tsx +++ b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage.tsx @@ -17,7 +17,7 @@ const AddNewLicensePage: FC = () => { } = useMutation(createLicense, { onSuccess: () => { displaySuccess("You have successfully added a license") - navigate("/settings/deployment/licenses?success=true") + navigate("/deployment/licenses?success=true") }, onError: () => displayError("Failed to save license key"), }) @@ -28,7 +28,7 @@ const AddNewLicensePage: FC = () => { { onSuccess: () => { displaySuccess("You have successfully added a license") - navigate("/settings/deployment/licenses?success=true") + navigate("/deployment/licenses?success=true") }, onError: () => displayError("Failed to save license key"), }, diff --git a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePageView.tsx b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePageView.tsx index 48d0bb992b71a..a7a2fbc346718 100644 --- a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePageView.tsx +++ b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePageView.tsx @@ -60,7 +60,7 @@ export const AddNewLicensePageView: FC = ({ diff --git a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx index b1f5c678ac630..fe3286eab8c8c 100644 --- a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx @@ -57,7 +57,7 @@ const LicensesSettingsPageView: FC = ({ - )} + {canCreateUser && ( + + )} {canCreateGroup && isTemplateRBACEnabled && ( From 69ec8d774b658b32eac3524ebca2edf3a841afea Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Mon, 21 Aug 2023 17:53:26 -0500 Subject: [PATCH 196/277] fix(cli/server): apply log filter to log message as well as name (#9232) --- cli/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/server.go b/cli/server.go index b3987db5699f3..1cd6ec475747a 100644 --- a/cli/server.go +++ b/cli/server.go @@ -1845,7 +1845,7 @@ func (f *debugFilterSink) compile(res []string) error { func (f *debugFilterSink) LogEntry(ctx context.Context, ent slog.SinkEntry) { if ent.Level == slog.LevelDebug { logName := strings.Join(ent.LoggerNames, ".") - if f.re != nil && !f.re.MatchString(logName) { + if f.re != nil && !f.re.MatchString(logName) && !f.re.MatchString(ent.Message) { return } } From 545a256b5726ba4cdf02ce94537d0c57a1fd3d3f Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Mon, 21 Aug 2023 21:55:39 -0500 Subject: [PATCH 197/277] fix: correctly reject quota-violating builds (#9233) Due to a logical error in CommitQuota, all workspace Stop->Start operations were being accepted, regardless of the Quota limit. This issue only appeared after #9201, so this was a minor regression in main for about 3 days. This PR adds a test to make sure this kind of bug doesn't recur. To make the new test possible, we give the echo provisioner the ability to simulate responses to specific transitions. --- enterprise/coderd/coderd.go | 5 +- enterprise/coderd/workspacequota.go | 33 ++++--- enterprise/coderd/workspacequota_test.go | 110 +++++++++++++++++++++-- enterprise/tailnet/workspaceproxy.go | 4 + provisioner/echo/serve.go | 105 ++++++++++++++-------- provisioner/echo/serve_test.go | 86 ++++++++++++++++++ provisionerd/runner/runner.go | 3 +- 7 files changed, 292 insertions(+), 54 deletions(-) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index fc74ae281fca8..ddba75da35269 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -498,7 +498,10 @@ func (api *API) updateEntitlements(ctx context.Context) error { if initial, changed, enabled := featureChanged(codersdk.FeatureTemplateRBAC); shouldUpdate(initial, changed, enabled) { if enabled { - committer := committer{Database: api.Database} + committer := committer{ + Log: api.Logger.Named("quota_committer"), + Database: api.Database, + } ptr := proto.QuotaCommitter(&committer) api.AGPL.QuotaCommitter.Store(&ptr) } else { diff --git a/enterprise/coderd/workspacequota.go b/enterprise/coderd/workspacequota.go index bb25771f6775e..44ea3f302ff37 100644 --- a/enterprise/coderd/workspacequota.go +++ b/enterprise/coderd/workspacequota.go @@ -3,10 +3,12 @@ package coderd import ( "context" "database/sql" + "errors" "net/http" "github.com/google/uuid" - "golang.org/x/xerrors" + + "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" @@ -17,6 +19,7 @@ import ( ) type committer struct { + Log slog.Logger Database database.Store } @@ -28,12 +31,12 @@ func (c *committer) CommitQuota( return nil, err } - build, err := c.Database.GetWorkspaceBuildByJobID(ctx, jobID) + nextBuild, err := c.Database.GetWorkspaceBuildByJobID(ctx, jobID) if err != nil { return nil, err } - workspace, err := c.Database.GetWorkspaceByID(ctx, build.WorkspaceID) + workspace, err := c.Database.GetWorkspaceByID(ctx, nextBuild.WorkspaceID) if err != nil { return nil, err } @@ -58,25 +61,35 @@ func (c *committer) CommitQuota( // If the new build will reduce overall quota consumption, then we // allow it even if the user is over quota. netIncrease := true - previousBuild, err := s.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{ + prevBuild, err := s.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{ WorkspaceID: workspace.ID, - BuildNumber: build.BuildNumber - 1, + BuildNumber: nextBuild.BuildNumber - 1, }) if err == nil { - if build.DailyCost < previousBuild.DailyCost { - netIncrease = false - } - } else if !xerrors.Is(err, sql.ErrNoRows) { + netIncrease = request.DailyCost >= prevBuild.DailyCost + c.Log.Debug( + ctx, "previous build cost", + slog.F("prev_cost", prevBuild.DailyCost), + slog.F("next_cost", request.DailyCost), + slog.F("net_increase", netIncrease), + ) + } else if !errors.Is(err, sql.ErrNoRows) { return err } newConsumed := int64(request.DailyCost) + consumed if newConsumed > budget && netIncrease { + c.Log.Debug( + ctx, "over quota, rejecting", + slog.F("prev_consumed", consumed), + slog.F("next_consumed", newConsumed), + slog.F("budget", budget), + ) return nil } err = s.UpdateWorkspaceBuildCostByID(ctx, database.UpdateWorkspaceBuildCostByIDParams{ - ID: build.ID, + ID: nextBuild.ID, DailyCost: request.DailyCost, }) if err != nil { diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go index a1e80da7c8f75..3119168696a36 100644 --- a/enterprise/coderd/workspacequota_test.go +++ b/enterprise/coderd/workspacequota_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" @@ -31,12 +32,13 @@ func verifyQuota(ctx context.Context, t *testing.T, client *codersdk.Client, con } func TestWorkspaceQuota(t *testing.T) { - // TODO: refactor for new impl - t.Parallel() - t.Run("BlocksBuild", func(t *testing.T) { + // This first test verifies the behavior of creating and deleting workspaces. + // It also tests multi-group quota stacking and the everyone group. + t.Run("CreateDelete", func(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() max := 1 @@ -49,8 +51,6 @@ func TestWorkspaceQuota(t *testing.T) { }, }) coderdtest.NewProvisionerDaemon(t, api.AGPL) - coderdtest.NewProvisionerDaemon(t, api.AGPL) - coderdtest.NewProvisionerDaemon(t, api.AGPL) verifyQuota(ctx, t, client, 0, 0) @@ -157,4 +157,104 @@ func TestWorkspaceQuota(t *testing.T) { verifyQuota(ctx, t, client, 4, 4) require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) }) + + t.Run("StartStop", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + max := 1 + client, _, api, user := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + UserWorkspaceQuota: max, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + coderdtest.NewProvisionerDaemon(t, api.AGPL) + + verifyQuota(ctx, t, client, 0, 0) + + // Patch the 'Everyone' group to verify its quota allowance is being accounted for. + _, err := client.PatchGroup(ctx, user.OrganizationID, codersdk.PatchGroupRequest{ + QuotaAllowance: ptr.Ref(4), + }) + require.NoError(t, err) + verifyQuota(ctx, t, client, 0, 4) + + stopResp := []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + DailyCost: 1, + }}, + }, + }, + }} + + startResp := []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + DailyCost: 2, + }}, + }, + }, + }} + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlanMap: map[proto.WorkspaceTransition][]*proto.Provision_Response{ + proto.WorkspaceTransition_START: startResp, + proto.WorkspaceTransition_STOP: stopResp, + }, + ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Provision_Response{ + proto.WorkspaceTransition_START: startResp, + proto.WorkspaceTransition_STOP: stopResp, + }, + }) + + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + // Spin up two workspaces. + var wg sync.WaitGroup + var workspaces []codersdk.Workspace + for i := 0; i < 2; i++ { + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + workspaces = append(workspaces, workspace) + build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + assert.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) + } + wg.Wait() + verifyQuota(ctx, t, client, 4, 4) + + // Next one must fail + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + require.Contains(t, build.Job.Error, "quota") + + // Consumed shouldn't bump + verifyQuota(ctx, t, client, 4, 4) + require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status) + + build = coderdtest.CreateWorkspaceBuild(t, client, workspaces[0], database.WorkspaceTransitionStop) + build = coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID) + + // Quota goes down one + verifyQuota(ctx, t, client, 3, 4) + require.Equal(t, codersdk.WorkspaceStatusStopped, build.Status) + + build = coderdtest.CreateWorkspaceBuild(t, client, workspaces[0], database.WorkspaceTransitionStart) + build = coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID) + + // Quota goes back up + verifyQuota(ctx, t, client, 4, 4) + require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) + }) } diff --git a/enterprise/tailnet/workspaceproxy.go b/enterprise/tailnet/workspaceproxy.go index 3011f100e6a5d..3150890c13fa9 100644 --- a/enterprise/tailnet/workspaceproxy.go +++ b/enterprise/tailnet/workspaceproxy.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net" "time" @@ -26,6 +27,9 @@ func ServeWorkspaceProxy(ctx context.Context, conn net.Conn, ma agpl.MultiAgentC var msg wsproxysdk.CoordinateMessage err := decoder.Decode(&msg) if err != nil { + if errors.Is(err, net.ErrClosed) { + return nil + } return xerrors.Errorf("read json: %w", err) } diff --git a/provisioner/echo/serve.go b/provisioner/echo/serve.go index 50f6cc60b2257..c057254704f3d 100644 --- a/provisioner/echo/serve.go +++ b/provisioner/echo/serve.go @@ -127,19 +127,35 @@ func (e *echo) Provision(stream proto.DRPCProvisioner_ProvisionStream) error { return nil } - for index := 0; ; index++ { +outer: + for i := 0; ; i++ { var extension string if msg.GetPlan() != nil { extension = ".plan.protobuf" } else { extension = ".apply.protobuf" } - path := filepath.Join(config.Directory, fmt.Sprintf("%d.provision"+extension, index)) - _, err := e.filesystem.Stat(path) - if err != nil { - if index == 0 { - // Error if nothing is around to enable failed states. - return xerrors.New("no state") + var ( + path string + pathIndex int + ) + // Try more specific path first, then fallback to generic. + paths := []string{ + filepath.Join(config.Directory, fmt.Sprintf("%d.%s.provision"+extension, i, strings.ToLower(config.GetMetadata().GetWorkspaceTransition().String()))), + filepath.Join(config.Directory, fmt.Sprintf("%d.provision"+extension, i)), + } + for pathIndex, path = range paths { + _, err := e.filesystem.Stat(path) + if err != nil && pathIndex == len(paths)-1 { + // If there are zero messages, something is wrong. + if i == 0 { + // Error if nothing is around to enable failed states. + return xerrors.New("no state") + } + // Otherwise, we're done with the entire provision. + break outer + } else if err != nil { + continue } break } @@ -170,16 +186,28 @@ func (*echo) Shutdown(_ context.Context, _ *proto.Empty) (*proto.Empty, error) { return &proto.Empty{}, nil } +// Responses is a collection of mocked responses to Provision operations. type Responses struct { - Parse []*proto.Parse_Response + Parse []*proto.Parse_Response + + // ProvisionApply and ProvisionPlan are used to mock ALL responses of + // Apply and Plan, regardless of transition. ProvisionApply []*proto.Provision_Response ProvisionPlan []*proto.Provision_Response + + // ProvisionApplyMap and ProvisionPlanMap are used to mock specific + // transition responses. They are prioritized over the generic responses. + ProvisionApplyMap map[proto.WorkspaceTransition][]*proto.Provision_Response + ProvisionPlanMap map[proto.WorkspaceTransition][]*proto.Provision_Response } // Tar returns a tar archive of responses to provisioner operations. func Tar(responses *Responses) ([]byte, error) { if responses == nil { - responses = &Responses{ParseComplete, ProvisionComplete, ProvisionComplete} + responses = &Responses{ + ParseComplete, ProvisionComplete, ProvisionComplete, + nil, nil, + } } if responses.ProvisionPlan == nil { responses.ProvisionPlan = responses.ProvisionApply @@ -187,58 +215,61 @@ func Tar(responses *Responses) ([]byte, error) { var buffer bytes.Buffer writer := tar.NewWriter(&buffer) - for index, response := range responses.Parse { - data, err := protobuf.Marshal(response) + + writeProto := func(name string, message protobuf.Message) error { + data, err := protobuf.Marshal(message) if err != nil { - return nil, err + return err } + err = writer.WriteHeader(&tar.Header{ - Name: fmt.Sprintf("%d.parse.protobuf", index), + Name: name, Size: int64(len(data)), Mode: 0o644, }) if err != nil { - return nil, err + return err } + _, err = writer.Write(data) if err != nil { - return nil, err + return err } + + return nil } - for index, response := range responses.ProvisionApply { - data, err := protobuf.Marshal(response) - if err != nil { - return nil, err - } - err = writer.WriteHeader(&tar.Header{ - Name: fmt.Sprintf("%d.provision.apply.protobuf", index), - Size: int64(len(data)), - Mode: 0o644, - }) + for index, response := range responses.Parse { + err := writeProto(fmt.Sprintf("%d.parse.protobuf", index), response) if err != nil { return nil, err } - _, err = writer.Write(data) + } + for index, response := range responses.ProvisionApply { + err := writeProto(fmt.Sprintf("%d.provision.apply.protobuf", index), response) if err != nil { return nil, err } } for index, response := range responses.ProvisionPlan { - data, err := protobuf.Marshal(response) + err := writeProto(fmt.Sprintf("%d.provision.plan.protobuf", index), response) if err != nil { return nil, err } - err = writer.WriteHeader(&tar.Header{ - Name: fmt.Sprintf("%d.provision.plan.protobuf", index), - Size: int64(len(data)), - Mode: 0o644, - }) - if err != nil { - return nil, err + } + for trans, m := range responses.ProvisionApplyMap { + for i, rs := range m { + err := writeProto(fmt.Sprintf("%d.%s.provision.apply.protobuf", i, strings.ToLower(trans.String())), rs) + if err != nil { + return nil, err + } } - _, err = writer.Write(data) - if err != nil { - return nil, err + } + for trans, m := range responses.ProvisionPlanMap { + for i, rs := range m { + err := writeProto(fmt.Sprintf("%d.%s.provision.plan.protobuf", i, strings.ToLower(trans.String())), rs) + if err != nil { + return nil, err + } } } err := writer.Flush() diff --git a/provisioner/echo/serve_test.go b/provisioner/echo/serve_test.go index 539fab0c57536..01b283f8a55f5 100644 --- a/provisioner/echo/serve_test.go +++ b/provisioner/echo/serve_test.go @@ -112,6 +112,92 @@ func TestEcho(t *testing.T) { complete.GetComplete().Resources[0].Name) }) + t.Run("ProvisionStop", func(t *testing.T) { + t.Parallel() + + // Stop responses should be returned when the workspace is being stopped. + + defaultResponses := []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "DEFAULT", + }}, + }, + }, + }} + stopResponses := []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "STOP", + }}, + }, + }, + }} + data, err := echo.Tar(&echo.Responses{ + ProvisionApply: defaultResponses, + ProvisionPlan: defaultResponses, + ProvisionPlanMap: map[proto.WorkspaceTransition][]*proto.Provision_Response{ + proto.WorkspaceTransition_STOP: stopResponses, + }, + ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Provision_Response{ + proto.WorkspaceTransition_STOP: stopResponses, + }, + }) + require.NoError(t, err) + + client, err := api.Provision(ctx) + require.NoError(t, err) + + // Do stop. + err = client.Send(&proto.Provision_Request{ + Type: &proto.Provision_Request_Plan{ + Plan: &proto.Provision_Plan{ + Config: &proto.Provision_Config{ + Directory: unpackTar(t, fs, data), + Metadata: &proto.Provision_Metadata{ + WorkspaceTransition: proto.WorkspaceTransition_STOP, + }, + }, + }, + }, + }) + require.NoError(t, err) + + complete, err := client.Recv() + require.NoError(t, err) + require.Equal(t, + stopResponses[0].GetComplete().Resources[0].Name, + complete.GetComplete().Resources[0].Name, + ) + + // Do start. + client, err = api.Provision(ctx) + require.NoError(t, err) + + err = client.Send(&proto.Provision_Request{ + Type: &proto.Provision_Request_Plan{ + Plan: &proto.Provision_Plan{ + Config: &proto.Provision_Config{ + Directory: unpackTar(t, fs, data), + Metadata: &proto.Provision_Metadata{ + WorkspaceTransition: proto.WorkspaceTransition_START, + }, + }, + }, + }, + }) + require.NoError(t, err) + + complete, err = client.Recv() + require.NoError(t, err) + require.Equal(t, + defaultResponses[0].GetComplete().Resources[0].Name, + complete.GetComplete().Resources[0].Name, + ) + }) + t.Run("ProvisionWithLogLevel", func(t *testing.T) { t.Parallel() diff --git a/provisionerd/runner/runner.go b/provisionerd/runner/runner.go index a42ce5c2da375..5911004f98e2e 100644 --- a/provisionerd/runner/runner.go +++ b/provisionerd/runner/runner.go @@ -964,10 +964,11 @@ func (r *Runner) buildWorkspace(ctx context.Context, stage string, req *sdkproto } func (r *Runner) commitQuota(ctx context.Context, resources []*sdkproto.Resource) *proto.FailedJob { + cost := sumDailyCost(resources) r.logger.Debug(ctx, "committing quota", slog.F("resources", resources), + slog.F("cost", cost), ) - cost := sumDailyCost(resources) if cost == 0 { return nil } From 8a1da743cca6e5f9bd5f8ab353056197b7974fd6 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 22 Aug 2023 14:21:32 +0200 Subject: [PATCH 198/277] test(site): e2e: create workspace with rich parameters (#9185) --- site/e2e/helpers.ts | 133 ++++++++++++++++- site/e2e/parameters.ts | 138 ++++++++++++++++++ site/e2e/tests/createWorkspace.spec.ts | 100 ++++++++++++- .../RichParameterInput/RichParameterInput.tsx | 5 + 4 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 site/e2e/parameters.ts diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index bdfc8b015352d..6525fa3b01f9f 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -13,29 +13,135 @@ import { Provision_Complete, Provision_Response, Resource, + RichParameter, } from "./provisionerGenerated" import { port } from "./playwright.config" import * as ssh from "ssh2" import { Duplex } from "stream" +import { WorkspaceBuildParameter } from "api/typesGenerated" // createWorkspace creates a workspace for a template. // It does not wait for it to be running, but it does navigate to the page. export const createWorkspace = async ( page: Page, templateName: string, + richParameters: RichParameter[] = [], + buildParameters: WorkspaceBuildParameter[] = [], ): Promise => { await page.goto("/templates/" + templateName + "/workspace", { waitUntil: "networkidle", }) const name = randomName() await page.getByLabel("name").fill(name) + + for (const buildParameter of buildParameters) { + const richParameter = richParameters.find( + (richParam) => richParam.name === buildParameter.name, + ) + if (!richParameter) { + throw new Error( + "build parameter is expected to be present in rich parameter schema", + ) + } + + const parameterLabel = await page.waitForSelector( + "[data-testid='parameter-field-" + richParameter.name + "']", + { state: "visible" }, + ) + + if (richParameter.type === "bool") { + const parameterField = await parameterLabel.waitForSelector( + "[data-testid='parameter-field-bool'] .MuiRadio-root input[value='" + + buildParameter.value + + "']", + ) + await parameterField.check() + } else if (richParameter.options.length > 0) { + const parameterField = await parameterLabel.waitForSelector( + "[data-testid='parameter-field-options'] .MuiRadio-root input[value='" + + buildParameter.value + + "']", + ) + await parameterField.check() + } else if (richParameter.type === "list(string)") { + throw new Error("not implemented yet") // FIXME + } else { + // text or number + const parameterField = await parameterLabel.waitForSelector( + "[data-testid='parameter-field-text'] input", + ) + await parameterField.fill(buildParameter.value) + } + } + await page.getByTestId("form-submit").click() await expect(page).toHaveURL("/@admin/" + name) - await page.getByTestId("build-status").isVisible() + await page.waitForSelector("[data-testid='build-status']", { + state: "visible", + }) return name } +export const verifyParameters = async ( + page: Page, + workspaceName: string, + richParameters: RichParameter[], + expectedBuildParameters: WorkspaceBuildParameter[], +) => { + await page.goto("/@admin/" + workspaceName + "/settings/parameters", { + waitUntil: "networkidle", + }) + await expect(page).toHaveURL( + "/@admin/" + workspaceName + "/settings/parameters", + ) + + for (const buildParameter of expectedBuildParameters) { + const richParameter = richParameters.find( + (richParam) => richParam.name === buildParameter.name, + ) + if (!richParameter) { + throw new Error( + "build parameter is expected to be present in rich parameter schema", + ) + } + + const parameterLabel = await page.waitForSelector( + "[data-testid='parameter-field-" + richParameter.name + "']", + { state: "visible" }, + ) + + const muiDisabled = richParameter.mutable ? "" : ".Mui-disabled" + + if (richParameter.type === "bool") { + const parameterField = await parameterLabel.waitForSelector( + "[data-testid='parameter-field-bool'] .MuiRadio-root.Mui-checked" + + muiDisabled + + " input", + ) + const value = await parameterField.inputValue() + expect(value).toEqual(buildParameter.value) + } else if (richParameter.options.length > 0) { + const parameterField = await parameterLabel.waitForSelector( + "[data-testid='parameter-field-options'] .MuiRadio-root.Mui-checked" + + muiDisabled + + " input", + ) + const value = await parameterField.inputValue() + expect(value).toEqual(buildParameter.value) + } else if (richParameter.type === "list(string)") { + throw new Error("not implemented yet") // FIXME + } else { + // text or number + const parameterField = await parameterLabel.waitForSelector( + "[data-testid='parameter-field-text'] input" + muiDisabled, + ) + const value = await parameterField.inputValue() + expect(value).toEqual(buildParameter.value) + } + } +} + // createTemplate navigates to the /templates/new page and uploads a template // with the resources provided in the responses argument. export const createTemplate = async ( @@ -401,3 +507,28 @@ const findSessionToken = async (page: Page): Promise => { } return sessionCookie.value } + +export const echoResponsesWithParameters = ( + richParameters: RichParameter[], +): EchoProvisionerResponses => { + return { + plan: [ + { + complete: { + parameters: richParameters, + }, + }, + ], + apply: [ + { + complete: { + resources: [ + { + name: "example", + }, + ], + }, + }, + ], + } +} diff --git a/site/e2e/parameters.ts b/site/e2e/parameters.ts new file mode 100644 index 0000000000000..c575fdcf83162 --- /dev/null +++ b/site/e2e/parameters.ts @@ -0,0 +1,138 @@ +import { RichParameter } from "./provisionerGenerated" + +// Rich parameters + +const emptyParameter: RichParameter = { + name: "", + description: "", + type: "", + mutable: false, + defaultValue: "", + icon: "", + options: [], + validationRegex: "", + validationError: "", + validationMin: undefined, + validationMax: undefined, + validationMonotonic: "", + required: false, + displayName: "", + order: 0, + ephemeral: false, +} + +// firstParameter is mutable string with a default value (parameter value not required). +export const firstParameter: RichParameter = { + ...emptyParameter, + + name: "first_parameter", + displayName: "First parameter", + type: "number", + options: [], + description: "This is first parameter.", + icon: "/emojis/1f310.png", + defaultValue: "123", + mutable: true, + order: 1, +} + +// secondParameter is immutable string with a default value (parameter value not required). +export const secondParameter: RichParameter = { + ...emptyParameter, + + name: "second_parameter", + displayName: "Second parameter", + type: "string", + options: [], + description: "This is second parameter.", + defaultValue: "abc", + icon: "", + order: 2, +} + +// thirdParameter is mutable string with an empty default value (parameter value not required). +export const thirdParameter: RichParameter = { + ...emptyParameter, + + name: "third_parameter", + type: "string", + options: [], + description: "This is third parameter.", + defaultValue: "", + mutable: true, + order: 3, +} + +// fourthParameter is immutable boolean with a default "true" value (parameter value not required). +export const fourthParameter: RichParameter = { + ...emptyParameter, + + name: "fourth_parameter", + type: "bool", + options: [], + description: "This is fourth parameter.", + defaultValue: "true", + icon: "", + order: 3, +} + +// fifthParameter is immutable "string with options", with a default option selected (parameter value not required). +export const fifthParameter: RichParameter = { + ...emptyParameter, + + name: "fifth_parameter", + displayName: "Fifth parameter", + type: "string", + options: [ + { + name: "ABC", + description: "This is ABC", + value: "abc", + icon: "", + }, + { + name: "DEF", + description: "This is DEF", + value: "def", + icon: "", + }, + { + name: "GHI", + description: "This is GHI", + value: "ghi", + icon: "", + }, + ], + description: "This is fifth parameter.", + defaultValue: "def", + icon: "", + order: 3, +} + +// sixthParameter is mutable string without a default value (parameter value is required). +export const sixthParameter: RichParameter = { + ...emptyParameter, + + name: "sixth_parameter", + displayName: "Sixth parameter", + type: "number", + options: [], + description: "This is sixth parameter.", + icon: "/emojis/1f310.png", + required: true, + mutable: true, + order: 1, +} + +// seventhParameter is immutable string without a default value (parameter value is required). +export const seventhParameter: RichParameter = { + ...emptyParameter, + + name: "seventh_parameter", + displayName: "Seventh parameter", + type: "string", + options: [], + description: "This is seventh parameter.", + required: true, + order: 1, +} diff --git a/site/e2e/tests/createWorkspace.spec.ts b/site/e2e/tests/createWorkspace.spec.ts index 3317691300f5f..10630e2f46ab3 100644 --- a/site/e2e/tests/createWorkspace.spec.ts +++ b/site/e2e/tests/createWorkspace.spec.ts @@ -1,5 +1,21 @@ import { test } from "@playwright/test" -import { createTemplate, createWorkspace } from "../helpers" +import { + createTemplate, + createWorkspace, + echoResponsesWithParameters, + verifyParameters, +} from "../helpers" + +import { + secondParameter, + fourthParameter, + fifthParameter, + firstParameter, + thirdParameter, + seventhParameter, + sixthParameter, +} from "../parameters" +import { RichParameter } from "../provisionerGenerated" test("create workspace", async ({ page }) => { const template = await createTemplate(page, { @@ -17,3 +33,85 @@ test("create workspace", async ({ page }) => { }) await createWorkspace(page, template) }) + +test("create workspace with default immutable parameters", async ({ page }) => { + const richParameters: RichParameter[] = [ + secondParameter, + fourthParameter, + fifthParameter, + ] + const template = await createTemplate( + page, + echoResponsesWithParameters(richParameters), + ) + const workspaceName = await createWorkspace(page, template) + await verifyParameters(page, workspaceName, richParameters, [ + { name: secondParameter.name, value: secondParameter.defaultValue }, + { name: fourthParameter.name, value: fourthParameter.defaultValue }, + { name: fifthParameter.name, value: fifthParameter.defaultValue }, + ]) +}) + +test("create workspace with default mutable parameters", async ({ page }) => { + const richParameters: RichParameter[] = [firstParameter, thirdParameter] + const template = await createTemplate( + page, + echoResponsesWithParameters(richParameters), + ) + const workspaceName = await createWorkspace(page, template) + await verifyParameters(page, workspaceName, richParameters, [ + { name: firstParameter.name, value: firstParameter.defaultValue }, + { name: thirdParameter.name, value: thirdParameter.defaultValue }, + ]) +}) + +test("create workspace with default and required parameters", async ({ + page, +}) => { + const richParameters: RichParameter[] = [ + secondParameter, + fourthParameter, + sixthParameter, + seventhParameter, + ] + const buildParameters = [ + { name: sixthParameter.name, value: "12345" }, + { name: seventhParameter.name, value: "abcdef" }, + ] + const template = await createTemplate( + page, + echoResponsesWithParameters(richParameters), + ) + const workspaceName = await createWorkspace( + page, + template, + richParameters, + buildParameters, + ) + await verifyParameters(page, workspaceName, richParameters, [ + // user values: + ...buildParameters, + // default values: + { name: secondParameter.name, value: secondParameter.defaultValue }, + { name: fourthParameter.name, value: fourthParameter.defaultValue }, + ]) +}) + +test("create workspace and overwrite default parameters", async ({ page }) => { + const richParameters: RichParameter[] = [secondParameter, fourthParameter] + const buildParameters = [ + { name: secondParameter.name, value: "AAAAA" }, + { name: fourthParameter.name, value: "false" }, + ] + const template = await createTemplate( + page, + echoResponsesWithParameters(richParameters), + ) + const workspaceName = await createWorkspace( + page, + template, + richParameters, + buildParameters, + ) + await verifyParameters(page, workspaceName, richParameters, buildParameters) +}) diff --git a/site/src/components/RichParameterInput/RichParameterInput.tsx b/site/src/components/RichParameterInput/RichParameterInput.tsx index 7d5b83894f09e..4e3a67170601f 100644 --- a/site/src/components/RichParameterInput/RichParameterInput.tsx +++ b/site/src/components/RichParameterInput/RichParameterInput.tsx @@ -84,6 +84,7 @@ export const RichParameterInput: FC = ({ direction="column" spacing={size === "small" ? 1.25 : 2} className={size} + data-testid={`parameter-field-${parameter.name}`} > @@ -114,6 +115,7 @@ const RichParameterField: React.FC = ({ if (isBoolean(parameter)) { return ( { @@ -139,6 +141,7 @@ const RichParameterField: React.FC = ({ if (parameter.options.length > 0) { return ( { @@ -185,6 +188,7 @@ const RichParameterField: React.FC = ({ return ( { @@ -206,6 +210,7 @@ const RichParameterField: React.FC = ({ return ( Date: Tue, 22 Aug 2023 14:32:03 +0100 Subject: [PATCH 199/277] feat: add script to run a local keycloak instance (#9242) --- scripts/dev-oidc.sh | 81 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100755 scripts/dev-oidc.sh diff --git a/scripts/dev-oidc.sh b/scripts/dev-oidc.sh new file mode 100755 index 0000000000000..017c7f07c646d --- /dev/null +++ b/scripts/dev-oidc.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") +# shellcheck source=scripts/lib.sh +source "${SCRIPT_DIR}/lib.sh" + +# Allow toggling verbose output +[[ -n ${VERBOSE:-} ]] && set -x +set -euo pipefail + +KEYCLOAK_VERSION="${KEYCLOAK_VERSION:-22.0}" + +cat </tmp/example-realm.json +{ + "realm": "coder", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": true, + "privateKey": "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=", + "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "requiredCredentials": ["password"], + "users": [ + { + "username": "oidcuser", + "email": "oidcuser@coder.com", + "emailVerified": true, + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "password" + } + ], + "clientRoles": { + "realm-management": ["realm-admin"], + "account": ["manage-account"] + } + } + ], + "clients": [ + { + "clientId": "coder", + "directAccessGrantsEnabled": true, + "enabled": true, + "fullScopeAllowed": true, + "baseUrl": "/coder", + "redirectUris": ["*"], + "secret": "coder" + } + ] +} +EOF + +echo '== Starting Keycloak' +docker rm -f keycloak || true +# Start Keycloak +docker run --rm -d \ + --name keycloak \ + -p 9080:8080 \ + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=password \ + -v /tmp/example-realm.json:/opt/keycloak/data/import/example-realm.json \ + "quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}" \ + start-dev \ + --import-realm + +echo '== Waiting for keycloak to become ready' +# Start the timeout in the background so interrupting this script +# doesn't hang for 60s. +timeout 60s bash -c 'until curl -s --fail http://localhost:9080/realms/coder/.well-known/openid-configuration > /dev/null 2>&1; do sleep 0.5; done' || + fatal 'Keycloak did not become ready in time' & +wait $! + +echo '== Starting Coder' +hostname=$(hostname -f) +export CODER_OIDC_ISSUER_URL="http://${hostname}:9080/realms/coder" +export CODER_OIDC_CLIENT_ID=coder +export CODER_OIDC_CLIENT_SECRET=coder +export CODER_DEV_ACCESS_URL="http://${hostname}:8080" + +exec "${SCRIPT_DIR}/develop.sh" "$@" From 37a3b42c550467c19ec5d93c14fa805fe4995b2f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 22 Aug 2023 08:41:58 -0500 Subject: [PATCH 200/277] feat: add last_used search params to workspaces (#9230) * feat: add last_used search params to workspaces --- coderd/database/dbfake/dbfake.go | 12 ++++++ coderd/database/dbgen/dbgen.go | 9 +++-- coderd/database/modelqueries.go | 4 +- coderd/database/queries.sql.go | 21 ++++++++-- coderd/database/queries/workspaces.sql | 11 +++++ coderd/searchquery/search.go | 2 + coderd/workspaces_test.go | 56 ++++++++++++++++++++++++++ 7 files changed, 107 insertions(+), 8 deletions(-) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 589c17efcae0a..85c442ddbb75e 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -6064,6 +6064,18 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. continue } + if !arg.LastUsedBefore.IsZero() { + if workspace.LastUsedAt.After(arg.LastUsedBefore) { + continue + } + } + + if !arg.LastUsedAfter.IsZero() { + if workspace.LastUsedAt.Before(arg.LastUsedAfter) { + continue + } + } + if arg.Status != "" { build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) if err != nil { diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 369c3974a7b7f..2c3088b9be3b0 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -317,8 +317,9 @@ func ProvisionerJob(t testing.TB, db database.Store, orig database.ProvisionerJo // Make sure when we acquire the job, we only get this one. orig.Tags[id.String()] = "true" } + jobID := takeFirst(orig.ID, uuid.New()) job, err := db.InsertProvisionerJob(genCtx, database.InsertProvisionerJobParams{ - ID: takeFirst(orig.ID, uuid.New()), + ID: jobID, CreatedAt: takeFirst(orig.CreatedAt, database.Now()), UpdatedAt: takeFirst(orig.UpdatedAt, database.Now()), OrganizationID: takeFirst(orig.OrganizationID, uuid.New()), @@ -343,7 +344,7 @@ func ProvisionerJob(t testing.TB, db database.Store, orig database.ProvisionerJo if !orig.CompletedAt.Time.IsZero() || orig.Error.String != "" { err := db.UpdateProvisionerJobWithCompleteByID(genCtx, database.UpdateProvisionerJobWithCompleteByIDParams{ - ID: job.ID, + ID: jobID, UpdatedAt: job.UpdatedAt, CompletedAt: orig.CompletedAt, Error: orig.Error, @@ -353,14 +354,14 @@ func ProvisionerJob(t testing.TB, db database.Store, orig database.ProvisionerJo } if !orig.CanceledAt.Time.IsZero() { err := db.UpdateProvisionerJobWithCancelByID(genCtx, database.UpdateProvisionerJobWithCancelByIDParams{ - ID: job.ID, + ID: jobID, CanceledAt: orig.CanceledAt, CompletedAt: orig.CompletedAt, }) require.NoError(t, err) } - job, err = db.GetProvisionerJobByID(genCtx, job.ID) + job, err = db.GetProvisionerJobByID(genCtx, jobID) require.NoError(t, err) return job diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 725ce690c106d..193a046f5cec1 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -218,11 +218,13 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, arg.LockedAt, + arg.LastUsedBefore, + arg.LastUsedAfter, arg.Offset, arg.Limit, ) if err != nil { - return nil, xerrors.Errorf("get authorized workspaces: %w", err) + return nil, err } defer rows.Close() var items []GetWorkspacesRow diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 86c953643bdeb..c7bddab3e52a3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9498,6 +9498,17 @@ WHERE ELSE locked_at IS NULL END + -- Filter by last_used + AND CASE + WHEN $11 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN + workspaces.last_used_at <= $11 + ELSE true + END + AND CASE + WHEN $12 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN + workspaces.last_used_at >= $12 + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY @@ -9509,11 +9520,11 @@ ORDER BY LOWER(workspaces.name) ASC LIMIT CASE - WHEN $12 :: integer > 0 THEN - $12 + WHEN $14 :: integer > 0 THEN + $14 END OFFSET - $11 + $13 ` type GetWorkspacesParams struct { @@ -9527,6 +9538,8 @@ type GetWorkspacesParams struct { HasAgent string `db:"has_agent" json:"has_agent"` AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` LockedAt time.Time `db:"locked_at" json:"locked_at"` + LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"` + LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` } @@ -9563,6 +9576,8 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, arg.LockedAt, + arg.LastUsedBefore, + arg.LastUsedAfter, arg.Offset, arg.Limit, ) diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 9dd8aa00b5f55..54b904cda0262 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -267,6 +267,17 @@ WHERE ELSE locked_at IS NULL END + -- Filter by last_used + AND CASE + WHEN @last_used_before :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN + workspaces.last_used_at <= @last_used_before + ELSE true + END + AND CASE + WHEN @last_used_after :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN + workspaces.last_used_at >= @last_used_after + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 821518c832eec..17d1990880727 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -115,6 +115,8 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus])) filter.HasAgent = parser.String(values, "", "has-agent") filter.LockedAt = parser.Time(values, time.Time{}, "locked_at", "2006-01-02") + filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after") + filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before") if _, ok := values["deleting_by"]; ok { postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02")) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 140265f58073d..b42f4517db82d 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1447,6 +1447,62 @@ func TestWorkspaceFilterManual(t *testing.T) { require.Len(t, res.Workspaces, 1) require.NotNil(t, res.Workspaces[0].LockedAt) }) + + t.Run("LastUsed", func(t *testing.T) { + t.Parallel() + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + // update template with inactivity ttl + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + now := database.Now() + before := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, before.LatestBuild.ID) + + after := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, after.LatestBuild.ID) + + //nolint:gocritic // Unit testing context + err := api.Database.UpdateWorkspaceLastUsedAt(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceLastUsedAtParams{ + ID: before.ID, + LastUsedAt: now.UTC().Add(time.Hour * -1), + }) + require.NoError(t, err) + + // Unit testing context + //nolint:gocritic // Unit testing context + err = api.Database.UpdateWorkspaceLastUsedAt(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceLastUsedAtParams{ + ID: after.ID, + LastUsedAt: now.UTC().Add(time.Hour * 1), + }) + require.NoError(t, err) + + beforeRes, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("last_used_before:%q", now.Format(time.RFC3339)), + }) + require.NoError(t, err) + require.Len(t, beforeRes.Workspaces, 1) + require.Equal(t, before.ID, beforeRes.Workspaces[0].ID) + + afterRes, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("last_used_after:%q", now.Format(time.RFC3339)), + }) + require.NoError(t, err) + require.Len(t, afterRes.Workspaces, 1) + require.Equal(t, after.ID, afterRes.Workspaces[0].ID) + }) } func TestOffsetLimit(t *testing.T) { From 262d7692b60162633715f6834bfad78b83d48f34 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 22 Aug 2023 09:26:43 -0500 Subject: [PATCH 201/277] feat: add force refresh of license entitlements (#9155) * feat: add force refresh of license entitlements * send "going away" mesasge on licenses pubsub on close * Add manual refresh to licenses page --- coderd/apidoc/docs.go | 29 ++++++++ coderd/apidoc/swagger.json | 25 +++++++ codersdk/deployment.go | 1 + docs/api/enterprise.md | 1 + docs/api/organizations.md | 38 ++++++++++ docs/api/schemas.md | 2 + enterprise/coderd/coderd.go | 17 ++++- enterprise/coderd/license/license.go | 1 + enterprise/coderd/licenses.go | 70 +++++++++++++++++++ site/src/api/api.ts | 5 ++ site/src/api/typesGenerated.ts | 1 + .../LicensesSettingsPage.tsx | 14 +++- .../LicensesSettingsPageView.tsx | 35 ++++++++-- site/src/testHelpers/entities.ts | 4 ++ .../deploymentStats/deploymentStatsMachine.ts | 1 + .../entitlements/entitlementsXService.ts | 33 ++++++++- 16 files changed, 264 insertions(+), 13 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 04aed7c9be52a..561848ba4a1bb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1024,6 +1024,31 @@ const docTemplate = `{ } } }, + "/licenses/refresh-entitlements": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Organizations" + ], + "summary": "Update license entitlements", + "operationId": "update-license-entitlements", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/licenses/{id}": { "delete": { "security": [ @@ -8068,6 +8093,10 @@ const docTemplate = `{ "has_license": { "type": "boolean" }, + "refreshed_at": { + "type": "string", + "format": "date-time" + }, "require_telemetry": { "type": "boolean" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ee710a7f8e51f..ee7c78c369698 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -880,6 +880,27 @@ } } }, + "/licenses/refresh-entitlements": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Organizations"], + "summary": "Update license entitlements", + "operationId": "update-license-entitlements", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/licenses/{id}": { "delete": { "security": [ @@ -7223,6 +7244,10 @@ "has_license": { "type": "boolean" }, + "refreshed_at": { + "type": "string", + "format": "date-time" + }, "require_telemetry": { "type": "boolean" }, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 0b8c7f9090a61..708bc9e899784 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -103,6 +103,7 @@ type Entitlements struct { HasLicense bool `json:"has_license"` Trial bool `json:"trial"` RequireTelemetry bool `json:"require_telemetry"` + RefreshedAt time.Time `json:"refreshed_at" format:"date-time"` } func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) { diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index 6edfcafa385d7..b60b9cf9fc4c6 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -134,6 +134,7 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \ } }, "has_license": true, + "refreshed_at": "2019-08-24T14:15:22Z", "require_telemetry": true, "trial": true, "warnings": ["string"] diff --git a/docs/api/organizations.md b/docs/api/organizations.md index 4b5b15c5dca16..011d3cac5eb2e 100644 --- a/docs/api/organizations.md +++ b/docs/api/organizations.md @@ -49,6 +49,44 @@ curl -X POST http://coder-server:8080/api/v2/licenses \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Update license entitlements + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/licenses/refresh-entitlements \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /licenses/refresh-entitlements` + +### Example responses + +> 201 Response + +```json +{ + "detail": "string", + "message": "string", + "validations": [ + { + "detail": "string", + "field": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------------ | ----------- | ------------------------------------------------ | +| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.Response](schemas.md#codersdkresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Create organization ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index a615825d266d3..019043cfda687 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2674,6 +2674,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } }, "has_license": true, + "refreshed_at": "2019-08-24T14:15:22Z", "require_telemetry": true, "trial": true, "warnings": ["string"] @@ -2688,6 +2689,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `features` | object | false | | | | » `[any property]` | [codersdk.Feature](#codersdkfeature) | false | | | | `has_license` | boolean | false | | | +| `refreshed_at` | string | false | | | | `require_telemetry` | boolean | false | | | | `trial` | boolean | false | | | | `warnings` | array of string | false | | | diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index ddba75da35269..655aff827df4a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -130,6 +130,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { }) r.Route("/licenses", func(r chi.Router) { r.Use(apiKeyMiddleware) + r.Post("/refresh-entitlements", api.postRefreshEntitlements) r.Post("/", api.postLicense) r.Get("/", api.licenses) r.Delete("/{id}", api.deleteLicense) @@ -403,10 +404,13 @@ type API struct { } func (api *API) Close() error { - api.cancel() + // Replica manager should be closed first. This is because the replica + // manager updates the replica's table in the database when it closes. + // This tells other Coderds that it is now offline. if api.replicaManager != nil { _ = api.replicaManager.Close() } + api.cancel() if api.derpMesh != nil { _ = api.derpMesh.Close() } @@ -802,6 +806,17 @@ func (api *API) runEntitlementsLoop(ctx context.Context) { updates := make(chan struct{}, 1) subscribed := false + defer func() { + // If this function ends, it means the context was cancelled and this + // coderd is shutting down. In this case, post a pubsub message to + // tell other coderd's to resync their entitlements. This is required to + // make sure things like replica counts are updated in the UI. + // Ignore the error, as this is just a best effort. If it fails, + // the system will eventually recover as replicas timeout + // if their heartbeats stop. The best effort just tries to update the + // UI faster if it succeeds. + _ = api.Pubsub.Publish(PubsubEventLicenses, []byte("going away")) + }() for { select { case <-ctx.Done(): diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 14caea39e0323..4abf8dfcd218e 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -225,6 +225,7 @@ func Entitlements( entitlements.Features[featureName] = feature } } + entitlements.RefreshedAt = now return entitlements, nil } diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index 6b64348ba4d42..aff3f41fa5a76 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -8,6 +8,7 @@ import ( _ "embed" "encoding/base64" "encoding/json" + "fmt" "net/http" "strconv" "strings" @@ -150,6 +151,75 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, rawClaims)) } +// postRefreshEntitlements forces an `updateEntitlements` call and publishes +// a message to the PubsubEventLicenses topic to force other replicas +// to update their entitlements. +// Updates happen automatically on a timer, however that time is every 10 minutes, +// and we want to be able to force an update immediately in some cases. +// +// @Summary Update license entitlements +// @ID update-license-entitlements +// @Security CoderSessionToken +// @Produce json +// @Tags Organizations +// @Success 201 {object} codersdk.Response +// @Router /licenses/refresh-entitlements [post] +func (api *API) postRefreshEntitlements(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // If the user cannot create a new license, then they cannot refresh entitlements. + // Refreshing entitlements is a way to force a refresh of the license, so it is + // equivalent to creating a new license. + if !api.AGPL.Authorize(r, rbac.ActionCreate, rbac.ResourceLicense) { + httpapi.Forbidden(rw) + return + } + + // Prevent abuse by limiting how often we allow a forced refresh. + now := time.Now() + if diff := now.Sub(api.entitlements.RefreshedAt); diff < time.Minute { + wait := time.Minute - diff + rw.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds()))) + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Entitlements already recently refreshed, please wait %d seconds to force a new refresh", int(wait.Seconds())), + Detail: fmt.Sprintf("Last refresh at %s", now.UTC().String()), + }) + return + } + + err := api.replicaManager.UpdateNow(ctx) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to sync replicas", + Detail: err.Error(), + }) + return + } + + err = api.updateEntitlements(ctx) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update entitlements", + Detail: err.Error(), + }) + return + } + + err = api.Pubsub.Publish(PubsubEventLicenses, []byte("refresh")) + if err != nil { + api.Logger.Error(context.Background(), "failed to publish forced entitlement update", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to publish forced entitlement update. Other replicas might not be updated.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ + Message: "Entitlements updated", + }) +} + // @Summary Get licenses // @ID get-licenses // @Security CoderSessionToken diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4d5c88fe74b16..7625368589e31 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -808,6 +808,10 @@ export const putWorkspaceExtension = async ( }) } +export const refreshEntitlements = async (): Promise => { + await axios.post("/api/v2/licenses/refresh-entitlements") +} + export const getEntitlements = async (): Promise => { try { const response = await axios.get("/api/v2/entitlements") @@ -821,6 +825,7 @@ export const getEntitlements = async (): Promise => { require_telemetry: false, trial: false, warnings: [], + refreshed_at: "", } } throw ex diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e0592b966bca4..a9104e8f3cf90 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -416,6 +416,7 @@ export interface Entitlements { readonly has_license: boolean readonly trial: boolean readonly require_telemetry: boolean + readonly refreshed_at: string } // From codersdk/deployment.go diff --git a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx index 2a8ae72b396e4..01e4d8d582822 100644 --- a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx @@ -9,14 +9,20 @@ import useToggle from "react-use/lib/useToggle" import { pageTitle } from "utils/page" import { entitlementsMachine } from "xServices/entitlements/entitlementsXService" import LicensesSettingsPageView from "./LicensesSettingsPageView" +import { getErrorMessage } from "api/errors" const LicensesSettingsPage: FC = () => { const queryClient = useQueryClient() - const [entitlementsState] = useMachine(entitlementsMachine) - const { entitlements } = entitlementsState.context + const [entitlementsState, sendEvent] = useMachine(entitlementsMachine) + const { entitlements, getEntitlementsError } = entitlementsState.context const [searchParams, setSearchParams] = useSearchParams() const success = searchParams.get("success") const [confettiOn, toggleConfettiOn] = useToggle(false) + if (getEntitlementsError) { + displayError( + getErrorMessage(getEntitlementsError, "Failed to fetch entitlements"), + ) + } const { mutate: removeLicenseApi, isLoading: isRemovingLicense } = useMutation(removeLicense, { @@ -58,6 +64,10 @@ const LicensesSettingsPage: FC = () => { licenses={licenses} isRemovingLicense={isRemovingLicense} removeLicense={(licenseId: number) => removeLicenseApi(licenseId)} + refreshEntitlements={() => { + const x = sendEvent("REFRESH") + return !x.context.getEntitlementsError + }} /> ) diff --git a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx index fe3286eab8c8c..d54873a2b4552 100644 --- a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx @@ -2,6 +2,7 @@ import Button from "@mui/material/Button" import { makeStyles, useTheme } from "@mui/styles" import Skeleton from "@mui/material/Skeleton" import AddIcon from "@mui/icons-material/AddOutlined" +import RefreshIcon from "@mui/icons-material/Refresh" import { GetLicensesResponse } from "api/api" import { Header } from "components/DeploySettingsLayout/Header" import { LicenseCard } from "components/LicenseCard/LicenseCard" @@ -11,6 +12,8 @@ import Confetti from "react-confetti" import { Link } from "react-router-dom" import useWindowSize from "react-use/lib/useWindowSize" import MuiLink from "@mui/material/Link" +import { displaySuccess } from "components/GlobalSnackbar/utils" +import Tooltip from "@mui/material/Tooltip" type Props = { showConfetti: boolean @@ -20,6 +23,7 @@ type Props = { licenses?: GetLicensesResponse[] isRemovingLicense: boolean removeLicense: (licenseId: number) => void + refreshEntitlements?: () => boolean } const LicensesSettingsPageView: FC = ({ @@ -30,6 +34,7 @@ const LicensesSettingsPageView: FC = ({ licenses, isRemovingLicense, removeLicense, + refreshEntitlements, }) => { const styles = useStyles() const { width, height } = useWindowSize() @@ -55,13 +60,29 @@ const LicensesSettingsPageView: FC = ({ description="Manage licenses to unlock Enterprise features." /> - + + + + + + {isLoading && } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 777ece4dd47ea..2a77a174e8831 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1450,6 +1450,7 @@ export const MockEntitlements: TypesGen.Entitlements = { features: withDefaultFeatures({}), require_telemetry: false, trial: false, + refreshed_at: "2022-05-20T16:45:57.122Z", } export const MockEntitlementsWithWarnings: TypesGen.Entitlements = { @@ -1458,6 +1459,7 @@ export const MockEntitlementsWithWarnings: TypesGen.Entitlements = { has_license: true, trial: false, require_telemetry: false, + refreshed_at: "2022-05-20T16:45:57.122Z", features: withDefaultFeatures({ user_limit: { enabled: true, @@ -1482,6 +1484,7 @@ export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = { has_license: true, require_telemetry: false, trial: false, + refreshed_at: "2022-05-20T16:45:57.122Z", features: withDefaultFeatures({ audit_log: { enabled: true, @@ -1496,6 +1499,7 @@ export const MockEntitlementsWithScheduling: TypesGen.Entitlements = { has_license: true, require_telemetry: false, trial: false, + refreshed_at: "2022-05-20T16:45:57.122Z", features: withDefaultFeatures({ advanced_template_scheduling: { enabled: true, diff --git a/site/src/xServices/deploymentStats/deploymentStatsMachine.ts b/site/src/xServices/deploymentStats/deploymentStatsMachine.ts index a68447b54b45a..aacbd97230fe8 100644 --- a/site/src/xServices/deploymentStats/deploymentStatsMachine.ts +++ b/site/src/xServices/deploymentStats/deploymentStatsMachine.ts @@ -4,6 +4,7 @@ import { assign, createMachine } from "xstate" export const deploymentStatsMachine = createMachine( { + /** @xstate-layout N4IgpgJg5mDOIC5QTABwDYHsCeBbMAdgC4DKRAhkbALLkDGAFgJYFgB0sFVAxBJq2xYA3TAGt2KDDnzEylGvWYDO8hMMx1KTfgG0ADAF19BxKFSZYTItoKmQAD0QBWAMwuANCGyIATAHYATgBfIM9JLDxCUi4FRhZ2FR4wACdkzGS2DEoAM3TcNnDpKLkqWjjlGLUCEU1rXUNjO3NLOtskB0QAvU9vBAAWVxCwtAiZaPkypXYmCHQwbgAlAFEAGQB5AEEAEUb25qsbO0cEAEY9AA4exDOANhDQkAJMFHh2wsjZGMn4posD-iOiD6Piup3ObCcQxA7zGJViUw4MV+LUO7WOfT03S81xOLihMOKX0U8UEszAyP+bVAxxc5z6oJ8AT6EPuQSAA */ id: "deploymentStatsMachine", predictableActionArguments: true, diff --git a/site/src/xServices/entitlements/entitlementsXService.ts b/site/src/xServices/entitlements/entitlementsXService.ts index 1b35c39b797d2..e213159c3315e 100644 --- a/site/src/xServices/entitlements/entitlementsXService.ts +++ b/site/src/xServices/entitlements/entitlementsXService.ts @@ -9,6 +9,7 @@ export type EntitlementsContext = { export const entitlementsMachine = createMachine( { + /** @xstate-layout N4IgpgJg5mDOIC5RgHYBcCWaA2YC2qasAsgIYDGAFhimAHQBOYAZk7JQMQQD2tdNAN24Brek1ZxKAUXRZcBdLADaABgC6iUAAdusLBl6aQAD0QAWAGwAOOgGYAjACYArABoQAT0SOA7AE46C0czP2cVHzNIlRV7HwBfOPdCOXxCEgpqPnE2TjAGBm4GOi1sUjRmQrxGFhyZTBxUxVUNJBAdPUxDVtMESxsHF3cvBFtbAKcQsIiomPjE8FkGhSIyKhp6GDRMFCg6lOXYLl56QRENsDQ9pbTmo3b9LtAe2wtnOmcBi1Hos0c-Hx8Q0QVnsdBCfj8Vh8UKsYVscySi3kaVWmXOWxouyRjSIHDyBSKJTKFQYVU2V2RTXUd10DxQRmer3en2+Kl+-0BnkQsTM7yCZimkTM0ViCUR9UpKwy634EFwHAASlIAGJKgDKAAlbq17p16d1uZCgb1HBZ3n54fZIrCgrZHGKFhKcek1nx8YVFSr1VrqTraXqjMN7KE6CoLT4rWYbY4HJyev5QabnBYzA4LX5XmYEvMUNwIHAjMlropUesaR0DPqnogALRR401s3RaKORwqWyw2Ehe3zIuSl1o6oSdjlukMxDwlR0HwWAFsiz-PwqZz-Y0r3m2AXhaGhPxmHzOB1952lvibbZYp0HUcBg0m42wuwp5wfeHP9s98X7FHSvgYOVgDelbjggKjGiENgrpa1rJjGn6Ot+Ja-vQsAAK7kOQcDwH6FaPCYiBQXQSYpmmYyZsa9gqI4oYDM49gWMGPimrOR7Ygcp70O6DBAXhPSEcRqbBmRzhmBRIZhtBUawbG2ZxEAA */ id: "entitlementsMachine", predictableActionArguments: true, tsTypes: {} as import("./entitlementsXService.typegen").Typegen0, @@ -22,13 +23,27 @@ export const entitlementsMachine = createMachine( }, initial: "gettingEntitlements", states: { + refresh: { + invoke: { + id: "refreshEntitlements", + src: "refreshEntitlements", + onDone: { + target: "gettingEntitlements", + }, + onError: { + target: "error", + actions: ["assignGetEntitlementsError"], + }, + }, + entry: "clearGetEntitlementsError", + }, gettingEntitlements: { entry: "clearGetEntitlementsError", invoke: { id: "getEntitlements", src: "getEntitlements", onDone: { - target: "success", + target: "idle", actions: ["assignEntitlements"], }, onError: { @@ -37,11 +52,18 @@ export const entitlementsMachine = createMachine( }, }, }, + idle: { + on: { + REFRESH: "refresh", + }, + }, success: { type: "final", }, error: { - type: "final", + on: { + REFRESH: "refresh", + }, }, }, }, @@ -51,13 +73,18 @@ export const entitlementsMachine = createMachine( entitlements: (_, event) => event.data, }), assignGetEntitlementsError: assign({ - getEntitlementsError: (_, event) => event.data, + getEntitlementsError: (_, event) => { + return event.data + }, }), clearGetEntitlementsError: assign({ getEntitlementsError: (_) => undefined, }), }, services: { + refreshEntitlements: async () => { + return API.refreshEntitlements() + }, getEntitlements: async () => { // Entitlements is injected by the Coder server into the HTML document. const entitlements = document.querySelector( From 306615c674302161d055e832736d04d949227406 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Tue, 22 Aug 2023 11:09:33 -0500 Subject: [PATCH 202/277] docs: add v2.1.1 changelog (#9249) * add WPL to manifest * docs: add v2.1.1 changelog --- docs/changelogs/README.md | 10 +++++---- docs/changelogs/v2.1.1.md | 37 +++++++++++++++++++++++++++++++ docs/manifest.json | 6 +++++ docs/templates/process-logging.md | 8 +++---- 4 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 docs/changelogs/v2.1.1.md diff --git a/docs/changelogs/README.md b/docs/changelogs/README.md index 7b1945aa02a2d..8345df3cbf473 100644 --- a/docs/changelogs/README.md +++ b/docs/changelogs/README.md @@ -9,9 +9,11 @@ These changelogs are currently not kept in sync with GitHub releases. Use [GitHu Run this command to generate release notes: ```sh +export CODER_IGNORE_MISSING_COMMIT_METADATA=1 +export BRANCH=main ./scripts/release/generate_release_notes.sh \ - --old-version=v0.27.0 \ - --new-version=v0.28.0 \ - --ref=$(git rev-parse --short "${ref:-origin/$branch}") \ - > ./docs/changelogs/v0.28.0.md + --old-version=v2.1.0 \ + --new-version=v2.1.1 \ + --ref=$(git rev-parse --short "${ref:-origin/$BRANCH}") \ + > ./docs/changelogs/v2.1.1.md ``` diff --git a/docs/changelogs/v2.1.1.md b/docs/changelogs/v2.1.1.md new file mode 100644 index 0000000000000..78e138428714a --- /dev/null +++ b/docs/changelogs/v2.1.1.md @@ -0,0 +1,37 @@ +## Changelog + +### Features + +- Add `last_used` search params to workspaces. This can be used to find inactive workspaces (#9230) (@Emyrk) + ![Last used](https://user-images.githubusercontent.com/22407953/262407146-06cded4e-684e-4cff-86b7-4388270e7d03.png) + > You can use `last_used_before` and `last_used_after` in the workspaces search with [RFC3339Nano](RFC3339Nano) datetimes +- Add `daily_cost`` to `coder ls` to show [quota](https://coder.com/docs/v2/latest/admin/quotas) consumption (#9200) (@ammario) +- Added `coder_app` usage to template insights (#9138) (@mafredri) + ![code-server usage](https://user-images.githubusercontent.com/22407953/262412524-180390de-b1a9-4d57-8473-c8774ec3fd6e.png) +- Added documentation for [workspace process logging](http://localhost:3000/docs/v2/latest/templates/process-logging). This enterprise feature can be used to log all system-level processes in workspaces. (#9002) (@deansheather) + +### Bug fixes + +- Avoid temporary license banner when Coder is upgraded via Helm + button to refresh license entitlements (#9155) (@Emyrk) +- Parameters in the page "Create workspace" will show the display name as the primary field (#9158) (@aslilac) + ![Parameter order](https://user-images.githubusercontent.com/418348/261439836-e7e7d9bd-9204-42be-8d13-eae9a9afd17c.png) +- Fix race in PGCoord at startup (#9144) (@spikecurtis) +- Do not install strace on OSX (#9167) (@mtojek) +- Use proper link to workspace proxies page (#9183) (@bpmct) +- Correctly assess quota for stopped resources (#9201) (@ammario) +- Add workspace_proxy type to auditlog friendly strings (#9194) (@Emyrk) +- Always show add user button (#9229) (@aslilac) +- Correctly reject quota-violating builds (#9233) (@ammario) +- Log correct script timeout for startup script (#9190) (@mafredri) +- Remove prompt for immutable parameters on start and restart (#9173) (@mtojek) +- Server logs: apply filter to log message as well as name (#9232) (@ammario) + +Compare: [`v2.1.0...v2.1.1`](https://github.com/coder/coder/compare/v2.1.0...v2.1.1) + +## Container image + +- `docker pull ghcr.io/coder/coder:v2.1.1` + +## Install/upgrade + +Refer to our docs to [install](https://coder.com/docs/v2/latest/install) or [upgrade](https://coder.com/docs/v2/latest/admin/upgrade) Coder, or use a release asset below. diff --git a/docs/manifest.json b/docs/manifest.json index d6c59b477f053..38c6103286346 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -196,6 +196,12 @@ "title": "Terraform Modules", "description": "Reuse code across Coder templates", "path": "./templates/modules.md" + }, + { + "title": "Process Logging", + "description": "Audit commands in workspaces with exectrace", + "path": "./templates/process-logging.md", + "state": "enterprise" } ] }, diff --git a/docs/templates/process-logging.md b/docs/templates/process-logging.md index 066ba943b9d9a..51bf613238a44 100644 --- a/docs/templates/process-logging.md +++ b/docs/templates/process-logging.md @@ -5,10 +5,6 @@ processes executing in the workspace. > **Note:** This feature is only available on Linux in Kubernetes. There are > additional requirements outlined further in this document. -> -> This is an [Enterprise](https://coder.com/docs/v2/latest/enterprise) feature. -> To learn more about Coder Enterprise, please -> [contact sales](https://coder.com/contact). Workspace process logging adds a sidecar container to workspace pods that will log all processes started in the workspace container (e.g., commands executed in @@ -20,6 +16,10 @@ monitoring stack, such as CloudWatch, for further analysis or long-term storage. Please note that these logs are not recorded or captured by the Coder organization in any way, shape, or form. +> This is an [Enterprise](https://coder.com/docs/v2/latest/enterprise) feature. +> To learn more about Coder Enterprise, please +> [contact sales](https://coder.com/contact). + ## How this works Coder uses [eBPF](https://ebpf.io/) (which we chose for its minimal performance From 697b0283c565a7591bacf3764e84a1118fc26a2e Mon Sep 17 00:00:00 2001 From: Kayla Washburn Date: Tue, 22 Aug 2023 12:32:37 -0600 Subject: [PATCH 203/277] chore: fix low hanging lint issues (#9253) --- coderd/httpmw/workspaceagent_test.go | 1 + coderd/oauthpki/oidcpki.go | 5 ++--- enterprise/coderd/coderd.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/coderd/httpmw/workspaceagent_test.go b/coderd/httpmw/workspaceagent_test.go index 62472fe13513d..126526e963199 100644 --- a/coderd/httpmw/workspaceagent_test.go +++ b/coderd/httpmw/workspaceagent_test.go @@ -52,6 +52,7 @@ func TestWorkspaceAgent(t *testing.T) { req.Header.Set(codersdk.SessionTokenHeader, authToken.String()) rtr.ServeHTTP(rw, req) + //nolint:bodyclose // Closed in `t.Cleanup` res := rw.Result() t.Cleanup(func() { _ = res.Body.Close() }) require.Equal(t, http.StatusOK, res.StatusCode) diff --git a/coderd/oauthpki/oidcpki.go b/coderd/oauthpki/oidcpki.go index 3f3ceaf221941..d5bc625336ab7 100644 --- a/coderd/oauthpki/oidcpki.go +++ b/coderd/oauthpki/oidcpki.go @@ -8,7 +8,6 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" - "fmt" "io" "net/http" "net/url" @@ -243,7 +242,7 @@ func (src *jwtTokenSource) Token() (*oauth2.Token, error) { } if unmarshalError != nil { - return nil, fmt.Errorf("oauth2: cannot unmarshal token: %w", err) + return nil, xerrors.Errorf("oauth2: cannot unmarshal token: %w", err) } newToken := &oauth2.Token{ @@ -264,7 +263,7 @@ func (src *jwtTokenSource) Token() (*oauth2.Token, error) { // decode returned id token to get expiry claimSet, err := jws.Decode(v) if err != nil { - return nil, fmt.Errorf("oauth2: error decoding JWT token: %w", err) + return nil, xerrors.Errorf("oauth2: error decoding JWT token: %w", err) } newToken.Expiry = time.Unix(claimSet.Exp, 0) } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 655aff827df4a..a2be3e466e2ab 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -807,7 +807,7 @@ func (api *API) runEntitlementsLoop(ctx context.Context) { subscribed := false defer func() { - // If this function ends, it means the context was cancelled and this + // If this function ends, it means the context was canceled and this // coderd is shutting down. In this case, post a pubsub message to // tell other coderd's to resync their entitlements. This is required to // make sure things like replica counts are updated in the UI. From 6214117d3d254305b11157403641ab060e9cd8d7 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Tue, 22 Aug 2023 13:55:00 -0500 Subject: [PATCH 204/277] fix: pull agent metadata even when rate is high (#9251) This commit fixes a bug where when the rate of metadata updates was too high, the debounce caused a new update to get indefinitely delayed. --- coderd/workspaceagents.go | 35 +++++++++++++++---- go.sum | 22 ++++++++++++ .../components/Resources/AgentMetadata.tsx | 1 + 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 55d858b28f320..2839ae4b83cce 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -21,12 +21,12 @@ import ( "sync/atomic" "time" - "github.com/bep/debounce" "github.com/go-chi/chi/v5" "github.com/google/uuid" "golang.org/x/exp/slices" "golang.org/x/mod/semver" "golang.org/x/sync/errgroup" + "golang.org/x/time/rate" "golang.org/x/xerrors" "nhooyr.io/websocket" "tailscale.com/tailcfg" @@ -1485,6 +1485,13 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques }) } +func ellipse(v string, n int) string { + if len(v) > n { + return v[:n] + "..." + } + return v +} + // @Summary Submit workspace agent metadata // @ID submit-workspace-agent-metadata // @Security CoderSessionToken @@ -1557,6 +1564,7 @@ func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Reque slog.F("workspace_id", workspace.ID), slog.F("collected_at", datum.CollectedAt), slog.F("key", datum.Key), + slog.F("value", ellipse(datum.Value, 16)), ) err = api.Pubsub.Publish(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), []byte(datum.Key)) @@ -1580,6 +1588,9 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ var ( ctx = r.Context() workspaceAgent = httpmw.WorkspaceAgentParam(r) + log = api.Logger.Named("workspace_metadata_watcher").With( + slog.F("workspace_agent_id", workspaceAgent.ID), + ) ) sendEvent, senderClosed, err := httpapi.ServerSentEventSender(rw, r) @@ -1605,6 +1616,7 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ ) sendMetadata := func(pull bool) { + log.Debug(ctx, "sending metadata update", "pull", pull) lastDBMetaMu.Lock() defer lastDBMetaMu.Unlock() @@ -1638,19 +1650,28 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ }) } - // We debounce metadata updates to avoid overloading the frontend when - // an agent is sending a lot of updates. - pubsubDebounce := debounce.New(time.Second) + // Note: we previously used a debounce here, but when the rate of metadata updates was too + // high the debounce would never fire. + // + // The rate-limit has its own caveat. If the agent sends a burst of metadata + // but then goes quiet, we will never pull the new metadata and the frontend + // will go stale until refresh. This would only happen if the agent was + // under extreme load. Under normal operations, the interval between metadata + // updates is constant so there is no burst phenomenon. + pubsubRatelimit := rate.NewLimiter(rate.Every(time.Second), 2) if flag.Lookup("test.v") != nil { - pubsubDebounce = debounce.New(time.Millisecond * 100) + // We essentially disable the rate-limit in tests for determinism. + pubsubRatelimit = rate.NewLimiter(rate.Every(time.Second*100), 100) } // Send metadata on updates, we must ensure subscription before sending // initial metadata to guarantee that events in-between are not missed. cancelSub, err := api.Pubsub.Subscribe(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), func(_ context.Context, _ []byte) { - pubsubDebounce(func() { + allow := pubsubRatelimit.Allow() + log.Debug(ctx, "received metadata update", "allow", allow) + if allow { sendMetadata(true) - }) + } }) if err != nil { httpapi.InternalServerError(rw, err) diff --git a/go.sum b/go.sum index 6951d73fcdccf..84e23ae754a95 100644 --- a/go.sum +++ b/go.sum @@ -69,6 +69,8 @@ github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8 github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756 h1:L6S7kR7SlhQKplIBpkra3s6yhcZV51lhRnXmYc4HohI= github.com/ProtonMail/go-crypto v0.0.0-20230426101702-58e86b294756/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= @@ -281,6 +283,7 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= @@ -323,6 +326,7 @@ github.com/go-chi/httprate v0.7.1 h1:d5kXARdms2PREQfU4pHvq44S6hJ1hPu4OXLeBKmCKWs github.com/go-chi/httprate v0.7.1/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A= github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8= github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -330,6 +334,7 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -366,6 +371,7 @@ github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -505,6 +511,7 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUKaCaGKZ/dR2roBXv0vKbSCnssIldfQdI= @@ -551,6 +558,7 @@ github.com/hdevalence/ed25519consensus v0.1.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3s github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= github.com/iancoleman/orderedmap v0.2.0 h1:sq1N/TFpYH++aViPcaKjys3bDClUEU7s5B+z6jq8pNA= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -560,6 +568,7 @@ github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16 h1:+aAGyK41KRn8jbF2Q7PLL0Sxwg6dShGcQSeCC7nZQ8E= github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16/go.mod h1:IKrnDWs3/Mqq5n0lI+RxA2sB7MvN/vbMBP3ehXg65UI= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty/v6 v6.4.0 h1:YlI/2zYDrweA4MThiYMKtGRfT+2qZOO65ulej8GTcVI= github.com/jedib0t/go-pretty/v6 v6.4.0/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -576,14 +585,17 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jsimonetti/rtnetlink v1.3.2 h1:dcn0uWkfxycEEyNy0IGfx3GrhQ38LH7odjxAghimsVI= github.com/jsimonetti/rtnetlink v1.3.2/go.mod h1:BBu4jZCpTjP6Gk0/wfrO8qcqymnN3g0hoFqObRmUo6U= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/juju/errors v1.0.0 h1:yiq7kjCLll1BiaRuNY53MGI0+EQ3rF6GB+wvboZDefM= github.com/juju/errors v1.0.0/go.mod h1:B5x9thDqx0wIMH3+aLIMP9HjItInYWObRovoCFM5Qe8= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -659,10 +671,12 @@ github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= @@ -676,6 +690,7 @@ github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= @@ -695,8 +710,10 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= @@ -712,6 +729,7 @@ github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4Y github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek= github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o= @@ -783,6 +801,7 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM= github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4= github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= @@ -790,6 +809,7 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= @@ -907,6 +927,7 @@ github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGj github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= github.com/zclconf/go-cty v1.13.2 h1:4GvrUxe/QUDYuJKAav4EYqdM47/kZa672LwmXFmEKT0= github.com/zclconf/go-cty v1.13.2/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/errs v1.3.0 h1:hmiaKqgYZzcVgRL1Vkc1Mn2914BbzB0IBxs+ebeutGs= github.com/zeebo/errs v1.3.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= @@ -1365,6 +1386,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/site/src/components/Resources/AgentMetadata.tsx b/site/src/components/Resources/AgentMetadata.tsx index 0c309492b86f4..0f5d45c624615 100644 --- a/site/src/components/Resources/AgentMetadata.tsx +++ b/site/src/components/Resources/AgentMetadata.tsx @@ -33,6 +33,7 @@ const MetadataItem: FC<{ item: WorkspaceAgentMetadata }> = ({ item }) => { const staleThreshold = Math.max( item.description.interval + item.description.timeout * 2, + // In case there is intense backpressure, we give a little bit of slack. 5, ) From 6e41cd1eda64fcc8825c299269c5a5c5e3e94bb9 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 22 Aug 2023 15:15:13 -0500 Subject: [PATCH 205/277] feat: add activity bumping to template scheduling (#9040) --- coderd/database/dbauthz/dbauthz.go | 14 +- coderd/database/dbfake/dbfake.go | 34 +++- coderd/database/dbmetrics/dbmetrics.go | 13 +- coderd/database/dbmock/dbmock.go | 26 ++- coderd/database/querier.go | 3 +- coderd/database/queries.sql.go | 41 ++++- coderd/database/queries/workspaces.sql | 23 ++- coderd/schedule/template.go | 12 ++ coderd/templates.go | 8 +- codersdk/templates.go | 9 + enterprise/coderd/schedule/template.go | 27 ++- enterprise/coderd/templates_test.go | 122 ++++++++++++- site/src/api/typesGenerated.ts | 2 + .../Dialogs/ConfirmDialog/ConfirmDialog.tsx | 168 ++++++++++++++++++ site/src/components/Workspace/Workspace.tsx | 32 ++-- .../components/WorkspaceActions/Buttons.tsx | 10 +- .../WorkspaceActions/WorkspaceActions.tsx | 14 +- .../components/WorkspaceActions/constants.ts | 6 +- .../ImpendingDeletionBanner.tsx | 20 +-- .../WorkspaceStatusBadge.tsx | 9 +- site/src/i18n/en/templateSettingsPage.json | 12 +- .../TemplateSettingsForm.tsx | 2 + .../TemplateSettingsPage.test.tsx | 2 + .../TemplateScheduleForm.tsx | 151 +++++++++++----- .../TemplateScheduleForm/formHelpers.tsx | 8 +- .../useWorkspacesToBeDeleted.ts | 41 ++--- .../TemplateSchedulePage.test.tsx | 16 +- .../WorkspacePage/WorkspaceReadyPage.tsx | 2 +- .../WorkspacesPage/WorkspacesPageView.tsx | 4 +- .../xServices/workspace/workspaceXService.ts | 28 +-- 30 files changed, 669 insertions(+), 190 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 7ae81c3b041d5..1fc1c9782235e 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2385,6 +2385,14 @@ func (q *querier) UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.Conte return q.db.UpdateTemplateVersionGitAuthProvidersByJobID(ctx, arg) } +func (q *querier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { + fetch := func(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) (database.Template, error) { + return q.db.GetTemplateByID(ctx, arg.TemplateID) + } + + return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateTemplateWorkspacesLastUsedAt)(ctx, arg) +} + // UpdateUserDeletedByID // Deprecated: Delete this function in favor of 'SoftDeleteUserByID'. Deletes are // irreversible. @@ -2663,12 +2671,12 @@ func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWor return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceTTL)(ctx, arg) } -func (q *querier) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { - fetch := func(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) (database.Template, error) { +func (q *querier) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { + fetch := func(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) (database.Template, error) { return q.db.GetTemplateByID(ctx, arg.TemplateID) } - return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspacesDeletingAtByTemplateID)(ctx, arg) + return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspacesLockedDeletingAtByTemplateID)(ctx, arg) } func (q *querier) UpsertAppSecurityKey(ctx context.Context, data string) error { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 85c442ddbb75e..8415955e78f86 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -5199,6 +5199,26 @@ func (q *FakeQuerier) UpdateTemplateVersionGitAuthProvidersByJobID(_ context.Con return sql.ErrNoRows } +func (q *FakeQuerier) UpdateTemplateWorkspacesLastUsedAt(_ context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, ws := range q.workspaces { + if ws.TemplateID != arg.TemplateID { + continue + } + ws.LastUsedAt = arg.LastUsedAt + q.workspaces[i] = ws + } + + return nil +} + func (q *FakeQuerier) UpdateUserDeletedByID(_ context.Context, params database.UpdateUserDeletedByIDParams) error { if err := validateDatabaseType(params); err != nil { return err @@ -5796,7 +5816,7 @@ func (q *FakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspacesDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { +func (q *FakeQuerier) UpdateWorkspacesLockedDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -5806,9 +5826,21 @@ func (q *FakeQuerier) UpdateWorkspacesDeletingAtByTemplateID(_ context.Context, } for i, ws := range q.workspaces { + if ws.TemplateID != arg.TemplateID { + continue + } + if ws.LockedAt.Time.IsZero() { continue } + + if !arg.LockedAt.IsZero() { + ws.LockedAt = sql.NullTime{ + Valid: true, + Time: arg.LockedAt, + } + } + deletingAt := sql.NullTime{ Valid: arg.LockedTtlMs > 0, } diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 991be6b64ac6b..498ca57b504e4 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1453,6 +1453,13 @@ func (m metricsStore) UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.C return err } +func (m metricsStore) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { + start := time.Now() + r0 := m.s.UpdateTemplateWorkspacesLastUsedAt(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateWorkspacesLastUsedAt").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpdateUserDeletedByID(ctx context.Context, arg database.UpdateUserDeletedByIDParams) error { start := time.Now() err := m.s.UpdateUserDeletedByID(ctx, arg) @@ -1635,10 +1642,10 @@ func (m metricsStore) UpdateWorkspaceTTL(ctx context.Context, arg database.Updat return r0 } -func (m metricsStore) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { +func (m metricsStore) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { start := time.Now() - r0 := m.s.UpdateWorkspacesDeletingAtByTemplateID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspacesDeletingAtByTemplateID").Observe(time.Since(start).Seconds()) + r0 := m.s.UpdateWorkspacesLockedDeletingAtByTemplateID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspacesLockedDeletingAtByTemplateID").Observe(time.Since(start).Seconds()) return r0 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 6f07fb72790ce..ac1e782e7d398 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3062,6 +3062,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateVersionGitAuthProvidersByJobID(ar return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionGitAuthProvidersByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionGitAuthProvidersByJobID), arg0, arg1) } +// UpdateTemplateWorkspacesLastUsedAt mocks base method. +func (m *MockStore) UpdateTemplateWorkspacesLastUsedAt(arg0 context.Context, arg1 database.UpdateTemplateWorkspacesLastUsedAtParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTemplateWorkspacesLastUsedAt", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTemplateWorkspacesLastUsedAt indicates an expected call of UpdateTemplateWorkspacesLastUsedAt. +func (mr *MockStoreMockRecorder) UpdateTemplateWorkspacesLastUsedAt(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateWorkspacesLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateTemplateWorkspacesLastUsedAt), arg0, arg1) +} + // UpdateUserDeletedByID mocks base method. func (m *MockStore) UpdateUserDeletedByID(arg0 context.Context, arg1 database.UpdateUserDeletedByIDParams) error { m.ctrl.T.Helper() @@ -3437,18 +3451,18 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceTTL(arg0, arg1 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceTTL), arg0, arg1) } -// UpdateWorkspacesDeletingAtByTemplateID mocks base method. -func (m *MockStore) UpdateWorkspacesDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { +// UpdateWorkspacesLockedDeletingAtByTemplateID mocks base method. +func (m *MockStore) UpdateWorkspacesLockedDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspacesDeletingAtByTemplateID", arg0, arg1) + ret := m.ctrl.Call(m, "UpdateWorkspacesLockedDeletingAtByTemplateID", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } -// UpdateWorkspacesDeletingAtByTemplateID indicates an expected call of UpdateWorkspacesDeletingAtByTemplateID. -func (mr *MockStoreMockRecorder) UpdateWorkspacesDeletingAtByTemplateID(arg0, arg1 interface{}) *gomock.Call { +// UpdateWorkspacesLockedDeletingAtByTemplateID indicates an expected call of UpdateWorkspacesLockedDeletingAtByTemplateID. +func (mr *MockStoreMockRecorder) UpdateWorkspacesLockedDeletingAtByTemplateID(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesDeletingAtByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesDeletingAtByTemplateID), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesLockedDeletingAtByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesLockedDeletingAtByTemplateID), arg0, arg1) } // UpsertAppSecurityKey mocks base method. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2c0cc90277f9a..a217648035e90 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -270,6 +270,7 @@ type sqlcQuerier interface { UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionGitAuthProvidersByJobIDParams) error + UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error UpdateUserDeletedByID(ctx context.Context, arg UpdateUserDeletedByIDParams) error UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error) @@ -297,7 +298,7 @@ type sqlcQuerier interface { UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error - UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDeletingAtByTemplateIDParams) error + UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error UpsertAppSecurityKey(ctx context.Context, value string) error // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c7bddab3e52a3..f1bb80a6e0afd 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9786,6 +9786,24 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar return i, err } +const updateTemplateWorkspacesLastUsedAt = `-- name: UpdateTemplateWorkspacesLastUsedAt :exec +UPDATE workspaces +SET + last_used_at = $1::timestamptz +WHERE + template_id = $2 +` + +type UpdateTemplateWorkspacesLastUsedAtParams struct { + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` +} + +func (q *sqlQuerier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateWorkspacesLastUsedAt, arg.LastUsedAt, arg.TemplateID) + return err +} + const updateWorkspace = `-- name: UpdateWorkspace :one UPDATE workspaces @@ -9945,23 +9963,28 @@ func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspace return err } -const updateWorkspacesDeletingAtByTemplateID = `-- name: UpdateWorkspacesDeletingAtByTemplateID :exec -UPDATE - workspaces +const updateWorkspacesLockedDeletingAtByTemplateID = `-- name: UpdateWorkspacesLockedDeletingAtByTemplateID :exec +UPDATE workspaces SET - deleting_at = CASE WHEN $1::bigint = 0 THEN NULL ELSE locked_at + interval '1 milliseconds' * $1::bigint END + deleting_at = CASE + WHEN $1::bigint = 0 THEN NULL + WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN ($2::timestamptz) + interval '1 milliseconds' * $1::bigint + ELSE locked_at + interval '1 milliseconds' * $1::bigint + END, + locked_at = CASE WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN $2::timestamptz ELSE locked_at END WHERE - template_id = $2 + template_id = $3 AND - locked_at IS NOT NULL + locked_at IS NOT NULL ` -type UpdateWorkspacesDeletingAtByTemplateIDParams struct { +type UpdateWorkspacesLockedDeletingAtByTemplateIDParams struct { LockedTtlMs int64 `db:"locked_ttl_ms" json:"locked_ttl_ms"` + LockedAt time.Time `db:"locked_at" json:"locked_at"` TemplateID uuid.UUID `db:"template_id" json:"template_id"` } -func (q *sqlQuerier) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDeletingAtByTemplateIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspacesDeletingAtByTemplateID, arg.LockedTtlMs, arg.TemplateID) +func (q *sqlQuerier) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspacesLockedDeletingAtByTemplateID, arg.LockedTtlMs, arg.LockedAt, arg.TemplateID) return err } diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 54b904cda0262..1ff5971d3266d 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -512,12 +512,23 @@ AND workspaces.id = $1 RETURNING workspaces.*; --- name: UpdateWorkspacesDeletingAtByTemplateID :exec -UPDATE - workspaces +-- name: UpdateWorkspacesLockedDeletingAtByTemplateID :exec +UPDATE workspaces SET - deleting_at = CASE WHEN @locked_ttl_ms::bigint = 0 THEN NULL ELSE locked_at + interval '1 milliseconds' * @locked_ttl_ms::bigint END + deleting_at = CASE + WHEN @locked_ttl_ms::bigint = 0 THEN NULL + WHEN @locked_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@locked_at::timestamptz) + interval '1 milliseconds' * @locked_ttl_ms::bigint + ELSE locked_at + interval '1 milliseconds' * @locked_ttl_ms::bigint + END, + locked_at = CASE WHEN @locked_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN @locked_at::timestamptz ELSE locked_at END WHERE - template_id = @template_id + template_id = @template_id AND - locked_at IS NOT NULL; + locked_at IS NOT NULL; + +-- name: UpdateTemplateWorkspacesLastUsedAt :exec +UPDATE workspaces +SET + last_used_at = @last_used_at::timestamptz +WHERE + template_id = @template_id; diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 7c6a906c6b62c..0e3774b798358 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -105,6 +105,18 @@ type TemplateScheduleOptions struct { // LockedTTL dictates the duration after which locked workspaces will be // permanently deleted. LockedTTL time.Duration `json:"locked_ttl"` + // UpdateWorkspaceLastUsedAt updates the template's workspaces' + // last_used_at field. This is useful for preventing updates to the + // templates inactivity_ttl immediately triggering a lock action against + // workspaces whose last_used_at field violates the new template + // inactivity_ttl threshold. + UpdateWorkspaceLastUsedAt bool `json:"update_workspace_last_used_at"` + // UpdateWorkspaceLockedAt updates the template's workspaces' + // locked_at field. This is useful for preventing updates to the + // templates locked_ttl immediately triggering a delete action against + // workspaces whose locked_at field violates the new template locked_ttl + // threshold. + UpdateWorkspaceLockedAt bool `json:"update_workspace_locked_at"` } // TemplateScheduleStore provides an interface for retrieving template diff --git a/coderd/templates.go b/coderd/templates.go index 75c18402d7ca5..f51f42668e1a1 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -622,9 +622,11 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { DaysOfWeek: restartRequirementDaysOfWeekParsed, Weeks: req.RestartRequirement.Weeks, }, - FailureTTL: failureTTL, - InactivityTTL: inactivityTTL, - LockedTTL: lockedTTL, + FailureTTL: failureTTL, + InactivityTTL: inactivityTTL, + LockedTTL: lockedTTL, + UpdateWorkspaceLastUsedAt: req.UpdateWorkspaceLastUsedAt, + UpdateWorkspaceLockedAt: req.UpdateWorkspaceLockedAt, }) if err != nil { return xerrors.Errorf("set template schedule options: %w", err) diff --git a/codersdk/templates.go b/codersdk/templates.go index 2022c876db360..f566ac4f2ce32 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -192,6 +192,15 @@ type UpdateTemplateMeta struct { FailureTTLMillis int64 `json:"failure_ttl_ms,omitempty"` InactivityTTLMillis int64 `json:"inactivity_ttl_ms,omitempty"` LockedTTLMillis int64 `json:"locked_ttl_ms,omitempty"` + // UpdateWorkspaceLastUsedAt updates the last_used_at field of workspaces + // spawned from the template. This is useful for preventing workspaces being + // immediately locked when updating the inactivity_ttl field to a new, shorter + // value. + UpdateWorkspaceLastUsedAt bool `json:"update_workspace_last_used_at"` + // UpdateWorkspaceLockedAt updates the locked_at field of workspaces spawned + // from the template. This is useful for preventing locked workspaces being immediately + // deleted when updating the locked_ttl field to a new, shorter value. + UpdateWorkspaceLockedAt bool `json:"update_workspace_locked_at"` } type TemplateExample struct { diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 19309454aacde..c5613c44e7880 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -113,11 +113,11 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S } var template database.Template - err = db.InTx(func(db database.Store) error { + err = db.InTx(func(tx database.Store) error { ctx, span := tracing.StartSpanWithName(ctx, "(*schedule.EnterpriseTemplateScheduleStore).Set()-InTx()") defer span.End() - err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + err := tx.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ ID: tpl.ID, UpdatedAt: s.now(), AllowUserAutostart: opts.UserAutostartEnabled, @@ -134,19 +134,36 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S return xerrors.Errorf("update template schedule: %w", err) } + var lockedAt time.Time + if opts.UpdateWorkspaceLockedAt { + lockedAt = database.Now() + } + // If we updated the locked_ttl we need to update all the workspaces deleting_at // to ensure workspaces are being cleaned up correctly. Similarly if we are // disabling it (by passing 0), then we want to delete nullify the deleting_at // fields of all the template workspaces. - err = db.UpdateWorkspacesDeletingAtByTemplateID(ctx, database.UpdateWorkspacesDeletingAtByTemplateIDParams{ + err = tx.UpdateWorkspacesLockedDeletingAtByTemplateID(ctx, database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams{ TemplateID: tpl.ID, LockedTtlMs: opts.LockedTTL.Milliseconds(), + LockedAt: lockedAt, }) if err != nil { return xerrors.Errorf("update deleting_at of all workspaces for new locked_ttl %q: %w", opts.LockedTTL, err) } - template, err = db.GetTemplateByID(ctx, tpl.ID) + if opts.UpdateWorkspaceLastUsedAt { + err = tx.UpdateTemplateWorkspacesLastUsedAt(ctx, database.UpdateTemplateWorkspacesLastUsedAtParams{ + TemplateID: tpl.ID, + LastUsedAt: database.Now(), + }) + if err != nil { + return xerrors.Errorf("update template workspaces last_used_at: %w", err) + } + } + + // TODO: update all workspace max_deadlines to be within new bounds + template, err = tx.GetTemplateByID(ctx, tpl.ID) if err != nil { return xerrors.Errorf("get updated template schedule: %w", err) } @@ -154,7 +171,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S // Recalculate max_deadline and deadline for all running workspace // builds on this template. if s.UseRestartRequirement.Load() { - err = s.updateWorkspaceBuilds(ctx, db, template) + err = s.updateWorkspaceBuilds(ctx, tx, template) if err != nil { return xerrors.Errorf("update workspace builds: %w", err) } diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 4ac4a0e35d518..af364d3578b1c 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -283,10 +283,11 @@ func TestTemplates(t *testing.T) { require.Nil(t, unlockedWorkspace.LockedAt) require.Nil(t, unlockedWorkspace.DeletingAt) - lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) - require.NotNil(t, lockedWorkspace.LockedAt) - require.NotNil(t, lockedWorkspace.DeletingAt) - require.Equal(t, lockedWorkspace.LockedAt.Add(lockedTTL), *lockedWorkspace.DeletingAt) + updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) + require.NotNil(t, updatedLockedWorkspace.LockedAt) + require.NotNil(t, updatedLockedWorkspace.DeletingAt) + require.Equal(t, updatedLockedWorkspace.LockedAt.Add(lockedTTL), *updatedLockedWorkspace.DeletingAt) + require.Equal(t, updatedLockedWorkspace.LockedAt, lockedWorkspace.LockedAt) // Disable the locked_ttl on the template, then we can assert that the workspaces // no longer have a deleting_at field. @@ -307,6 +308,119 @@ func TestTemplates(t *testing.T) { require.NotNil(t, lockedWorkspace.LockedAt) require.Nil(t, lockedWorkspace.DeletingAt) }) + + t.Run("UpdateLockedAt", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }, + }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + unlockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + require.Nil(t, unlockedWorkspace.DeletingAt) + require.Nil(t, lockedWorkspace.DeletingAt) + + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, unlockedWorkspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + + err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{ + Lock: true, + }) + require.NoError(t, err) + + lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) + require.NotNil(t, lockedWorkspace.LockedAt) + // The deleting_at field should be nil since there is no template locked_ttl set. + require.Nil(t, lockedWorkspace.DeletingAt) + + lockedTTL := time.Minute + updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + LockedTTLMillis: lockedTTL.Milliseconds(), + UpdateWorkspaceLockedAt: true, + }) + require.NoError(t, err) + require.Equal(t, lockedTTL.Milliseconds(), updated.LockedTTLMillis) + + unlockedWorkspace = coderdtest.MustWorkspace(t, client, unlockedWorkspace.ID) + require.Nil(t, unlockedWorkspace.LockedAt) + require.Nil(t, unlockedWorkspace.DeletingAt) + + updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) + require.NotNil(t, updatedLockedWorkspace.LockedAt) + require.NotNil(t, updatedLockedWorkspace.DeletingAt) + // Validate that the workspace locked_at value is updated. + require.True(t, updatedLockedWorkspace.LockedAt.After(*lockedWorkspace.LockedAt)) + require.Equal(t, updatedLockedWorkspace.LockedAt.Add(lockedTTL), *updatedLockedWorkspace.DeletingAt) + }) + + t.Run("UpdateLastUsedAt", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }, + }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + unlockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + require.Nil(t, unlockedWorkspace.DeletingAt) + require.Nil(t, lockedWorkspace.DeletingAt) + + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, unlockedWorkspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + + err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{ + Lock: true, + }) + require.NoError(t, err) + + lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) + require.NotNil(t, lockedWorkspace.LockedAt) + // The deleting_at field should be nil since there is no template locked_ttl set. + require.Nil(t, lockedWorkspace.DeletingAt) + + inactivityTTL := time.Minute + updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + InactivityTTLMillis: inactivityTTL.Milliseconds(), + UpdateWorkspaceLastUsedAt: true, + }) + require.NoError(t, err) + require.Equal(t, inactivityTTL.Milliseconds(), updated.InactivityTTLMillis) + + updatedUnlockedWS := coderdtest.MustWorkspace(t, client, unlockedWorkspace.ID) + require.Nil(t, updatedUnlockedWS.LockedAt) + require.Nil(t, updatedUnlockedWS.DeletingAt) + require.True(t, updatedUnlockedWS.LastUsedAt.After(unlockedWorkspace.LastUsedAt)) + + updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) + require.NotNil(t, updatedLockedWorkspace.LockedAt) + require.Nil(t, updatedLockedWorkspace.DeletingAt) + // Validate that the workspace locked_at value is updated. + require.Equal(t, updatedLockedWorkspace.LockedAt, lockedWorkspace.LockedAt) + require.True(t, updatedLockedWorkspace.LastUsedAt.After(lockedWorkspace.LastUsedAt)) + }) } func TestTemplateACL(t *testing.T) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a9104e8f3cf90..7d93591344fb8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1153,6 +1153,8 @@ export interface UpdateTemplateMeta { readonly failure_ttl_ms?: number readonly inactivity_ttl_ms?: number readonly locked_ttl_ms?: number + readonly update_workspace_last_used_at: boolean + readonly update_workspace_locked_at: boolean } // From codersdk/users.go diff --git a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx index 5d77d34b28906..5a1cfcc80060e 100644 --- a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx +++ b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx @@ -7,6 +7,9 @@ import { DialogActionButtonsProps, } from "../Dialog" import { ConfirmDialogType } from "../types" +import Checkbox from "@mui/material/Checkbox" +import FormControlLabel from "@mui/material/FormControlLabel" +import { Stack } from "@mui/system" interface ConfirmDialogTypeConfig { confirmText: ReactNode @@ -151,3 +154,168 @@ export const ConfirmDialog: FC> = ({ ) } + +export interface ScheduleDialogProps extends ConfirmDialogProps { + readonly inactiveWorkspacesToGoDormant: number + readonly inactiveWorkspacesToGoDormantInWeek: number + readonly dormantWorkspacesToBeDeleted: number + readonly dormantWorkspacesToBeDeletedInWeek: number + readonly updateLockedWorkspaces: (confirm: boolean) => void + readonly updateInactiveWorkspaces: (confirm: boolean) => void + readonly dormantValueChanged: boolean + readonly deletionValueChanged: boolean +} + +export const ScheduleDialog: FC> = ({ + cancelText, + confirmLoading, + disabled = false, + hideCancel, + onClose, + onConfirm, + type, + open = false, + title, + inactiveWorkspacesToGoDormant, + inactiveWorkspacesToGoDormantInWeek, + dormantWorkspacesToBeDeleted, + dormantWorkspacesToBeDeletedInWeek, + updateLockedWorkspaces, + updateInactiveWorkspaces, + dormantValueChanged, + deletionValueChanged, +}) => { + const styles = useScheduleStyles({ type }) + + const defaults = CONFIRM_DIALOG_DEFAULTS["delete"] + + if (typeof hideCancel === "undefined") { + hideCancel = defaults.hideCancel + } + + const showDormancyWarning = + dormantValueChanged && + (inactiveWorkspacesToGoDormant > 0 || + inactiveWorkspacesToGoDormantInWeek > 0) + const showDeletionWarning = + deletionValueChanged && + (dormantWorkspacesToBeDeleted > 0 || dormantWorkspacesToBeDeletedInWeek > 0) + + return ( + +
+

{title}

+ <> + {showDormancyWarning && ( + <> +

{"Dormancy Threshold"}

+ +
{` + This change will result in ${inactiveWorkspacesToGoDormant} workspaces being immediately transitioned to the dormant state and ${inactiveWorkspacesToGoDormantInWeek} over the next seven days. To prevent this, do you want to reset the inactivity period for all template workspaces?`}
+ { + updateInactiveWorkspaces(e.target.checked) + }} + /> + } + label="Reset" + /> +
+ + )} + + {showDeletionWarning && ( + <> +

{"Dormancy Auto-Deletion"}

+ +
{`This change will result in ${dormantWorkspacesToBeDeleted} workspaces being immediately deleted and ${dormantWorkspacesToBeDeletedInWeek} over the next 7 days. To prevent this, do you want to reset the dormancy period for all template workspaces?`}
+ { + updateLockedWorkspaces(e.target.checked) + }} + /> + } + label="Reset" + /> +
+ + )} + +
+ + + + +
+ ) +} + +const useScheduleStyles = makeStyles((theme) => ({ + dialogWrapper: { + "& .MuiPaper-root": { + background: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + width: "100%", + maxWidth: theme.spacing(125), + }, + "& .MuiDialogActions-spacing": { + padding: `0 ${theme.spacing(5)} ${theme.spacing(5)}`, + }, + }, + dialogContent: { + color: theme.palette.text.secondary, + padding: theme.spacing(5), + }, + dialogTitle: { + margin: 0, + marginBottom: theme.spacing(2), + color: theme.palette.text.primary, + fontWeight: 400, + fontSize: theme.spacing(2.5), + }, + dialogDescription: { + color: theme.palette.text.secondary, + lineHeight: "160%", + fontSize: 16, + + "& strong": { + color: theme.palette.text.primary, + }, + + "& p:not(.MuiFormHelperText-root)": { + margin: 0, + }, + + "& > p": { + margin: theme.spacing(1, 0), + }, + }, +})) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 4e97fa0bb4b12..d64e25dd89979 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,6 +1,5 @@ import Button from "@mui/material/Button" import { makeStyles } from "@mui/styles" -import LockIcon from "@mui/icons-material/Lock" import { Avatar } from "components/Avatar/Avatar" import { AgentRow } from "components/Resources/AgentRow" import { @@ -27,7 +26,7 @@ import { } from "components/PageHeader/FullWidthPageHeader" import { TemplateVersionWarnings } from "components/TemplateVersionWarnings/TemplateVersionWarnings" import { ErrorAlert } from "components/Alert/ErrorAlert" -import { LockedWorkspaceBanner } from "components/WorkspaceDeletion" +import { DormantWorkspaceBanner } from "components/WorkspaceDeletion" import { useLocalStorage } from "hooks" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import AlertTitle from "@mui/material/AlertTitle" @@ -54,7 +53,7 @@ export interface WorkspaceProps { handleCancel: () => void handleSettings: () => void handleChangeVersion: () => void - handleUnlock: () => void + handleDormantActivate: () => void isUpdating: boolean isRestarting: boolean workspace: TypesGen.Workspace @@ -88,7 +87,7 @@ export const Workspace: FC> = ({ handleCancel, handleSettings, handleChangeVersion, - handleUnlock, + handleDormantActivate: handleDormantActivate, workspace, isUpdating, isRestarting, @@ -170,19 +169,14 @@ export const Workspace: FC> = ({ <> - {workspace.locked_at ? ( - - ) : ( - - {workspace.name} - - )} - + + {workspace.name} +
{workspace.name} {workspace.owner_name} @@ -211,7 +205,7 @@ export const Workspace: FC> = ({ handleCancel={handleCancel} handleSettings={handleSettings} handleChangeVersion={handleChangeVersion} - handleUnlock={handleUnlock} + handleDormantActivate={handleDormantActivate} canChangeVersions={canChangeVersions} isUpdating={isUpdating} isRestarting={isRestarting} @@ -262,7 +256,7 @@ export const Workspace: FC> = ({ {/* determines its own visibility */} - = ({ ) } -export const UnlockButton: FC = ({ +export const ActivateButton: FC = ({ handleAction, loading, }) => { return ( } + startIcon={} onClick={handleAction} > - Unlock + Activate ) } diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 409c77e673eab..23c2398372f40 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -12,7 +12,7 @@ import { StopButton, RestartButton, UpdateButton, - UnlockButton, + ActivateButton, } from "./Buttons" import { ButtonMapping, @@ -34,7 +34,7 @@ export interface WorkspaceActionsProps { handleCancel: () => void handleSettings: () => void handleChangeVersion: () => void - handleUnlock: () => void + handleDormantActivate: () => void isUpdating: boolean isRestarting: boolean children?: ReactNode @@ -51,7 +51,7 @@ export const WorkspaceActions: FC = ({ handleCancel, handleSettings, handleChangeVersion, - handleUnlock, + handleDormantActivate: handleDormantActivate, isUpdating, isRestarting, canChangeVersions, @@ -96,9 +96,11 @@ export const WorkspaceActions: FC = ({ [ButtonTypesEnum.canceling]: , [ButtonTypesEnum.deleted]: , [ButtonTypesEnum.pending]: , - [ButtonTypesEnum.unlock]: , - [ButtonTypesEnum.unlocking]: ( - + [ButtonTypesEnum.activate]: ( + + ), + [ButtonTypesEnum.activating]: ( + ), } diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index c21fdedc98249..1d2eeb9d4811e 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -12,8 +12,8 @@ export enum ButtonTypesEnum { deleting = "deleting", update = "update", updating = "updating", - unlock = "lock", - unlocking = "unlocking", + activate = "activate", + activating = "activating", // disabled buttons canceling = "canceling", deleted = "deleted", @@ -36,7 +36,7 @@ export const actionsByWorkspaceStatus = ( ): WorkspaceAbilities => { if (workspace.locked_at) { return { - actions: [ButtonTypesEnum.unlock], + actions: [ButtonTypesEnum.activate], canCancel: false, canAcceptJobs: false, } diff --git a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx index 501dd50dfa95f..b65f3a5cdc28b 100644 --- a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx +++ b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx @@ -10,7 +10,7 @@ export enum Count { Multiple, } -export const LockedWorkspaceBanner = ({ +export const DormantWorkspaceBanner = ({ workspaces, onDismiss, shouldRedisplayBanner, @@ -61,18 +61,18 @@ export const LockedWorkspaceBanner = ({ hasDeletionScheduledWorkspaces.deleting_at && hasDeletionScheduledWorkspaces.locked_at ) { - return `This workspace has been locked since ${formatDistanceToNow( + return `This workspace has been dormant for ${formatDistanceToNow( Date.parse(hasDeletionScheduledWorkspaces.locked_at), - )} and is scheduled to be deleted at ${formatDate( + )} and is scheduled to be deleted on ${formatDate( hasDeletionScheduledWorkspaces.deleting_at, - )} . To keep it you must unlock the workspace.` + )} . To keep it you must activate the workspace.` } else if (hasLockedWorkspaces && hasLockedWorkspaces.locked_at) { - return `This workspace has been locked since ${formatDate( - hasLockedWorkspaces.locked_at, + return `This workspace has been dormant for ${formatDistanceToNow( + Date.parse(hasLockedWorkspaces.locked_at), )} and cannot be interacted - with. Locked workspaces are eligible for - permanent deletion. To prevent deletion, unlock + with. Dormant workspaces are eligible for + permanent deletion. To prevent deletion, activate the workspace.` } } @@ -92,8 +92,8 @@ export const LockedWorkspaceBanner = ({ > workspaces {" "} - that may be deleted soon due to inactivity. Unlock the workspaces you - wish to retain. + that may be deleted soon due to inactivity. Activate the workspaces + you wish to retain. )} diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 75a35f6839657..5618ba056d7d8 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -4,10 +4,7 @@ import { FC, PropsWithChildren } from "react" import { makeStyles } from "@mui/styles" import { combineClasses } from "utils/combineClasses" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" -import { - LockedBadge, - ImpendingDeletionText, -} from "components/WorkspaceDeletion" +import { ImpendingDeletionText } from "components/WorkspaceDeletion" import { getDisplayWorkspaceStatus } from "utils/workspace" import Tooltip, { TooltipProps, tooltipClasses } from "@mui/material/Tooltip" import { styled } from "@mui/material/styles" @@ -28,10 +25,6 @@ export const WorkspaceStatusBadge: FC< ) return ( - {/* determines its own visibility */} - - - = ({ icon: template.icon, allow_user_cancel_workspace_jobs: template.allow_user_cancel_workspace_jobs, + update_workspace_last_used_at: false, + update_workspace_locked_at: false, }, validationSchema, onSubmit, diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 597ad0a15387e..9f9f0d231f9c4 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -33,6 +33,8 @@ const validFormValues: FormValues = { failure_ttl_ms: 0, inactivity_ttl_ms: 0, locked_ttl_ms: 0, + update_workspace_last_used_at: false, + update_workspace_locked_at: false, } const renderTemplateSettingsPage = async () => { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index 1efdcec885cfb..180d81df978b8 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -16,7 +16,6 @@ import Link from "@mui/material/Link" import Checkbox from "@mui/material/Checkbox" import FormControlLabel from "@mui/material/FormControlLabel" import Switch from "@mui/material/Switch" -import { DeleteLockedDialog, InactivityDialog } from "./InactivityDialog" import { useWorkspacesToBeLocked, useWorkspacesToBeDeleted, @@ -24,6 +23,7 @@ import { import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers" import { TTLHelperText } from "./TTLHelperText" import { docs } from "utils/docs" +import { ScheduleDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" const MS_HOUR_CONVERSION = 3600000 const MS_DAY_CONVERSION = 86400000 @@ -87,21 +87,30 @@ export const TemplateScheduleForm: FC = ({ allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms), locked_cleanup_enabled: allowAdvancedScheduling && Boolean(template.locked_ttl_ms), + update_workspace_last_used_at: false, + update_workspace_locked_at: false, }, validationSchema, onSubmit: () => { - if ( + const dormancyChanged = + form.initialValues.inactivity_ttl_ms !== form.values.inactivity_ttl_ms + const deletionChanged = + form.initialValues.locked_ttl_ms !== form.values.locked_ttl_ms + + const dormancyScheduleChanged = + form.values.inactivity_cleanup_enabled && + dormancyChanged && + workspacesToDormancyInWeek && + workspacesToDormancyInWeek.length > 0 + + const deletionScheduleChanged = form.values.inactivity_cleanup_enabled && - workspacesToBeLockedToday && - workspacesToBeLockedToday.length > 0 - ) { - setIsInactivityDialogOpen(true) - } else if ( - form.values.locked_cleanup_enabled && - workspacesToBeDeletedToday && - workspacesToBeDeletedToday.length > 0 - ) { - setIsLockedDialogOpen(true) + deletionChanged && + workspacesToBeDeletedInWeek && + workspacesToBeDeletedInWeek.length > 0 + + if (dormancyScheduleChanged || deletionScheduleChanged) { + setIsScheduleDialogOpen(true) } else { submitValues() } @@ -115,18 +124,44 @@ export const TemplateScheduleForm: FC = ({ const { t } = useTranslation("templateSettingsPage") const styles = useStyles() - const workspacesToBeLockedToday = useWorkspacesToBeLocked( + const now = new Date() + const weekFromNow = new Date(now) + weekFromNow.setDate(now.getDate() + 7) + + const workspacesToDormancyNow = useWorkspacesToBeLocked( template, form.values, + now, ) - const workspacesToBeDeletedToday = useWorkspacesToBeDeleted( + + const workspacesToDormancyInWeek = useWorkspacesToBeLocked( + template, + form.values, + weekFromNow, + ) + + const workspacesToBeDeletedNow = useWorkspacesToBeDeleted( template, form.values, + now, ) - const [isInactivityDialogOpen, setIsInactivityDialogOpen] = + const workspacesToBeDeletedInWeek = useWorkspacesToBeDeleted( + template, + form.values, + weekFromNow, + ) + + const showScheduleDialog = + workspacesToDormancyNow && + workspacesToBeDeletedNow && + workspacesToDormancyInWeek && + workspacesToBeDeletedInWeek && + (workspacesToDormancyInWeek.length > 0 || + workspacesToBeDeletedInWeek.length > 0) + + const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false) - const [isLockedDialogOpen, setIsLockedDialogOpen] = useState(false) const submitValues = () => { // on submit, convert from hours => ms @@ -149,6 +184,8 @@ export const TemplateScheduleForm: FC = ({ allow_user_autostart: form.values.allow_user_autostart, allow_user_autostop: form.values.allow_user_autostop, + update_workspace_last_used_at: form.values.update_workspace_last_used_at, + update_workspace_locked_at: form.values.update_workspace_locked_at, }) } @@ -345,25 +382,25 @@ export const TemplateScheduleForm: FC = ({ } - label="Enable Inactivity TTL" + label="Enable Dormancy Threshold" /> , )} @@ -372,58 +409,88 @@ export const TemplateScheduleForm: FC = ({ } fullWidth inputProps={{ min: 0, step: "any" }} - label="Time until cleanup (days)" + label="Time until dormant (days)" type="number" /> } - label="Enable Locked TTL" + label="Enable Dormancy Auto-Deletion" /> , )} disabled={isSubmitting || !form.values.locked_cleanup_enabled} fullWidth inputProps={{ min: 0, step: "any" }} - label="Time until cleanup (days)" + label="Time until deletion (days)" type="number" /> )} - {workspacesToBeLockedToday && workspacesToBeLockedToday.length > 0 && ( - - )} - {workspacesToBeDeletedToday && workspacesToBeDeletedToday.length > 0 && ( - { + submitValues() + setIsScheduleDialogOpen(false) + // These fields are request-scoped so they should be reset + // after every submission. + form + .setFieldValue("update_workspace_locked_at", false) + .catch((error) => { + throw error + }) + form + .setFieldValue("update_workspace_last_used_at", false) + .catch((error) => { + throw error + }) + }} + inactiveWorkspacesToGoDormant={workspacesToDormancyNow.length} + inactiveWorkspacesToGoDormantInWeek={ + workspacesToDormancyInWeek.length - workspacesToDormancyNow.length + } + dormantWorkspacesToBeDeleted={workspacesToBeDeletedNow.length} + dormantWorkspacesToBeDeletedInWeek={ + workspacesToBeDeletedInWeek.length - workspacesToBeDeletedNow.length + } + open={isScheduleDialogOpen} + onClose={() => { + setIsScheduleDialogOpen(false) + }} + title="Workspace Scheduling" + updateLockedWorkspaces={(update: boolean) => + form.setFieldValue("update_workspace_locked_at", update) + } + updateInactiveWorkspaces={(update: boolean) => + form.setFieldValue("update_workspace_last_used_at", update) + } + dormantValueChanged={ + form.initialValues.inactivity_ttl_ms !== + form.values.inactivity_ttl_ms + } + deletionValueChanged={ + form.initialValues.locked_ttl_ms !== form.values.locked_ttl_ms + } /> )} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx index ee1fe7b13d302..d36fcd85b021c 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx @@ -51,10 +51,10 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => }, ), inactivity_ttl_ms: Yup.number() - .min(0, "Inactivity cleanup days must not be less than 0.") + .min(0, "Dormancy threshold days must not be less than 0.") .test( "positive-if-enabled", - "Inactivity cleanup days must be greater than zero when enabled.", + "Dormancy threshold days must be greater than zero when enabled.", function (value) { const parent = this.parent as TemplateScheduleFormValues if (parent.inactivity_cleanup_enabled) { @@ -65,10 +65,10 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => }, ), locked_ttl_ms: Yup.number() - .min(0, "Locked cleanup days must not be less than 0.") + .min(0, "Dormancy auto-deletion days must not be less than 0.") .test( "positive-if-enabled", - "Locked cleanup days must be greater than zero when enabled.", + "Dormancy auto-deletion days must be greater than zero when enabled.", function (value) { const parent = this.parent as TemplateScheduleFormValues if (parent.locked_cleanup_enabled) { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts index 9836c5b273b3f..346d371f02951 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts @@ -1,23 +1,20 @@ -import { useQuery } from "@tanstack/react-query" -import { getWorkspaces } from "api/api" import { compareAsc } from "date-fns" import { Workspace, Template } from "api/typesGenerated" import { TemplateScheduleFormValues } from "./formHelpers" +import { useWorkspacesData } from "pages/WorkspacesPage/data" export const useWorkspacesToBeLocked = ( template: Template, formValues: TemplateScheduleFormValues, + fromDate: Date, ) => { - const { data: workspacesData } = useQuery({ - queryKey: ["workspaces"], - queryFn: () => - getWorkspaces({ - q: "template:" + template.name, - }), - enabled: formValues.inactivity_cleanup_enabled, + const { data } = useWorkspacesData({ + page: 0, + limit: 0, + query: "template:" + template.name, }) - return workspacesData?.workspaces?.filter((workspace: Workspace) => { + return data?.workspaces?.filter((workspace: Workspace) => { if (!formValues.inactivity_ttl_ms) { return } @@ -28,38 +25,38 @@ export const useWorkspacesToBeLocked = ( const proposedLocking = new Date( new Date(workspace.last_used_at).getTime() + - formValues.inactivity_ttl_ms * 86400000, + formValues.inactivity_ttl_ms * DayInMS, ) - if (compareAsc(proposedLocking, new Date()) < 1) { + if (compareAsc(proposedLocking, fromDate) < 1) { return workspace } }) } +const DayInMS = 86400000 + export const useWorkspacesToBeDeleted = ( template: Template, formValues: TemplateScheduleFormValues, + fromDate: Date, ) => { - const { data: workspacesData } = useQuery({ - queryKey: ["workspaces"], - queryFn: () => - getWorkspaces({ - q: "template:" + template.name, - }), - enabled: formValues.locked_cleanup_enabled, + const { data } = useWorkspacesData({ + page: 0, + limit: 0, + query: "template:" + template.name + " locked_at:1970-01-01", }) - return workspacesData?.workspaces?.filter((workspace: Workspace) => { + return data?.workspaces?.filter((workspace: Workspace) => { if (!workspace.locked_at || !formValues.locked_ttl_ms) { return false } const proposedLocking = new Date( new Date(workspace.locked_at).getTime() + - formValues.locked_ttl_ms * 86400000, + formValues.locked_ttl_ms * DayInMS, ) - if (compareAsc(proposedLocking, new Date()) < 1) { + if (compareAsc(proposedLocking, fromDate) < 1) { return workspace } }) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index 15612a544d89e..dd03610717829 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -23,6 +23,8 @@ const validFormValues = { failure_ttl_ms: 7, inactivity_ttl_ms: 180, locked_ttl_ms: 30, + update_workspace_last_used_at: false, + update_workspace_locked_at: false, } const renderTemplateSchedulePage = async () => { @@ -63,12 +65,12 @@ const fillAndSubmitForm = async ({ await user.type(failureTtlField, failure_ttl_ms.toString()) const inactivityTtlField = screen.getByRole("checkbox", { - name: /Inactivity TTL/i, + name: /Dormancy Threshold/i, }) await user.type(inactivityTtlField, inactivity_ttl_ms.toString()) const lockedTtlField = screen.getByRole("checkbox", { - name: /Locked TTL/i, + name: /Dormancy Auto-Deletion/i, }) await user.type(lockedTtlField, locked_ttl_ms.toString()) @@ -123,7 +125,7 @@ describe("TemplateSchedulePage", () => { ) }) - test("failure, inactivity, and locked ttl converted to and from days", async () => { + test("failure, dormancy, and dormancy auto-deletion converted to and from days", async () => { await renderTemplateSchedulePage() jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({ @@ -237,11 +239,11 @@ describe("TemplateSchedulePage", () => { } const validate = () => getValidationSchema().validateSync(values) expect(validate).toThrowError( - "Inactivity cleanup days must not be less than 0.", + "Dormancy threshold days must not be less than 0.", ) }) - it("allows a locked ttl of 7 days", () => { + it("allows a dormancy ttl of 7 days", () => { const values: UpdateTemplateMeta = { ...validFormValues, locked_ttl_ms: 86400000 * 7, @@ -250,7 +252,7 @@ describe("TemplateSchedulePage", () => { expect(validate).not.toThrowError() }) - it("allows a locked ttl of 0", () => { + it("allows a dormancy ttl of 0", () => { const values: UpdateTemplateMeta = { ...validFormValues, locked_ttl_ms: 0, @@ -266,7 +268,7 @@ describe("TemplateSchedulePage", () => { } const validate = () => getValidationSchema().validateSync(values) expect(validate).toThrowError( - "Locked cleanup days must not be less than 0.", + "Dormancy auto-deletion days must not be less than 0.", ) }) }) diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 4dc714960e57e..e7a92458e63bd 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -176,7 +176,7 @@ export const WorkspaceReadyPage = ({ handleChangeVersion={() => { setChangeVersionDialogOpen(true) }} - handleUnlock={() => workspaceSend({ type: "UNLOCK" })} + handleDormantActivate={() => workspaceSend({ type: "ACTIVATE" })} resources={workspace.latest_build.resources} builds={builds} canUpdateWorkspace={canUpdateWorkspace} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 78e61d7be39b4..9190db98c9575 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -14,7 +14,7 @@ import { Stack } from "components/Stack/Stack" import { WorkspaceHelpTooltip } from "components/Tooltips" import { WorkspacesTable } from "pages/WorkspacesPage/WorkspacesTable" import { useLocalStorage } from "hooks" -import { LockedWorkspaceBanner, Count } from "components/WorkspaceDeletion" +import { DormantWorkspaceBanner, Count } from "components/WorkspaceDeletion" import { ErrorAlert } from "components/Alert/ErrorAlert" import { WorkspacesFilter } from "./filter/filter" import { hasError, isApiValidationError } from "api/errors" @@ -101,7 +101,7 @@ export const WorkspacesPageView: FC< {/* determines its own visibility */} - diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index ead81dcf5a3e4..d3e9f7204e25c 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -96,7 +96,7 @@ export type WorkspaceEvent = | { type: "INCREASE_DEADLINE"; hours: number } | { type: "DECREASE_DEADLINE"; hours: number } | { type: "RETRY_BUILD" } - | { type: "UNLOCK" } + | { type: "ACTIVATE" } export const checks = { readWorkspace: "readWorkspace", @@ -171,7 +171,7 @@ export const workspaceMachine = createMachine( cancelWorkspace: { data: Types.Message } - unlockWorkspace: { + activateWorkspace: { data: Types.Message } listening: { @@ -264,7 +264,7 @@ export const workspaceMachine = createMachine( actions: ["enableDebugMode"], }, ], - UNLOCK: "requestingUnlock", + ACTIVATE: "requestingActivate", }, }, askingDelete: { @@ -410,15 +410,15 @@ export const workspaceMachine = createMachine( ], }, }, - requestingUnlock: { + requestingActivate: { entry: ["clearBuildError"], invoke: { - src: "unlockWorkspace", - id: "unlockWorkspace", + src: "activateWorkspace", + id: "activateWorkspace", onDone: "idle", onError: { target: "idle", - actions: ["displayUnlockError"], + actions: ["displayActivateError"], }, }, }, @@ -576,8 +576,8 @@ export const workspaceMachine = createMachine( ) displayError(message) }, - displayUnlockError: (_, { data }) => { - const message = getErrorMessage(data, "Error unlocking workspace.") + displayActivateError: (_, { data }) => { + const message = getErrorMessage(data, "Error activate workspace.") displayError(message) }, assignMissedParameters: assign({ @@ -695,16 +695,16 @@ export const workspaceMachine = createMachine( throw Error("Cannot cancel workspace without build id") } }, - unlockWorkspace: (context) => async (send) => { + activateWorkspace: (context) => async (send) => { if (context.workspace) { - const unlockWorkspacePromise = await API.updateWorkspaceLock( + const activateWorkspacePromise = await API.updateWorkspaceLock( context.workspace.id, false, ) - send({ type: "REFRESH_WORKSPACE", data: unlockWorkspacePromise }) - return unlockWorkspacePromise + send({ type: "REFRESH_WORKSPACE", data: activateWorkspacePromise }) + return activateWorkspacePromise } else { - throw Error("Cannot unlock workspace without workspace id") + throw Error("Cannot activate workspace without workspace id") } }, listening: (context) => (send) => { From 31ffb566d0cedbf1d80f140bc9de36049dc1fe0a Mon Sep 17 00:00:00 2001 From: Kayla Washburn Date: Tue, 22 Aug 2023 14:57:46 -0600 Subject: [PATCH 206/277] fix: disable setup page once setup has been completed (#9198) --- site/src/pages/SetupPage/SetupPage.test.tsx | 101 +++++++++++++++++++- site/src/pages/SetupPage/SetupPage.tsx | 22 +++-- 2 files changed, 113 insertions(+), 10 deletions(-) diff --git a/site/src/pages/SetupPage/SetupPage.test.tsx b/site/src/pages/SetupPage/SetupPage.test.tsx index 7d53fe1dce8c2..90272e00fea9f 100644 --- a/site/src/pages/SetupPage/SetupPage.test.tsx +++ b/site/src/pages/SetupPage/SetupPage.test.tsx @@ -1,7 +1,8 @@ import { fireEvent, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { rest } from "msw" -import { render } from "testHelpers/renderHelpers" +import { createMemoryRouter } from "react-router-dom" +import { render, renderWithRouter } from "testHelpers/renderHelpers" import { server } from "testHelpers/server" import { SetupPage } from "./SetupPage" import { Language as PageViewLanguage } from "./SetupPageView" @@ -35,6 +36,12 @@ describe("Setup Page", () => { rest.get("/api/v2/users/me", (req, res, ctx) => { return res(ctx.status(401), ctx.json({ message: "no user here" })) }), + rest.get("/api/v2/users/first", (req, res, ctx) => { + return res( + ctx.status(404), + ctx.json({ message: "no first user has been created" }), + ) + }), ) }) @@ -63,23 +70,109 @@ describe("Setup Page", () => { ) }), ) + render() await fillForm() const errorMessage = await screen.findByText(fieldErrorMessage) expect(errorMessage).toBeDefined() }) - it("redirects to workspaces page when success", async () => { + it("redirects to the app when setup is successful", async () => { + let userHasBeenCreated = false + + server.use( + rest.get("/api/v2/users/me", (req, res, ctx) => { + if (!userHasBeenCreated) { + return res(ctx.status(401), ctx.json({ message: "no user here" })) + } + return res(ctx.status(200), ctx.json(MockUser)) + }), + rest.get("/api/v2/users/first", (req, res, ctx) => { + if (!userHasBeenCreated) { + return res( + ctx.status(404), + ctx.json({ message: "no first user has been created" }), + ) + } + return res( + ctx.status(200), + ctx.json({ message: "hooray, someone exists!" }), + ) + }), + rest.post("/api/v2/users/first", (req, res, ctx) => { + userHasBeenCreated = true + return res( + ctx.status(200), + ctx.json({ data: "user setup was successful!" }), + ) + }), + ) + render() + await fillForm() + await waitFor(() => expect(window.location).toBeAt("/")) + }) + + it("redirects to login if setup has already completed", async () => { + // simulates setup having already been completed + server.use( + rest.get("/api/v2/users/first", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ message: "hooray, someone exists!" }), + ) + }), + ) + + renderWithRouter( + createMemoryRouter( + [ + { + path: "/setup", + element: , + }, + { + path: "/login", + element:

Login

, + }, + ], + { initialEntries: ["/setup"] }, + ), + ) + await screen.findByText("Login") + }) + + it("redirects to the app when already logged in", async () => { // simulates the user will be authenticated server.use( rest.get("/api/v2/users/me", (req, res, ctx) => { return res(ctx.status(200), ctx.json(MockUser)) }), + rest.get("/api/v2/users/first", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ message: "hooray, someone exists!" }), + ) + }), ) - await fillForm() - await waitFor(() => expect(window.location).toBeAt("/workspaces")) + renderWithRouter( + createMemoryRouter( + [ + { + path: "/setup", + element: , + }, + { + path: "/", + element:

Workspaces

, + }, + ], + { initialEntries: ["/setup"] }, + ), + ) + + await screen.findByText("Workspaces") }) }) diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index 56c9b9b78f5f0..17aef0f3188a0 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -1,10 +1,11 @@ import { useMachine } from "@xstate/react" import { useAuth } from "components/AuthProvider/AuthProvider" -import { FC, useEffect } from "react" +import { FC } from "react" import { Helmet } from "react-helmet-async" import { pageTitle } from "utils/page" import { setupMachine } from "xServices/setup/setupXService" import { SetupPageView } from "./SetupPageView" +import { Navigate } from "react-router-dom" export const SetupPage: FC = () => { const [authState, authSend] = useAuth() @@ -24,11 +25,20 @@ export const SetupPage: FC = () => { }) const { error } = setupState.context - useEffect(() => { - if (authState.matches("signedIn")) { - window.location.assign("/workspaces") - } - }, [authState]) + const userIsSignedIn = authState.matches("signedIn") + const setupIsComplete = + !authState.matches("loadingInitialAuthData") && + !authState.matches("configuringTheFirstUser") + + // If the user is logged in, navigate to the app + if (userIsSignedIn) { + return + } + + // If we've already completed setup, navigate to the login page + if (setupIsComplete) { + return + } return ( <> From ed2b1236c0ae23f86d9fd969d7cd91bb3b02bd59 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 23 Aug 2023 11:58:25 +0300 Subject: [PATCH 207/277] fix(coderd/batchstats): fix init race and close flush (#9248) --- coderd/batchstats/batcher.go | 11 +++++++++-- coderd/batchstats/batcher_internal_test.go | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/coderd/batchstats/batcher.go b/coderd/batchstats/batcher.go index e53a24cb4c1d1..b3b881f2133e9 100644 --- a/coderd/batchstats/batcher.go +++ b/coderd/batchstats/batcher.go @@ -105,6 +105,8 @@ func New(ctx context.Context, opts ...Option) (*Batcher, func(), error) { b.tickCh = b.ticker.C } + b.initBuf(b.batchSize) + cancelCtx, cancelFunc := context.WithCancel(ctx) done := make(chan struct{}) go func() { @@ -172,7 +174,6 @@ func (b *Batcher) Add( // Run runs the batcher. func (b *Batcher) run(ctx context.Context) { - b.initBuf(b.batchSize) // nolint:gocritic // This is only ever used for one thing - inserting agent stats. authCtx := dbauthz.AsSystemRestricted(ctx) for { @@ -184,7 +185,13 @@ func (b *Batcher) run(ctx context.Context) { b.flush(authCtx, true, "reaching capacity") case <-ctx.Done(): b.log.Debug(ctx, "context done, flushing before exit") - b.flush(authCtx, true, "exit") + + // We must create a new context here as the parent context is done. + ctxTimeout, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() //nolint:revive // We're returning, defer is fine. + + // nolint:gocritic // This is only ever used for one thing - inserting agent stats. + b.flush(dbauthz.AsSystemRestricted(ctxTimeout), true, "exit") return } } diff --git a/coderd/batchstats/batcher_internal_test.go b/coderd/batchstats/batcher_internal_test.go index b5d18e81327fd..8c1c367f7db5b 100644 --- a/coderd/batchstats/batcher_internal_test.go +++ b/coderd/batchstats/batcher_internal_test.go @@ -31,7 +31,7 @@ func TestBatchStats(t *testing.T) { deps1 := setupDeps(t, store) deps2 := setupDeps(t, store) tick := make(chan time.Time) - flushed := make(chan int) + flushed := make(chan int, 1) b, closer, err := New(ctx, WithStore(store), From d37f6d80f77694b2fb890635d58299346dd59c3a Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Wed, 23 Aug 2023 12:27:57 +0300 Subject: [PATCH 208/277] chore(docs): update docs for correct use of shell and console and enforce linewidth (#9245) --- .prettierrc.yaml | 10 +- README.md | 2 +- SECURITY.md | 86 +++--- docs/CONTRIBUTING.md | 139 ++++++--- docs/about/architecture.md | 33 ++- docs/admin/app-logs.md | 19 +- docs/admin/appearance.md | 12 +- docs/admin/audit-logs.md | 40 ++- docs/admin/auth.md | 243 ++++++++++------ docs/admin/automation.md | 37 ++- docs/admin/configure.md | 91 +++--- docs/admin/git-providers.md | 65 +++-- docs/admin/groups.md | 7 +- docs/admin/high-availability.md | 46 +-- docs/admin/prometheus.md | 32 +- docs/admin/provisioners.md | 104 ++++--- docs/admin/quotas.md | 37 +-- docs/admin/rbac.md | 10 +- docs/admin/scale.md | 197 ++++++++----- docs/admin/telemetry.md | 27 +- docs/admin/upgrade.md | 25 +- docs/admin/users.md | 83 ++++-- docs/admin/workspace-proxies.md | 67 ++++- docs/api/authentication.md | 2 +- docs/api/index.md | 4 +- docs/changelogs/README.md | 2 +- docs/changelogs/v0.25.0.md | 42 ++- docs/changelogs/v0.26.0.md | 28 +- docs/changelogs/v0.26.1.md | 16 +- docs/changelogs/v0.27.0.md | 60 ++-- docs/changelogs/v0.27.1.md | 7 +- docs/changelogs/v0.27.3.md | 7 +- docs/changelogs/v2.0.0.md | 132 ++++++--- docs/changelogs/v2.0.2.md | 52 +++- docs/changelogs/v2.1.0.md | 47 ++- docs/changelogs/v2.1.1.md | 28 +- docs/cli.md | 9 +- docs/contributing/CODE_OF_CONDUCT.md | 31 +- docs/contributing/SECURITY.md | 4 +- docs/contributing/documentation.md | 31 +- docs/contributing/feature-stages.md | 14 +- docs/contributing/frontend.md | 156 +++++++--- docs/dotfiles.md | 17 +- docs/enterprise.md | 6 +- docs/ides.md | 28 +- docs/ides/emacs-tramp.md | 90 ++++-- docs/ides/gateway.md | 33 ++- docs/ides/remote-desktops.md | 20 +- docs/ides/web-ides.md | 15 +- docs/install/binary.md | 21 +- docs/install/database.md | 30 +- docs/install/docker.md | 48 +-- docs/install/install.sh.md | 13 +- docs/install/kubernetes.md | 81 +++--- docs/install/offline.md | 66 +++-- docs/install/openshift.md | 101 ++++--- docs/install/packages.md | 9 +- docs/install/uninstall.md | 8 +- docs/install/windows.md | 14 +- docs/networking/index.md | 53 ++-- docs/networking/port-forwarding.md | 51 ++-- docs/platforms/aws.md | 51 +++- docs/platforms/azure.md | 70 +++-- docs/platforms/docker.md | 37 ++- docs/platforms/google-cloud-platform.md | 65 +++-- docs/platforms/jfrog.md | 77 +++-- .../kubernetes/additional-clusters.md | 64 ++-- docs/platforms/kubernetes/deployment-logs.md | 33 ++- docs/platforms/kubernetes/index.md | 12 +- docs/platforms/other.md | 5 +- docs/secrets.md | 39 +-- .../0001_user_apikeys_invalidation.md | 35 ++- docs/security/index.md | 13 +- docs/templates/README.md | 28 +- docs/templates/agent-metadata.md | 46 +-- docs/templates/authentication.md | 33 ++- docs/templates/change-management.md | 10 +- docs/templates/devcontainers.md | 42 ++- docs/templates/docker-in-workspaces.md | 126 +++++--- docs/templates/index.md | 273 +++++++++++++----- docs/templates/modules.md | 60 ++-- docs/templates/open-in-coder.md | 26 +- docs/templates/parameters.md | 115 +++++--- docs/templates/resource-metadata.md | 17 +- docs/templates/resource-persistence.md | 36 ++- docs/workspaces.md | 61 ++-- dogfood/guide.md | 50 +++- examples/lima/README.md | 2 +- examples/templates/community-templates.md | 33 ++- examples/web-server/apache/README.md | 26 +- examples/web-server/caddy/README.md | 10 +- examples/web-server/nginx/README.md | 28 +- helm/provisioner/Chart.yaml | 4 +- .../apidocgen/markdown-template/security.def | 2 +- scripts/apidocgen/postprocess/main.go | 4 +- site/.prettierrc.yaml | 10 +- 96 files changed, 2856 insertions(+), 1475 deletions(-) diff --git a/.prettierrc.yaml b/.prettierrc.yaml index 9ba1d2ca9db7a..7fe31e7338ad4 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -2,6 +2,7 @@ # formatting for prettier-supported files. See `.editorconfig` and # `site/.editorconfig`for whitespace formatting options. printWidth: 80 +proseWrap: always semi: false trailingComma: all useTabs: false @@ -9,10 +10,9 @@ tabWidth: 2 overrides: - files: - README.md + - docs/api/**/*.md + - docs/cli/**/*.md + - .github/**/*.{yaml,yml,toml} + - scripts/**/*.{yaml,yml,toml} options: proseWrap: preserve - - files: - - "site/**/*.yaml" - - "site/**/*.yml" - options: - proseWrap: always diff --git a/README.md b/README.md index 9443eb6b701fd..3f7d835125ff9 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ You can run the install script with `--dry-run` to see the commands that will be Once installed, you can start a production deployment1 with a single command: -```console +```shell # Automatically sets up an external access URL on *.try.coder.app coder server diff --git a/SECURITY.md b/SECURITY.md index 46986c9d3aadf..ee5ac8075eaf9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,7 @@ # Coder Security -Coder welcomes feedback from security researchers and the general public -to help improve our security. If you believe you have discovered a vulnerability, +Coder welcomes feedback from security researchers and the general public to help +improve our security. If you believe you have discovered a vulnerability, privacy issue, exposed data, or other security issues in any of our assets, we want to hear from you. This policy outlines steps for reporting vulnerabilities to us, what we expect, what you can expect from us. @@ -10,64 +10,72 @@ You can see the pretty version [here](https://coder.com/security/policy) # Why Coder's security matters -If an attacker could fully compromise a Coder installation, they could spin -up expensive workstations, steal valuable credentials, or steal proprietary -source code. We take this risk very seriously and employ routine pen testing, -vulnerability scanning, and code reviews. We also welcome the contributions -from the community that helped make this product possible. +If an attacker could fully compromise a Coder installation, they could spin up +expensive workstations, steal valuable credentials, or steal proprietary source +code. We take this risk very seriously and employ routine pen testing, +vulnerability scanning, and code reviews. We also welcome the contributions from +the community that helped make this product possible. # Where should I report security issues? -Please report security issues to security@coder.com, providing -all relevant information. The more details you provide, the easier it will be -for us to triage and fix the issue. +Please report security issues to security@coder.com, providing all relevant +information. The more details you provide, the easier it will be for us to +triage and fix the issue. # Out of Scope -Our primary concern is around an abuse of the Coder application that allows -an attacker to gain access to another users workspace, or spin up unwanted +Our primary concern is around an abuse of the Coder application that allows an +attacker to gain access to another users workspace, or spin up unwanted workspaces. - DOS/DDOS attacks affecting availability --> While we do support rate limiting - of requests, we primarily leave this to the owner of the Coder installation. Our - rationale is that a DOS attack only affecting availability is not a valuable - target for attackers. + of requests, we primarily leave this to the owner of the Coder installation. + Our rationale is that a DOS attack only affecting availability is not a + valuable target for attackers. - Abuse of a compromised user credential --> If a user credential is compromised - outside of the Coder ecosystem, then we consider it beyond the scope of our application. - However, if an unprivileged user could escalate their permissions or gain access - to another workspace, that is a cause for concern. + outside of the Coder ecosystem, then we consider it beyond the scope of our + application. However, if an unprivileged user could escalate their permissions + or gain access to another workspace, that is a cause for concern. - Vulnerabilities in third party systems --> Vulnerabilities discovered in - out-of-scope systems should be reported to the appropriate vendor or applicable authority. + out-of-scope systems should be reported to the appropriate vendor or + applicable authority. # Our Commitments When working with us, according to this policy, you can expect us to: -- Respond to your report promptly, and work with you to understand and validate your report; -- Strive to keep you informed about the progress of a vulnerability as it is processed; -- Work to remediate discovered vulnerabilities in a timely manner, within our operational constraints; and -- Extend Safe Harbor for your vulnerability research that is related to this policy. +- Respond to your report promptly, and work with you to understand and validate + your report; +- Strive to keep you informed about the progress of a vulnerability as it is + processed; +- Work to remediate discovered vulnerabilities in a timely manner, within our + operational constraints; and +- Extend Safe Harbor for your vulnerability research that is related to this + policy. # Our Expectations -In participating in our vulnerability disclosure program in good faith, we ask that you: +In participating in our vulnerability disclosure program in good faith, we ask +that you: -- Play by the rules, including following this policy and any other relevant agreements. - If there is any inconsistency between this policy and any other applicable terms, the - terms of this policy will prevail; +- Play by the rules, including following this policy and any other relevant + agreements. If there is any inconsistency between this policy and any other + applicable terms, the terms of this policy will prevail; - Report any vulnerability you’ve discovered promptly; -- Avoid violating the privacy of others, disrupting our systems, destroying data, and/or - harming user experience; +- Avoid violating the privacy of others, disrupting our systems, destroying + data, and/or harming user experience; - Use only the Official Channels to discuss vulnerability information with us; -- Provide us a reasonable amount of time (at least 90 days from the initial report) to - resolve the issue before you disclose it publicly; -- Perform testing only on in-scope systems, and respect systems and activities which - are out-of-scope; -- If a vulnerability provides unintended access to data: Limit the amount of data you - access to the minimum required for effectively demonstrating a Proof of Concept; and - cease testing and submit a report immediately if you encounter any user data during testing, - such as Personally Identifiable Information (PII), Personal Healthcare Information (PHI), - credit card data, or proprietary information; -- You should only interact with test accounts you own or with explicit permission from +- Provide us a reasonable amount of time (at least 90 days from the initial + report) to resolve the issue before you disclose it publicly; +- Perform testing only on in-scope systems, and respect systems and activities + which are out-of-scope; +- If a vulnerability provides unintended access to data: Limit the amount of + data you access to the minimum required for effectively demonstrating a Proof + of Concept; and cease testing and submit a report immediately if you encounter + any user data during testing, such as Personally Identifiable Information + (PII), Personal Healthcare Information (PHI), credit card data, or proprietary + information; +- You should only interact with test accounts you own or with explicit + permission from - the account holder; and - Do not engage in extortion. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 291f4e1444e4b..710152a9f38bb 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -2,7 +2,11 @@ ## Requirements -We recommend using the [Nix](https://nix.dev/) package manager as it makes any pain related to maintaining dependency versions [just disappear](https://twitter.com/mitchellh/status/1491102567296040961). Once nix [has been installed](https://nixos.org/download.html) the development environment can be _manually instantiated_ through the `nix-shell` command: +We recommend using the [Nix](https://nix.dev/) package manager as it makes any +pain related to maintaining dependency versions +[just disappear](https://twitter.com/mitchellh/status/1491102567296040961). Once +nix [has been installed](https://nixos.org/download.html) the development +environment can be _manually instantiated_ through the `nix-shell` command: ```shell cd ~/code/coder @@ -17,7 +21,10 @@ copying path '/nix/store/v2gvj8whv241nj4lzha3flq8pnllcmvv-ignore-5.2.0.tgz' from ... ``` -If [direnv](https://direnv.net/) is installed and the [hooks are configured](https://direnv.net/docs/hook.html) then the development environment can be _automatically instantiated_ by creating the following `.envrc`, thus removing the need to run `nix-shell` by hand! +If [direnv](https://direnv.net/) is installed and the +[hooks are configured](https://direnv.net/docs/hook.html) then the development +environment can be _automatically instantiated_ by creating the following +`.envrc`, thus removing the need to run `nix-shell` by hand! ```shell cd ~/code/coder @@ -25,7 +32,9 @@ echo "use nix" >.envrc direnv allow ``` -Now, whenever you enter the project folder, [`direnv`](https://direnv.net/docs/hook.html) will prepare the environment for you: +Now, whenever you enter the project folder, +[`direnv`](https://direnv.net/docs/hook.html) will prepare the environment for +you: ```shell cd ~/code/coder @@ -37,7 +46,8 @@ direnv: export +AR +AS +CC +CONFIG_SHELL +CXX +HOST_PATH +IN_NIX_SHELL +LD +NIX_ 🎉 ``` -Alternatively if you do not want to use nix then you'll need to install the need the following tools by hand: +Alternatively if you do not want to use nix then you'll need to install the need +the following tools by hand: - Go 1.18+ - on macOS, run `brew install go` @@ -76,35 +86,46 @@ Use the following `make` commands and scripts in development: - Run `./scripts/develop.sh` - Access `http://localhost:8080` -- The default user is `admin@coder.com` and the default password is `SomeSecurePassword!` +- The default user is `admin@coder.com` and the default password is + `SomeSecurePassword!` ### Deploying a PR -You can test your changes by creating a PR deployment. There are two ways to do this: +You can test your changes by creating a PR deployment. There are two ways to do +this: 1. By running `./scripts/deploy-pr.sh` -2. By manually triggering the [`pr-deploy.yaml`](https://github.com/coder/coder/actions/workflows/pr-deploy.yaml) GitHub Action workflow - ![Deploy PR manually](./images/deploy-pr-manually.png) +2. By manually triggering the + [`pr-deploy.yaml`](https://github.com/coder/coder/actions/workflows/pr-deploy.yaml) + GitHub Action workflow ![Deploy PR manually](./images/deploy-pr-manually.png) #### Available options - `-d` or `--deploy`, force deploys the PR by deleting the existing deployment. -- `-b` or `--build`, force builds the Docker image. (generally not needed as we are intelligently checking if the image needs to be built) -- `-e EXPERIMENT1,EXPERIMENT2` or `--experiments EXPERIMENT1,EXPERIMENT2`, will enable the specified experiments. (defaults to `*`) -- `-n` or `--dry-run` will display the context without deployment. e.g., branch name and PR number, etc. +- `-b` or `--build`, force builds the Docker image. (generally not needed as we + are intelligently checking if the image needs to be built) +- `-e EXPERIMENT1,EXPERIMENT2` or `--experiments EXPERIMENT1,EXPERIMENT2`, will + enable the specified experiments. (defaults to `*`) +- `-n` or `--dry-run` will display the context without deployment. e.g., branch + name and PR number, etc. - `-y` or `--yes`, will skip the CLI confirmation prompt. -> Note: PR deployment will be re-deployed automatically when the PR is updated. It will use the last values automatically for redeployment. +> Note: PR deployment will be re-deployed automatically when the PR is updated. +> It will use the last values automatically for redeployment. -> You need to be a member or collaborator of the of [coder](github.com/coder) GitHub organization to be able to deploy a PR. +> You need to be a member or collaborator of the of [coder](github.com/coder) +> GitHub organization to be able to deploy a PR. -Once the deployment is finished, a unique link and credentials will be posted in the [#pr-deployments](https://codercom.slack.com/archives/C05DNE982E8) Slack channel. +Once the deployment is finished, a unique link and credentials will be posted in +the [#pr-deployments](https://codercom.slack.com/archives/C05DNE982E8) Slack +channel. ### Adding database migrations and fixtures #### Database migrations -Database migrations are managed with [`migrate`](https://github.com/golang-migrate/migrate). +Database migrations are managed with +[`migrate`](https://github.com/golang-migrate/migrate). To add new migrations, use the following command: @@ -125,11 +146,15 @@ much data as possible. There are two types of fixtures that are used to test that migrations don't break existing Coder deployments: -- Partial fixtures [`migrations/testdata/fixtures`](../coderd/database/migrations/testdata/fixtures) -- Full database dumps [`migrations/testdata/full_dumps`](../coderd/database/migrations/testdata/full_dumps) +- Partial fixtures + [`migrations/testdata/fixtures`](../coderd/database/migrations/testdata/fixtures) +- Full database dumps + [`migrations/testdata/full_dumps`](../coderd/database/migrations/testdata/full_dumps) -Both types behave like database migrations (they also [`migrate`](https://github.com/golang-migrate/migrate)). Their behavior mirrors Coder migrations such that when migration -number `000022` is applied, fixture `000022` is applied afterwards. +Both types behave like database migrations (they also +[`migrate`](https://github.com/golang-migrate/migrate)). Their behavior mirrors +Coder migrations such that when migration number `000022` is applied, fixture +`000022` is applied afterwards. Partial fixtures are used to conveniently add data to newly created tables so that we can ensure that this data is migrated without issue. @@ -175,19 +200,20 @@ This helps in naming the dump (e.g. `000069` above). ### Documentation -Our style guide for authoring documentation can be found [here](./contributing/documentation.md). +Our style guide for authoring documentation can be found +[here](./contributing/documentation.md). ### Backend #### Use Go style -Contributions must adhere to the guidelines outlined in [Effective -Go](https://go.dev/doc/effective_go). We prefer linting rules over documenting -styles (run ours with `make lint`); humans are error-prone! +Contributions must adhere to the guidelines outlined in +[Effective Go](https://go.dev/doc/effective_go). We prefer linting rules over +documenting styles (run ours with `make lint`); humans are error-prone! -Read [Go's Code Review Comments -Wiki](https://github.com/golang/go/wiki/CodeReviewComments) for information on -common comments made during reviews of Go code. +Read +[Go's Code Review Comments Wiki](https://github.com/golang/go/wiki/CodeReviewComments) +for information on common comments made during reviews of Go code. #### Avoid unused packages @@ -202,8 +228,8 @@ Our frontend guide can be found [here](./contributing/frontend.md). ## Reviews -> The following information has been borrowed from [Go's review -> philosophy](https://go.dev/doc/contribute#reviews). +> The following information has been borrowed from +> [Go's review philosophy](https://go.dev/doc/contribute#reviews). Coder values thorough reviews. For each review comment that you receive, please "close" it by implementing the suggestion or providing an explanation on why the @@ -220,27 +246,45 @@ be applied selectively or to discourage anyone from contributing. ## Releases -Coder releases are initiated via [`./scripts/release.sh`](../scripts/release.sh) and automated via GitHub Actions. Specifically, the [`release.yaml`](../.github/workflows/release.yaml) workflow. They are created based on the current [`main`](https://github.com/coder/coder/tree/main) branch. +Coder releases are initiated via [`./scripts/release.sh`](../scripts/release.sh) +and automated via GitHub Actions. Specifically, the +[`release.yaml`](../.github/workflows/release.yaml) workflow. They are created +based on the current [`main`](https://github.com/coder/coder/tree/main) branch. -The release notes for a release are automatically generated from commit titles and metadata from PRs that are merged into `main`. +The release notes for a release are automatically generated from commit titles +and metadata from PRs that are merged into `main`. ### Creating a release -The creation of a release is initiated via [`./scripts/release.sh`](../scripts/release.sh). This script will show a preview of the release that will be created, and if you choose to continue, create and push the tag which will trigger the creation of the release via GitHub Actions. +The creation of a release is initiated via +[`./scripts/release.sh`](../scripts/release.sh). This script will show a preview +of the release that will be created, and if you choose to continue, create and +push the tag which will trigger the creation of the release via GitHub Actions. See `./scripts/release.sh --help` for more information. ### Creating a release (via workflow dispatch) -Typically the workflow dispatch is only used to test (dry-run) a release, meaning no actual release will take place. The workflow can be dispatched manually from [Actions: Release](https://github.com/coder/coder/actions/workflows/release.yaml). Simply press "Run workflow" and choose dry-run. +Typically the workflow dispatch is only used to test (dry-run) a release, +meaning no actual release will take place. The workflow can be dispatched +manually from +[Actions: Release](https://github.com/coder/coder/actions/workflows/release.yaml). +Simply press "Run workflow" and choose dry-run. -If a release has failed after the tag has been created and pushed, it can be retried by again, pressing "Run workflow", changing "Use workflow from" from "Branch: main" to "Tag: vX.X.X" and not selecting dry-run. +If a release has failed after the tag has been created and pushed, it can be +retried by again, pressing "Run workflow", changing "Use workflow from" from +"Branch: main" to "Tag: vX.X.X" and not selecting dry-run. ### Commit messages -Commit messages should follow the [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) specification. +Commit messages should follow the +[Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) +specification. -Allowed commit types (`feat`, `fix`, etc.) are listed in [conventional-commit-types](https://github.com/commitizen/conventional-commit-types/blob/c3a9be4c73e47f2e8197de775f41d981701407fb/index.json). Note that these types are also used to automatically sort and organize the release notes. +Allowed commit types (`feat`, `fix`, etc.) are listed in +[conventional-commit-types](https://github.com/commitizen/conventional-commit-types/blob/c3a9be4c73e47f2e8197de775f41d981701407fb/index.json). +Note that these types are also used to automatically sort and organize the +release notes. A good commit message title uses the imperative, present tense and is ~50 characters long (no more than 72). @@ -250,21 +294,34 @@ Examples: - Good: `feat(api): add feature X` - Bad: `feat(api): added feature X` (past tense) -A good rule of thumb for writing good commit messages is to recite: [If applied, this commit will ...](https://reflectoring.io/meaningful-commit-messages/). +A good rule of thumb for writing good commit messages is to recite: +[If applied, this commit will ...](https://reflectoring.io/meaningful-commit-messages/). -**Note:** We lint PR titles to ensure they follow the Conventional Commits specification, however, it's still possible to merge PRs on GitHub with a badly formatted title. Take care when merging single-commit PRs as GitHub may prefer to use the original commit title instead of the PR title. +**Note:** We lint PR titles to ensure they follow the Conventional Commits +specification, however, it's still possible to merge PRs on GitHub with a badly +formatted title. Take care when merging single-commit PRs as GitHub may prefer +to use the original commit title instead of the PR title. ### Breaking changes Breaking changes can be triggered in two ways: -- Add `!` to the commit message title, e.g. `feat(api)!: remove deprecated endpoint /test` -- Add the [`release/breaking`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Arelease%2Fbreaking) label to a PR that has, or will be, merged into `main`. +- Add `!` to the commit message title, e.g. + `feat(api)!: remove deprecated endpoint /test` +- Add the + [`release/breaking`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Arelease%2Fbreaking) + label to a PR that has, or will be, merged into `main`. ### Security -The [`security`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Asecurity) label can be added to PRs that have, or will be, merged into `main`. Doing so will make sure the change stands out in the release notes. +The +[`security`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Asecurity) +label can be added to PRs that have, or will be, merged into `main`. Doing so +will make sure the change stands out in the release notes. ### Experimental -The [`release/experimental`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Arelease%2Fexperimental) label can be used to move the note to the bottom of the release notes under a separate title. +The +[`release/experimental`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Arelease%2Fexperimental) +label can be used to move the note to the bottom of the release notes under a +separate title. diff --git a/docs/about/architecture.md b/docs/about/architecture.md index 45ef36b99b891..9489ee7fc8e16 100644 --- a/docs/about/architecture.md +++ b/docs/about/architecture.md @@ -8,9 +8,9 @@ This document provides a high level overview of Coder's architecture. ## coderd -coderd is the service created by running `coder server`. It is a thin -API that connects workspaces, provisioners and users. coderd stores its state in -Postgres and is the only service that communicates with Postgres. +coderd is the service created by running `coder server`. It is a thin API that +connects workspaces, provisioners and users. coderd stores its state in Postgres +and is the only service that communicates with Postgres. It offers: @@ -22,16 +22,18 @@ It offers: ## provisionerd -provisionerd is the execution context for infrastructure modifying providers. -At the moment, the only provider is Terraform (running `terraform`). +provisionerd is the execution context for infrastructure modifying providers. At +the moment, the only provider is Terraform (running `terraform`). -By default, the Coder server runs multiple provisioner daemons. [External provisioners](../admin/provisioners.md) can be added for security or scalability purposes. +By default, the Coder server runs multiple provisioner daemons. +[External provisioners](../admin/provisioners.md) can be added for security or +scalability purposes. ## Agents -An agent is the Coder service that runs within a user's remote workspace. -It provides a consistent interface for coderd and clients to communicate -with workspaces regardless of operating system, architecture, or cloud. +An agent is the Coder service that runs within a user's remote workspace. It +provides a consistent interface for coderd and clients to communicate with +workspaces regardless of operating system, architecture, or cloud. It offers the following services along with much more: @@ -40,15 +42,20 @@ It offers the following services along with much more: - Liveness checks - `startup_script` automation -Templates are responsible for [creating and running agents](../templates/index.md#coder-agent) within workspaces. +Templates are responsible for +[creating and running agents](../templates/index.md#coder-agent) within +workspaces. ## Service Bundling -While coderd and Postgres can be orchestrated independently,our default installation -paths bundle them all together into one system service. It's perfectly fine to run a production deployment this way, but there are certain situations that necessitate decomposition: +While coderd and Postgres can be orchestrated independently,our default +installation paths bundle them all together into one system service. It's +perfectly fine to run a production deployment this way, but there are certain +situations that necessitate decomposition: - Reducing global client latency (distribute coderd and centralize database) -- Achieving greater availability and efficiency (horizontally scale individual services) +- Achieving greater availability and efficiency (horizontally scale individual + services) ## Workspaces diff --git a/docs/admin/app-logs.md b/docs/admin/app-logs.md index 87efe05ae6061..8235fda06eda8 100644 --- a/docs/admin/app-logs.md +++ b/docs/admin/app-logs.md @@ -1,21 +1,28 @@ # Application Logs -In Coderd, application logs refer to the records of events, messages, and activities generated by the application during its execution. -These logs provide valuable information about the application's behavior, performance, and any issues that may have occurred. +In Coderd, application logs refer to the records of events, messages, and +activities generated by the application during its execution. These logs provide +valuable information about the application's behavior, performance, and any +issues that may have occurred. -Application logs include entries that capture events on different levels of severity: +Application logs include entries that capture events on different levels of +severity: - Informational messages - Warnings - Errors - Debugging information -By analyzing application logs, system administrators can gain insights into the application's behavior, identify and diagnose problems, track performance metrics, and make informed decisions to improve the application's stability and efficiency. +By analyzing application logs, system administrators can gain insights into the +application's behavior, identify and diagnose problems, track performance +metrics, and make informed decisions to improve the application's stability and +efficiency. ## Error logs -To ensure effective monitoring and timely response to critical events in the Coder application, it is recommended to configure log alerts -that specifically watch for the following log entries: +To ensure effective monitoring and timely response to critical events in the +Coder application, it is recommended to configure log alerts that specifically +watch for the following log entries: | Log Level | Module | Log message | Potential issues | | --------- | ---------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------- | diff --git a/docs/admin/appearance.md b/docs/admin/appearance.md index 5d061b3bb1f6d..f80ffc8c1bcfe 100644 --- a/docs/admin/appearance.md +++ b/docs/admin/appearance.md @@ -2,12 +2,15 @@ ## Support Links -Support links let admins adjust the user dropdown menu to include links referring to internal company resources. The menu section replaces the original menu positions: documentation, report a bug to GitHub, or join the Discord server. +Support links let admins adjust the user dropdown menu to include links +referring to internal company resources. The menu section replaces the original +menu positions: documentation, report a bug to GitHub, or join the Discord +server. ![support links](../images/admin/support-links.png) -Custom links can be set in the deployment configuration using the `-c ` -flag to `coder server`. +Custom links can be set in the deployment configuration using the +`-c ` flag to `coder server`. ```yaml supportLinks: @@ -27,7 +30,8 @@ The link icons are optional, and limited to: `bug`, `chat`, and `docs`. ## Service Banners (enterprise) -Service Banners let admins post important messages to all site users. Only Site Owners may set the service banner. +Service Banners let admins post important messages to all site users. Only Site +Owners may set the service banner. ![service banners](../images/admin/service-banners.png) diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 143ff59344285..3ad9395e3556f 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -1,7 +1,6 @@ # Audit Logs -Audit Logs allows **Auditors** to monitor user operations in -their deployment. +Audit Logs allows **Auditors** to monitor user operations in their deployment. ## Tracked Events @@ -27,34 +26,48 @@ We track the following resources: ## Filtering logs -In the Coder UI you can filter your audit logs using the pre-defined filter or by using the Coder's filter query like the examples below: +In the Coder UI you can filter your audit logs using the pre-defined filter or +by using the Coder's filter query like the examples below: - `resource_type:workspace action:delete` to find deleted workspaces - `resource_type:template action:create` to find created templates The supported filters are: -- `resource_type` - The type of the resource. It can be a workspace, template, user, etc. You can [find here](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ResourceType) all the resource types that are supported. +- `resource_type` - The type of the resource. It can be a workspace, template, + user, etc. You can + [find here](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#ResourceType) + all the resource types that are supported. - `resource_id` - The ID of the resource. -- `resource_target` - The name of the resource. Can be used instead of `resource_id`. -- `action`- The action applied to a resource. You can [find here](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#AuditAction) all the actions that are supported. -- `username` - The username of the user who triggered the action. You can also use `me` as a convenient alias for the logged-in user. +- `resource_target` - The name of the resource. Can be used instead of + `resource_id`. +- `action`- The action applied to a resource. You can + [find here](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#AuditAction) + all the actions that are supported. +- `username` - The username of the user who triggered the action. You can also + use `me` as a convenient alias for the logged-in user. - `email` - The email of the user who triggered the action. - `date_from` - The inclusive start date with format `YYYY-MM-DD`. - `date_to` - The inclusive end date with format `YYYY-MM-DD`. -- `build_reason` - To be used with `resource_type:workspace_build`, the [initiator](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#BuildReason) behind the build start or stop. +- `build_reason` - To be used with `resource_type:workspace_build`, the + [initiator](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#BuildReason) + behind the build start or stop. ## Capturing/Exporting Audit Logs -In addition to the user interface, there are multiple ways to consume or query audit trails. +In addition to the user interface, there are multiple ways to consume or query +audit trails. ## REST API -Audit logs can be accessed through our REST API. You can find detailed information about this in our [endpoint documentation](../api/audit.md#get-audit-logs). +Audit logs can be accessed through our REST API. You can find detailed +information about this in our +[endpoint documentation](../api/audit.md#get-audit-logs). ## Service Logs -Audit trails are also dispatched as service logs and can be captured and categorized using any log management tool such as [Splunk](https://splunk.com). +Audit trails are also dispatched as service logs and can be captured and +categorized using any log management tool such as [Splunk](https://splunk.com). Example of a [JSON formatted](../cli/server.md#--log-json) audit log entry: @@ -93,10 +106,11 @@ Example of a [JSON formatted](../cli/server.md#--log-json) audit log entry: Example of a [human readable](../cli/server.md#--log-human) audit log entry: -```sh +```console 2023-06-13 03:43:29.233 [info] coderd: audit_log ID=95f7c392-da3e-480c-a579-8909f145fbe2 Time="2023-06-13T03:43:29.230422Z" UserID=6c405053-27e3-484a-9ad7-bcb64e7bfde6 OrganizationID=00000000-0000-0000-0000-000000000000 Ip= UserAgent= ResourceType=workspace_build ResourceID=988ae133-5b73-41e3-a55e-e1e9d3ef0b66 ResourceTarget="" Action=start Diff="{}" StatusCode=200 AdditionalFields="{\"workspace_name\":\"linux-container\",\"build_number\":\"7\",\"build_reason\":\"initiator\",\"workspace_owner\":\"\"}" RequestID=9682b1b5-7b9f-4bf2-9a39-9463f8e41cd6 ResourceIcon="" ``` ## Enabling this feature -This feature is only available with an enterprise license. [Learn more](../enterprise.md) +This feature is only available with an enterprise license. +[Learn more](../enterprise.md) diff --git a/docs/admin/auth.md b/docs/admin/auth.md index 4a512bfc3672d..fb278cf09b058 100644 --- a/docs/admin/auth.md +++ b/docs/admin/auth.md @@ -14,12 +14,19 @@ The following steps explain how to set up GitHub OAuth or OpenID Connect. ### Step 1: Configure the OAuth application in GitHub -First, [register a GitHub OAuth app](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). GitHub will ask you for the following Coder parameters: +First, +[register a GitHub OAuth app](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). +GitHub will ask you for the following Coder parameters: -- **Homepage URL**: Set to your Coder deployments [`CODER_ACCESS_URL`](https://coder.com/docs/v2/latest/cli/server#--access-url) (e.g. `https://coder.domain.com`) +- **Homepage URL**: Set to your Coder deployments + [`CODER_ACCESS_URL`](../cli/server.md#--access-url) (e.g. + `https://coder.domain.com`) - **User Authorization Callback URL**: Set to `https://coder.domain.com` -> Note: If you want to allow multiple coder deployments hosted on subdomains e.g. coder1.domain.com, coder2.domain.com, to be able to authenticate with the same GitHub OAuth app, then you can set **User Authorization Callback URL** to the `https://domain.com` +> Note: If you want to allow multiple coder deployments hosted on subdomains +> e.g. coder1.domain.com, coder2.domain.com, to be able to authenticate with the +> same GitHub OAuth app, then you can set **User Authorization Callback URL** to +> the `https://domain.com` Note the Client ID and Client Secret generated by GitHub. You will use these values in the next step. @@ -29,17 +36,18 @@ values in the next step. Navigate to your Coder host and run the following command to start up the Coder server: -```console +```shell coder server --oauth2-github-allow-signups=true --oauth2-github-allowed-orgs="your-org" --oauth2-github-client-id="8d1...e05" --oauth2-github-client-secret="57ebc9...02c24c" ``` -> For GitHub Enterprise support, specify the `--oauth2-github-enterprise-base-url` flag. +> For GitHub Enterprise support, specify the +> `--oauth2-github-enterprise-base-url` flag. Alternatively, if you are running Coder as a system service, you can achieve the same result as the command above by adding the following environment variables to the `/etc/coder.d/coder.env` file: -```console +```env CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=true CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org" CODER_OAUTH2_GITHUB_CLIENT_ID="8d1...e05" @@ -48,7 +56,7 @@ CODER_OAUTH2_GITHUB_CLIENT_SECRET="57ebc9...02c24c" **Note:** To allow everyone to signup using GitHub, set: -```console +```env CODER_OAUTH2_GITHUB_ALLOW_EVERYONE=true ``` @@ -76,7 +84,7 @@ coder: To upgrade Coder, run: -```console +```shell helm upgrade coder-v2/coder -n -f values.yaml ``` @@ -86,7 +94,8 @@ helm upgrade coder-v2/coder -n -f values.yaml ## OpenID Connect -The following steps through how to integrate any OpenID Connect provider (Okta, Active Directory, etc.) to Coder. +The following steps through how to integrate any OpenID Connect provider (Okta, +Active Directory, etc.) to Coder. ### Step 1: Set Redirect URI with your OIDC provider @@ -99,15 +108,15 @@ Your OIDC provider will ask you for the following parameter: Navigate to your Coder host and run the following command to start up the Coder server: -```console +```shell coder server --oidc-issuer-url="https://issuer.corp.com" --oidc-email-domain="your-domain-1,your-domain-2" --oidc-client-id="533...des" --oidc-client-secret="G0CSP...7qSM" ``` -If you are running Coder as a system service, you can achieve the -same result as the command above by adding the following environment variables -to the `/etc/coder.d/coder.env` file: +If you are running Coder as a system service, you can achieve the same result as +the command above by adding the following environment variables to the +`/etc/coder.d/coder.env` file: -```console +```env CODER_OIDC_ISSUER_URL="https://issuer.corp.com" CODER_OIDC_EMAIL_DOMAIN="your-domain-1,your-domain-2" CODER_OIDC_CLIENT_ID="533...des" @@ -134,46 +143,46 @@ coder: To upgrade Coder, run: -```console +```shell helm upgrade coder-v2/coder -n -f values.yaml ``` ## OIDC Claims -When a user logs in for the first time via OIDC, Coder will merge both -the claims from the ID token and the claims obtained from hitting the -upstream provider's `userinfo` endpoint, and use the resulting data -as a basis for creating a new user or looking up an existing user. +When a user logs in for the first time via OIDC, Coder will merge both the +claims from the ID token and the claims obtained from hitting the upstream +provider's `userinfo` endpoint, and use the resulting data as a basis for +creating a new user or looking up an existing user. -To troubleshoot claims, set `CODER_VERBOSE=true` and follow the logs -while signing in via OIDC as a new user. Coder will log the claim fields -returned by the upstream identity provider in a message containing the -string `got oidc claims`, as well as the user info returned. +To troubleshoot claims, set `CODER_VERBOSE=true` and follow the logs while +signing in via OIDC as a new user. Coder will log the claim fields returned by +the upstream identity provider in a message containing the string +`got oidc claims`, as well as the user info returned. -> **Note:** If you need to ensure that Coder only uses information from -> the ID token and does not hit the UserInfo endpoint, you can set the -> configuration option `CODER_OIDC_IGNORE_USERINFO=true`. +> **Note:** If you need to ensure that Coder only uses information from the ID +> token and does not hit the UserInfo endpoint, you can set the configuration +> option `CODER_OIDC_IGNORE_USERINFO=true`. ### Email Addresses -By default, Coder will look for the OIDC claim named `email` and use that -value for the newly created user's email address. +By default, Coder will look for the OIDC claim named `email` and use that value +for the newly created user's email address. If your upstream identity provider users a different claim, you can set `CODER_OIDC_EMAIL_FIELD` to the desired claim. -> **Note:** If this field is not present, Coder will attempt to use the -> claim field configured for `username` as an email address. If this field -> is not a valid email address, OIDC logins will fail. +> **Note** If this field is not present, Coder will attempt to use the claim +> field configured for `username` as an email address. If this field is not a +> valid email address, OIDC logins will fail. ### Email Address Verification -Coder requires all OIDC email addresses to be verified by default. If -the `email_verified` claim is present in the token response from the identity +Coder requires all OIDC email addresses to be verified by default. If the +`email_verified` claim is present in the token response from the identity provider, Coder will validate that its value is `true`. If needed, you can disable this behavior with the following setting: -```console +```env CODER_OIDC_IGNORE_EMAIL_VERIFIED=true ``` @@ -182,14 +191,14 @@ CODER_OIDC_IGNORE_EMAIL_VERIFIED=true ### Usernames -When a new user logs in via OIDC, Coder will by default use the value -of the claim field named `preferred_username` as the the username. +When a new user logs in via OIDC, Coder will by default use the value of the +claim field named `preferred_username` as the the username. -If your upstream identity provider uses a different claim, you can -set `CODER_OIDC_USERNAME_FIELD` to the desired claim. +If your upstream identity provider uses a different claim, you can set +`CODER_OIDC_USERNAME_FIELD` to the desired claim. -> **Note:** If this claim is empty, the email address will be stripped of -> the domain, and become the username (e.g. `example@coder.com` becomes `example`). +> **Note:** If this claim is empty, the email address will be stripped of the +> domain, and become the username (e.g. `example@coder.com` becomes `example`). > To avoid conflicts, Coder may also append a random word to the resulting > username. @@ -198,36 +207,38 @@ set `CODER_OIDC_USERNAME_FIELD` to the desired claim. If you'd like to change the OpenID Connect button text and/or icon, you can configure them like so: -```console +```env CODER_OIDC_SIGN_IN_TEXT="Sign in with Gitea" CODER_OIDC_ICON_URL=https://gitea.io/images/gitea.png ``` ## Disable Built-in Authentication -To remove email and password login, set the following environment variable on your -Coder deployment: +To remove email and password login, set the following environment variable on +your Coder deployment: -```console +```env CODER_DISABLE_PASSWORD_AUTH=true ``` ## SCIM (enterprise) Coder supports user provisioning and deprovisioning via SCIM 2.0 with header -authentication. Upon deactivation, users are [suspended](./users.md#suspend-a-user) -and are not deleted. [Configure](./configure.md) your SCIM application with an -auth key and supply it the Coder server. +authentication. Upon deactivation, users are +[suspended](./users.md#suspend-a-user) and are not deleted. +[Configure](./configure.md) your SCIM application with an auth key and supply it +the Coder server. -```console +```env CODER_SCIM_API_KEY="your-api-key" ``` ## TLS -If your OpenID Connect provider requires client TLS certificates for authentication, you can configure them like so: +If your OpenID Connect provider requires client TLS certificates for +authentication, you can configure them like so: -```console +```env CODER_TLS_CLIENT_CERT_FILE=/path/to/cert.pem CODER_TLS_CLIENT_KEY_FILE=/path/to/key.pem ``` @@ -237,22 +248,31 @@ CODER_TLS_CLIENT_KEY_FILE=/path/to/key.pem If your OpenID Connect provider supports group claims, you can configure Coder to synchronize groups in your auth provider to groups within Coder. -To enable group sync, ensure that the `groups` claim is set by adding the correct scope to request. If group sync is -enabled, the user's groups will be controlled by the OIDC provider. This means -manual group additions/removals will be overwritten on the next login. +To enable group sync, ensure that the `groups` claim is set by adding the +correct scope to request. If group sync is enabled, the user's groups will be +controlled by the OIDC provider. This means manual group additions/removals will +be overwritten on the next login. -```console +```env # as an environment variable CODER_OIDC_SCOPES=openid,profile,email,groups +``` + +```shell # as a flag --oidc-scopes openid,profile,email,groups ``` -With the `groups` scope requested, we also need to map the `groups` claim name. Coder recommends using `groups` for the claim name. This step is necessary if your **scope's name** is something other than `groups`. +With the `groups` scope requested, we also need to map the `groups` claim name. +Coder recommends using `groups` for the claim name. This step is necessary if +your **scope's name** is something other than `groups`. -```console +```env # as an environment variable CODER_OIDC_GROUP_FIELD=groups +``` + +```shell # as a flag --oidc-group-field groups ``` @@ -264,9 +284,12 @@ For cases when an OIDC provider only returns group IDs ([Azure AD][azure-gids]) or you want to have different group names in Coder than in your OIDC provider, you can configure mapping between the two. -```console +```env # as an environment variable CODER_OIDC_GROUP_MAPPING='{"myOIDCGroupID": "myCoderGroupName"}' +``` + +```shell # as a flag --oidc-group-mapping '{"myOIDCGroupID": "myCoderGroupName"}' ``` @@ -286,7 +309,8 @@ OIDC provider will be added to the `myCoderGroupName` group in Coder. > **Note:** Groups are only updated on login. -[azure-gids]: https://github.com/MicrosoftDocs/azure-docs/issues/59766#issuecomment-664387195 +[azure-gids]: + https://github.com/MicrosoftDocs/azure-docs/issues/59766#issuecomment-664387195 ### Troubleshooting @@ -294,22 +318,34 @@ Some common issues when enabling group sync. #### User not being assigned / Group does not exist -If you want Coder to create groups that do not exist, you can set the following environment variable. If you enable this, your OIDC provider might be sending over many unnecessary groups. Use filtering options on the OIDC provider to limit the groups sent over to prevent creating excess groups. +If you want Coder to create groups that do not exist, you can set the following +environment variable. If you enable this, your OIDC provider might be sending +over many unnecessary groups. Use filtering options on the OIDC provider to +limit the groups sent over to prevent creating excess groups. -```console +```env # as an environment variable CODER_OIDC_GROUP_AUTO_CREATE=true +``` +```shell # as a flag --oidc-group-auto-create=true ``` -A basic regex filtering option on the Coder side is available. This is applied **after** the group mapping (`CODER_OIDC_GROUP_MAPPING`), meaning if the group is remapped, the remapped value is tested in the regex. This is useful if you want to filter out groups that do not match a certain pattern. For example, if you want to only allow groups that start with `my-group-` to be created, you can set the following environment variable. +A basic regex filtering option on the Coder side is available. This is applied +**after** the group mapping (`CODER_OIDC_GROUP_MAPPING`), meaning if the group +is remapped, the remapped value is tested in the regex. This is useful if you +want to filter out groups that do not match a certain pattern. For example, if +you want to only allow groups that start with `my-group-` to be created, you can +set the following environment variable. -```console +```env # as an environment variable CODER_OIDC_GROUP_REGEX_FILTER="^my-group-.*$" +``` +```shell # as a flag --oidc-group-regex-filter="^my-group-.*$" ``` @@ -322,28 +358,39 @@ If you see an error like the following, you may have an invalid scope. The application '' asked for scope 'groups' that doesn't exist on the resource... ``` -This can happen because the identity provider has a different name for the scope. For example, Azure AD uses `GroupMember.Read.All` instead of `groups`. You can find the correct scope name in the IDP's documentation. Some IDP's allow configuring the name of this scope. +This can happen because the identity provider has a different name for the +scope. For example, Azure AD uses `GroupMember.Read.All` instead of `groups`. +You can find the correct scope name in the IDP's documentation. Some IDP's allow +configuring the name of this scope. -The solution is to update the value of `CODER_OIDC_SCOPES` to the correct value for the identity provider. +The solution is to update the value of `CODER_OIDC_SCOPES` to the correct value +for the identity provider. #### No `group` claim in the `got oidc claims` log Steps to troubleshoot. -1. Ensure the user is a part of a group in the IDP. If the user has 0 groups, no `groups` claim will be sent. -2. Check if another claim appears to be the correct claim with a different name. A common name is `memberOf` instead of `groups`. If this is present, update `CODER_OIDC_GROUP_FIELD=memberOf`. -3. Make sure the number of groups being sent is under the limit of the IDP. Some IDPs will return an error, while others will just omit the `groups` claim. A common solution is to create a filter on the identity provider that returns less than the limit for your IDP. +1. Ensure the user is a part of a group in the IDP. If the user has 0 groups, no + `groups` claim will be sent. +2. Check if another claim appears to be the correct claim with a different name. + A common name is `memberOf` instead of `groups`. If this is present, update + `CODER_OIDC_GROUP_FIELD=memberOf`. +3. Make sure the number of groups being sent is under the limit of the IDP. Some + IDPs will return an error, while others will just omit the `groups` claim. A + common solution is to create a filter on the identity provider that returns + less than the limit for your IDP. - [Azure AD limit is 200, and omits groups if exceeded.](https://learn.microsoft.com/en-us/azure/active-directory/hybrid/connect/how-to-connect-fed-group-claims#options-for-applications-to-consume-group-information) - [Okta limit is 100, and returns an error if exceeded.](https://developer.okta.com/docs/reference/api/oidc/#scope-dependent-claims-not-always-returned) ## Role sync (enterprise) If your OpenID Connect provider supports roles claims, you can configure Coder -to synchronize roles in your auth provider to deployment-wide roles within Coder. +to synchronize roles in your auth provider to deployment-wide roles within +Coder. Set the following in your Coder server [configuration](./configure.md). -```console +```env # Depending on your identity provider configuration, you may need to explicitly request a "roles" scope CODER_OIDC_SCOPES=openid,profile,email,roles @@ -352,7 +399,8 @@ CODER_OIDC_USER_ROLE_FIELD=roles CODER_OIDC_USER_ROLE_MAPPING='{"TemplateAuthor":["template-admin","user-admin"]}' ``` -> One role from your identity provider can be mapped to many roles in Coder (e.g. the example above maps to 2 roles in Coder.) +> One role from your identity provider can be mapped to many roles in Coder +> (e.g. the example above maps to 2 roles in Coder.) ## Provider-Specific Guides @@ -362,17 +410,20 @@ Below are some details specific to individual OIDC providers. > **Note:** Tested on ADFS 4.0, Windows Server 2019 -1. In your Federation Server, create a new application group for Coder. Follow the - steps as described [here.](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/development/msal/adfs-msal-web-app-web-api#app-registration-in-ad-fs) +1. In your Federation Server, create a new application group for Coder. Follow + the steps as described + [here.](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/development/msal/adfs-msal-web-app-web-api#app-registration-in-ad-fs) - **Server Application**: Note the Client ID. - **Configure Application Credentials**: Note the Client Secret. - **Configure Web API**: Set the Client ID as the relying party identifier. - - **Application Permissions**: Allow access to the claims `openid`, `email`, `profile`, and `allatclaims`. -1. Visit your ADFS server's `/.well-known/openid-configuration` URL and note - the value for `issuer`. - > **Note:** This is usually of the form `https://adfs.corp/adfs/.well-known/openid-configuration` -1. In Coder's configuration file (or Helm values as appropriate), set the following - environment variables or their corresponding CLI arguments: + - **Application Permissions**: Allow access to the claims `openid`, `email`, + `profile`, and `allatclaims`. +1. Visit your ADFS server's `/.well-known/openid-configuration` URL and note the + value for `issuer`. + > **Note:** This is usually of the form + > `https://adfs.corp/adfs/.well-known/openid-configuration` +1. In Coder's configuration file (or Helm values as appropriate), set the + following environment variables or their corresponding CLI arguments: - `CODER_OIDC_ISSUER_URL`: the `issuer` value from the previous step. - `CODER_OIDC_CLIENT_ID`: the Client ID from step 1. @@ -383,28 +434,44 @@ Below are some details specific to individual OIDC providers. {"resource":"$CLIENT_ID"} ``` - where `$CLIENT_ID` is the Client ID from step 1 ([see here](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-openid-connect-oauth-flows-scenarios#:~:text=scope%E2%80%AFopenid.-,resource,-optional)). - This is required for the upstream OIDC provider to return the requested claims. + where `$CLIENT_ID` is the Client ID from step 1 + ([see here](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-openid-connect-oauth-flows-scenarios#:~:text=scope%E2%80%AFopenid.-,resource,-optional)). + This is required for the upstream OIDC provider to return the requested + claims. - `CODER_OIDC_IGNORE_USERINFO`: Set to `true`. -1. Configure [Issuance Transform Rules](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-rule-to-send-ldap-attributes-as-claims) +1. Configure + [Issuance Transform Rules](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-rule-to-send-ldap-attributes-as-claims) on your federation server to send the following claims: - `preferred_username`: You can use e.g. "Display Name" as required. - - `email`: You can use e.g. the LDAP attribute "E-Mail-Addresses" as required. + - `email`: You can use e.g. the LDAP attribute "E-Mail-Addresses" as + required. - `email_verified`: Create a custom claim rule: ```console => issue(Type = "email_verified", Value = "true") ``` - - (Optional) If using Group Sync, send the required groups in the configured groups claim field. See [here](https://stackoverflow.com/a/55570286) for an example. + - (Optional) If using Group Sync, send the required groups in the configured + groups claim field. See [here](https://stackoverflow.com/a/55570286) for an + example. ### Keycloak -The access_type parameter has two possible values: "online" and "offline." By default, the value is set to "offline". This means that when a user authenticates using OIDC, the application requests offline access to the user's resources, including the ability to refresh access tokens without requiring the user to reauthenticate. - -To enable the `offline_access` scope, which allows for the refresh token functionality, you need to add it to the list of requested scopes during the authentication flow. Including the `offline_access` scope in the requested scopes ensures that the user is granted the necessary permissions to obtain refresh tokens. - -By combining the `{"access_type":"offline"}` parameter in the OIDC Auth URL with the `offline_access` scope, you can achieve the desired behavior of obtaining refresh tokens for offline access to the user's resources. +The access_type parameter has two possible values: "online" and "offline." By +default, the value is set to "offline". This means that when a user +authenticates using OIDC, the application requests offline access to the user's +resources, including the ability to refresh access tokens without requiring the +user to reauthenticate. + +To enable the `offline_access` scope, which allows for the refresh token +functionality, you need to add it to the list of requested scopes during the +authentication flow. Including the `offline_access` scope in the requested +scopes ensures that the user is granted the necessary permissions to obtain +refresh tokens. + +By combining the `{"access_type":"offline"}` parameter in the OIDC Auth URL with +the `offline_access` scope, you can achieve the desired behavior of obtaining +refresh tokens for offline access to the user's resources. diff --git a/docs/admin/automation.md b/docs/admin/automation.md index 18751755b4458..c9fc78833033b 100644 --- a/docs/admin/automation.md +++ b/docs/admin/automation.md @@ -1,6 +1,8 @@ # Automation -All actions possible through the Coder dashboard can also be automated as it utilizes the same public REST API. There are several ways to extend/automate Coder: +All actions possible through the Coder dashboard can also be automated as it +utilizes the same public REST API. There are several ways to extend/automate +Coder: - [CLI](../cli.md) - [REST API](../api/) @@ -10,13 +12,13 @@ All actions possible through the Coder dashboard can also be automated as it uti Generate a token on your Coder deployment by visiting: -```sh +```shell https://coder.example.com/settings/tokens ``` List your workspaces -```sh +```shell # CLI coder ls \ --url https://coder.example.com \ @@ -30,23 +32,34 @@ curl https://coder.example.com/api/v2/workspaces?q=owner:me \ ## Documentation -We publish an [API reference](../api/index.md) in our documentation. You can also enable a [Swagger endpoint](../cli/server.md#--swagger-enable) on your Coder deployment. +We publish an [API reference](../api/index.md) in our documentation. You can +also enable a [Swagger endpoint](../cli/server.md#--swagger-enable) on your +Coder deployment. ## Use cases -We strive to keep the following use cases up to date, but please note that changes to API queries and routes can occur. For the most recent queries and payloads, we recommend checking the CLI and API documentation. +We strive to keep the following use cases up to date, but please note that +changes to API queries and routes can occur. For the most recent queries and +payloads, we recommend checking the CLI and API documentation. ### Templates -- [Update templates in CI](../templates/change-management.md): Store all templates and git and update templates in CI/CD pipelines. +- [Update templates in CI](../templates/change-management.md): Store all + templates and git and update templates in CI/CD pipelines. ### Workspace agents -Workspace agents have a special token that can send logs, metrics, and workspace activity. +Workspace agents have a special token that can send logs, metrics, and workspace +activity. -- [Custom workspace logs](../api/agents.md#patch-workspace-agent-logs): Expose messages prior to the Coder init script running (e.g. pulling image, VM starting, restoring snapshot). [coder-logstream-kube](https://github.com/coder/coder-logstream-kube) uses this to show Kubernetes events, such as image pulls or ResourceQuota restrictions. +- [Custom workspace logs](../api/agents.md#patch-workspace-agent-logs): Expose + messages prior to the Coder init script running (e.g. pulling image, VM + starting, restoring snapshot). + [coder-logstream-kube](https://github.com/coder/coder-logstream-kube) uses + this to show Kubernetes events, such as image pulls or ResourceQuota + restrictions. - ```sh + ```shell curl -X PATCH https://coder.example.com/api/v2/workspaceagents/me/logs \ -H "Coder-Session-Token: $CODER_AGENT_TOKEN" \ -d "{ @@ -60,9 +73,11 @@ Workspace agents have a special token that can send logs, metrics, and workspace }" ``` -- [Manually send workspace activity](../api/agents.md#submit-workspace-agent-stats): Keep a workspace "active," even if there is not an open connection (e.g. for a long-running machine learning job). +- [Manually send workspace activity](../api/agents.md#submit-workspace-agent-stats): + Keep a workspace "active," even if there is not an open connection (e.g. for a + long-running machine learning job). - ```sh + ```shell #!/bin/bash # Send workspace activity as long as the job is still running diff --git a/docs/admin/configure.md b/docs/admin/configure.md index 2240ef4ed5d62..17ce483cb2f0f 100644 --- a/docs/admin/configure.md +++ b/docs/admin/configure.md @@ -1,23 +1,26 @@ -Coder server's primary configuration is done via environment variables. For a full list of the options, run `coder server --help` or see our [CLI documentation](../cli/server.md). +Coder server's primary configuration is done via environment variables. For a +full list of the options, run `coder server --help` or see our +[CLI documentation](../cli/server.md). ## Access URL -`CODER_ACCESS_URL` is required if you are not using the tunnel. Set this to the external URL -that users and workspaces use to connect to Coder (e.g. ). This -should not be localhost. +`CODER_ACCESS_URL` is required if you are not using the tunnel. Set this to the +external URL that users and workspaces use to connect to Coder (e.g. +). This should not be localhost. -> Access URL should be a external IP address or domain with DNS records pointing to Coder. +> Access URL should be a external IP address or domain with DNS records pointing +> to Coder. ### Tunnel -If an access URL is not specified, Coder will create -a publicly accessible URL to reverse proxy your deployment for simple setup. +If an access URL is not specified, Coder will create a publicly accessible URL +to reverse proxy your deployment for simple setup. ## Address You can change which port(s) Coder listens on. -```sh +```shell # Listen on port 80 export CODER_HTTP_ADDRESS=0.0.0.0:80 @@ -34,22 +37,27 @@ coder server ## Wildcard access URL -`CODER_WILDCARD_ACCESS_URL` is necessary for [port forwarding](../networking/port-forwarding.md#dashboard) -via the dashboard or running [coder_apps](../templates/index.md#coder-apps) on an absolute path. Set this to a wildcard -subdomain that resolves to Coder (e.g. `*.coder.example.com`). +`CODER_WILDCARD_ACCESS_URL` is necessary for +[port forwarding](../networking/port-forwarding.md#dashboard) via the dashboard +or running [coder_apps](../templates/index.md#coder-apps) on an absolute path. +Set this to a wildcard subdomain that resolves to Coder (e.g. +`*.coder.example.com`). If you are providing TLS certificates directly to the Coder server, either 1. Use a single certificate and key for both the root and wildcard domains. 2. Configure multiple certificates and keys via - [`coder.tls.secretNames`](https://github.com/coder/coder/blob/main/helm/coder/values.yaml) in the Helm Chart, or - [`--tls-cert-file`](../cli/server.md#--tls-cert-file) and [`--tls-key-file`](../cli/server.md#--tls-key-file) command - line options (these both take a comma separated list of files; list certificates and their respective keys in the - same order). + [`coder.tls.secretNames`](https://github.com/coder/coder/blob/main/helm/coder/values.yaml) + in the Helm Chart, or [`--tls-cert-file`](../cli/server.md#--tls-cert-file) + and [`--tls-key-file`](../cli/server.md#--tls-key-file) command line options + (these both take a comma separated list of files; list certificates and their + respective keys in the same order). ## TLS & Reverse Proxy -The Coder server can directly use TLS certificates with `CODER_TLS_ENABLE` and accompanying configuration flags. However, Coder can also run behind a reverse-proxy to terminate TLS certificates from LetsEncrypt, for example. +The Coder server can directly use TLS certificates with `CODER_TLS_ENABLE` and +accompanying configuration flags. However, Coder can also run behind a +reverse-proxy to terminate TLS certificates from LetsEncrypt, for example. - [Apache](https://github.com/coder/coder/tree/main/examples/web-server/apache) - [Caddy](https://github.com/coder/coder/tree/main/examples/web-server/caddy) @@ -57,17 +65,19 @@ The Coder server can directly use TLS certificates with `CODER_TLS_ENABLE` and a ### Kubernetes TLS configuration -Below are the steps to configure Coder to terminate TLS when running on Kubernetes. -You must have the certificate `.key` and `.crt` files in your working directory prior to step 1. +Below are the steps to configure Coder to terminate TLS when running on +Kubernetes. You must have the certificate `.key` and `.crt` files in your +working directory prior to step 1. 1. Create the TLS secret in your Kubernetes cluster -```console +```shell kubectl create secret tls coder-tls -n --key="tls.key" --cert="tls.crt" ``` -> You can use a single certificate for the both the access URL and wildcard access URL. -> The certificate CN must match the wildcard domain, such as `*.example.coder.com`. +> You can use a single certificate for the both the access URL and wildcard +> access URL. The certificate CN must match the wildcard domain, such as +> `*.example.coder.com`. 1. Reference the TLS secret in your Coder Helm chart values @@ -87,14 +97,16 @@ coder: ## PostgreSQL Database -Coder uses a PostgreSQL database to store users, workspace metadata, and other deployment information. -Use `CODER_PG_CONNECTION_URL` to set the database that Coder connects to. If unset, PostgreSQL binaries will be -downloaded from Maven () and store all data in the config root. +Coder uses a PostgreSQL database to store users, workspace metadata, and other +deployment information. Use `CODER_PG_CONNECTION_URL` to set the database that +Coder connects to. If unset, PostgreSQL binaries will be downloaded from Maven +() and store all data in the config root. > Postgres 13 is the minimum supported version. If you are using the built-in PostgreSQL deployment and need to use `psql` (aka -the PostgreSQL interactive terminal), output the connection URL with the following command: +the PostgreSQL interactive terminal), output the connection URL with the +following command: ```console coder server postgres-builtin-url @@ -103,21 +115,26 @@ psql "postgres://coder@localhost:49627/coder?sslmode=disable&password=feU...yI1" ### Migrating from the built-in database to an external database -To migrate from the built-in database to an external database, follow these steps: +To migrate from the built-in database to an external database, follow these +steps: 1. Stop your Coder deployment. 2. Run `coder server postgres-builtin-serve` in a background terminal. 3. Run `coder server postgres-builtin-url` and copy its output command. -4. Run `pg_dump > coder.sql` to dump the internal database to a file. -5. Restore that content to an external database with `psql < coder.sql`. -6. Start your Coder deployment with `CODER_PG_CONNECTION_URL=`. +4. Run `pg_dump > coder.sql` to dump the internal + database to a file. +5. Restore that content to an external database with + `psql < coder.sql`. +6. Start your Coder deployment with + `CODER_PG_CONNECTION_URL=`. ## System packages -If you've installed Coder via a [system package](../install/packages.md) Coder, you can -configure the server by setting the following variables in `/etc/coder.d/coder.env`: +If you've installed Coder via a [system package](../install/packages.md) Coder, +you can configure the server by setting the following variables in +`/etc/coder.d/coder.env`: -```console +```env # String. Specifies the external URL (HTTP/S) to access Coder. CODER_ACCESS_URL=https://coder.example.com @@ -145,7 +162,7 @@ CODER_TLS_KEY_FILE= To run Coder as a system service on the host: -```console +```shell # Use systemd to start Coder now and on reboot sudo systemctl enable --now coder @@ -155,15 +172,15 @@ journalctl -u coder.service -b To restart Coder after applying system changes: -```console +```shell sudo systemctl restart coder ``` ## Configuring Coder behind a proxy -To configure Coder behind a corporate proxy, set the environment variables `HTTP_PROXY` and -`HTTPS_PROXY`. Be sure to restart the server. Lowercase values (e.g. `http_proxy`) are also -respected in this case. +To configure Coder behind a corporate proxy, set the environment variables +`HTTP_PROXY` and `HTTPS_PROXY`. Be sure to restart the server. Lowercase values +(e.g. `http_proxy`) are also respected in this case. ## Up Next diff --git a/docs/admin/git-providers.md b/docs/admin/git-providers.md index 293c88ab3cabb..0cbd0e00c94fa 100644 --- a/docs/admin/git-providers.md +++ b/docs/admin/git-providers.md @@ -1,10 +1,13 @@ # Git Providers -Coder integrates with git providers to automate away the need for developers to authenticate with repositories within their workspace. +Coder integrates with git providers to automate away the need for developers to +authenticate with repositories within their workspace. ## How it works -When developers use `git` inside their workspace, they are prompted to authenticate. After that, Coder will store and refresh tokens for future operations. +When developers use `git` inside their workspace, they are prompted to +authenticate. After that, Coder will store and refresh tokens for future +operations.
)} diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index d64e25dd89979..afbff9e9f3b98 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -194,23 +194,25 @@ export const Workspace: FC> = ({ onDeadlinePlus={scheduleProps.onDeadlinePlus} /> - - - + {canUpdateWorkspace && ( + + + + )}
@@ -226,15 +228,17 @@ export const Workspace: FC> = ({ { - handleRestart() - }} - > - Restart - + canUpdateWorkspace && ( + + ) } > Workspace is unhealthy @@ -326,6 +330,7 @@ export const Workspace: FC> = ({ workspace={workspace} sshPrefix={sshPrefix} showApps={canUpdateWorkspace} + showBuiltinApps={canUpdateWorkspace} hideSSHButton={hideSSHButton} hideVSCodeDesktopButton={hideVSCodeDesktopButton} serverVersion={serverVersion} diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index e7a92458e63bd..b0df59a89fd05 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -111,7 +111,6 @@ export const WorkspaceReadyPage = ({ useEffect(() => { bannerSend({ type: "REFRESH_WORKSPACE", workspace }) }, [bannerSend, workspace]) - return ( <> From af939d1e946062da02a23ed938edd9a46531b462 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 24 Aug 2023 17:34:38 +0300 Subject: [PATCH 234/277] fix(coderd): optimize template app insights query for speed and decrease intervals (#9302) --- coderd/database/dbfake/dbfake.go | 4 +- coderd/database/queries.sql.go | 43 +++++++++---------- coderd/database/queries/insights.sql | 37 ++++++++-------- coderd/insights_test.go | 14 +++++- ..._workspaces_week_all_templates.json.golden | 4 +- ...orkspaces_week_deployment_wide.json.golden | 4 +- ...workspaces_week_first_template.json.golden | 4 +- ...r_timezone_(S\303\243o_Paulo).json.golden" | 5 ++- 8 files changed, 63 insertions(+), 52 deletions(-) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index d8afdd3d96b87..9455e8e69009b 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -2048,8 +2048,8 @@ func (q *FakeQuerier) GetTemplateAppInsights(ctx context.Context, arg database.G t = arg.StartTime } for t.Before(s.SessionEndedAt) && t.Before(arg.EndTime) { - appUsageIntervalsByUserAgentApp[key][t] = 300 // 5 minutes. - t = t.Add(5 * time.Minute) + appUsageIntervalsByUserAgentApp[key][t] = 60 // 1 minute. + t = t.Add(1 * time.Minute) } } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 49c3084c0a710..9d3fefccd842a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1462,19 +1462,10 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar } const getTemplateAppInsights = `-- name: GetTemplateAppInsights :many -WITH ts AS ( - SELECT - d::timestamptz AS from_, - (d::timestamptz + '5 minute'::interval) AS to_, - EXTRACT(epoch FROM '5 minute'::interval) AS seconds - FROM - -- Subtract 1 second from end_time to avoid including the next interval in the results. - generate_series($1::timestamptz, ($2::timestamptz) - '1 second'::interval, '5 minute'::interval) d -), app_stats_by_user_and_agent AS ( +WITH app_stats_by_user_and_agent AS ( SELECT - ts.from_, - ts.to_, - ts.seconds, + s.start_time, + 60 as seconds, w.template_id, was.user_id, was.agent_id, @@ -1483,15 +1474,10 @@ WITH ts AS ( wa.display_name, wa.icon, (wa.slug IS NOT NULL)::boolean AS is_app - FROM ts - JOIN workspace_app_stats was ON ( - (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_) - OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_) - OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_) - ) + FROM workspace_app_stats was JOIN workspaces w ON ( w.id = was.workspace_id - AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN w.template_id = ANY($3::uuid[]) ELSE TRUE END + AND CASE WHEN COALESCE(array_length($1::uuid[], 1), 0) > 0 THEN w.template_id = ANY($1::uuid[]) ELSE TRUE END ) -- We do a left join here because we want to include user IDs that have used -- e.g. ports when counting active users. @@ -1499,7 +1485,20 @@ WITH ts AS ( wa.agent_id = was.agent_id AND wa.slug = was.slug_or_port ) - GROUP BY ts.from_, ts.to_, ts.seconds, w.template_id, was.user_id, was.agent_id, was.access_method, was.slug_or_port, wa.display_name, wa.icon, wa.slug + -- This table contains both 1 minute entries and >1 minute entries, + -- to calculate this with our uniqueness constraints, we generate series + -- for the longer intervals. + CROSS JOIN LATERAL generate_series( + date_trunc('minute', was.session_started_at), + -- Subtract 1 microsecond to avoid creating an extra series. + date_trunc('minute', was.session_ended_at - '1 microsecond'::interval), + '1 minute'::interval + ) s(start_time) + WHERE + s.start_time >= $2::timestamptz + -- Subtract one minute because the series only contains the start time. + AND s.start_time < ($3::timestamptz) - '1 minute'::interval + GROUP BY s.start_time, w.template_id, was.user_id, was.agent_id, was.access_method, was.slug_or_port, wa.display_name, wa.icon, wa.slug ) SELECT @@ -1517,9 +1516,9 @@ GROUP BY access_method, slug_or_port, display_name, icon, is_app ` type GetTemplateAppInsightsParams struct { + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` StartTime time.Time `db:"start_time" json:"start_time"` EndTime time.Time `db:"end_time" json:"end_time"` - TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` } type GetTemplateAppInsightsRow struct { @@ -1537,7 +1536,7 @@ type GetTemplateAppInsightsRow struct { // timeframe. The result can be filtered on template_ids, meaning only user data // from workspaces based on those templates will be included. func (q *sqlQuerier) GetTemplateAppInsights(ctx context.Context, arg GetTemplateAppInsightsParams) ([]GetTemplateAppInsightsRow, error) { - rows, err := q.db.QueryContext(ctx, getTemplateAppInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs)) + rows, err := q.db.QueryContext(ctx, getTemplateAppInsights, pq.Array(arg.TemplateIDs), arg.StartTime, arg.EndTime) if err != nil { return nil, err } diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 93e195f41eb64..d76d106edd5d1 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -61,19 +61,10 @@ FROM agent_stats_by_interval_and_user; -- GetTemplateAppInsights returns the aggregate usage of each app in a given -- timeframe. The result can be filtered on template_ids, meaning only user data -- from workspaces based on those templates will be included. -WITH ts AS ( - SELECT - d::timestamptz AS from_, - (d::timestamptz + '5 minute'::interval) AS to_, - EXTRACT(epoch FROM '5 minute'::interval) AS seconds - FROM - -- Subtract 1 second from end_time to avoid including the next interval in the results. - generate_series(@start_time::timestamptz, (@end_time::timestamptz) - '1 second'::interval, '5 minute'::interval) d -), app_stats_by_user_and_agent AS ( +WITH app_stats_by_user_and_agent AS ( SELECT - ts.from_, - ts.to_, - ts.seconds, + s.start_time, + 60 as seconds, w.template_id, was.user_id, was.agent_id, @@ -82,12 +73,7 @@ WITH ts AS ( wa.display_name, wa.icon, (wa.slug IS NOT NULL)::boolean AS is_app - FROM ts - JOIN workspace_app_stats was ON ( - (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_) - OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_) - OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_) - ) + FROM workspace_app_stats was JOIN workspaces w ON ( w.id = was.workspace_id AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN w.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END @@ -98,7 +84,20 @@ WITH ts AS ( wa.agent_id = was.agent_id AND wa.slug = was.slug_or_port ) - GROUP BY ts.from_, ts.to_, ts.seconds, w.template_id, was.user_id, was.agent_id, was.access_method, was.slug_or_port, wa.display_name, wa.icon, wa.slug + -- This table contains both 1 minute entries and >1 minute entries, + -- to calculate this with our uniqueness constraints, we generate series + -- for the longer intervals. + CROSS JOIN LATERAL generate_series( + date_trunc('minute', was.session_started_at), + -- Subtract 1 microsecond to avoid creating an extra series. + date_trunc('minute', was.session_ended_at - '1 microsecond'::interval), + '1 minute'::interval + ) s(start_time) + WHERE + s.start_time >= @start_time::timestamptz + -- Subtract one minute because the series only contains the start time. + AND s.start_time < (@end_time::timestamptz) - '1 minute'::interval + GROUP BY s.start_time, w.template_id, was.user_id, was.agent_id, was.access_method, was.slug_or_port, wa.display_name, wa.icon, wa.slug ) SELECT diff --git a/coderd/insights_test.go b/coderd/insights_test.go index b6bc1ab424e31..351905e9b698e 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -778,7 +778,19 @@ func TestTemplateInsights_Golden(t *testing.T) { endedAt: frozenWeekAgo.Add(time.Hour), requests: 1, }, - { // used an app on the last day, counts as active user, 12m -> 15m rounded. + { // 30s of app usage -> 1m rounded. + app: users[0].workspaces[0].apps[0], + startedAt: frozenWeekAgo.Add(2*time.Hour + 10*time.Second), + endedAt: frozenWeekAgo.Add(2*time.Hour + 40*time.Second), + requests: 1, + }, + { // 1m30s of app usage -> 2m rounded (included in São Paulo). + app: users[0].workspaces[0].apps[0], + startedAt: frozenWeekAgo.Add(3*time.Hour + 30*time.Second), + endedAt: frozenWeekAgo.Add(3*time.Hour + 90*time.Second), + requests: 1, + }, + { // used an app on the last day, counts as active user, 12m. app: users[0].workspaces[0].apps[2], startedAt: frozenWeekAgo.AddDate(0, 0, 6), endedAt: frozenWeekAgo.AddDate(0, 0, 6).Add(12 * time.Minute), diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_all_templates.json.golden b/coderd/testdata/insights/multiple_users_and_workspaces_week_all_templates.json.golden index c4164fe1248ce..664e2fed8f250 100644 --- a/coderd/testdata/insights/multiple_users_and_workspaces_week_all_templates.json.golden +++ b/coderd/testdata/insights/multiple_users_and_workspaces_week_all_templates.json.golden @@ -66,7 +66,7 @@ "display_name": "app1", "slug": "app1", "icon": "/icon1.png", - "seconds": 25200 + "seconds": 25380 }, { "template_ids": [ @@ -76,7 +76,7 @@ "display_name": "app3", "slug": "app3", "icon": "/icon2.png", - "seconds": 900 + "seconds": 720 }, { "template_ids": [ diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_deployment_wide.json.golden b/coderd/testdata/insights/multiple_users_and_workspaces_week_deployment_wide.json.golden index c4164fe1248ce..664e2fed8f250 100644 --- a/coderd/testdata/insights/multiple_users_and_workspaces_week_deployment_wide.json.golden +++ b/coderd/testdata/insights/multiple_users_and_workspaces_week_deployment_wide.json.golden @@ -66,7 +66,7 @@ "display_name": "app1", "slug": "app1", "icon": "/icon1.png", - "seconds": 25200 + "seconds": 25380 }, { "template_ids": [ @@ -76,7 +76,7 @@ "display_name": "app3", "slug": "app3", "icon": "/icon2.png", - "seconds": 900 + "seconds": 720 }, { "template_ids": [ diff --git a/coderd/testdata/insights/multiple_users_and_workspaces_week_first_template.json.golden b/coderd/testdata/insights/multiple_users_and_workspaces_week_first_template.json.golden index c7132bf9f3340..d96469dc5c724 100644 --- a/coderd/testdata/insights/multiple_users_and_workspaces_week_first_template.json.golden +++ b/coderd/testdata/insights/multiple_users_and_workspaces_week_first_template.json.golden @@ -55,7 +55,7 @@ "display_name": "app1", "slug": "app1", "icon": "/icon1.png", - "seconds": 3600 + "seconds": 3780 }, { "template_ids": [ @@ -65,7 +65,7 @@ "display_name": "app3", "slug": "app3", "icon": "/icon2.png", - "seconds": 900 + "seconds": 720 } ], "parameters_usage": [] diff --git "a/coderd/testdata/insights/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" "b/coderd/testdata/insights/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" index 9a623ea92fe49..8f447e4112dd0 100644 --- "a/coderd/testdata/insights/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" +++ "b/coderd/testdata/insights/multiple_users_and_workspaces_week_other_timezone_(S\303\243o_Paulo).json.golden" @@ -51,13 +51,14 @@ }, { "template_ids": [ + "00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002" ], "type": "app", "display_name": "app1", "slug": "app1", "icon": "/icon1.png", - "seconds": 21600 + "seconds": 21720 }, { "template_ids": [ @@ -67,7 +68,7 @@ "display_name": "app3", "slug": "app3", "icon": "/icon2.png", - "seconds": 4500 + "seconds": 4320 }, { "template_ids": [ From 9cb913fb1a8c51bba45e77c6c36f2553135ad513 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 24 Aug 2023 18:08:52 +0300 Subject: [PATCH 235/277] fix(go.mod): upgrade cdr.dev/slog to fix isTTY race (#9305) --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 611a1713ae1d6..8e27769541d8a 100644 --- a/go.mod +++ b/go.mod @@ -68,7 +68,7 @@ replace github.com/gliderlabs/ssh => github.com/coder/ssh v0.0.0-20230621095435- replace github.com/imulab/go-scim/pkg/v2 => github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136 require ( - cdr.dev/slog v1.6.1 + cdr.dev/slog v1.6.2-0.20230817204240-b386d5d10a80 cloud.google.com/go/compute/metadata v0.2.3 github.com/AlecAivazis/survey/v2 v2.3.5 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d @@ -202,7 +202,7 @@ require ( require ( cloud.google.com/go/compute v1.23.0 // indirect - cloud.google.com/go/logging v1.7.0 // indirect + cloud.google.com/go/logging v1.8.1 // indirect cloud.google.com/go/longrunning v0.5.1 // indirect filippo.io/edwards25519 v1.0.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect diff --git a/go.sum b/go.sum index ccdb57b437e56..6a891c532ff6c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cdr.dev/slog v1.6.1 h1:IQjWZD0x6//sfv5n+qEhbu3wBkmtBQY5DILXNvMaIv4= -cdr.dev/slog v1.6.1/go.mod h1:eHEYQLaZvxnIAXC+XdTSNLb/kgA/X2RVSF72v5wsxEI= +cdr.dev/slog v1.6.2-0.20230817204240-b386d5d10a80 h1:CB4BlMetboYpi9FgPgvRpdRe5gkGukmhBVEcOhSvY8w= +cdr.dev/slog v1.6.2-0.20230817204240-b386d5d10a80/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -31,8 +31,8 @@ cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGB cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/logging v1.7.0 h1:CJYxlNNNNAMkHp9em/YEXcfJg+rPDg7YfwoRpMU+t5I= -cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= +cloud.google.com/go/logging v1.8.1 h1:26skQWPeYhvIasWKm48+Eq7oUqdcdbwsCVwz5Ys0FvU= +cloud.google.com/go/logging v1.8.1/go.mod h1:TJjR+SimHwuC8MZ9cjByQulAMgni+RkXeI3wwctHJEI= cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI= cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= From 64df0763282def2288acaa144ebcf1aad986368d Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 24 Aug 2023 10:22:31 -0700 Subject: [PATCH 236/277] feat: add server flag to force DERP to use always websockets (#9238) --- agent/agent.go | 21 +++-- cli/netcheck_test.go | 2 +- cli/testdata/coder_server_--help.golden | 7 ++ cli/testdata/server-config.yaml.golden | 6 ++ coderd/apidoc/docs.go | 12 +++ coderd/apidoc/swagger.json | 12 +++ coderd/coderd.go | 1 + coderd/coderd_test.go | 94 +++++++++++++++++++ coderd/coderdtest/coderdtest.go | 2 +- coderd/tailnet.go | 8 +- coderd/tailnet_test.go | 1 + coderd/workspaceagents.go | 12 ++- coderd/wsconncache/wsconncache_test.go | 7 +- codersdk/agentsdk/agentsdk.go | 1 + codersdk/deployment.go | 16 +++- codersdk/workspaceagents.go | 12 ++- docs/api/agents.md | 2 + docs/api/general.md | 1 + docs/api/schemas.md | 35 ++++--- docs/cli/server.md | 10 ++ .../cli/testdata/coder_server_--help.golden | 7 ++ enterprise/coderd/workspaceproxy.go | 11 ++- enterprise/wsproxy/wsproxy.go | 8 +- enterprise/wsproxy/wsproxysdk/wsproxysdk.go | 9 +- go.mod | 2 +- go.sum | 4 +- site/src/api/typesGenerated.ts | 1 + tailnet/conn.go | 44 ++++++--- 28 files changed, 280 insertions(+), 68 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 7622820385bd8..532e7e5a88392 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -678,7 +678,7 @@ func (a *agent) run(ctx context.Context) error { network := a.network a.closeMutex.Unlock() if network == nil { - network, err = a.createTailnet(ctx, manifest.AgentID, manifest.DERPMap, manifest.DisableDirectConnections) + network, err = a.createTailnet(ctx, manifest.AgentID, manifest.DERPMap, manifest.DERPForceWebSockets, manifest.DisableDirectConnections) if err != nil { return xerrors.Errorf("create tailnet: %w", err) } @@ -701,8 +701,10 @@ func (a *agent) run(ctx context.Context) error { if err != nil { a.logger.Error(ctx, "update tailnet addresses", slog.Error(err)) } - // Update the DERP map and allow/disallow direct connections. + // Update the DERP map, force WebSocket setting and allow/disallow + // direct connections. network.SetDERPMap(manifest.DERPMap) + network.SetDERPForceWebSockets(manifest.DERPForceWebSockets) network.SetBlockEndpoints(manifest.DisableDirectConnections) } @@ -756,14 +758,15 @@ func (a *agent) trackConnGoroutine(fn func()) error { return nil } -func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *tailcfg.DERPMap, disableDirectConnections bool) (_ *tailnet.Conn, err error) { +func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *tailcfg.DERPMap, derpForceWebSockets, disableDirectConnections bool) (_ *tailnet.Conn, err error) { network, err := tailnet.NewConn(&tailnet.Options{ - ID: agentID, - Addresses: a.wireguardAddresses(agentID), - DERPMap: derpMap, - Logger: a.logger.Named("net.tailnet"), - ListenPort: a.tailnetListenPort, - BlockEndpoints: disableDirectConnections, + ID: agentID, + Addresses: a.wireguardAddresses(agentID), + DERPMap: derpMap, + DERPForceWebSockets: derpForceWebSockets, + Logger: a.logger.Named("net.tailnet"), + ListenPort: a.tailnetListenPort, + BlockEndpoints: disableDirectConnections, }) if err != nil { return nil, xerrors.Errorf("create tailnet: %w", err) diff --git a/cli/netcheck_test.go b/cli/netcheck_test.go index 75fda30ff870a..aff65d565bd27 100644 --- a/cli/netcheck_test.go +++ b/cli/netcheck_test.go @@ -31,7 +31,7 @@ func TestNetcheck(t *testing.T) { require.NoError(t, json.Unmarshal(b, &report)) assert.True(t, report.Healthy) - require.Len(t, report.Regions, 1+5) // 1 built-in region + 5 STUN regions by default + require.Len(t, report.Regions, 1+1) // 1 built-in region + 1 test-managed STUN region for _, v := range report.Regions { require.Len(t, v.NodeReports, len(v.Region.Nodes)) } diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 46fddeed2d6cc..d3a5d74bcddbe 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -172,6 +172,13 @@ backed by Tailscale and WireGuard. URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/. + --derp-force-websockets bool, $CODER_DERP_FORCE_WEBSOCKETS + Force clients and agents to always use WebSocket to connect to DERP + relay servers. By default, DERP uses `Upgrade: derp`, which may cause + issues with some reverse proxies. Clients may automatically fallback + to WebSocket if they detect an issue with `Upgrade: derp`, but this + does not work in all situations. + --derp-server-enable bool, $CODER_DERP_SERVER_ENABLE (default: true) Whether to enable or disable the embedded DERP relay server. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 381920662cf05..166e9f02d9465 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -136,6 +136,12 @@ networking: # this change has been made, but new connections will still be proxied regardless. # (default: , type: bool) blockDirect: false + # Force clients and agents to always use WebSocket to connect to DERP relay + # servers. By default, DERP uses `Upgrade: derp`, which may cause issues with some + # reverse proxies. Clients may automatically fallback to WebSocket if they detect + # an issue with `Upgrade: derp`, but this does not work in all situations. + # (default: , type: bool) + forceWebSockets: false # URL to fetch a DERP mapping on startup. See: # https://tailscale.com/kb/1118/custom-derp-servers/. # (default: , type: string) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 951e1ed04d254..0af41e03271ea 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6416,6 +6416,9 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.WorkspaceApp" } }, + "derp_force_websockets": { + "type": "boolean" + }, "derpmap": { "$ref": "#/definitions/tailcfg.DERPMap" }, @@ -7781,6 +7784,9 @@ const docTemplate = `{ "block_direct": { "type": "boolean" }, + "force_websockets": { + "type": "boolean" + }, "path": { "type": "string" }, @@ -10710,6 +10716,9 @@ const docTemplate = `{ "codersdk.WorkspaceAgentConnectionInfo": { "type": "object", "properties": { + "derp_force_websockets": { + "type": "boolean" + }, "derp_map": { "$ref": "#/definitions/tailcfg.DERPMap" }, @@ -11973,6 +11982,9 @@ const docTemplate = `{ "app_security_key": { "type": "string" }, + "derp_force_websockets": { + "type": "boolean" + }, "derp_map": { "$ref": "#/definitions/tailcfg.DERPMap" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a0cea25ad07ed..008534328fd70 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5656,6 +5656,9 @@ "$ref": "#/definitions/codersdk.WorkspaceApp" } }, + "derp_force_websockets": { + "type": "boolean" + }, "derpmap": { "$ref": "#/definitions/tailcfg.DERPMap" }, @@ -6936,6 +6939,9 @@ "block_direct": { "type": "boolean" }, + "force_websockets": { + "type": "boolean" + }, "path": { "type": "string" }, @@ -9712,6 +9718,9 @@ "codersdk.WorkspaceAgentConnectionInfo": { "type": "object", "properties": { + "derp_force_websockets": { + "type": "boolean" + }, "derp_map": { "$ref": "#/definitions/tailcfg.DERPMap" }, @@ -10934,6 +10943,9 @@ "app_security_key": { "type": "string" }, + "derp_force_websockets": { + "type": "boolean" + }, "derp_map": { "$ref": "#/definitions/tailcfg.DERPMap" }, diff --git a/coderd/coderd.go b/coderd/coderd.go index 5f3bbab1a0360..8fbad62794ab5 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -405,6 +405,7 @@ func New(options *Options) *API { options.Logger, options.DERPServer, api.DERPMap, + options.DeploymentValues.DERP.Config.ForceWebSockets.Value(), func(context.Context) (tailnet.MultiAgentConn, error) { return (*api.TailnetCoordinator.Load()).ServeMultiAgent(uuid.New()), nil }, diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index 1805b15c959ce..1924c68439508 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -7,9 +7,13 @@ import ( "net/http" "net/netip" "strconv" + "strings" "sync" + "sync/atomic" "testing" + "github.com/davecgh/go-spew/spew" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" @@ -18,8 +22,13 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/tailnet" "github.com/coder/coder/v2/testutil" ) @@ -119,6 +128,91 @@ func TestDERP(t *testing.T) { w2.Close() } +func TestDERPForceWebSockets(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.DERP.Config.ForceWebSockets = true + dv.DERP.Config.BlockDirect = true // to ensure the test always uses DERP + + // Manually create a server so we can influence the HTTP handler. + options := &coderdtest.Options{ + DeploymentValues: dv, + } + setHandler, cancelFunc, serverURL, newOptions := coderdtest.NewOptions(t, options) + coderAPI := coderd.New(newOptions) + t.Cleanup(func() { + cancelFunc() + _ = coderAPI.Close() + }) + + // Set the HTTP handler to a custom one that ensures all /derp calls are + // WebSockets and not `Upgrade: derp`. + var upgradeCount int64 + setHandler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/derp") { + up := r.Header.Get("Upgrade") + if up != "" && up != "websocket" { + t.Errorf("expected Upgrade: websocket, got %q", up) + } else { + atomic.AddInt64(&upgradeCount, 1) + } + } + + coderAPI.RootHandler.ServeHTTP(rw, r) + })) + + // Start a provisioner daemon. + provisionerCloser := coderdtest.NewProvisionerDaemon(t, coderAPI) + t.Cleanup(func() { + _ = provisionerCloser.Close() + }) + + client := codersdk.New(serverURL) + t.Cleanup(func() { + client.HTTPClient.CloseIdleConnections() + }) + user := coderdtest.CreateFirstUser(t, client) + + gen, err := client.WorkspaceAgentConnectionInfoGeneric(context.Background()) + require.NoError(t, err) + t.Log(spew.Sdump(gen)) + + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + agentCloser := agent.New(agent.Options{ + Client: agentClient, + Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), + }) + defer func() { + _ = agentCloser.Close() + }() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) + require.NoError(t, err) + defer func() { + _ = conn.Close() + }() + conn.AwaitReachable(ctx) + + require.GreaterOrEqual(t, atomic.LoadInt64(&upgradeCount), int64(1), "expected at least one /derp call") +} + func TestDERPLatencyCheck(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index bc2cb5e5925a0..b915b9ffbd3da 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -326,7 +326,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can stunAddresses []string dvStunAddresses = options.DeploymentValues.DERP.Server.STUNAddresses.Value() ) - if len(dvStunAddresses) == 0 || (len(dvStunAddresses) == 1 && dvStunAddresses[0] == "stun.l.google.com:19302") { + if len(dvStunAddresses) == 0 || dvStunAddresses[0] == "stun.l.google.com:19302" { stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{}) stunAddr.IP = net.ParseIP("127.0.0.1") t.Cleanup(stunCleanup) diff --git a/coderd/tailnet.go b/coderd/tailnet.go index 048fd9752f4e0..ca2a86d27f71e 100644 --- a/coderd/tailnet.go +++ b/coderd/tailnet.go @@ -45,6 +45,7 @@ func NewServerTailnet( logger slog.Logger, derpServer *derp.Server, derpMapFn func() *tailcfg.DERPMap, + derpForceWebSockets bool, getMultiAgent func(context.Context) (tailnet.MultiAgentConn, error), cache *wsconncache.Cache, traceProvider trace.TracerProvider, @@ -52,9 +53,10 @@ func NewServerTailnet( logger = logger.Named("servertailnet") originalDerpMap := derpMapFn() conn, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, - DERPMap: originalDerpMap, - Logger: logger, + Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, + DERPMap: originalDerpMap, + DERPForceWebSockets: derpForceWebSockets, + Logger: logger, }) if err != nil { return nil, xerrors.Errorf("create tailnet conn: %w", err) diff --git a/coderd/tailnet_test.go b/coderd/tailnet_test.go index 634a715abfbd7..2a0b0dfdbae70 100644 --- a/coderd/tailnet_test.go +++ b/coderd/tailnet_test.go @@ -232,6 +232,7 @@ func setupAgent(t *testing.T, agentAddresses []netip.Prefix) (uuid.UUID, agent.A logger, derpServer, func() *tailcfg.DERPMap { return manifest.DERPMap }, + false, func(context.Context) (tailnet.MultiAgentConn, error) { return coord.ServeMultiAgent(uuid.New()), nil }, cache, trace.NewNoopTracerProvider(), diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 2839ae4b83cce..3f90abb3a4b9b 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -167,6 +167,7 @@ func (api *API) workspaceAgentManifest(rw http.ResponseWriter, r *http.Request) AgentID: apiAgent.ID, Apps: convertApps(dbApps), DERPMap: api.DERPMap(), + DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), GitAuthConfigs: len(api.GitAuthConfigs), EnvironmentVariables: apiAgent.EnvironmentVariables, StartupScript: apiAgent.StartupScript, @@ -733,10 +734,11 @@ func (api *API) _dialWorkspaceAgentTailnet(agentID uuid.UUID) (*codersdk.Workspa derpMap := api.DERPMap() conn, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, - DERPMap: api.DERPMap(), - Logger: api.Logger.Named("net.tailnet"), - BlockEndpoints: api.DeploymentValues.DERP.Config.BlockDirect.Value(), + Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, + DERPMap: api.DERPMap(), + DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), + Logger: api.Logger.Named("net.tailnet"), + BlockEndpoints: api.DeploymentValues.DERP.Config.BlockDirect.Value(), }) if err != nil { _ = clientConn.Close() @@ -831,6 +833,7 @@ func (api *API) workspaceAgentConnection(rw http.ResponseWriter, r *http.Request httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentConnectionInfo{ DERPMap: api.DERPMap(), + DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), DisableDirectConnections: api.DeploymentValues.DERP.Config.BlockDirect.Value(), }) } @@ -851,6 +854,7 @@ func (api *API) workspaceAgentConnectionGeneric(rw http.ResponseWriter, r *http. httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentConnectionInfo{ DERPMap: api.DERPMap(), + DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), DisableDirectConnections: api.DeploymentValues.DERP.Config.BlockDirect.Value(), }) } diff --git a/coderd/wsconncache/wsconncache_test.go b/coderd/wsconncache/wsconncache_test.go index f06f836bd3ab7..68e41b17517fa 100644 --- a/coderd/wsconncache/wsconncache_test.go +++ b/coderd/wsconncache/wsconncache_test.go @@ -179,9 +179,10 @@ func setupAgent(t *testing.T, manifest agentsdk.Manifest, ptyTimeout time.Durati _ = closer.Close() }) conn, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, - DERPMap: manifest.DERPMap, - Logger: slogtest.Make(t, nil).Named("tailnet").Leveled(slog.LevelDebug), + Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, + DERPMap: manifest.DERPMap, + DERPForceWebSockets: manifest.DERPForceWebSockets, + Logger: slogtest.Make(t, nil).Named("tailnet").Leveled(slog.LevelDebug), }) require.NoError(t, err) clientConn, serverConn := net.Pipe() diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index b81aacc96bc64..fb1b2f497410b 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -89,6 +89,7 @@ type Manifest struct { VSCodePortProxyURI string `json:"vscode_port_proxy_uri"` Apps []codersdk.WorkspaceApp `json:"apps"` DERPMap *tailcfg.DERPMap `json:"derpmap"` + DERPForceWebSockets bool `json:"derp_force_websockets"` EnvironmentVariables map[string]string `json:"environment_variables"` StartupScript string `json:"startup_script"` StartupScriptTimeout time.Duration `json:"startup_script_timeout"` diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 708bc9e899784..a8356b6816554 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -229,9 +229,10 @@ type DERPServerConfig struct { } type DERPConfig struct { - BlockDirect clibase.Bool `json:"block_direct" typescript:",notnull"` - URL clibase.String `json:"url" typescript:",notnull"` - Path clibase.String `json:"path" typescript:",notnull"` + BlockDirect clibase.Bool `json:"block_direct" typescript:",notnull"` + ForceWebSockets clibase.Bool `json:"force_websockets" typescript:",notnull"` + URL clibase.String `json:"url" typescript:",notnull"` + Path clibase.String `json:"path" typescript:",notnull"` } type PrometheusConfig struct { @@ -797,6 +798,15 @@ when required by your organization's security policy.`, Group: &deploymentGroupNetworkingDERP, YAML: "blockDirect", }, + { + Name: "DERP Force WebSockets", + Description: "Force clients and agents to always use WebSocket to connect to DERP relay servers. By default, DERP uses `Upgrade: derp`, which may cause issues with some reverse proxies. Clients may automatically fallback to WebSocket if they detect an issue with `Upgrade: derp`, but this does not work in all situations.", + Flag: "derp-force-websockets", + Env: "CODER_DERP_FORCE_WEBSOCKETS", + Value: &c.DERP.Config.ForceWebSockets, + Group: &deploymentGroupNetworkingDERP, + YAML: "forceWebSockets", + }, { Name: "DERP Config URL", Description: "URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/.", diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 7eb20a3d5d9f9..bbb6c373c6984 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -186,6 +186,7 @@ type DERPRegion struct { // @typescript-ignore WorkspaceAgentConnectionInfo type WorkspaceAgentConnectionInfo struct { DERPMap *tailcfg.DERPMap `json:"derp_map"` + DERPForceWebSockets bool `json:"derp_force_websockets"` DisableDirectConnections bool `json:"disable_direct_connections"` } @@ -247,11 +248,12 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti header = headerTransport.Header() } conn, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)}, - DERPMap: connInfo.DERPMap, - DERPHeader: &header, - Logger: options.Logger, - BlockEndpoints: c.DisableDirectConnections || options.BlockEndpoints, + Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)}, + DERPMap: connInfo.DERPMap, + DERPHeader: &header, + DERPForceWebSockets: connInfo.DERPForceWebSockets, + Logger: options.Logger, + BlockEndpoints: c.DisableDirectConnections || options.BlockEndpoints, }) if err != nil { return nil, xerrors.Errorf("create tailnet: %w", err) diff --git a/docs/api/agents.md b/docs/api/agents.md index 73b4abf8ec84b..e1eaafa060411 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -392,6 +392,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/manifest \ "url": "string" } ], + "derp_force_websockets": true, "derpmap": { "homeParams": { "regionScore": { @@ -743,6 +744,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con ```json { + "derp_force_websockets": true, "derp_map": { "homeParams": { "regionScore": { diff --git a/docs/api/general.md b/docs/api/general.md index 170d52a0f3260..604a2993723e5 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -168,6 +168,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "derp": { "config": { "block_direct": true, + "force_websockets": true, "path": "string", "url": "string" }, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index b61907f0cd906..3cb49b84bf833 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -201,6 +201,7 @@ "url": "string" } ], + "derp_force_websockets": true, "derpmap": { "homeParams": { "regionScore": { @@ -291,6 +292,7 @@ | ---------------------------- | ------------------------------------------------------------------------------------------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `agent_id` | string | false | | | | `apps` | array of [codersdk.WorkspaceApp](#codersdkworkspaceapp) | false | | | +| `derp_force_websockets` | boolean | false | | | | `derpmap` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | | | `directory` | string | false | | | | `disable_direct_connections` | boolean | false | | | @@ -1812,6 +1814,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "config": { "block_direct": true, + "force_websockets": true, "path": "string", "url": "string" }, @@ -1850,6 +1853,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "block_direct": true, + "force_websockets": true, "path": "string", "url": "string" } @@ -1857,11 +1861,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------- | ------- | -------- | ------------ | ----------- | -| `block_direct` | boolean | false | | | -| `path` | string | false | | | -| `url` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------- | -------- | ------------ | ----------- | +| `block_direct` | boolean | false | | | +| `force_websockets` | boolean | false | | | +| `path` | string | false | | | +| `url` | string | false | | | ## codersdk.DERPRegion @@ -1985,6 +1990,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "derp": { "config": { "block_direct": true, + "force_websockets": true, "path": "string", "url": "string" }, @@ -2347,6 +2353,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "derp": { "config": { "block_direct": true, + "force_websockets": true, "path": "string", "url": "string" }, @@ -5642,6 +5649,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { + "derp_force_websockets": true, "derp_map": { "homeParams": { "regionScore": { @@ -5709,6 +5717,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | Name | Type | Required | Restrictions | Description | | ---------------------------- | ---------------------------------- | -------- | ------------ | ----------- | +| `derp_force_websockets` | boolean | false | | | | `derp_map` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | | | `disable_direct_connections` | boolean | false | | | @@ -7722,6 +7731,7 @@ _None_ ```json { "app_security_key": "string", + "derp_force_websockets": true, "derp_map": { "homeParams": { "regionScore": { @@ -7799,13 +7809,14 @@ _None_ ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | --------------------------------------------- | -------- | ------------ | -------------------------------------------------------------------------------------- | -| `app_security_key` | string | false | | | -| `derp_map` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | | -| `derp_mesh_key` | string | false | | | -| `derp_region_id` | integer | false | | | -| `sibling_replicas` | array of [codersdk.Replica](#codersdkreplica) | false | | Sibling replicas is a list of all other replicas of the proxy that have not timed out. | +| Name | Type | Required | Restrictions | Description | +| ----------------------- | --------------------------------------------- | -------- | ------------ | -------------------------------------------------------------------------------------- | +| `app_security_key` | string | false | | | +| `derp_force_websockets` | boolean | false | | | +| `derp_map` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | | +| `derp_mesh_key` | string | false | | | +| `derp_region_id` | integer | false | | | +| `sibling_replicas` | array of [codersdk.Replica](#codersdkreplica) | false | | Sibling replicas is a list of all other replicas of the proxy that have not timed out. | ## wsproxysdk.ReportAppStatsRequest diff --git a/docs/cli/server.md b/docs/cli/server.md index a2f29c39dca04..49ba37d7a4236 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -118,6 +118,16 @@ Path to read a DERP mapping from. See: https://tailscale.com/kb/1118/custom-derp URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/. +### --derp-force-websockets + +| | | +| ----------- | -------------------------------------------- | +| Type | bool | +| Environment | $CODER_DERP_FORCE_WEBSOCKETS | +| YAML | networking.derp.forceWebSockets | + +Force clients and agents to always use WebSocket to connect to DERP relay servers. By default, DERP uses `Upgrade: derp`, which may cause issues with some reverse proxies. Clients may automatically fallback to WebSocket if they detect an issue with `Upgrade: derp`, but this does not work in all situations. + ### --derp-server-enable | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 46fddeed2d6cc..d3a5d74bcddbe 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -172,6 +172,13 @@ backed by Tailscale and WireGuard. URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/. + --derp-force-websockets bool, $CODER_DERP_FORCE_WEBSOCKETS + Force clients and agents to always use WebSocket to connect to DERP + relay servers. By default, DERP uses `Upgrade: derp`, which may cause + issues with some reverse proxies. Clients may automatically fallback + to WebSocket if they detect an issue with `Upgrade: derp`, but this + does not work in all situations. + --derp-server-enable bool, $CODER_DERP_SERVER_ENABLE (default: true) Whether to enable or disable the embedded DERP relay server. diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go index faf665ffb8e5d..22ab937e7f3ce 100644 --- a/enterprise/coderd/workspaceproxy.go +++ b/enterprise/coderd/workspaceproxy.go @@ -717,11 +717,12 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request) // aReq.New = updatedProxy httpapi.Write(ctx, rw, http.StatusCreated, wsproxysdk.RegisterWorkspaceProxyResponse{ - AppSecurityKey: api.AppSecurityKey.String(), - DERPMeshKey: api.DERPServer.MeshKey(), - DERPRegionID: regionID, - DERPMap: api.AGPL.DERPMap(), - SiblingReplicas: siblingsRes, + AppSecurityKey: api.AppSecurityKey.String(), + DERPMeshKey: api.DERPServer.MeshKey(), + DERPRegionID: regionID, + DERPMap: api.AGPL.DERPMap(), + DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), + SiblingReplicas: siblingsRes, }) go api.forceWorkspaceProxyHealthUpdate(api.ctx) diff --git a/enterprise/wsproxy/wsproxy.go b/enterprise/wsproxy/wsproxy.go index 9b3deab5624a2..b0194d69d3f26 100644 --- a/enterprise/wsproxy/wsproxy.go +++ b/enterprise/wsproxy/wsproxy.go @@ -11,6 +11,7 @@ import ( "reflect" "regexp" "strings" + "sync/atomic" "time" "github.com/go-chi/chi/v5" @@ -121,7 +122,7 @@ type Server struct { // DERP derpMesh *derpmesh.Mesh - latestDERPMap *tailcfg.DERPMap + latestDERPMap atomic.Pointer[tailcfg.DERPMap] // Used for graceful shutdown. Required for the dialer. ctx context.Context @@ -247,8 +248,9 @@ func New(ctx context.Context, opts *Options) (*Server, error) { s.Logger, nil, func() *tailcfg.DERPMap { - return s.latestDERPMap + return s.latestDERPMap.Load() }, + regResp.DERPForceWebSockets, s.DialCoordinator, wsconncache.New(s.DialWorkspaceAgent, 0), s.TracerProvider, @@ -455,7 +457,7 @@ func (s *Server) handleRegister(_ context.Context, res wsproxysdk.RegisterWorksp } s.derpMesh.SetAddresses(addresses, false) - s.latestDERPMap = res.DERPMap + s.latestDERPMap.Store(res.DERPMap) return nil } diff --git a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go index 711614908d834..74c381c2d8b4a 100644 --- a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go +++ b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go @@ -207,10 +207,11 @@ type RegisterWorkspaceProxyRequest struct { } type RegisterWorkspaceProxyResponse struct { - AppSecurityKey string `json:"app_security_key"` - DERPMeshKey string `json:"derp_mesh_key"` - DERPRegionID int32 `json:"derp_region_id"` - DERPMap *tailcfg.DERPMap `json:"derp_map"` + AppSecurityKey string `json:"app_security_key"` + DERPMeshKey string `json:"derp_mesh_key"` + DERPRegionID int32 `json:"derp_region_id"` + DERPMap *tailcfg.DERPMap `json:"derp_map"` + DERPForceWebSockets bool `json:"derp_force_websockets"` // SiblingReplicas is a list of all other replicas of the proxy that have // not timed out. SiblingReplicas []codersdk.Replica `json:"sibling_replicas"` diff --git a/go.mod b/go.mod index 8e27769541d8a..496f42ebf312d 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ replace github.com/dlclark/regexp2 => github.com/dlclark/regexp2 v1.7.0 // There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here: // https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main -replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20230815060514-ebed8c967bd2 +replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20230824143504-4a17d5b8a684 // This is replaced to include a fix that causes a deadlock when closing the // wireguard network. diff --git a/go.sum b/go.sum index 6a891c532ff6c..3971c3fed5e5f 100644 --- a/go.sum +++ b/go.sum @@ -218,8 +218,8 @@ github.com/coder/retry v1.4.0 h1:g0fojHFxcdgM3sBULqgjFDxw1UIvaCqk4ngUDu0EWag= github.com/coder/retry v1.4.0/go.mod h1:blHMk9vs6LkoRT9ZHyuZo360cufXEhrxqvEzeMtRGoY= github.com/coder/ssh v0.0.0-20230621095435-9a7e23486f1c h1:TI7TzdFI0UvQmwgyQhtI1HeyYNRxAQpr8Tw/rjT8VSA= github.com/coder/ssh v0.0.0-20230621095435-9a7e23486f1c/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ= -github.com/coder/tailscale v1.1.1-0.20230815060514-ebed8c967bd2 h1:kHuTT70/yda7hdB8vi87gmgp5SgFf+oFT9d9aQ8aeXw= -github.com/coder/tailscale v1.1.1-0.20230815060514-ebed8c967bd2/go.mod h1:L8tPrwSi31RAMEMV8rjb0vYTGs7rXt8rAHbqY/p41j4= +github.com/coder/tailscale v1.1.1-0.20230824143504-4a17d5b8a684 h1:U1Nn5eL1gid6mOvu+L0u6t0gIB7uLV/7CFTOQNwsu6A= +github.com/coder/tailscale v1.1.1-0.20230824143504-4a17d5b8a684/go.mod h1:L8tPrwSi31RAMEMV8rjb0vYTGs7rXt8rAHbqY/p41j4= github.com/coder/terraform-provider-coder v0.11.1 h1:1sXcHfQrX8XhmLbtKxBED2lZ5jk3/ezBtaw6uVhpJZ4= github.com/coder/terraform-provider-coder v0.11.1/go.mod h1:UIfU3bYNeSzJJvHyJ30tEKjD6Z9utloI+HUM/7n94CY= github.com/coder/wgtunnel v0.1.5 h1:WP3sCj/3iJ34eKvpMQEp1oJHvm24RYh0NHbj1kfUKfs= diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ebe8fb61218e2..39f5401d5ff29 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -305,6 +305,7 @@ export interface DERP { // From codersdk/deployment.go export interface DERPConfig { readonly block_direct: boolean + readonly force_websockets: boolean readonly url: string readonly path: string } diff --git a/tailnet/conn.go b/tailnet/conn.go index 62865ea4db926..2a453a8a10340 100644 --- a/tailnet/conn.go +++ b/tailnet/conn.go @@ -88,6 +88,11 @@ type Options struct { Addresses []netip.Prefix DERPMap *tailcfg.DERPMap DERPHeader *http.Header + // DERPForceWebSockets determines whether websockets is always used for DERP + // connections, rather than trying `Upgrade: derp` first and potentially + // falling back. This is useful for misbehaving proxies that prevent + // fallback due to odd behavior, like Azure App Proxy. + DERPForceWebSockets bool // BlockEndpoints specifies whether P2P endpoints are blocked. // If so, only DERPs can establish connections. @@ -214,6 +219,7 @@ func NewConn(options *Options) (conn *Conn, err error) { sys.Set(wireguardEngine) magicConn := sys.MagicSock.Get() + magicConn.SetDERPForceWebsockets(options.DERPForceWebSockets) if options.DERPHeader != nil { magicConn.SetDERPHeader(options.DERPHeader.Clone()) } @@ -277,6 +283,7 @@ func NewConn(options *Options) (conn *Conn, err error) { dialContext, dialCancel := context.WithCancel(context.Background()) server := &Conn{ blockEndpoints: options.BlockEndpoints, + derpForceWebSockets: options.DERPForceWebSockets, dialContext: dialContext, dialCancel: dialCancel, closed: make(chan struct{}), @@ -285,7 +292,7 @@ func NewConn(options *Options) (conn *Conn, err error) { dialer: dialer, listeners: map[listenKey]*listener{}, peerMap: map[tailcfg.NodeID]*tailcfg.Node{}, - lastDERPForcedWebsockets: map[int]string{}, + lastDERPForcedWebSockets: map[int]string{}, tunDevice: sys.Tun.Get(), netMap: netMap, netStack: netStack, @@ -338,11 +345,11 @@ func NewConn(options *Options) (conn *Conn, err error) { magicConn.SetDERPForcedWebsocketCallback(func(region int, reason string) { server.logger.Debug(context.Background(), "derp forced websocket", slog.F("region", region), slog.F("reason", reason)) server.lastMutex.Lock() - if server.lastDERPForcedWebsockets[region] == reason { + if server.lastDERPForcedWebSockets[region] == reason { server.lastMutex.Unlock() return } - server.lastDERPForcedWebsockets[region] = reason + server.lastDERPForcedWebSockets[region] = reason server.lastMutex.Unlock() server.sendNode() }) @@ -383,12 +390,13 @@ func IPFromUUID(uid uuid.UUID) netip.Addr { // Conn is an actively listening Wireguard connection. type Conn struct { - dialContext context.Context - dialCancel context.CancelFunc - mutex sync.Mutex - closed chan struct{} - logger slog.Logger - blockEndpoints bool + dialContext context.Context + dialCancel context.CancelFunc + mutex sync.Mutex + closed chan struct{} + logger slog.Logger + blockEndpoints bool + derpForceWebSockets bool dialer *tsdial.Dialer tunDevice *tstun.Wrapper @@ -408,7 +416,7 @@ type Conn struct { // so the values must be stored for retrieval later on. lastStatus time.Time lastEndpoints []tailcfg.Endpoint - lastDERPForcedWebsockets map[int]string + lastDERPForcedWebSockets map[int]string lastNetInfo *tailcfg.NetInfo nodeCallback func(node *Node) @@ -461,6 +469,10 @@ func (c *Conn) SetDERPMap(derpMap *tailcfg.DERPMap) { c.wireguardEngine.SetNetworkMap(&netMapCopy) } +func (c *Conn) SetDERPForceWebSockets(v bool) { + c.magicConn.SetDERPForceWebsockets(v) +} + // SetBlockEndpoints sets whether or not to block P2P endpoints. This setting // will only apply to new peers. func (c *Conn) SetBlockEndpoints(blockEndpoints bool) { @@ -838,8 +850,16 @@ func (c *Conn) selfNode() *Node { if c.lastNetInfo != nil { preferredDERP = c.lastNetInfo.PreferredDERP derpLatency = c.lastNetInfo.DERPLatency - for k, v := range c.lastDERPForcedWebsockets { - derpForcedWebsocket[k] = v + + if c.derpForceWebSockets { + // We only need to store this for a single region, since this is + // mostly used for debugging purposes and doesn't actually have a + // code purpose. + derpForcedWebsocket[preferredDERP] = "DERP is configured to always fallback to WebSockets" + } else { + for k, v := range c.lastDERPForcedWebSockets { + derpForcedWebsocket[k] = v + } } } From ebd878b6b568a521210114829026d1aa88135f9c Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Thu, 24 Aug 2023 12:35:00 -0500 Subject: [PATCH 237/277] chore: v2.1.3 changelog (#9311) --- docs/changelogs/README.md | 6 +++--- docs/changelogs/v2.1.3.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 docs/changelogs/v2.1.3.md diff --git a/docs/changelogs/README.md b/docs/changelogs/README.md index 5d0634717bfe6..b0ffdbd4218b0 100644 --- a/docs/changelogs/README.md +++ b/docs/changelogs/README.md @@ -12,8 +12,8 @@ Run this command to generate release notes: export CODER_IGNORE_MISSING_COMMIT_METADATA=1 export BRANCH=main ./scripts/release/generate_release_notes.sh \ - --old-version=v2.1.2 \ - --new-version=v2.1.3 \ + --old-version=v2.1.3 \ + --new-version=v2.1.4 \ --ref=$(git rev-parse --short "${ref:-origin/$BRANCH}") \ - > ./docs/changelogs/v2.1.3.md + > ./docs/changelogs/v2.1.4.md ``` diff --git a/docs/changelogs/v2.1.3.md b/docs/changelogs/v2.1.3.md new file mode 100644 index 0000000000000..ecd7c85582d82 --- /dev/null +++ b/docs/changelogs/v2.1.3.md @@ -0,0 +1,31 @@ +## Changelog + +### Bug fixes + +- Prevent oidc refresh being ignored (#9293) (@coryb) +- Use stable sorting for insights and improve test coverage (#9250) (@mafredri) +- Rewrite template insights query for speed and fix intervals (#9300) + (@mafredri) +- Optimize template app insights query for speed and decrease intervals (#9302) + (@mafredri) +- Upgrade cdr.dev/slog to fix isTTY race (#9305) (@mafredri) +- Fix vertical scroll in the bottom bar (#9270) (@BrunoQuaresma) + +### Documentation + +- Explain + [incompatibility in parameter options](https://coder.com/docs/v2/latest/templates/parameters#incompatibility-in-parameter-options-for-workspace-builds) + for workspace builds (#9297) (@mtojek) + +Compare: +[`v2.1.2...v2.1.3`](https://github.com/coder/coder/compare/v2.1.2...v2.1.3) + +## Container image + +- `docker pull ghcr.io/coder/coder:v2.1.3` + +## Install/upgrade + +Refer to our docs to [install](https://coder.com/docs/v2/latest/install) or +[upgrade](https://coder.com/docs/v2/latest/admin/upgrade) Coder, or use a +release asset below. From 7f14b50dbed5f72018d774f9e087ee24b7d081bc Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 24 Aug 2023 13:25:54 -0500 Subject: [PATCH 238/277] chore: rename locked to dormant (#9290) * chore: rename locked to dormant - The following columns have been updated: - workspace.locked_at -> dormant_at - template.inactivity_ttl -> time_til_dormant - template.locked_ttl -> time_til_dormant_autodelete This change has also been reflected in the SDK. A route has also been updated from /workspaces//lock to /workspaces//dormant --- cli/templatecreate.go | 2 +- cli/templateedit.go | 2 +- cli/templateedit_test.go | 8 +- cli/testdata/coder_list_--output_json.golden | 2 +- coderd/apidoc/docs.go | 70 +++--- coderd/apidoc/swagger.json | 70 +++--- coderd/autobuild/lifecycle_executor.go | 56 ++--- coderd/autobuild/lifecycle_executor_test.go | 2 +- coderd/coderd.go | 2 +- coderd/database/dbauthz/dbauthz.go | 18 +- coderd/database/dbfake/dbfake.go | 86 ++++---- coderd/database/dbmetrics/dbmetrics.go | 20 +- coderd/database/dbmock/dbmock.go | 42 ++-- coderd/database/dump.sql | 10 +- .../migrations/000151_rename_locked.down.sql | 26 +++ .../migrations/000151_rename_locked.up.sql | 25 +++ coderd/database/modelmethods.go | 18 +- coderd/database/modelqueries.go | 8 +- coderd/database/models.go | 14 +- coderd/database/querier.go | 4 +- coderd/database/queries.sql.go | 180 ++++++++-------- coderd/database/queries/templates.sql | 4 +- coderd/database/queries/workspaces.sql | 48 ++--- coderd/database/sqlc.yaml | 3 +- coderd/rbac/object.go | 6 +- coderd/rbac/object_gen.go | 2 +- coderd/rbac/roles.go | 6 +- coderd/rbac/roles_test.go | 4 +- coderd/schedule/template.go | 32 +-- coderd/searchquery/search.go | 8 +- coderd/templates.go | 94 ++++---- coderd/templates_test.go | 90 ++++---- coderd/workspaces.go | 42 ++-- coderd/workspaces_test.go | 74 +++---- codersdk/organizations.go | 10 +- codersdk/templates.go | 30 +-- codersdk/workspaces.go | 29 +-- docs/admin/audit-logs.md | 26 +-- docs/api/schemas.md | 120 +++++------ docs/api/templates.md | 30 +-- docs/api/workspaces.md | 134 ++++++------ enterprise/audit/table.go | 6 +- enterprise/coderd/schedule/template.go | 32 +-- enterprise/coderd/schedule/template_test.go | 12 +- enterprise/coderd/templates_test.go | 200 +++++++++--------- enterprise/coderd/workspaces_test.go | 126 +++++------ site/src/api/api.ts | 10 +- site/src/api/typesGenerated.ts | 20 +- .../Dialogs/ConfirmDialog/ConfirmDialog.tsx | 6 +- .../components/WorkspaceActions/constants.ts | 2 +- .../ImpendingDeletionBadge.tsx | 17 -- .../ImpendingDeletionBanner.tsx | 16 +- .../src/components/WorkspaceDeletion/index.ts | 1 - .../TemplateSettingsForm.tsx | 2 +- .../TemplateSettingsPage.test.tsx | 6 +- .../InactivityDialog.stories.tsx | 19 -- .../TemplateScheduleForm/InactivityDialog.tsx | 59 ------ .../TemplateScheduleForm.tsx | 91 ++++---- .../TemplateScheduleForm/formHelpers.tsx | 8 +- .../useWorkspacesToBeDeleted.ts | 16 +- .../TemplateSchedulePage.test.tsx | 42 ++-- .../pages/WorkspacesPage/WorkspacesPage.tsx | 24 +-- .../WorkspacesPage/WorkspacesPageView.tsx | 14 +- .../pages/WorkspacesPage/filter/filter.tsx | 4 +- site/src/testHelpers/entities.ts | 4 +- site/src/utils/filters.ts | 2 +- .../xServices/workspace/workspaceXService.ts | 2 +- 67 files changed, 1083 insertions(+), 1115 deletions(-) create mode 100644 coderd/database/migrations/000151_rename_locked.down.sql create mode 100644 coderd/database/migrations/000151_rename_locked.up.sql delete mode 100644 site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx delete mode 100644 site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx delete mode 100644 site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 9a54bf814eb60..38f5d7d0d7fd0 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -134,7 +134,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { VersionID: job.ID, DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), FailureTTLMillis: ptr.Ref(failureTTL.Milliseconds()), - InactivityTTLMillis: ptr.Ref(inactivityTTL.Milliseconds()), + TimeTilDormantMillis: ptr.Ref(inactivityTTL.Milliseconds()), DisableEveryoneGroupAccess: disableEveryone, } diff --git a/cli/templateedit.go b/cli/templateedit.go index 7ce8fb00daec0..5fcd73c432f58 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -104,7 +104,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Weeks: restartRequirementWeeks, }, FailureTTLMillis: failureTTL.Milliseconds(), - InactivityTTLMillis: inactivityTTL.Milliseconds(), + TimeTilDormantMillis: inactivityTTL.Milliseconds(), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, AllowUserAutostart: allowUserAutostart, AllowUserAutostop: allowUserAutostop, diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 775a25f91c6bd..0aff5166e9ca8 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -752,7 +752,7 @@ func TestTemplateEdit(t *testing.T) { ctr.DefaultTTLMillis = nil ctr.RestartRequirement = nil ctr.FailureTTLMillis = nil - ctr.InactivityTTLMillis = nil + ctr.TimeTilDormantMillis = nil }) // Test the cli command with --allow-user-autostart. @@ -798,7 +798,7 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) - assert.Equal(t, template.InactivityTTLMillis, updated.InactivityTTLMillis) + assert.Equal(t, template.TimeTilDormantMillis, updated.TimeTilDormantMillis) }) t.Run("BlockedNotEntitled", func(t *testing.T) { @@ -892,7 +892,7 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) - assert.Equal(t, template.InactivityTTLMillis, updated.InactivityTTLMillis) + assert.Equal(t, template.TimeTilDormantMillis, updated.TimeTilDormantMillis) }) t.Run("Entitled", func(t *testing.T) { t.Parallel() @@ -990,7 +990,7 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) - assert.Equal(t, template.InactivityTTLMillis, updated.InactivityTTLMillis) + assert.Equal(t, template.TimeTilDormantMillis, updated.TimeTilDormantMillis) }) }) } diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 49e51d408285c..f680c9e210cbc 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -52,7 +52,7 @@ "ttl_ms": 28800000, "last_used_at": "[timestamp]", "deleting_at": null, - "locked_at": null, + "dormant_at": null, "health": { "healthy": true, "failing_agents": [] diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0af41e03271ea..2668bf41b024d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6082,7 +6082,7 @@ const docTemplate = `{ } } }, - "/workspaces/{workspace}/extend": { + "/workspaces/{workspace}/dormant": { "put": { "security": [ { @@ -6098,8 +6098,8 @@ const docTemplate = `{ "tags": [ "Workspaces" ], - "summary": "Extend workspace deadline by ID", - "operationId": "extend-workspace-deadline-by-id", + "summary": "Update workspace dormancy status by id.", + "operationId": "update-workspace-dormancy-status-by-id", "parameters": [ { "type": "string", @@ -6110,12 +6110,12 @@ const docTemplate = `{ "required": true }, { - "description": "Extend deadline update request", + "description": "Make a workspace dormant or active", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.PutExtendWorkspaceRequest" + "$ref": "#/definitions/codersdk.UpdateWorkspaceDormancy" } } ], @@ -6123,13 +6123,13 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Response" + "$ref": "#/definitions/codersdk.Workspace" } } } } }, - "/workspaces/{workspace}/lock": { + "/workspaces/{workspace}/extend": { "put": { "security": [ { @@ -6145,8 +6145,8 @@ const docTemplate = `{ "tags": [ "Workspaces" ], - "summary": "Update workspace lock by id.", - "operationId": "update-workspace-lock-by-id", + "summary": "Extend workspace deadline by ID", + "operationId": "extend-workspace-deadline-by-id", "parameters": [ { "type": "string", @@ -6157,12 +6157,12 @@ const docTemplate = `{ "required": true }, { - "description": "Lock or unlock a workspace", + "description": "Extend deadline update request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.UpdateWorkspaceLock" + "$ref": "#/definitions/codersdk.PutExtendWorkspaceRequest" } } ], @@ -6170,7 +6170,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Workspace" + "$ref": "#/definitions/codersdk.Response" } } } @@ -7394,6 +7394,10 @@ const docTemplate = `{ "description": "DefaultTTLMillis allows optionally specifying the default TTL\nfor all workspaces created from this template.", "type": "integer" }, + "delete_ttl_ms": { + "description": "TimeTilDormantAutoDeleteMillis allows optionally specifying the max lifetime before Coder\npermanently deletes dormant workspaces created from this template.", + "type": "integer" + }, "description": { "description": "Description is a description of what the template contains. It must be\nless than 128 bytes.", "type": "string" @@ -7406,6 +7410,10 @@ const docTemplate = `{ "description": "DisplayName is the displayed name of the template.", "type": "string" }, + "dormant_ttl_ms": { + "description": "TimeTilDormantMillis allows optionally specifying the max lifetime before Coder\nlocks inactive workspaces created from this template.", + "type": "integer" + }, "failure_ttl_ms": { "description": "FailureTTLMillis allows optionally specifying the max lifetime before Coder\nstops all resources for failed workspaces created from this template.", "type": "integer" @@ -7414,14 +7422,6 @@ const docTemplate = `{ "description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.", "type": "string" }, - "inactivity_ttl_ms": { - "description": "InactivityTTLMillis allows optionally specifying the max lifetime before Coder\nlocks inactive workspaces created from this template.", - "type": "integer" - }, - "locked_ttl_ms": { - "description": "LockedTTLMillis allows optionally specifying the max lifetime before Coder\npermanently deletes locked workspaces created from this template.", - "type": "integer" - }, "max_ttl_ms": { "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" @@ -9540,7 +9540,7 @@ const docTemplate = `{ "type": "string" }, "failure_ttl_ms": { - "description": "FailureTTLMillis, InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their\nvalues are used if your license is entitled to use the advanced\ntemplate scheduling feature.", + "description": "FailureTTLMillis, TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their\nvalues are used if your license is entitled to use the advanced\ntemplate scheduling feature.", "type": "integer" }, "icon": { @@ -9550,12 +9550,6 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, - "inactivity_ttl_ms": { - "type": "integer" - }, - "locked_ttl_ms": { - "type": "integer" - }, "max_ttl_ms": { "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" @@ -9581,6 +9575,12 @@ const docTemplate = `{ } ] }, + "time_til_dormant_autodelete_ms": { + "type": "integer" + }, + "time_til_dormant_ms": { + "type": "integer" + }, "updated_at": { "type": "string", "format": "date-time" @@ -10254,10 +10254,10 @@ const docTemplate = `{ } } }, - "codersdk.UpdateWorkspaceLock": { + "codersdk.UpdateWorkspaceDormancy": { "type": "object", "properties": { - "lock": { + "dormant": { "type": "boolean" } } @@ -10510,7 +10510,12 @@ const docTemplate = `{ "format": "date-time" }, "deleting_at": { - "description": "DeletingAt indicates the time of the upcoming workspace deletion, if applicable; otherwise it is nil.\nWorkspaces may have impending deletions if Template.InactivityTTL feature is turned on and the workspace is inactive.", + "description": "DeletingAt indicates the time at which the workspace will be permanently deleted.\nA workspace is eligible for deletion if it is dormant (a non-nil dormant_at value)\nand a value has been specified for time_til_dormant_autodelete on its template.", + "type": "string", + "format": "date-time" + }, + "dormant_at": { + "description": "DormantAt being non-nil indicates a workspace that is dormant.\nA dormant workspace is no longer accessible must be activated.\nIt is subject to deletion if it breaches\nthe duration of the time_til_ field on its template.", "type": "string", "format": "date-time" }, @@ -10533,11 +10538,6 @@ const docTemplate = `{ "latest_build": { "$ref": "#/definitions/codersdk.WorkspaceBuild" }, - "locked_at": { - "description": "LockedAt being non-nil indicates a workspace that has been locked.\nA locked workspace is no longer accessible by a user and must be\nunlocked by an admin. It is subject to deletion if it breaches\nthe duration of the locked_ttl field on its template.", - "type": "string", - "format": "date-time" - }, "name": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 008534328fd70..ccf504f4d7cc7 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5366,7 +5366,7 @@ } } }, - "/workspaces/{workspace}/extend": { + "/workspaces/{workspace}/dormant": { "put": { "security": [ { @@ -5376,8 +5376,8 @@ "consumes": ["application/json"], "produces": ["application/json"], "tags": ["Workspaces"], - "summary": "Extend workspace deadline by ID", - "operationId": "extend-workspace-deadline-by-id", + "summary": "Update workspace dormancy status by id.", + "operationId": "update-workspace-dormancy-status-by-id", "parameters": [ { "type": "string", @@ -5388,12 +5388,12 @@ "required": true }, { - "description": "Extend deadline update request", + "description": "Make a workspace dormant or active", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.PutExtendWorkspaceRequest" + "$ref": "#/definitions/codersdk.UpdateWorkspaceDormancy" } } ], @@ -5401,13 +5401,13 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Response" + "$ref": "#/definitions/codersdk.Workspace" } } } } }, - "/workspaces/{workspace}/lock": { + "/workspaces/{workspace}/extend": { "put": { "security": [ { @@ -5417,8 +5417,8 @@ "consumes": ["application/json"], "produces": ["application/json"], "tags": ["Workspaces"], - "summary": "Update workspace lock by id.", - "operationId": "update-workspace-lock-by-id", + "summary": "Extend workspace deadline by ID", + "operationId": "extend-workspace-deadline-by-id", "parameters": [ { "type": "string", @@ -5429,12 +5429,12 @@ "required": true }, { - "description": "Lock or unlock a workspace", + "description": "Extend deadline update request", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.UpdateWorkspaceLock" + "$ref": "#/definitions/codersdk.PutExtendWorkspaceRequest" } } ], @@ -5442,7 +5442,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Workspace" + "$ref": "#/definitions/codersdk.Response" } } } @@ -6587,6 +6587,10 @@ "description": "DefaultTTLMillis allows optionally specifying the default TTL\nfor all workspaces created from this template.", "type": "integer" }, + "delete_ttl_ms": { + "description": "TimeTilDormantAutoDeleteMillis allows optionally specifying the max lifetime before Coder\npermanently deletes dormant workspaces created from this template.", + "type": "integer" + }, "description": { "description": "Description is a description of what the template contains. It must be\nless than 128 bytes.", "type": "string" @@ -6599,6 +6603,10 @@ "description": "DisplayName is the displayed name of the template.", "type": "string" }, + "dormant_ttl_ms": { + "description": "TimeTilDormantMillis allows optionally specifying the max lifetime before Coder\nlocks inactive workspaces created from this template.", + "type": "integer" + }, "failure_ttl_ms": { "description": "FailureTTLMillis allows optionally specifying the max lifetime before Coder\nstops all resources for failed workspaces created from this template.", "type": "integer" @@ -6607,14 +6615,6 @@ "description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.", "type": "string" }, - "inactivity_ttl_ms": { - "description": "InactivityTTLMillis allows optionally specifying the max lifetime before Coder\nlocks inactive workspaces created from this template.", - "type": "integer" - }, - "locked_ttl_ms": { - "description": "LockedTTLMillis allows optionally specifying the max lifetime before Coder\npermanently deletes locked workspaces created from this template.", - "type": "integer" - }, "max_ttl_ms": { "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" @@ -8609,7 +8609,7 @@ "type": "string" }, "failure_ttl_ms": { - "description": "FailureTTLMillis, InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their\nvalues are used if your license is entitled to use the advanced\ntemplate scheduling feature.", + "description": "FailureTTLMillis, TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their\nvalues are used if your license is entitled to use the advanced\ntemplate scheduling feature.", "type": "integer" }, "icon": { @@ -8619,12 +8619,6 @@ "type": "string", "format": "uuid" }, - "inactivity_ttl_ms": { - "type": "integer" - }, - "locked_ttl_ms": { - "type": "integer" - }, "max_ttl_ms": { "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" @@ -8648,6 +8642,12 @@ } ] }, + "time_til_dormant_autodelete_ms": { + "type": "integer" + }, + "time_til_dormant_ms": { + "type": "integer" + }, "updated_at": { "type": "string", "format": "date-time" @@ -9274,10 +9274,10 @@ } } }, - "codersdk.UpdateWorkspaceLock": { + "codersdk.UpdateWorkspaceDormancy": { "type": "object", "properties": { - "lock": { + "dormant": { "type": "boolean" } } @@ -9512,7 +9512,12 @@ "format": "date-time" }, "deleting_at": { - "description": "DeletingAt indicates the time of the upcoming workspace deletion, if applicable; otherwise it is nil.\nWorkspaces may have impending deletions if Template.InactivityTTL feature is turned on and the workspace is inactive.", + "description": "DeletingAt indicates the time at which the workspace will be permanently deleted.\nA workspace is eligible for deletion if it is dormant (a non-nil dormant_at value)\nand a value has been specified for time_til_dormant_autodelete on its template.", + "type": "string", + "format": "date-time" + }, + "dormant_at": { + "description": "DormantAt being non-nil indicates a workspace that is dormant.\nA dormant workspace is no longer accessible must be activated.\nIt is subject to deletion if it breaches\nthe duration of the time_til_ field on its template.", "type": "string", "format": "date-time" }, @@ -9535,11 +9540,6 @@ "latest_build": { "$ref": "#/definitions/codersdk.WorkspaceBuild" }, - "locked_at": { - "description": "LockedAt being non-nil indicates a workspace that has been locked.\nA locked workspace is no longer accessible by a user and must be\nunlocked by an admin. It is subject to deletion if it breaches\nthe duration of the locked_ttl field on its template.", - "type": "string", - "format": "date-time" - }, "name": { "type": "string" }, diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 3ce0aad5a4202..f603d7895531d 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -175,35 +175,35 @@ func (e *Executor) runOnce(t time.Time) Stats { } } - // Lock the workspace if it has breached the template's + // Transition the workspace to dormant if it has breached the template's // threshold for inactivity. if reason == database.BuildReasonAutolock { - ws, err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{ + ws, err = tx.UpdateWorkspaceDormantDeletingAt(e.ctx, database.UpdateWorkspaceDormantDeletingAtParams{ ID: ws.ID, - LockedAt: sql.NullTime{ + DormantAt: sql.NullTime{ Time: database.Now(), Valid: true, }, }) if err != nil { - log.Error(e.ctx, "unable to lock workspace", + log.Error(e.ctx, "unable to transition workspace to dormant", slog.F("transition", nextTransition), slog.Error(err), ) return nil } - log.Info(e.ctx, "locked workspace", + log.Info(e.ctx, "dormant workspace", slog.F("last_used_at", ws.LastUsedAt), - slog.F("inactivity_ttl", templateSchedule.InactivityTTL), + slog.F("time_til_dormant", templateSchedule.TimeTilDormant), slog.F("since_last_used_at", time.Since(ws.LastUsedAt)), ) } if reason == database.BuildReasonAutodelete { log.Info(e.ctx, "deleted workspace", - slog.F("locked_at", ws.LockedAt.Time), - slog.F("locked_ttl", templateSchedule.LockedTTL), + slog.F("dormant_at", ws.DormantAt.Time), + slog.F("time_til_dormant_autodelete", templateSchedule.TimeTilDormantAutoDelete), ) } @@ -246,7 +246,7 @@ func (e *Executor) runOnce(t time.Time) Stats { // for this function to return a nil error as well as an empty transition. // In such cases it means no provisioning should occur but the workspace // may be "transitioning" to a new state (such as an inactive, stopped -// workspace transitioning to the locked state). +// workspace transitioning to the dormant state). func getNextTransition( ws database.Workspace, latestBuild database.WorkspaceBuild, @@ -265,13 +265,13 @@ func getNextTransition( return database.WorkspaceTransitionStart, database.BuildReasonAutostart, nil case isEligibleForFailedStop(latestBuild, latestJob, templateSchedule, currentTick): return database.WorkspaceTransitionStop, database.BuildReasonAutostop, nil - case isEligibleForLockedStop(ws, templateSchedule, currentTick): + case isEligibleForDormantStop(ws, templateSchedule, currentTick): // Only stop started workspaces. if latestBuild.Transition == database.WorkspaceTransitionStart { return database.WorkspaceTransitionStop, database.BuildReasonAutolock, nil } // We shouldn't transition the workspace but we should still - // lock it. + // make it dormant. return "", database.BuildReasonAutolock, nil case isEligibleForDelete(ws, templateSchedule, currentTick): @@ -288,8 +288,8 @@ func isEligibleForAutostart(ws database.Workspace, build database.WorkspaceBuild return false } - // If the workspace is locked we should not autostart it. - if ws.LockedAt.Valid { + // If the workspace is dormant we should not autostart it. + if ws.DormantAt.Valid { return false } @@ -322,8 +322,8 @@ func isEligibleForAutostop(ws database.Workspace, build database.WorkspaceBuild, return false } - // If the workspace is locked we should not autostop it. - if ws.LockedAt.Valid { + // If the workspace is dormant we should not autostop it. + if ws.DormantAt.Valid { return false } @@ -334,23 +334,23 @@ func isEligibleForAutostop(ws database.Workspace, build database.WorkspaceBuild, !currentTick.Before(build.Deadline) } -// isEligibleForLockedStop returns true if the workspace should be locked +// isEligibleForDormantStop returns true if the workspace should be dormant // for breaching the inactivity threshold of the template. -func isEligibleForLockedStop(ws database.Workspace, templateSchedule schedule.TemplateScheduleOptions, currentTick time.Time) bool { - // Only attempt to lock workspaces not already locked. - return !ws.LockedAt.Valid && - // The template must specify an inactivity TTL. - templateSchedule.InactivityTTL > 0 && - // The workspace must breach the inactivity TTL. - currentTick.Sub(ws.LastUsedAt) > templateSchedule.InactivityTTL +func isEligibleForDormantStop(ws database.Workspace, templateSchedule schedule.TemplateScheduleOptions, currentTick time.Time) bool { + // Only attempt against workspaces not already dormant. + return !ws.DormantAt.Valid && + // The template must specify an time_til_dormant value. + templateSchedule.TimeTilDormant > 0 && + // The workspace must breach the time_til_dormant value. + currentTick.Sub(ws.LastUsedAt) > templateSchedule.TimeTilDormant } func isEligibleForDelete(ws database.Workspace, templateSchedule schedule.TemplateScheduleOptions, currentTick time.Time) bool { - // Only attempt to delete locked workspaces. - return ws.LockedAt.Valid && ws.DeletingAt.Valid && - // Locked workspaces should only be deleted if a locked_ttl is specified. - templateSchedule.LockedTTL > 0 && - // The workspace must breach the locked_ttl. + // Only attempt to delete dormant workspaces. + return ws.DormantAt.Valid && ws.DeletingAt.Valid && + // Dormant workspaces should only be deleted if a time_til_dormant_autodelete value is specified. + templateSchedule.TimeTilDormantAutoDelete > 0 && + // The workspace must breach the time_til_dormant_autodelete value. currentTick.After(ws.DeletingAt.Time) } diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index b1b854167e4b2..356926d9bbff9 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -737,7 +737,7 @@ func TestExecutorInactiveWorkspace(t *testing.T) { ProvisionApply: echo.ProvisionComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.InactivityTTLMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) + ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) diff --git a/coderd/coderd.go b/coderd/coderd.go index 8fbad62794ab5..4aac3867b60f9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -855,7 +855,7 @@ func New(options *Options) *API { }) r.Get("/watch", api.watchWorkspace) r.Put("/extend", api.putExtendWorkspace) - r.Put("/lock", api.putWorkspaceLock) + r.Put("/dormant", api.putWorkspaceDormant) }) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 1fc1c9782235e..9115e9b5ac184 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2636,18 +2636,18 @@ func (q *querier) UpdateWorkspaceDeletedByID(ctx context.Context, arg database.U return deleteQ(q.log, q.auth, fetch, q.db.UpdateWorkspaceDeletedByID)(ctx, arg) } -func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.UpdateWorkspaceLastUsedAtParams) error { - fetch := func(ctx context.Context, arg database.UpdateWorkspaceLastUsedAtParams) (database.Workspace, error) { +func (q *querier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg database.UpdateWorkspaceDormantDeletingAtParams) (database.Workspace, error) { + fetch := func(ctx context.Context, arg database.UpdateWorkspaceDormantDeletingAtParams) (database.Workspace, error) { return q.db.GetWorkspaceByID(ctx, arg.ID) } - return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg) + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceDormantDeletingAt)(ctx, arg) } -func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { - fetch := func(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { +func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.UpdateWorkspaceLastUsedAtParams) error { + fetch := func(ctx context.Context, arg database.UpdateWorkspaceLastUsedAtParams) (database.Workspace, error) { return q.db.GetWorkspaceByID(ctx, arg.ID) } - return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg) + return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg) } func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { @@ -2671,12 +2671,12 @@ func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWor return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceTTL)(ctx, arg) } -func (q *querier) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { - fetch := func(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) (database.Template, error) { +func (q *querier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error { + fetch := func(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) (database.Template, error) { return q.db.GetTemplateByID(ctx, arg.TemplateID) } - return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspacesLockedDeletingAtByTemplateID)(ctx, arg) + return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspacesDormantDeletingAtByTemplateID)(ctx, arg) } func (q *querier) UpsertAppSecurityKey(ctx context.Context, data string) error { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 9455e8e69009b..b8be4b2e64ef8 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -341,7 +341,7 @@ func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspac AutostartSchedule: w.AutostartSchedule, Ttl: w.Ttl, LastUsedAt: w.LastUsedAt, - LockedAt: w.LockedAt, + DormantAt: w.DormantAt, DeletingAt: w.DeletingAt, Count: count, } @@ -3737,14 +3737,14 @@ func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, no if build.Transition == database.WorkspaceTransitionStart && !build.Deadline.IsZero() && build.Deadline.Before(now) && - !workspace.LockedAt.Valid { + !workspace.DormantAt.Valid { workspaces = append(workspaces, workspace) continue } if build.Transition == database.WorkspaceTransitionStop && workspace.AutostartSchedule.Valid && - !workspace.LockedAt.Valid { + !workspace.DormantAt.Valid { workspaces = append(workspaces, workspace) continue } @@ -3762,11 +3762,11 @@ func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, no if err != nil { return nil, xerrors.Errorf("get template by ID: %w", err) } - if !workspace.LockedAt.Valid && template.InactivityTTL > 0 { + if !workspace.DormantAt.Valid && template.TimeTilDormant > 0 { workspaces = append(workspaces, workspace) continue } - if workspace.LockedAt.Valid && template.LockedTTL > 0 { + if workspace.DormantAt.Valid && template.TimeTilDormantAutoDelete > 0 { workspaces = append(workspaces, workspace) continue } @@ -5130,8 +5130,8 @@ func (q *FakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database tpl.RestartRequirementDaysOfWeek = arg.RestartRequirementDaysOfWeek tpl.RestartRequirementWeeks = arg.RestartRequirementWeeks tpl.FailureTTL = arg.FailureTTL - tpl.InactivityTTL = arg.InactivityTTL - tpl.LockedTTL = arg.LockedTTL + tpl.TimeTilDormant = arg.TimeTilDormant + tpl.TimeTilDormantAutoDelete = arg.TimeTilDormantAutoDelete q.templates[idx] = tpl return nil } @@ -5699,27 +5699,7 @@ func (q *FakeQuerier) UpdateWorkspaceDeletedByID(_ context.Context, arg database return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database.UpdateWorkspaceLastUsedAtParams) error { - if err := validateDatabaseType(arg); err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - for index, workspace := range q.workspaces { - if workspace.ID != arg.ID { - continue - } - workspace.LastUsedAt = arg.LastUsedAt - q.workspaces[index] = workspace - return nil - } - - return sql.ErrNoRows -} - -func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { +func (q *FakeQuerier) UpdateWorkspaceDormantDeletingAt(_ context.Context, arg database.UpdateWorkspaceDormantDeletingAtParams) (database.Workspace, error) { if err := validateDatabaseType(arg); err != nil { return database.Workspace{}, err } @@ -5729,12 +5709,12 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat if workspace.ID != arg.ID { continue } - workspace.LockedAt = arg.LockedAt - if workspace.LockedAt.Time.IsZero() { + workspace.DormantAt = arg.DormantAt + if workspace.DormantAt.Time.IsZero() { workspace.LastUsedAt = database.Now() workspace.DeletingAt = sql.NullTime{} } - if !workspace.LockedAt.Time.IsZero() { + if !workspace.DormantAt.Time.IsZero() { var template database.TemplateTable for _, t := range q.templates { if t.ID == workspace.TemplateID { @@ -5745,10 +5725,10 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat if template.ID == uuid.Nil { return database.Workspace{}, xerrors.Errorf("unable to find workspace template") } - if template.LockedTTL > 0 { + if template.TimeTilDormantAutoDelete > 0 { workspace.DeletingAt = sql.NullTime{ Valid: true, - Time: workspace.LockedAt.Time.Add(time.Duration(template.LockedTTL)), + Time: workspace.DormantAt.Time.Add(time.Duration(template.TimeTilDormantAutoDelete)), } } } @@ -5758,6 +5738,26 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat return database.Workspace{}, sql.ErrNoRows } +func (q *FakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database.UpdateWorkspaceLastUsedAtParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, workspace := range q.workspaces { + if workspace.ID != arg.ID { + continue + } + workspace.LastUsedAt = arg.LastUsedAt + q.workspaces[index] = workspace + return nil + } + + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -5818,7 +5818,7 @@ func (q *FakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspacesLockedDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { +func (q *FakeQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -5832,22 +5832,22 @@ func (q *FakeQuerier) UpdateWorkspacesLockedDeletingAtByTemplateID(_ context.Con continue } - if ws.LockedAt.Time.IsZero() { + if ws.DormantAt.Time.IsZero() { continue } - if !arg.LockedAt.IsZero() { - ws.LockedAt = sql.NullTime{ + if !arg.DormantAt.IsZero() { + ws.DormantAt = sql.NullTime{ Valid: true, - Time: arg.LockedAt, + Time: arg.DormantAt, } } deletingAt := sql.NullTime{ - Valid: arg.LockedTtlMs > 0, + Valid: arg.TimeTilDormantAutodeleteMs > 0, } - if arg.LockedTtlMs > 0 { - deletingAt.Time = ws.LockedAt.Time.Add(time.Duration(arg.LockedTtlMs) * time.Millisecond) + if arg.TimeTilDormantAutodeleteMs > 0 { + deletingAt.Time = ws.DormantAt.Time.Add(time.Duration(arg.TimeTilDormantAutodeleteMs) * time.Millisecond) } ws.DeletingAt = deletingAt q.workspaces[i] = ws @@ -6224,12 +6224,12 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } // We omit locked workspaces by default. - if arg.LockedAt.IsZero() && workspace.LockedAt.Valid { + if arg.DormantAt.IsZero() && workspace.DormantAt.Valid { continue } // Filter out workspaces that are locked after the timestamp. - if !arg.LockedAt.IsZero() && workspace.LockedAt.Time.Before(arg.LockedAt) { + if !arg.DormantAt.IsZero() && workspace.DormantAt.Time.Before(arg.DormantAt) { continue } diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 498ca57b504e4..8526eb4da1078 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1607,6 +1607,13 @@ func (m metricsStore) UpdateWorkspaceDeletedByID(ctx context.Context, arg databa return err } +func (m metricsStore) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg database.UpdateWorkspaceDormantDeletingAtParams) (database.Workspace, error) { + start := time.Now() + ws, r0 := m.s.UpdateWorkspaceDormantDeletingAt(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceDormantDeletingAt").Observe(time.Since(start).Seconds()) + return ws, r0 +} + func (m metricsStore) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.UpdateWorkspaceLastUsedAtParams) error { start := time.Now() err := m.s.UpdateWorkspaceLastUsedAt(ctx, arg) @@ -1614,13 +1621,6 @@ func (m metricsStore) UpdateWorkspaceLastUsedAt(ctx context.Context, arg databas return err } -func (m metricsStore) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { - start := time.Now() - ws, r0 := m.s.UpdateWorkspaceLockedDeletingAt(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspaceLockedDeletingAt").Observe(time.Since(start).Seconds()) - return ws, r0 -} - func (m metricsStore) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { start := time.Now() proxy, err := m.s.UpdateWorkspaceProxy(ctx, arg) @@ -1642,10 +1642,10 @@ func (m metricsStore) UpdateWorkspaceTTL(ctx context.Context, arg database.Updat return r0 } -func (m metricsStore) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { +func (m metricsStore) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error { start := time.Now() - r0 := m.s.UpdateWorkspacesLockedDeletingAtByTemplateID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspacesLockedDeletingAtByTemplateID").Observe(time.Since(start).Seconds()) + r0 := m.s.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspacesDormantDeletingAtByTemplateID").Observe(time.Since(start).Seconds()) return r0 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index ac1e782e7d398..b0ae7955a458d 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3379,6 +3379,21 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceDeletedByID(arg0, arg1 interface return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceDeletedByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceDeletedByID), arg0, arg1) } +// UpdateWorkspaceDormantDeletingAt mocks base method. +func (m *MockStore) UpdateWorkspaceDormantDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceDormantDeletingAtParams) (database.Workspace, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWorkspaceDormantDeletingAt", arg0, arg1) + ret0, _ := ret[0].(database.Workspace) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateWorkspaceDormantDeletingAt indicates an expected call of UpdateWorkspaceDormantDeletingAt. +func (mr *MockStoreMockRecorder) UpdateWorkspaceDormantDeletingAt(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceDormantDeletingAt", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceDormantDeletingAt), arg0, arg1) +} + // UpdateWorkspaceLastUsedAt mocks base method. func (m *MockStore) UpdateWorkspaceLastUsedAt(arg0 context.Context, arg1 database.UpdateWorkspaceLastUsedAtParams) error { m.ctrl.T.Helper() @@ -3393,21 +3408,6 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceLastUsedAt(arg0, arg1 interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceLastUsedAt), arg0, arg1) } -// UpdateWorkspaceLockedDeletingAt mocks base method. -func (m *MockStore) UpdateWorkspaceLockedDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspaceLockedDeletingAt", arg0, arg1) - ret0, _ := ret[0].(database.Workspace) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// UpdateWorkspaceLockedDeletingAt indicates an expected call of UpdateWorkspaceLockedDeletingAt. -func (mr *MockStoreMockRecorder) UpdateWorkspaceLockedDeletingAt(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceLockedDeletingAt", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceLockedDeletingAt), arg0, arg1) -} - // UpdateWorkspaceProxy mocks base method. func (m *MockStore) UpdateWorkspaceProxy(arg0 context.Context, arg1 database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { m.ctrl.T.Helper() @@ -3451,18 +3451,18 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceTTL(arg0, arg1 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceTTL), arg0, arg1) } -// UpdateWorkspacesLockedDeletingAtByTemplateID mocks base method. -func (m *MockStore) UpdateWorkspacesLockedDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { +// UpdateWorkspacesDormantDeletingAtByTemplateID mocks base method. +func (m *MockStore) UpdateWorkspacesDormantDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspacesLockedDeletingAtByTemplateID", arg0, arg1) + ret := m.ctrl.Call(m, "UpdateWorkspacesDormantDeletingAtByTemplateID", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } -// UpdateWorkspacesLockedDeletingAtByTemplateID indicates an expected call of UpdateWorkspacesLockedDeletingAtByTemplateID. -func (mr *MockStoreMockRecorder) UpdateWorkspacesLockedDeletingAtByTemplateID(arg0, arg1 interface{}) *gomock.Call { +// UpdateWorkspacesDormantDeletingAtByTemplateID indicates an expected call of UpdateWorkspacesDormantDeletingAtByTemplateID. +func (mr *MockStoreMockRecorder) UpdateWorkspacesDormantDeletingAtByTemplateID(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesLockedDeletingAtByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesLockedDeletingAtByTemplateID), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesDormantDeletingAtByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesDormantDeletingAtByTemplateID), arg0, arg1) } // UpsertAppSecurityKey mocks base method. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 71106e5da771d..a2767c9cfd5e1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -635,8 +635,8 @@ CREATE TABLE templates ( allow_user_autostart boolean DEFAULT true NOT NULL, allow_user_autostop boolean DEFAULT true NOT NULL, failure_ttl bigint DEFAULT 0 NOT NULL, - inactivity_ttl bigint DEFAULT 0 NOT NULL, - locked_ttl bigint DEFAULT 0 NOT NULL, + time_til_dormant bigint DEFAULT 0 NOT NULL, + time_til_dormant_autodelete bigint DEFAULT 0 NOT NULL, restart_requirement_days_of_week smallint DEFAULT 0 NOT NULL, restart_requirement_weeks bigint DEFAULT 0 NOT NULL ); @@ -676,8 +676,8 @@ CREATE VIEW template_with_users AS templates.allow_user_autostart, templates.allow_user_autostop, templates.failure_ttl, - templates.inactivity_ttl, - templates.locked_ttl, + templates.time_til_dormant, + templates.time_til_dormant_autodelete, templates.restart_requirement_days_of_week, templates.restart_requirement_weeks, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, @@ -1003,7 +1003,7 @@ CREATE TABLE workspaces ( autostart_schedule text, ttl bigint, last_used_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL, - locked_at timestamp with time zone, + dormant_at timestamp with time zone, deleting_at timestamp with time zone ); diff --git a/coderd/database/migrations/000151_rename_locked.down.sql b/coderd/database/migrations/000151_rename_locked.down.sql new file mode 100644 index 0000000000000..4dfb254268fa2 --- /dev/null +++ b/coderd/database/migrations/000151_rename_locked.down.sql @@ -0,0 +1,26 @@ +BEGIN; + +ALTER TABLE templates RENAME COLUMN time_til_dormant TO inactivity_ttl; +ALTER TABLE templates RENAME COLUMN time_til_dormant_autodelete TO locked_ttl; +ALTER TABLE workspaces RENAME COLUMN dormant_at TO locked_at; + +-- Update the template_with_users view; +DROP VIEW template_with_users; +-- If you need to update this view, put 'DROP VIEW template_with_users;' before this. +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; + +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/migrations/000151_rename_locked.up.sql b/coderd/database/migrations/000151_rename_locked.up.sql new file mode 100644 index 0000000000000..ae72c7efa98cb --- /dev/null +++ b/coderd/database/migrations/000151_rename_locked.up.sql @@ -0,0 +1,25 @@ +BEGIN; +ALTER TABLE templates RENAME COLUMN inactivity_ttl TO time_til_dormant; +ALTER TABLE templates RENAME COLUMN locked_ttl TO time_til_dormant_autodelete; +ALTER TABLE workspaces RENAME COLUMN locked_at TO dormant_at; + +-- Update the template_with_users view;a +DROP VIEW template_with_users; +-- If you need to update this view, put 'DROP VIEW template_with_users;' before this. +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; + +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 364b812ac3c94..1cccdd949ecc8 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -146,8 +146,8 @@ func (w Workspace) RBACObject() rbac.Object { func (w Workspace) ExecutionRBAC() rbac.Object { // If a workspace is locked it cannot be accessed. - if w.LockedAt.Valid { - return w.LockedRBAC() + if w.DormantAt.Valid { + return w.DormantRBAC() } return rbac.ResourceWorkspaceExecution. @@ -158,8 +158,8 @@ func (w Workspace) ExecutionRBAC() rbac.Object { func (w Workspace) ApplicationConnectRBAC() rbac.Object { // If a workspace is locked it cannot be accessed. - if w.LockedAt.Valid { - return w.LockedRBAC() + if w.DormantAt.Valid { + return w.DormantRBAC() } return rbac.ResourceWorkspaceApplicationConnect. @@ -173,9 +173,9 @@ func (w Workspace) WorkspaceBuildRBAC(transition WorkspaceTransition) rbac.Objec // However we need to allow stopping a workspace by a caller once a workspace // is locked (e.g. for autobuild). Additionally, if a user wants to delete // a locked workspace, they shouldn't have to have it unlocked first. - if w.LockedAt.Valid && transition != WorkspaceTransitionStop && + if w.DormantAt.Valid && transition != WorkspaceTransitionStop && transition != WorkspaceTransitionDelete { - return w.LockedRBAC() + return w.DormantRBAC() } return rbac.ResourceWorkspaceBuild. @@ -184,8 +184,8 @@ func (w Workspace) WorkspaceBuildRBAC(transition WorkspaceTransition) rbac.Objec WithOwner(w.OwnerID.String()) } -func (w Workspace) LockedRBAC() rbac.Object { - return rbac.ResourceWorkspaceLocked. +func (w Workspace) DormantRBAC() rbac.Object { + return rbac.ResourceWorkspaceDormant. WithID(w.ID). InOrg(w.OrganizationID). WithOwner(w.OwnerID.String()) @@ -355,7 +355,7 @@ func ConvertWorkspaceRows(rows []GetWorkspacesRow) []Workspace { AutostartSchedule: r.AutostartSchedule, Ttl: r.Ttl, LastUsedAt: r.LastUsedAt, - LockedAt: r.LockedAt, + DormantAt: r.DormantAt, DeletingAt: r.DeletingAt, } } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 193a046f5cec1..5ccf3282e677c 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -81,8 +81,8 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, - &i.InactivityTTL, - &i.LockedTTL, + &i.TimeTilDormant, + &i.TimeTilDormantAutoDelete, &i.RestartRequirementDaysOfWeek, &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, @@ -217,7 +217,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, - arg.LockedAt, + arg.DormantAt, arg.LastUsedBefore, arg.LastUsedAfter, arg.Offset, @@ -242,7 +242,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, &i.TemplateName, &i.TemplateVersionID, diff --git a/coderd/database/models.go b/coderd/database/models.go index e795049c16413..85f90020d9fc1 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1729,8 +1729,8 @@ type Template struct { AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` - InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` - LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` + TimeTilDormant int64 `db:"time_til_dormant" json:"time_til_dormant"` + TimeTilDormantAutoDelete int64 `db:"time_til_dormant_autodelete" json:"time_til_dormant_autodelete"` RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"` CreatedByAvatarURL sql.NullString `db:"created_by_avatar_url" json:"created_by_avatar_url"` @@ -1761,10 +1761,10 @@ type TemplateTable struct { // Allow users to specify an autostart schedule for workspaces (enterprise). AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` // Allow users to specify custom autostop values for workspaces (enterprise). - AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` - FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` - InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` - LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` + AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` + FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` + TimeTilDormant int64 `db:"time_til_dormant" json:"time_til_dormant"` + TimeTilDormantAutoDelete int64 `db:"time_til_dormant_autodelete" json:"time_til_dormant_autodelete"` // A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused. RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` // The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles. @@ -1903,7 +1903,7 @@ type Workspace struct { AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` Ttl sql.NullInt64 `db:"ttl" json:"ttl"` LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - LockedAt sql.NullTime `db:"locked_at" json:"locked_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a217648035e90..520266bd1d25c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -292,13 +292,13 @@ type sqlcQuerier interface { UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error + UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (Workspace, error) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error - UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) (Workspace, error) // This allows editing the properties of a workspace proxy. UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error - UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error + UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error UpsertAppSecurityKey(ctx context.Context, value string) error // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9d3fefccd842a..364ae4c546267 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4317,7 +4317,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users WHERE @@ -4350,8 +4350,8 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, - &i.InactivityTTL, - &i.LockedTTL, + &i.TimeTilDormant, + &i.TimeTilDormantAutoDelete, &i.RestartRequirementDaysOfWeek, &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, @@ -4362,7 +4362,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -4403,8 +4403,8 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, - &i.InactivityTTL, - &i.LockedTTL, + &i.TimeTilDormant, + &i.TimeTilDormantAutoDelete, &i.RestartRequirementDaysOfWeek, &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, @@ -4414,7 +4414,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates ORDER BY (name, id) ASC ` @@ -4448,8 +4448,8 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, - &i.InactivityTTL, - &i.LockedTTL, + &i.TimeTilDormant, + &i.TimeTilDormantAutoDelete, &i.RestartRequirementDaysOfWeek, &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, @@ -4470,7 +4470,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -4541,8 +4541,8 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.AllowUserAutostart, &i.AllowUserAutostop, &i.FailureTTL, - &i.InactivityTTL, - &i.LockedTTL, + &i.TimeTilDormant, + &i.TimeTilDormantAutoDelete, &i.RestartRequirementDaysOfWeek, &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, @@ -4732,8 +4732,8 @@ SET restart_requirement_days_of_week = $7, restart_requirement_weeks = $8, failure_ttl = $9, - inactivity_ttl = $10, - locked_ttl = $11 + time_til_dormant = $10, + time_til_dormant_autodelete = $11 WHERE id = $1 ` @@ -4748,8 +4748,8 @@ type UpdateTemplateScheduleByIDParams struct { RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"` FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` - InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` - LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` + TimeTilDormant int64 `db:"time_til_dormant" json:"time_til_dormant"` + TimeTilDormantAutoDelete int64 `db:"time_til_dormant_autodelete" json:"time_til_dormant_autodelete"` } func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error { @@ -4763,8 +4763,8 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT arg.RestartRequirementDaysOfWeek, arg.RestartRequirementWeeks, arg.FailureTTL, - arg.InactivityTTL, - arg.LockedTTL, + arg.TimeTilDormant, + arg.TimeTilDormantAutoDelete, ) return err } @@ -9104,7 +9104,7 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at FROM workspaces WHERE @@ -9147,7 +9147,7 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err @@ -9155,7 +9155,7 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI const getWorkspaceByID = `-- name: GetWorkspaceByID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at FROM workspaces WHERE @@ -9179,7 +9179,7 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err @@ -9187,7 +9187,7 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at FROM workspaces WHERE @@ -9218,7 +9218,7 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err @@ -9226,7 +9226,7 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo const getWorkspaceByWorkspaceAppID = `-- name: GetWorkspaceByWorkspaceAppID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at FROM workspaces WHERE @@ -9276,7 +9276,7 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err @@ -9284,7 +9284,7 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace const getWorkspaces = `-- name: GetWorkspaces :many SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.locked_at, workspaces.deleting_at, + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, @@ -9468,13 +9468,13 @@ WHERE ) > 0 ELSE true END - -- Filter by locked workspaces. By default we do not return locked + -- Filter by dormant workspaces. By default we do not return dormant -- workspaces since they are considered soft-deleted. AND CASE WHEN $10 :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN - locked_at IS NOT NULL AND locked_at >= $10 + dormant_at IS NOT NULL AND dormant_at >= $10 ELSE - locked_at IS NULL + dormant_at IS NULL END -- Filter by last_used AND CASE @@ -9515,7 +9515,7 @@ type GetWorkspacesParams struct { Name string `db:"name" json:"name"` HasAgent string `db:"has_agent" json:"has_agent"` AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` - LockedAt time.Time `db:"locked_at" json:"locked_at"` + DormantAt time.Time `db:"dormant_at" json:"dormant_at"` LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"` LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` Offset int32 `db:"offset_" json:"offset_"` @@ -9534,7 +9534,7 @@ type GetWorkspacesRow struct { AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` Ttl sql.NullInt64 `db:"ttl" json:"ttl"` LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - LockedAt sql.NullTime `db:"locked_at" json:"locked_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` TemplateName string `db:"template_name" json:"template_name"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` @@ -9553,7 +9553,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, - arg.LockedAt, + arg.DormantAt, arg.LastUsedBefore, arg.LastUsedAfter, arg.Offset, @@ -9578,7 +9578,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, &i.TemplateName, &i.TemplateVersionID, @@ -9600,7 +9600,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.locked_at, workspaces.deleting_at + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at FROM workspaces LEFT JOIN @@ -9649,17 +9649,17 @@ WHERE ) OR -- If the workspace's template has an inactivity_ttl set - -- it may be eligible for locking. + -- it may be eligible for dormancy. ( - templates.inactivity_ttl > 0 AND - workspaces.locked_at IS NULL + templates.time_til_dormant > 0 AND + workspaces.dormant_at IS NULL ) OR - -- If the workspace's template has a locked_ttl set - -- and the workspace is already locked + -- If the workspace's template has a time_til_dormant_autodelete set + -- and the workspace is already dormant. ( - templates.locked_ttl > 0 AND - workspaces.locked_at IS NOT NULL + templates.time_til_dormant_autodelete > 0 AND + workspaces.dormant_at IS NOT NULL ) ) AND workspaces.deleted = 'false' ` @@ -9685,7 +9685,7 @@ func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ); err != nil { return nil, err @@ -9716,7 +9716,7 @@ INSERT INTO last_used_at ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at ` type InsertWorkspaceParams struct { @@ -9758,7 +9758,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err @@ -9790,7 +9790,7 @@ SET WHERE id = $1 AND deleted = false -RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, locked_at, deleting_at +RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at ` type UpdateWorkspaceParams struct { @@ -9813,7 +9813,7 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err @@ -9857,52 +9857,33 @@ func (q *sqlQuerier) UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateW return err } -const updateWorkspaceLastUsedAt = `-- name: UpdateWorkspaceLastUsedAt :exec -UPDATE - workspaces -SET - last_used_at = $2 -WHERE - id = $1 -` - -type UpdateWorkspaceLastUsedAtParams struct { - ID uuid.UUID `db:"id" json:"id"` - LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` -} - -func (q *sqlQuerier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceLastUsedAt, arg.ID, arg.LastUsedAt) - return err -} - -const updateWorkspaceLockedDeletingAt = `-- name: UpdateWorkspaceLockedDeletingAt :one +const updateWorkspaceDormantDeletingAt = `-- name: UpdateWorkspaceDormantDeletingAt :one UPDATE workspaces SET - locked_at = $2, - -- When a workspace is unlocked we want to update the last_used_at to avoid the workspace getting re-locked. - -- if we're locking the workspace then we leave it alone. + dormant_at = $2, + -- When a workspace is active we want to update the last_used_at to avoid the workspace going + -- immediately dormant. If we're transition the workspace to dormant then we leave it alone. last_used_at = CASE WHEN $2::timestamptz IS NULL THEN now() at time zone 'utc' ELSE last_used_at END, - -- If locked_at is null (meaning unlocked) or the template-defined locked_ttl is 0 we should set - -- deleting_at to NULL else set it to the locked_at + locked_ttl duration. - deleting_at = CASE WHEN $2::timestamptz IS NULL OR templates.locked_ttl = 0 THEN NULL ELSE $2::timestamptz + INTERVAL '1 milliseconds' * templates.locked_ttl / 1000000 END + -- If dormant_at is null (meaning active) or the template-defined time_til_dormant_autodelete is 0 we should set + -- deleting_at to NULL else set it to the dormant_at + time_til_dormant_autodelete duration. + deleting_at = CASE WHEN $2::timestamptz IS NULL OR templates.time_til_dormant_autodelete = 0 THEN NULL ELSE $2::timestamptz + INTERVAL '1 milliseconds' * templates.time_til_dormant_autodelete / 1000000 END FROM templates WHERE workspaces.template_id = templates.id AND workspaces.id = $1 -RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.locked_at, workspaces.deleting_at +RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at ` -type UpdateWorkspaceLockedDeletingAtParams struct { - ID uuid.UUID `db:"id" json:"id"` - LockedAt sql.NullTime `db:"locked_at" json:"locked_at"` +type UpdateWorkspaceDormantDeletingAtParams struct { + ID uuid.UUID `db:"id" json:"id"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` } -func (q *sqlQuerier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) (Workspace, error) { - row := q.db.QueryRowContext(ctx, updateWorkspaceLockedDeletingAt, arg.ID, arg.LockedAt) +func (q *sqlQuerier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (Workspace, error) { + row := q.db.QueryRowContext(ctx, updateWorkspaceDormantDeletingAt, arg.ID, arg.DormantAt) var i Workspace err := row.Scan( &i.ID, @@ -9916,12 +9897,31 @@ func (q *sqlQuerier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg Up &i.AutostartSchedule, &i.Ttl, &i.LastUsedAt, - &i.LockedAt, + &i.DormantAt, &i.DeletingAt, ) return i, err } +const updateWorkspaceLastUsedAt = `-- name: UpdateWorkspaceLastUsedAt :exec +UPDATE + workspaces +SET + last_used_at = $2 +WHERE + id = $1 +` + +type UpdateWorkspaceLastUsedAtParams struct { + ID uuid.UUID `db:"id" json:"id"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` +} + +func (q *sqlQuerier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceLastUsedAt, arg.ID, arg.LastUsedAt) + return err +} + const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec UPDATE workspaces @@ -9941,28 +9941,28 @@ func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspace return err } -const updateWorkspacesLockedDeletingAtByTemplateID = `-- name: UpdateWorkspacesLockedDeletingAtByTemplateID :exec +const updateWorkspacesDormantDeletingAtByTemplateID = `-- name: UpdateWorkspacesDormantDeletingAtByTemplateID :exec UPDATE workspaces SET deleting_at = CASE WHEN $1::bigint = 0 THEN NULL WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN ($2::timestamptz) + interval '1 milliseconds' * $1::bigint - ELSE locked_at + interval '1 milliseconds' * $1::bigint + ELSE dormant_at + interval '1 milliseconds' * $1::bigint END, - locked_at = CASE WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN $2::timestamptz ELSE locked_at END + dormant_at = CASE WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN $2::timestamptz ELSE dormant_at END WHERE template_id = $3 AND - locked_at IS NOT NULL + dormant_at IS NOT NULL ` -type UpdateWorkspacesLockedDeletingAtByTemplateIDParams struct { - LockedTtlMs int64 `db:"locked_ttl_ms" json:"locked_ttl_ms"` - LockedAt time.Time `db:"locked_at" json:"locked_at"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` +type UpdateWorkspacesDormantDeletingAtByTemplateIDParams struct { + TimeTilDormantAutodeleteMs int64 `db:"time_til_dormant_autodelete_ms" json:"time_til_dormant_autodelete_ms"` + DormantAt time.Time `db:"dormant_at" json:"dormant_at"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` } -func (q *sqlQuerier) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspacesLockedDeletingAtByTemplateID, arg.LockedTtlMs, arg.LockedAt, arg.TemplateID) +func (q *sqlQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspacesDormantDeletingAtByTemplateID, arg.TimeTilDormantAutodeleteMs, arg.DormantAt, arg.TemplateID) return err } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 7f4c9ce5de4ab..5387bea009c2d 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -121,8 +121,8 @@ SET restart_requirement_days_of_week = $7, restart_requirement_weeks = $8, failure_ttl = $9, - inactivity_ttl = $10, - locked_ttl = $11 + time_til_dormant = $10, + time_til_dormant_autodelete = $11 WHERE id = $1 ; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 1ff5971d3266d..0aa073301eb8f 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -259,13 +259,13 @@ WHERE ) > 0 ELSE true END - -- Filter by locked workspaces. By default we do not return locked + -- Filter by dormant workspaces. By default we do not return dormant -- workspaces since they are considered soft-deleted. AND CASE - WHEN @locked_at :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN - locked_at IS NOT NULL AND locked_at >= @locked_at + WHEN @dormant_at :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN + dormant_at IS NOT NULL AND dormant_at >= @dormant_at ELSE - locked_at IS NULL + dormant_at IS NULL END -- Filter by last_used AND CASE @@ -479,31 +479,31 @@ WHERE ) OR -- If the workspace's template has an inactivity_ttl set - -- it may be eligible for locking. + -- it may be eligible for dormancy. ( - templates.inactivity_ttl > 0 AND - workspaces.locked_at IS NULL + templates.time_til_dormant > 0 AND + workspaces.dormant_at IS NULL ) OR - -- If the workspace's template has a locked_ttl set - -- and the workspace is already locked + -- If the workspace's template has a time_til_dormant_autodelete set + -- and the workspace is already dormant. ( - templates.locked_ttl > 0 AND - workspaces.locked_at IS NOT NULL + templates.time_til_dormant_autodelete > 0 AND + workspaces.dormant_at IS NOT NULL ) ) AND workspaces.deleted = 'false'; --- name: UpdateWorkspaceLockedDeletingAt :one +-- name: UpdateWorkspaceDormantDeletingAt :one UPDATE workspaces SET - locked_at = $2, - -- When a workspace is unlocked we want to update the last_used_at to avoid the workspace getting re-locked. - -- if we're locking the workspace then we leave it alone. + dormant_at = $2, + -- When a workspace is active we want to update the last_used_at to avoid the workspace going + -- immediately dormant. If we're transition the workspace to dormant then we leave it alone. last_used_at = CASE WHEN $2::timestamptz IS NULL THEN now() at time zone 'utc' ELSE last_used_at END, - -- If locked_at is null (meaning unlocked) or the template-defined locked_ttl is 0 we should set - -- deleting_at to NULL else set it to the locked_at + locked_ttl duration. - deleting_at = CASE WHEN $2::timestamptz IS NULL OR templates.locked_ttl = 0 THEN NULL ELSE $2::timestamptz + INTERVAL '1 milliseconds' * templates.locked_ttl / 1000000 END + -- If dormant_at is null (meaning active) or the template-defined time_til_dormant_autodelete is 0 we should set + -- deleting_at to NULL else set it to the dormant_at + time_til_dormant_autodelete duration. + deleting_at = CASE WHEN $2::timestamptz IS NULL OR templates.time_til_dormant_autodelete = 0 THEN NULL ELSE $2::timestamptz + INTERVAL '1 milliseconds' * templates.time_til_dormant_autodelete / 1000000 END FROM templates WHERE @@ -512,19 +512,19 @@ AND workspaces.id = $1 RETURNING workspaces.*; --- name: UpdateWorkspacesLockedDeletingAtByTemplateID :exec +-- name: UpdateWorkspacesDormantDeletingAtByTemplateID :exec UPDATE workspaces SET deleting_at = CASE - WHEN @locked_ttl_ms::bigint = 0 THEN NULL - WHEN @locked_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@locked_at::timestamptz) + interval '1 milliseconds' * @locked_ttl_ms::bigint - ELSE locked_at + interval '1 milliseconds' * @locked_ttl_ms::bigint + WHEN @time_til_dormant_autodelete_ms::bigint = 0 THEN NULL + WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@dormant_at::timestamptz) + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint + ELSE dormant_at + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint END, - locked_at = CASE WHEN @locked_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN @locked_at::timestamptz ELSE locked_at END + dormant_at = CASE WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN @dormant_at::timestamptz ELSE dormant_at END WHERE template_id = @template_id AND - locked_at IS NOT NULL; + dormant_at IS NOT NULL; -- name: UpdateTemplateWorkspacesLastUsedAt :exec UPDATE workspaces diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index ca1a2324e9ab0..7718b01e0335e 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -67,9 +67,8 @@ overrides: motd_file: MOTDFile uuid: UUID failure_ttl: FailureTTL - inactivity_ttl: InactivityTTL + time_til_dormant_autodelete: TimeTilDormantAutoDelete eof: EOF - locked_ttl: LockedTTL template_ids: TemplateIDs active_user_ids: ActiveUserIDs diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 39f57c7fcc6da..1e3f1f45e59ea 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -37,10 +37,10 @@ var ( Type: "workspace_build", } - // ResourceWorkspaceLocked is returned if a workspace is locked. + // ResourceWorkspaceDormant is returned if a workspace is dormant. // It grants restricted permissions on workspace builds. - ResourceWorkspaceLocked = Object{ - Type: "workspace_locked", + ResourceWorkspaceDormant = Object{ + Type: "workspace_dormant", } // ResourceWorkspaceProxy CRUD. Org diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 10506b3f719c2..86a03d4552d45 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -26,8 +26,8 @@ func AllResources() []Object { ResourceWorkspace, ResourceWorkspaceApplicationConnect, ResourceWorkspaceBuild, + ResourceWorkspaceDormant, ResourceWorkspaceExecution, - ResourceWorkspaceLocked, ResourceWorkspaceProxy, } } diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 93aeaca017592..4f159480a0491 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -121,7 +121,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { opts = &RoleOptions{} } - ownerAndAdminExceptions := []Object{ResourceWorkspaceLocked} + ownerAndAdminExceptions := []Object{ResourceWorkspaceDormant} if opts.NoOwnerWorkspaceExec { ownerAndAdminExceptions = append(ownerAndAdminExceptions, ResourceWorkspaceExecution, @@ -150,7 +150,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceProvisionerDaemon.Type: {ActionRead}, }), Org: map[string][]Permission{}, - User: append(allPermsExcept(ResourceWorkspaceLocked, ResourceUser, ResourceOrganizationMember), + User: append(allPermsExcept(ResourceWorkspaceDormant, ResourceUser, ResourceOrganizationMember), Permissions(map[string][]Action{ // Users cannot do create/update/delete on themselves, but they // can read their own details. @@ -246,7 +246,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: []Permission{}, Org: map[string][]Permission{ // Org admins should not have workspace exec perms. - organizationID: allPermsExcept(ResourceWorkspaceExecution, ResourceWorkspaceLocked), + organizationID: allPermsExcept(ResourceWorkspaceExecution, ResourceWorkspaceDormant), }, User: []Permission{}, } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index ad03529d81c0f..fc47413fd19f2 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -319,9 +319,9 @@ func TestRolePermissions(t *testing.T) { }, }, { - Name: "WorkspaceLocked", + Name: "WorkspaceDormant", Actions: rbac.AllActions(), - Resource: rbac.ResourceWorkspaceLocked.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID), + Resource: rbac.ResourceWorkspaceDormant.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID), AuthorizeMap: map[bool][]authSubject{ true: {}, false: {memberMe, orgAdmin, userAdmin, otherOrgAdmin, otherOrgMember, orgMemberMe, owner, templateAdmin}, diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 0e3774b798358..9c1b2fa5aa787 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -99,24 +99,24 @@ type TemplateScheduleOptions struct { // FailureTTL dictates the duration after which failed workspaces will be // stopped automatically. FailureTTL time.Duration `json:"failure_ttl"` - // InactivityTTL dictates the duration after which inactive workspaces will - // be locked. - InactivityTTL time.Duration `json:"inactivity_ttl"` - // LockedTTL dictates the duration after which locked workspaces will be + // TimeTilDormant dictates the duration after which inactive workspaces will + // go dormant. + TimeTilDormant time.Duration `json:"time_til_dormant"` + // TimeTilDormantAutoDelete dictates the duration after which dormant workspaces will be // permanently deleted. - LockedTTL time.Duration `json:"locked_ttl"` + TimeTilDormantAutoDelete time.Duration `json:"time_til_dormant_autodelete"` // UpdateWorkspaceLastUsedAt updates the template's workspaces' // last_used_at field. This is useful for preventing updates to the - // templates inactivity_ttl immediately triggering a lock action against + // templates inactivity_ttl immediately triggering a dormant action against // workspaces whose last_used_at field violates the new template // inactivity_ttl threshold. UpdateWorkspaceLastUsedAt bool `json:"update_workspace_last_used_at"` - // UpdateWorkspaceLockedAt updates the template's workspaces' - // locked_at field. This is useful for preventing updates to the + // UpdateWorkspaceDormantAt updates the template's workspaces' + // dormant_at field. This is useful for preventing updates to the // templates locked_ttl immediately triggering a delete action against - // workspaces whose locked_at field violates the new template locked_ttl + // workspaces whose dormant_at field violates the new template time_til_dormant_autodelete // threshold. - UpdateWorkspaceLockedAt bool `json:"update_workspace_locked_at"` + UpdateWorkspaceDormantAt bool `json:"update_workspace_dormant_at"` } // TemplateScheduleStore provides an interface for retrieving template @@ -150,16 +150,16 @@ func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, te UserAutostopEnabled: true, DefaultTTL: time.Duration(tpl.DefaultTTL), // Disregard the values in the database, since RestartRequirement, - // FailureTTL, InactivityTTL, and LockedTTL are enterprise features. + // FailureTTL, TimeTilDormant, and TimeTilDormantAutoDelete are enterprise features. UseRestartRequirement: false, MaxTTL: 0, RestartRequirement: TemplateRestartRequirement{ DaysOfWeek: 0, Weeks: 0, }, - FailureTTL: 0, - InactivityTTL: 0, - LockedTTL: 0, + FailureTTL: 0, + TimeTilDormant: 0, + TimeTilDormantAutoDelete: 0, }, nil } @@ -186,8 +186,8 @@ func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tp AllowUserAutostart: tpl.AllowUserAutostart, AllowUserAutostop: tpl.AllowUserAutostop, FailureTTL: tpl.FailureTTL, - InactivityTTL: tpl.InactivityTTL, - LockedTTL: tpl.LockedTTL, + TimeTilDormant: tpl.TimeTilDormant, + TimeTilDormantAutoDelete: tpl.TimeTilDormantAutoDelete, }) if err != nil { return xerrors.Errorf("update template schedule: %w", err) diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 17d1990880727..efdde1bb1d2e7 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -114,16 +114,16 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT filter.Name = parser.String(values, "", "name") filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus])) filter.HasAgent = parser.String(values, "", "has-agent") - filter.LockedAt = parser.Time(values, time.Time{}, "locked_at", "2006-01-02") + filter.DormantAt = parser.Time(values, time.Time{}, "dormant_at", "2006-01-02") filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after") filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before") if _, ok := values["deleting_by"]; ok { postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02")) - // We want to make sure to grab locked workspaces since they + // We want to make sure to grab dormant workspaces since they // are omitted by default. - if filter.LockedAt.IsZero() { - filter.LockedAt = time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) + if filter.DormantAt.IsZero() { + filter.DormantAt = time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) } } diff --git a/coderd/templates.go b/coderd/templates.go index f51f42668e1a1..7d6dc46d2bf90 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -219,8 +219,8 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque restartRequirementDaysOfWeek []string restartRequirementWeeks int64 failureTTL time.Duration - inactivityTTL time.Duration - lockedTTL time.Duration + dormantTTL time.Duration + dormantAutoDeletionTTL time.Duration ) if createTemplate.DefaultTTLMillis != nil { defaultTTL = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond @@ -232,11 +232,11 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque if createTemplate.FailureTTLMillis != nil { failureTTL = time.Duration(*createTemplate.FailureTTLMillis) * time.Millisecond } - if createTemplate.InactivityTTLMillis != nil { - inactivityTTL = time.Duration(*createTemplate.InactivityTTLMillis) * time.Millisecond + if createTemplate.TimeTilDormantMillis != nil { + dormantTTL = time.Duration(*createTemplate.TimeTilDormantMillis) * time.Millisecond } - if createTemplate.LockedTTLMillis != nil { - lockedTTL = time.Duration(*createTemplate.LockedTTLMillis) * time.Millisecond + if createTemplate.TimeTilDormantAutoDeleteMillis != nil { + dormantAutoDeletionTTL = time.Duration(*createTemplate.TimeTilDormantAutoDeleteMillis) * time.Millisecond } var ( @@ -270,11 +270,11 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque if failureTTL < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."}) } - if inactivityTTL < 0 { - validErrs = append(validErrs, codersdk.ValidationError{Field: "inactivity_ttl_ms", Detail: "Must be a positive integer."}) + if dormantTTL < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_autodeletion_ms", Detail: "Must be a positive integer."}) } - if lockedTTL < 0 { - validErrs = append(validErrs, codersdk.ValidationError{Field: "locked_ttl_ms", Detail: "Must be a positive integer."}) + if dormantAutoDeletionTTL < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_autodeletion_ms", Detail: "Must be a positive integer."}) } if len(validErrs) > 0 { @@ -340,9 +340,9 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque DaysOfWeek: restartRequirementDaysOfWeekParsed, Weeks: restartRequirementWeeks, }, - FailureTTL: failureTTL, - InactivityTTL: inactivityTTL, - LockedTTL: lockedTTL, + FailureTTL: failureTTL, + TimeTilDormant: dormantTTL, + TimeTilDormantAutoDelete: dormantAutoDeletionTTL, }) if err != nil { return xerrors.Errorf("set template schedule options: %s", err) @@ -533,13 +533,13 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if req.FailureTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."}) } - if req.InactivityTTLMillis < 0 { + if req.TimeTilDormantMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "inactivity_ttl_ms", Detail: "Must be a positive integer."}) } - if req.InactivityTTLMillis < 0 { + if req.TimeTilDormantMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "inactivity_ttl_ms", Detail: "Must be a positive integer."}) } - if req.LockedTTLMillis < 0 { + if req.TimeTilDormantAutoDeleteMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "locked_ttl_ms", Detail: "Must be a positive integer."}) } @@ -565,8 +565,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { restartRequirementDaysOfWeekParsed == scheduleOpts.RestartRequirement.DaysOfWeek && req.RestartRequirement.Weeks == scheduleOpts.RestartRequirement.Weeks && req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() && - req.InactivityTTLMillis == time.Duration(template.InactivityTTL).Milliseconds() && - req.LockedTTLMillis == time.Duration(template.LockedTTL).Milliseconds() { + req.TimeTilDormantMillis == time.Duration(template.TimeTilDormant).Milliseconds() && + req.TimeTilDormantAutoDeleteMillis == time.Duration(template.TimeTilDormantAutoDelete).Milliseconds() { return nil } @@ -598,16 +598,16 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond failureTTL := time.Duration(req.FailureTTLMillis) * time.Millisecond - inactivityTTL := time.Duration(req.InactivityTTLMillis) * time.Millisecond - lockedTTL := time.Duration(req.LockedTTLMillis) * time.Millisecond + inactivityTTL := time.Duration(req.TimeTilDormantMillis) * time.Millisecond + timeTilDormantAutoDelete := time.Duration(req.TimeTilDormantAutoDeleteMillis) * time.Millisecond if defaultTTL != time.Duration(template.DefaultTTL) || maxTTL != time.Duration(template.MaxTTL) || restartRequirementDaysOfWeekParsed != scheduleOpts.RestartRequirement.DaysOfWeek || req.RestartRequirement.Weeks != scheduleOpts.RestartRequirement.Weeks || failureTTL != time.Duration(template.FailureTTL) || - inactivityTTL != time.Duration(template.InactivityTTL) || - lockedTTL != time.Duration(template.LockedTTL) || + inactivityTTL != time.Duration(template.TimeTilDormant) || + timeTilDormantAutoDelete != time.Duration(template.TimeTilDormantAutoDelete) || req.AllowUserAutostart != template.AllowUserAutostart || req.AllowUserAutostop != template.AllowUserAutostop { updated, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, updated, schedule.TemplateScheduleOptions{ @@ -623,10 +623,10 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { Weeks: req.RestartRequirement.Weeks, }, FailureTTL: failureTTL, - InactivityTTL: inactivityTTL, - LockedTTL: lockedTTL, + TimeTilDormant: inactivityTTL, + TimeTilDormantAutoDelete: timeTilDormantAutoDelete, UpdateWorkspaceLastUsedAt: req.UpdateWorkspaceLastUsedAt, - UpdateWorkspaceLockedAt: req.UpdateWorkspaceLockedAt, + UpdateWorkspaceDormantAt: req.UpdateWorkspaceDormantAt, }) if err != nil { return xerrors.Errorf("set template schedule options: %w", err) @@ -738,28 +738,28 @@ func (api *API) convertTemplate( buildTimeStats := api.metricsCache.TemplateBuildTimeStats(template.ID) return codersdk.Template{ - ID: template.ID, - CreatedAt: template.CreatedAt, - UpdatedAt: template.UpdatedAt, - OrganizationID: template.OrganizationID, - Name: template.Name, - DisplayName: template.DisplayName, - Provisioner: codersdk.ProvisionerType(template.Provisioner), - ActiveVersionID: template.ActiveVersionID, - ActiveUserCount: activeCount, - BuildTimeStats: buildTimeStats, - Description: template.Description, - Icon: template.Icon, - DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(), - MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(), - CreatedByID: template.CreatedBy, - CreatedByName: template.CreatedByUsername, - AllowUserAutostart: template.AllowUserAutostart, - AllowUserAutostop: template.AllowUserAutostop, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - FailureTTLMillis: time.Duration(template.FailureTTL).Milliseconds(), - InactivityTTLMillis: time.Duration(template.InactivityTTL).Milliseconds(), - LockedTTLMillis: time.Duration(template.LockedTTL).Milliseconds(), + ID: template.ID, + CreatedAt: template.CreatedAt, + UpdatedAt: template.UpdatedAt, + OrganizationID: template.OrganizationID, + Name: template.Name, + DisplayName: template.DisplayName, + Provisioner: codersdk.ProvisionerType(template.Provisioner), + ActiveVersionID: template.ActiveVersionID, + ActiveUserCount: activeCount, + BuildTimeStats: buildTimeStats, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(), + MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(), + CreatedByID: template.CreatedBy, + CreatedByName: template.CreatedByUsername, + AllowUserAutostart: template.AllowUserAutostart, + AllowUserAutostop: template.AllowUserAutostop, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + FailureTTLMillis: time.Duration(template.FailureTTL).Milliseconds(), + TimeTilDormantMillis: time.Duration(template.TimeTilDormant).Milliseconds(), + TimeTilDormantAutoDeleteMillis: time.Duration(template.TimeTilDormantAutoDelete).Milliseconds(), RestartRequirement: codersdk.TemplateRestartRequirement{ DaysOfWeek: codersdk.BitmapToWeekdays(uint8(template.RestartRequirementDaysOfWeek)), Weeks: template.RestartRequirementWeeks, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 92a67710902dd..fcdb7e64e2e78 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -270,8 +270,8 @@ func TestPostTemplateByOrganization(t *testing.T) { RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), - InactivityTTL: int64(options.InactivityTTL), - LockedTTL: int64(options.LockedTTL), + TimeTilDormant: int64(options.TimeTilDormant), + TimeTilDormantAutoDelete: int64(options.TimeTilDormantAutoDelete), }) if !assert.NoError(t, err) { return database.Template{}, err @@ -320,8 +320,8 @@ func TestPostTemplateByOrganization(t *testing.T) { RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), - InactivityTTL: int64(options.InactivityTTL), - LockedTTL: int64(options.LockedTTL), + TimeTilDormant: int64(options.TimeTilDormant), + TimeTilDormantAutoDelete: int64(options.TimeTilDormantAutoDelete), }) if !assert.NoError(t, err) { return database.Template{}, err @@ -598,8 +598,8 @@ func TestPatchTemplateMeta(t *testing.T) { RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), - InactivityTTL: int64(options.InactivityTTL), - LockedTTL: int64(options.LockedTTL), + TimeTilDormant: int64(options.TimeTilDormant), + TimeTilDormantAutoDelete: int64(options.TimeTilDormantAutoDelete), }) if !assert.NoError(t, err) { return database.Template{}, err @@ -697,9 +697,9 @@ func TestPatchTemplateMeta(t *testing.T) { t.Parallel() const ( - failureTTL = 7 * 24 * time.Hour - inactivityTTL = 180 * 24 * time.Hour - lockedTTL = 360 * 24 * time.Hour + failureTTL = 7 * 24 * time.Hour + inactivityTTL = 180 * 24 * time.Hour + timeTilDormantAutoDelete = 360 * 24 * time.Hour ) t.Run("OK", func(t *testing.T) { @@ -711,12 +711,12 @@ func TestPatchTemplateMeta(t *testing.T) { SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { if atomic.AddInt64(&setCalled, 1) == 2 { require.Equal(t, failureTTL, options.FailureTTL) - require.Equal(t, inactivityTTL, options.InactivityTTL) - require.Equal(t, lockedTTL, options.LockedTTL) + require.Equal(t, inactivityTTL, options.TimeTilDormant) + require.Equal(t, timeTilDormantAutoDelete, options.TimeTilDormantAutoDelete) } template.FailureTTL = int64(options.FailureTTL) - template.InactivityTTL = int64(options.InactivityTTL) - template.LockedTTL = int64(options.LockedTTL) + template.TimeTilDormant = int64(options.TimeTilDormant) + template.TimeTilDormantAutoDelete = int64(options.TimeTilDormantAutoDelete) return template, nil }, }, @@ -725,31 +725,31 @@ func TestPatchTemplateMeta(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.FailureTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) - ctr.InactivityTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) - ctr.LockedTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + ctr.TimeTilDormantMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref(0 * time.Hour.Milliseconds()) }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, - DefaultTTLMillis: 0, - RestartRequirement: &template.RestartRequirement, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - FailureTTLMillis: failureTTL.Milliseconds(), - InactivityTTLMillis: inactivityTTL.Milliseconds(), - LockedTTLMillis: lockedTTL.Milliseconds(), + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: 0, + RestartRequirement: &template.RestartRequirement, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + FailureTTLMillis: failureTTL.Milliseconds(), + TimeTilDormantMillis: inactivityTTL.Milliseconds(), + TimeTilDormantAutoDeleteMillis: timeTilDormantAutoDelete.Milliseconds(), }) require.NoError(t, err) require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) require.Equal(t, failureTTL.Milliseconds(), got.FailureTTLMillis) - require.Equal(t, inactivityTTL.Milliseconds(), got.InactivityTTLMillis) - require.Equal(t, lockedTTL.Milliseconds(), got.LockedTTLMillis) + require.Equal(t, inactivityTTL.Milliseconds(), got.TimeTilDormantMillis) + require.Equal(t, timeTilDormantAutoDelete.Milliseconds(), got.TimeTilDormantAutoDeleteMillis) }) t.Run("IgnoredUnlicensed", func(t *testing.T) { @@ -760,29 +760,29 @@ func TestPatchTemplateMeta(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.FailureTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) - ctr.InactivityTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) - ctr.LockedTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + ctr.TimeTilDormantMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref(0 * time.Hour.Milliseconds()) }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, - DefaultTTLMillis: template.DefaultTTLMillis, - RestartRequirement: &template.RestartRequirement, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - FailureTTLMillis: failureTTL.Milliseconds(), - InactivityTTLMillis: inactivityTTL.Milliseconds(), - LockedTTLMillis: lockedTTL.Milliseconds(), + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: template.DefaultTTLMillis, + RestartRequirement: &template.RestartRequirement, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + FailureTTLMillis: failureTTL.Milliseconds(), + TimeTilDormantMillis: inactivityTTL.Milliseconds(), + TimeTilDormantAutoDeleteMillis: timeTilDormantAutoDelete.Milliseconds(), }) require.NoError(t, err) require.Zero(t, got.FailureTTLMillis) - require.Zero(t, got.InactivityTTLMillis) - require.Zero(t, got.LockedTTLMillis) + require.Zero(t, got.TimeTilDormantMillis) + require.Zero(t, got.TimeTilDormantAutoDeleteMillis) }) }) @@ -989,8 +989,8 @@ func TestPatchTemplateMeta(t *testing.T) { RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), - InactivityTTL: int64(options.InactivityTTL), - LockedTTL: int64(options.LockedTTL), + TimeTilDormant: int64(options.TimeTilDormant), + TimeTilDormantAutoDelete: int64(options.TimeTilDormantAutoDelete), }) if !assert.NoError(t, err) { return database.Template{}, err @@ -1058,8 +1058,8 @@ func TestPatchTemplateMeta(t *testing.T) { RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: options.RestartRequirement.Weeks, FailureTTL: int64(options.FailureTTL), - InactivityTTL: int64(options.InactivityTTL), - LockedTTL: int64(options.LockedTTL), + TimeTilDormant: int64(options.TimeTilDormant), + TimeTilDormantAutoDelete: int64(options.TimeTilDormantAutoDelete), }) if !assert.NoError(t, err) { return database.Template{}, err diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0f493e5995b24..92e4c029f3777 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -765,43 +765,43 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } -// @Summary Update workspace lock by id. -// @ID update-workspace-lock-by-id +// @Summary Update workspace dormancy status by id. +// @ID update-workspace-dormancy-status-by-id // @Security CoderSessionToken // @Accept json // @Produce json // @Tags Workspaces // @Param workspace path string true "Workspace ID" format(uuid) -// @Param request body codersdk.UpdateWorkspaceLock true "Lock or unlock a workspace" +// @Param request body codersdk.UpdateWorkspaceDormancy true "Make a workspace dormant or active" // @Success 200 {object} codersdk.Workspace -// @Router /workspaces/{workspace}/lock [put] -func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { +// @Router /workspaces/{workspace}/dormant [put] +func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspace := httpmw.WorkspaceParam(r) - var req codersdk.UpdateWorkspaceLock + var req codersdk.UpdateWorkspaceDormancy if !httpapi.Read(ctx, rw, r, &req) { return } // If the workspace is already in the desired state do nothing! - if workspace.LockedAt.Valid == req.Lock { + if workspace.DormantAt.Valid == req.Dormant { httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{ Message: "Nothing to do!", }) return } - lockedAt := sql.NullTime{ - Valid: req.Lock, + dormantAt := sql.NullTime{ + Valid: req.Dormant, } - if req.Lock { - lockedAt.Time = database.Now() + if req.Dormant { + dormantAt.Time = database.Now() } - workspace, err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{ - ID: workspace.ID, - LockedAt: lockedAt, + workspace, err := api.Database.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{ + ID: workspace.ID, + DormantAt: dormantAt, }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -1153,14 +1153,14 @@ func convertWorkspace( autostartSchedule = &workspace.AutostartSchedule.String } - var lockedAt *time.Time - if workspace.LockedAt.Valid { - lockedAt = &workspace.LockedAt.Time + var dormantAt *time.Time + if workspace.DormantAt.Valid { + dormantAt = &workspace.DormantAt.Time } - var deletedAt *time.Time + var deletingAt *time.Time if workspace.DeletingAt.Valid { - deletedAt = &workspace.DeletingAt.Time + deletingAt = &workspace.DeletingAt.Time } failingAgents := []uuid.UUID{} @@ -1192,8 +1192,8 @@ func convertWorkspace( AutostartSchedule: autostartSchedule, TTLMillis: ttlMillis, LastUsedAt: workspace.LastUsedAt, - DeletingAt: deletedAt, - LockedAt: lockedAt, + DeletingAt: deletingAt, + DormantAt: dormantAt, Health: codersdk.WorkspaceHealth{ Healthy: len(failingAgents) == 0, FailingAgents: failingAgents, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index b42f4517db82d..ebedb8497deae 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1363,9 +1363,9 @@ func TestWorkspaceFilterManual(t *testing.T) { TemplateScheduleStore: schedule.MockTemplateScheduleStore{ SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { if atomic.AddInt64(&setCalled, 1) == 2 { - assert.Equal(t, inactivityTTL, options.InactivityTTL) + assert.Equal(t, inactivityTTL, options.TimeTilDormant) } - template.InactivityTTL = int64(options.InactivityTTL) + template.TimeTilDormant = int64(options.TimeTilDormant) return template, nil }, }, @@ -1385,11 +1385,11 @@ func TestWorkspaceFilterManual(t *testing.T) { defer cancel() template, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - InactivityTTLMillis: inactivityTTL.Milliseconds(), + TimeTilDormantMillis: inactivityTTL.Milliseconds(), }) assert.NoError(t, err) - assert.Equal(t, inactivityTTL.Milliseconds(), template.InactivityTTLMillis) + assert.Equal(t, inactivityTTL.Milliseconds(), template.TimeTilDormantMillis) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) @@ -1404,11 +1404,11 @@ func TestWorkspaceFilterManual(t *testing.T) { assert.NoError(t, err) // we are expecting that no workspaces are returned as user is unlicensed - // and template.InactivityTTL should be 0 + // and template.TimeTilDormant should be 0 assert.Len(t, res.Workspaces, 0) }) - t.Run("LockedAt", func(t *testing.T) { + t.Run("DormantAt", func(t *testing.T) { // this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ @@ -1428,24 +1428,24 @@ func TestWorkspaceFilterManual(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + dormantWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, dormantWorkspace.LatestBuild.ID) - // Create another workspace to validate that we do not return unlocked workspaces. + // Create another workspace to validate that we do not return active workspaces. _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, dormantWorkspace.LatestBuild.ID) - err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: true, + err := client.UpdateWorkspaceDormancy(ctx, dormantWorkspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, }) require.NoError(t, err) res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ - FilterQuery: fmt.Sprintf("locked_at:%s", time.Now().Add(-time.Minute).Format("2006-01-02")), + FilterQuery: fmt.Sprintf("dormant_at:%s", time.Now().Add(-time.Minute).Format("2006-01-02")), }) require.NoError(t, err) require.Len(t, res.Workspaces, 1) - require.NotNil(t, res.Workspaces[0].LockedAt) + require.NotNil(t, res.Workspaces[0].DormantAt) }) t.Run("LastUsed", func(t *testing.T) { @@ -2782,21 +2782,21 @@ func TestWorkspaceWithEphemeralRichParameters(t *testing.T) { require.ElementsMatch(t, expectedBuildParameters, workspaceBuildParameters) } -func TestWorkspaceLock(t *testing.T) { +func TestWorkspaceDormant(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { t.Parallel() var ( - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - lockedTTL = time.Minute + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + timeTilDormantAutoDelete = time.Minute ) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.LockedTTLMillis = ptr.Ref[int64](lockedTTL.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](timeTilDormantAutoDelete.Milliseconds()) }) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) @@ -2805,32 +2805,32 @@ func TestWorkspaceLock(t *testing.T) { defer cancel() lastUsedAt := workspace.LastUsedAt - err := client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: true, + err := client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, }) require.NoError(t, err) workspace = coderdtest.MustWorkspace(t, client, workspace.ID) require.NoError(t, err, "fetch provisioned workspace") - // The template doesn't have a locked_ttl set so this should be nil. + // The template doesn't have a time_til_dormant_autodelete set so this should be nil. require.Nil(t, workspace.DeletingAt) - require.NotNil(t, workspace.LockedAt) - require.WithinRange(t, *workspace.LockedAt, time.Now().Add(-time.Second*10), time.Now()) + require.NotNil(t, workspace.DormantAt) + require.WithinRange(t, *workspace.DormantAt, time.Now().Add(-time.Second*10), time.Now()) require.Equal(t, lastUsedAt, workspace.LastUsedAt) workspace = coderdtest.MustWorkspace(t, client, workspace.ID) lastUsedAt = workspace.LastUsedAt - err = client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: false, + err = client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: false, }) require.NoError(t, err) workspace, err = client.Workspace(ctx, workspace.ID) require.NoError(t, err, "fetch provisioned workspace") - require.Nil(t, workspace.LockedAt) - // The template doesn't have a locked_ttl set so this should be nil. + require.Nil(t, workspace.DormantAt) + // The template doesn't have a time_til_dormant_autodelete set so this should be nil. require.Nil(t, workspace.DeletingAt) - // The last_used_at should get updated when we unlock the workspace. + // The last_used_at should get updated when we activate the workspace. require.True(t, workspace.LastUsedAt.After(lastUsedAt)) }) @@ -2849,23 +2849,23 @@ func TestWorkspaceLock(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - err := client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: true, + err := client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, }) require.NoError(t, err) - // Should be able to stop a workspace while it is locked. + // Should be able to stop a workspace while it is dormant. coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - // Should not be able to start a workspace while it is locked. + // Should not be able to start a workspace while it is dormant. _, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: template.ActiveVersionID, Transition: codersdk.WorkspaceTransition(database.WorkspaceTransitionStart), }) require.Error(t, err) - err = client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: false, + err = client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: false, }) require.NoError(t, err) coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 96b026a3197a5..0b4af0e67056a 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -108,12 +108,12 @@ type CreateTemplateRequest struct { // FailureTTLMillis allows optionally specifying the max lifetime before Coder // stops all resources for failed workspaces created from this template. FailureTTLMillis *int64 `json:"failure_ttl_ms,omitempty"` - // InactivityTTLMillis allows optionally specifying the max lifetime before Coder + // TimeTilDormantMillis allows optionally specifying the max lifetime before Coder // locks inactive workspaces created from this template. - InactivityTTLMillis *int64 `json:"inactivity_ttl_ms,omitempty"` - // LockedTTLMillis allows optionally specifying the max lifetime before Coder - // permanently deletes locked workspaces created from this template. - LockedTTLMillis *int64 `json:"locked_ttl_ms,omitempty"` + TimeTilDormantMillis *int64 `json:"dormant_ttl_ms,omitempty"` + // TimeTilDormantAutoDeleteMillis allows optionally specifying the max lifetime before Coder + // permanently deletes dormant workspaces created from this template. + TimeTilDormantAutoDeleteMillis *int64 `json:"delete_ttl_ms,omitempty"` // DisableEveryoneGroupAccess allows optionally disabling the default // behavior of granting the 'everyone' group access to use the template. diff --git a/codersdk/templates.go b/codersdk/templates.go index f566ac4f2ce32..406933c72b75f 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -44,12 +44,12 @@ type Template struct { AllowUserAutostop bool `json:"allow_user_autostop"` AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs"` - // FailureTTLMillis, InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their + // FailureTTLMillis, TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their // values are used if your license is entitled to use the advanced // template scheduling feature. - FailureTTLMillis int64 `json:"failure_ttl_ms"` - InactivityTTLMillis int64 `json:"inactivity_ttl_ms"` - LockedTTLMillis int64 `json:"locked_ttl_ms"` + FailureTTLMillis int64 `json:"failure_ttl_ms"` + TimeTilDormantMillis int64 `json:"time_til_dormant_ms"` + TimeTilDormantAutoDeleteMillis int64 `json:"time_til_dormant_autodelete_ms"` } // WeekdaysToBitmap converts a list of weekdays to a bitmap in accordance with @@ -185,22 +185,22 @@ type UpdateTemplateMeta struct { // RestartRequirement can only be set if your license includes the advanced // template scheduling feature. If you attempt to set this value while // unlicensed, it will be ignored. - RestartRequirement *TemplateRestartRequirement `json:"restart_requirement,omitempty"` - AllowUserAutostart bool `json:"allow_user_autostart,omitempty"` - AllowUserAutostop bool `json:"allow_user_autostop,omitempty"` - AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` - FailureTTLMillis int64 `json:"failure_ttl_ms,omitempty"` - InactivityTTLMillis int64 `json:"inactivity_ttl_ms,omitempty"` - LockedTTLMillis int64 `json:"locked_ttl_ms,omitempty"` + RestartRequirement *TemplateRestartRequirement `json:"restart_requirement,omitempty"` + AllowUserAutostart bool `json:"allow_user_autostart,omitempty"` + AllowUserAutostop bool `json:"allow_user_autostop,omitempty"` + AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` + FailureTTLMillis int64 `json:"failure_ttl_ms,omitempty"` + TimeTilDormantMillis int64 `json:"time_til_dormant_ms,omitempty"` + TimeTilDormantAutoDeleteMillis int64 `json:"time_til_dormant_autodelete_ms,omitempty"` // UpdateWorkspaceLastUsedAt updates the last_used_at field of workspaces // spawned from the template. This is useful for preventing workspaces being // immediately locked when updating the inactivity_ttl field to a new, shorter // value. UpdateWorkspaceLastUsedAt bool `json:"update_workspace_last_used_at"` - // UpdateWorkspaceLockedAt updates the locked_at field of workspaces spawned - // from the template. This is useful for preventing locked workspaces being immediately - // deleted when updating the locked_ttl field to a new, shorter value. - UpdateWorkspaceLockedAt bool `json:"update_workspace_locked_at"` + // UpdateWorkspaceDormant updates the dormant_at field of workspaces spawned + // from the template. This is useful for preventing dormant workspaces being immediately + // deleted when updating the dormant_ttl field to a new, shorter value. + UpdateWorkspaceDormantAt bool `json:"update_workspace_dormant_at"` } type TemplateExample struct { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 0f43143bfe0fb..d7b191c6273b6 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -35,14 +35,15 @@ type Workspace struct { TTLMillis *int64 `json:"ttl_ms,omitempty"` LastUsedAt time.Time `json:"last_used_at" format:"date-time"` - // DeletingAt indicates the time of the upcoming workspace deletion, if applicable; otherwise it is nil. - // Workspaces may have impending deletions if Template.InactivityTTL feature is turned on and the workspace is inactive. + // DeletingAt indicates the time at which the workspace will be permanently deleted. + // A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) + // and a value has been specified for time_til_dormant_autodelete on its template. DeletingAt *time.Time `json:"deleting_at" format:"date-time"` - // LockedAt being non-nil indicates a workspace that has been locked. - // A locked workspace is no longer accessible by a user and must be - // unlocked by an admin. It is subject to deletion if it breaches - // the duration of the locked_ttl field on its template. - LockedAt *time.Time `json:"locked_at" format:"date-time"` + // DormantAt being non-nil indicates a workspace that is dormant. + // A dormant workspace is no longer accessible must be activated. + // It is subject to deletion if it breaches + // the duration of the time_til_ field on its template. + DormantAt *time.Time `json:"dormant_at" format:"date-time"` // Health shows the health of the workspace and information about // what is causing an unhealthy status. Health WorkspaceHealth `json:"health"` @@ -293,14 +294,16 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx return nil } -// UpdateWorkspaceLock is a request to lock or unlock a workspace. -type UpdateWorkspaceLock struct { - Lock bool `json:"lock"` +// UpdateWorkspaceDormancy is a request to activate or make a workspace dormant. +// A value of false will activate a dormant workspace. +type UpdateWorkspaceDormancy struct { + Dormant bool `json:"dormant"` } -// UpdateWorkspaceLock locks or unlocks a workspace. -func (c *Client) UpdateWorkspaceLock(ctx context.Context, id uuid.UUID, req UpdateWorkspaceLock) error { - path := fmt.Sprintf("/api/v2/workspaces/%s/lock", id.String()) +// UpdateWorkspaceDormancy sets a workspace as dormant if dormant=true and activates a dormant workspace +// if dormant=false. +func (c *Client) UpdateWorkspaceDormancy(ctx context.Context, id uuid.UUID, req UpdateWorkspaceDormancy) error { + path := fmt.Sprintf("/api/v2/workspaces/%s/dormant", id.String()) res, err := c.Request(ctx, http.MethodPut, path, req) if err != nil { return xerrors.Errorf("update workspace lock: %w", err) diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 3ad9395e3556f..6d7293731f6cf 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -8,19 +8,19 @@ We track the following resources: -| Resource | | -| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_days_of_weektrue
restart_requirement_weekstrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
git_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
idtrue
last_used_atfalse
locked_attrue
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_days_of_weektrue
restart_requirement_weekstrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
git_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 3cb49b84bf833..f62da873c5c3d 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1479,13 +1479,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, "default_ttl_ms": 0, + "delete_ttl_ms": 0, "description": "string", "disable_everyone_group_access": true, "display_name": "string", + "dormant_ttl_ms": 0, "failure_ttl_ms": 0, "icon": "string", - "inactivity_ttl_ms": 0, - "locked_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "restart_requirement": { @@ -1504,13 +1504,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `allow_user_autostop` | boolean | false | | Allow user autostop allows users to set a custom workspace TTL to use in place of the template's DefaultTTL field. By default this is true. If false, the DefaultTTL will always be used. This can only be disabled when using an enterprise license. | | `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. \*bool as the default value is "true". | | `default_ttl_ms` | integer | false | | Default ttl ms allows optionally specifying the default TTL for all workspaces created from this template. | +| `delete_ttl_ms` | integer | false | | Delete ttl ms allows optionally specifying the max lifetime before Coder permanently deletes dormant workspaces created from this template. | | `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. | | `disable_everyone_group_access` | boolean | false | | Disable everyone group access allows optionally disabling the default behavior of granting the 'everyone' group access to use the template. If this is set to true, the template will not be available to all users, and must be explicitly granted to users or groups in the permissions settings of the template. | | `display_name` | string | false | | Display name is the displayed name of the template. | +| `dormant_ttl_ms` | integer | false | | Dormant ttl ms allows optionally specifying the max lifetime before Coder locks inactive workspaces created from this template. | | `failure_ttl_ms` | integer | false | | Failure ttl ms allows optionally specifying the max lifetime before Coder stops all resources for failed workspaces created from this template. | | `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | -| `inactivity_ttl_ms` | integer | false | | Inactivity ttl ms allows optionally specifying the max lifetime before Coder locks inactive workspaces created from this template. | -| `locked_ttl_ms` | integer | false | | Locked ttl ms allows optionally specifying the max lifetime before Coder permanently deletes locked workspaces created from this template. | | `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured | | `name` | string | true | | Name is the name of the template. | | `restart_requirement` | [codersdk.TemplateRestartRequirement](#codersdktemplaterestartrequirement) | false | | Restart requirement allows optionally specifying the restart requirement for workspaces created from this template. This is an enterprise feature. | @@ -4239,8 +4239,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "inactivity_ttl_ms": 0, - "locked_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -4249,37 +4247,39 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "days_of_week": ["monday"], "weeks": 0 }, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------------------------------- | -------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `active_user_count` | integer | false | | Active user count is set to -1 when loading. | -| `active_version_id` | string | false | | | -| `allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | -| `allow_user_autostop` | boolean | false | | | -| `allow_user_cancel_workspace_jobs` | boolean | false | | | -| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | | -| `created_at` | string | false | | | -| `created_by_id` | string | false | | | -| `created_by_name` | string | false | | | -| `default_ttl_ms` | integer | false | | | -| `description` | string | false | | | -| `display_name` | string | false | | | -| `failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | -| `icon` | string | false | | | -| `id` | string | false | | | -| `inactivity_ttl_ms` | integer | false | | | -| `locked_ttl_ms` | integer | false | | | -| `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured | -| `name` | string | false | | | -| `organization_id` | string | false | | | -| `provisioner` | string | false | | | -| `restart_requirement` | [codersdk.TemplateRestartRequirement](#codersdktemplaterestartrequirement) | false | | Restart requirement is an enterprise feature. Its value is only used if your license is entitled to use the advanced template scheduling feature. | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------------- | -------------------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `active_user_count` | integer | false | | Active user count is set to -1 when loading. | +| `active_version_id` | string | false | | | +| `allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | +| `allow_user_autostop` | boolean | false | | | +| `allow_user_cancel_workspace_jobs` | boolean | false | | | +| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | | +| `created_at` | string | false | | | +| `created_by_id` | string | false | | | +| `created_by_name` | string | false | | | +| `default_ttl_ms` | integer | false | | | +| `description` | string | false | | | +| `display_name` | string | false | | | +| `failure_ttl_ms` | integer | false | | Failure ttl ms TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | +| `icon` | string | false | | | +| `id` | string | false | | | +| `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured | +| `name` | string | false | | | +| `organization_id` | string | false | | | +| `provisioner` | string | false | | | +| `restart_requirement` | [codersdk.TemplateRestartRequirement](#codersdktemplaterestartrequirement) | false | | Restart requirement is an enterprise feature. Its value is only used if your license is entitled to use the advanced template scheduling feature. | +| `time_til_dormant_autodelete_ms` | integer | false | | | +| `time_til_dormant_ms` | integer | false | | | +| `updated_at` | string | false | | | #### Enumerated Values @@ -5051,19 +5051,19 @@ If the schedule is empty, the user will be updated to use the default schedule.| | ---------- | ------ | -------- | ------------ | ----------- | | `schedule` | string | false | | | -## codersdk.UpdateWorkspaceLock +## codersdk.UpdateWorkspaceDormancy ```json { - "lock": true + "dormant": true } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ------ | ------- | -------- | ------------ | ----------- | -| `lock` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +| --------- | ------- | -------- | ------------ | ----------- | +| `dormant` | boolean | false | | | ## codersdk.UpdateWorkspaceRequest @@ -5357,6 +5357,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", + "dormant_at": "2019-08-24T14:15:22Z", "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -5491,7 +5492,6 @@ If the schedule is empty, the user will be updated to use the default schedule.| "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", "workspace_owner_name": "string" }, - "locked_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "outdated": true, @@ -5509,28 +5509,28 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------------------------------- | ---------------------------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `autostart_schedule` | string | false | | | -| `created_at` | string | false | | | -| `deleting_at` | string | false | | Deleting at indicates the time of the upcoming workspace deletion, if applicable; otherwise it is nil. Workspaces may have impending deletions if Template.InactivityTTL feature is turned on and the workspace is inactive. | -| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | -| `id` | string | false | | | -| `last_used_at` | string | false | | | -| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | -| `locked_at` | string | false | | Locked at being non-nil indicates a workspace that has been locked. A locked workspace is no longer accessible by a user and must be unlocked by an admin. It is subject to deletion if it breaches the duration of the locked_ttl field on its template. | -| `name` | string | false | | | -| `organization_id` | string | false | | | -| `outdated` | boolean | false | | | -| `owner_id` | string | false | | | -| `owner_name` | string | false | | | -| `template_allow_user_cancel_workspace_jobs` | boolean | false | | | -| `template_display_name` | string | false | | | -| `template_icon` | string | false | | | -| `template_id` | string | false | | | -| `template_name` | string | false | | | -| `ttl_ms` | integer | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------------------------------- | ---------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `autostart_schedule` | string | false | | | +| `created_at` | string | false | | | +| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. | +| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time*til* field on its template. | +| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | +| `id` | string | false | | | +| `last_used_at` | string | false | | | +| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | +| `name` | string | false | | | +| `organization_id` | string | false | | | +| `outdated` | boolean | false | | | +| `owner_id` | string | false | | | +| `owner_name` | string | false | | | +| `template_allow_user_cancel_workspace_jobs` | boolean | false | | | +| `template_display_name` | string | false | | | +| `template_icon` | string | false | | | +| `template_id` | string | false | | | +| `template_name` | string | false | | | +| `ttl_ms` | integer | false | | | +| `updated_at` | string | false | | | ## codersdk.WorkspaceAgent @@ -6493,6 +6493,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", + "dormant_at": "2019-08-24T14:15:22Z", "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -6623,7 +6624,6 @@ If the schedule is empty, the user will be updated to use the default schedule.| "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", "workspace_owner_name": "string" }, - "locked_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "outdated": true, diff --git a/docs/api/templates.md b/docs/api/templates.md index 407ab84eba439..bb67535e786c2 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -50,8 +50,6 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "inactivity_ttl_ms": 0, - "locked_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -60,6 +58,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "days_of_week": ["monday"], "weeks": 0 }, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } ] @@ -93,11 +93,9 @@ Status Code **200** | `» default_ttl_ms` | integer | false | | | | `» description` | string | false | | | | `» display_name` | string | false | | | -| `» failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | +| `» failure_ttl_ms` | integer | false | | Failure ttl ms TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | | `» icon` | string | false | | | | `» id` | string(uuid) | false | | | -| `» inactivity_ttl_ms` | integer | false | | | -| `» locked_ttl_ms` | integer | false | | | | `» max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured | | `» name` | string | false | | | | `» organization_id` | string(uuid) | false | | | @@ -106,6 +104,8 @@ Status Code **200** | `»» days_of_week` | array | false | | »days of week is a list of days of the week on which restarts are required. Restarts happen within the user's quiet hours (in their configured timezone). If no days are specified, restarts are not required. Weekdays cannot be specified twice. | | Restarts will only happen on weekdays in this list on weeks which line up with Weeks. | | `»» weeks` | integer | false | | Weeks is the number of weeks between required restarts. Weeks are synced across all workspaces (and Coder deployments) using modulo math on a hardcoded epoch week of January 2nd, 2023 (the first Monday of 2023). Values of 0 or 1 indicate weekly restarts. Values of 2 indicate fortnightly restarts, etc. | +| `» time_til_dormant_autodelete_ms` | integer | false | | | +| `» time_til_dormant_ms` | integer | false | | | | `» updated_at` | string(date-time) | false | | | #### Enumerated Values @@ -138,13 +138,13 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "allow_user_autostop": true, "allow_user_cancel_workspace_jobs": true, "default_ttl_ms": 0, + "delete_ttl_ms": 0, "description": "string", "disable_everyone_group_access": true, "display_name": "string", + "dormant_ttl_ms": 0, "failure_ttl_ms": 0, "icon": "string", - "inactivity_ttl_ms": 0, - "locked_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "restart_requirement": { @@ -192,8 +192,6 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "inactivity_ttl_ms": 0, - "locked_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -202,6 +200,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "days_of_week": ["monday"], "weeks": 0 }, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -324,8 +324,6 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "inactivity_ttl_ms": 0, - "locked_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -334,6 +332,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "days_of_week": ["monday"], "weeks": 0 }, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -629,8 +629,6 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "inactivity_ttl_ms": 0, - "locked_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -639,6 +637,8 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "days_of_week": ["monday"], "weeks": 0 }, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -744,8 +744,6 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "inactivity_ttl_ms": 0, - "locked_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -754,6 +752,8 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "days_of_week": ["monday"], "weeks": 0 }, + "time_til_dormant_autodelete_ms": 0, + "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } ``` diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 01b85e21b3527..7c4e9319cd2b8 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -48,6 +48,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", + "dormant_at": "2019-08-24T14:15:22Z", "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -182,7 +183,6 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", "workspace_owner_name": "string" }, - "locked_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "outdated": true, @@ -236,6 +236,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", + "dormant_at": "2019-08-24T14:15:22Z", "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -370,7 +371,6 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", "workspace_owner_name": "string" }, - "locked_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "outdated": true, @@ -427,6 +427,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", + "dormant_at": "2019-08-24T14:15:22Z", "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -557,7 +558,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", "workspace_owner_name": "string" }, - "locked_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "outdated": true, @@ -612,6 +612,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", + "dormant_at": "2019-08-24T14:15:22Z", "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -746,7 +747,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", "workspace_owner_name": "string" }, - "locked_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "outdated": true, @@ -842,88 +842,34 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/autostart \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Extend workspace deadline by ID - -### Code samples - -```shell -# Example request using curl -curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/extend \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`PUT /workspaces/{workspace}/extend` - -> Body parameter - -```json -{ - "deadline": "2019-08-24T14:15:22Z" -} -``` - -### Parameters - -| Name | In | Type | Required | Description | -| ----------- | ---- | ---------------------------------------------------------------------------------- | -------- | ------------------------------ | -| `workspace` | path | string(uuid) | true | Workspace ID | -| `body` | body | [codersdk.PutExtendWorkspaceRequest](schemas.md#codersdkputextendworkspacerequest) | true | Extend deadline update request | - -### Example responses - -> 200 Response - -```json -{ - "detail": "string", - "message": "string", - "validations": [ - { - "detail": "string", - "field": "string" - } - ] -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Update workspace lock by id. +## Update workspace dormancy status by id. ### Code samples ```shell # Example request using curl -curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/lock \ +curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`PUT /workspaces/{workspace}/lock` +`PUT /workspaces/{workspace}/dormant` > Body parameter ```json { - "lock": true + "dormant": true } ``` ### Parameters -| Name | In | Type | Required | Description | -| ----------- | ---- | ---------------------------------------------------------------------- | -------- | -------------------------- | -| `workspace` | path | string(uuid) | true | Workspace ID | -| `body` | body | [codersdk.UpdateWorkspaceLock](schemas.md#codersdkupdateworkspacelock) | true | Lock or unlock a workspace | +| Name | In | Type | Required | Description | +| ----------- | ---- | ------------------------------------------------------------------------------ | -------- | ---------------------------------- | +| `workspace` | path | string(uuid) | true | Workspace ID | +| `body` | body | [codersdk.UpdateWorkspaceDormancy](schemas.md#codersdkupdateworkspacedormancy) | true | Make a workspace dormant or active | ### Example responses @@ -934,6 +880,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/lock \ "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", + "dormant_at": "2019-08-24T14:15:22Z", "health": { "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "healthy": false @@ -1068,7 +1015,6 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/lock \ "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", "workspace_owner_name": "string" }, - "locked_at": "2019-08-24T14:15:22Z", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "outdated": true, @@ -1092,6 +1038,60 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/lock \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Extend workspace deadline by ID + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/extend \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /workspaces/{workspace}/extend` + +> Body parameter + +```json +{ + "deadline": "2019-08-24T14:15:22Z" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ----------- | ---- | ---------------------------------------------------------------------------------- | -------- | ------------------------------ | +| `workspace` | path | string(uuid) | true | Workspace ID | +| `body` | body | [codersdk.PutExtendWorkspaceRequest](schemas.md#codersdkputextendworkspacerequest) | true | Extend deadline update request | + +### Example responses + +> 200 Response + +```json +{ + "detail": "string", + "message": "string", + "validations": [ + { + "detail": "string", + "field": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Update workspace TTL by ID ### Code samples diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 2e732a1d53a1d..a1dfef2d053d3 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -82,8 +82,8 @@ var auditableResourcesTypes = map[any]map[string]Action{ "allow_user_autostop": ActionTrack, "allow_user_cancel_workspace_jobs": ActionTrack, "failure_ttl": ActionTrack, - "inactivity_ttl": ActionTrack, - "locked_ttl": ActionTrack, + "time_til_dormant": ActionTrack, + "time_til_dormant_autodelete": ActionTrack, }, &database.TemplateVersion{}: { "id": ActionTrack, @@ -127,7 +127,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "autostart_schedule": ActionTrack, "ttl": ActionTrack, "last_used_at": ActionIgnore, - "locked_at": ActionTrack, + "dormant_at": ActionTrack, "deleting_at": ActionTrack, }, &database.WorkspaceBuild{}: { diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index c5613c44e7880..f37d9ded8d187 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -83,9 +83,9 @@ func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.S DaysOfWeek: uint8(tpl.RestartRequirementDaysOfWeek), Weeks: tpl.RestartRequirementWeeks, }, - FailureTTL: time.Duration(tpl.FailureTTL), - InactivityTTL: time.Duration(tpl.InactivityTTL), - LockedTTL: time.Duration(tpl.LockedTTL), + FailureTTL: time.Duration(tpl.FailureTTL), + TimeTilDormant: time.Duration(tpl.TimeTilDormant), + TimeTilDormantAutoDelete: time.Duration(tpl.TimeTilDormantAutoDelete), }, nil } @@ -99,8 +99,8 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S int16(opts.RestartRequirement.DaysOfWeek) == tpl.RestartRequirementDaysOfWeek && opts.RestartRequirement.Weeks == tpl.RestartRequirementWeeks && int64(opts.FailureTTL) == tpl.FailureTTL && - int64(opts.InactivityTTL) == tpl.InactivityTTL && - int64(opts.LockedTTL) == tpl.LockedTTL && + int64(opts.TimeTilDormant) == tpl.TimeTilDormant && + int64(opts.TimeTilDormantAutoDelete) == tpl.TimeTilDormantAutoDelete && opts.UserAutostartEnabled == tpl.AllowUserAutostart && opts.UserAutostopEnabled == tpl.AllowUserAutostop { // Avoid updating the UpdatedAt timestamp if nothing will be changed. @@ -127,29 +127,29 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S RestartRequirementDaysOfWeek: int16(opts.RestartRequirement.DaysOfWeek), RestartRequirementWeeks: opts.RestartRequirement.Weeks, FailureTTL: int64(opts.FailureTTL), - InactivityTTL: int64(opts.InactivityTTL), - LockedTTL: int64(opts.LockedTTL), + TimeTilDormant: int64(opts.TimeTilDormant), + TimeTilDormantAutoDelete: int64(opts.TimeTilDormantAutoDelete), }) if err != nil { return xerrors.Errorf("update template schedule: %w", err) } - var lockedAt time.Time - if opts.UpdateWorkspaceLockedAt { - lockedAt = database.Now() + var dormantAt time.Time + if opts.UpdateWorkspaceDormantAt { + dormantAt = database.Now() } - // If we updated the locked_ttl we need to update all the workspaces deleting_at + // If we updated the time_til_dormant_autodelete we need to update all the workspaces deleting_at // to ensure workspaces are being cleaned up correctly. Similarly if we are // disabling it (by passing 0), then we want to delete nullify the deleting_at // fields of all the template workspaces. - err = tx.UpdateWorkspacesLockedDeletingAtByTemplateID(ctx, database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams{ - TemplateID: tpl.ID, - LockedTtlMs: opts.LockedTTL.Milliseconds(), - LockedAt: lockedAt, + err = tx.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, database.UpdateWorkspacesDormantDeletingAtByTemplateIDParams{ + TemplateID: tpl.ID, + TimeTilDormantAutodeleteMs: opts.TimeTilDormantAutoDelete.Milliseconds(), + DormantAt: dormantAt, }) if err != nil { - return xerrors.Errorf("update deleting_at of all workspaces for new locked_ttl %q: %w", opts.LockedTTL, err) + return xerrors.Errorf("update deleting_at of all workspaces for new time_til_dormant_autodelete %q: %w", opts.TimeTilDormantAutoDelete, err) } if opts.UpdateWorkspaceLastUsedAt { diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go index aff7e2364fcec..14f7a384b0c12 100644 --- a/enterprise/coderd/schedule/template_test.go +++ b/enterprise/coderd/schedule/template_test.go @@ -225,9 +225,9 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { DaysOfWeek: 0b01111111, Weeks: 0, }, - FailureTTL: 0, - InactivityTTL: 0, - LockedTTL: 0, + FailureTTL: 0, + TimeTilDormant: 0, + TimeTilDormantAutoDelete: 0, }) require.NoError(t, err) @@ -500,9 +500,9 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) { DaysOfWeek: 0b01111111, Weeks: 0, }, - FailureTTL: 0, - InactivityTTL: 0, - LockedTTL: 0, + FailureTTL: 0, + TimeTilDormant: 0, + TimeTilDormantAutoDelete: 0, }) require.NoError(t, err) diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index af364d3578b1c..52d29acf76cd6 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -202,41 +202,41 @@ func TestTemplates(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - require.EqualValues(t, 0, template.InactivityTTLMillis) + require.EqualValues(t, 0, template.TimeTilDormantMillis) require.EqualValues(t, 0, template.FailureTTLMillis) - require.EqualValues(t, 0, template.LockedTTLMillis) + require.EqualValues(t, 0, template.TimeTilDormantAutoDeleteMillis) var ( failureTTL int64 = 1 inactivityTTL int64 = 2 - lockedTTL int64 = 3 + dormantTTL int64 = 3 ) updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - InactivityTTLMillis: inactivityTTL, - FailureTTLMillis: failureTTL, - LockedTTLMillis: lockedTTL, + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + TimeTilDormantMillis: inactivityTTL, + FailureTTLMillis: failureTTL, + TimeTilDormantAutoDeleteMillis: dormantTTL, }) require.NoError(t, err) require.Equal(t, failureTTL, updated.FailureTTLMillis) - require.Equal(t, inactivityTTL, updated.InactivityTTLMillis) - require.Equal(t, lockedTTL, updated.LockedTTLMillis) + require.Equal(t, inactivityTTL, updated.TimeTilDormantMillis) + require.Equal(t, dormantTTL, updated.TimeTilDormantAutoDeleteMillis) // Validate fetching the template returns the same values as updating // the template. template, err = client.Template(ctx, template.ID) require.NoError(t, err) require.Equal(t, failureTTL, updated.FailureTTLMillis) - require.Equal(t, inactivityTTL, updated.InactivityTTLMillis) - require.Equal(t, lockedTTL, updated.LockedTTLMillis) + require.Equal(t, inactivityTTL, updated.TimeTilDormantMillis) + require.Equal(t, dormantTTL, updated.TimeTilDormantAutoDeleteMillis) }) - t.Run("UpdateLockedTTL", func(t *testing.T) { + t.Run("UpdateTimeTilDormantAutoDelete", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) @@ -254,62 +254,62 @@ func TestTemplates(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - unlockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - require.Nil(t, unlockedWorkspace.DeletingAt) - require.Nil(t, lockedWorkspace.DeletingAt) + activeWS := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + dormantWS := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + require.Nil(t, activeWS.DeletingAt) + require.Nil(t, dormantWS.DeletingAt) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, unlockedWorkspace.LatestBuild.ID) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, activeWS.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, dormantWS.LatestBuild.ID) - err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: true, + err := client.UpdateWorkspaceDormancy(ctx, dormantWS.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, }) require.NoError(t, err) - lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) - require.NotNil(t, lockedWorkspace.LockedAt) - // The deleting_at field should be nil since there is no template locked_ttl set. - require.Nil(t, lockedWorkspace.DeletingAt) + dormantWS = coderdtest.MustWorkspace(t, client, dormantWS.ID) + require.NotNil(t, dormantWS.DormantAt) + // The deleting_at field should be nil since there is no template time_til_dormant_autodelete set. + require.Nil(t, dormantWS.DeletingAt) - lockedTTL := time.Minute + dormantTTL := time.Minute updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - LockedTTLMillis: lockedTTL.Milliseconds(), + TimeTilDormantAutoDeleteMillis: dormantTTL.Milliseconds(), }) require.NoError(t, err) - require.Equal(t, lockedTTL.Milliseconds(), updated.LockedTTLMillis) + require.Equal(t, dormantTTL.Milliseconds(), updated.TimeTilDormantAutoDeleteMillis) - unlockedWorkspace = coderdtest.MustWorkspace(t, client, unlockedWorkspace.ID) - require.Nil(t, unlockedWorkspace.LockedAt) - require.Nil(t, unlockedWorkspace.DeletingAt) + activeWS = coderdtest.MustWorkspace(t, client, activeWS.ID) + require.Nil(t, activeWS.DormantAt) + require.Nil(t, activeWS.DeletingAt) - updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) - require.NotNil(t, updatedLockedWorkspace.LockedAt) - require.NotNil(t, updatedLockedWorkspace.DeletingAt) - require.Equal(t, updatedLockedWorkspace.LockedAt.Add(lockedTTL), *updatedLockedWorkspace.DeletingAt) - require.Equal(t, updatedLockedWorkspace.LockedAt, lockedWorkspace.LockedAt) + updatedDormantWorkspace := coderdtest.MustWorkspace(t, client, dormantWS.ID) + require.NotNil(t, updatedDormantWorkspace.DormantAt) + require.NotNil(t, updatedDormantWorkspace.DeletingAt) + require.Equal(t, updatedDormantWorkspace.DormantAt.Add(dormantTTL), *updatedDormantWorkspace.DeletingAt) + require.Equal(t, updatedDormantWorkspace.DormantAt, dormantWS.DormantAt) - // Disable the locked_ttl on the template, then we can assert that the workspaces + // Disable the time_til_dormant_auto_delete on the template, then we can assert that the workspaces // no longer have a deleting_at field. updated, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - LockedTTLMillis: 0, + TimeTilDormantAutoDeleteMillis: 0, }) require.NoError(t, err) - require.EqualValues(t, 0, updated.LockedTTLMillis) + require.EqualValues(t, 0, updated.TimeTilDormantAutoDeleteMillis) - // The unlocked workspace should remain unchanged. - unlockedWorkspace = coderdtest.MustWorkspace(t, client, unlockedWorkspace.ID) - require.Nil(t, unlockedWorkspace.LockedAt) - require.Nil(t, unlockedWorkspace.DeletingAt) + // The active workspace should remain unchanged. + activeWS = coderdtest.MustWorkspace(t, client, activeWS.ID) + require.Nil(t, activeWS.DormantAt) + require.Nil(t, activeWS.DeletingAt) - // Fetch the locked workspace. It should still be locked, but it should no + // Fetch the dormant workspace. It should still be dormant, but it should no // longer be scheduled for deletion. - lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) - require.NotNil(t, lockedWorkspace.LockedAt) - require.Nil(t, lockedWorkspace.DeletingAt) + dormantWS = coderdtest.MustWorkspace(t, client, dormantWS.ID) + require.NotNil(t, dormantWS.DormantAt) + require.Nil(t, dormantWS.DeletingAt) }) - t.Run("UpdateLockedAt", func(t *testing.T) { + t.Run("UpdateDormantAt", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) @@ -327,42 +327,42 @@ func TestTemplates(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - unlockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - require.Nil(t, unlockedWorkspace.DeletingAt) - require.Nil(t, lockedWorkspace.DeletingAt) + activeWS := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + dormantWS := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + require.Nil(t, activeWS.DeletingAt) + require.Nil(t, dormantWS.DeletingAt) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, unlockedWorkspace.LatestBuild.ID) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, activeWS.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, dormantWS.LatestBuild.ID) - err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: true, + err := client.UpdateWorkspaceDormancy(ctx, dormantWS.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, }) require.NoError(t, err) - lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) - require.NotNil(t, lockedWorkspace.LockedAt) - // The deleting_at field should be nil since there is no template locked_ttl set. - require.Nil(t, lockedWorkspace.DeletingAt) + dormantWS = coderdtest.MustWorkspace(t, client, dormantWS.ID) + require.NotNil(t, dormantWS.DormantAt) + // The deleting_at field should be nil since there is no template time_til_dormant_autodelete set. + require.Nil(t, dormantWS.DeletingAt) - lockedTTL := time.Minute + dormantTTL := time.Minute updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - LockedTTLMillis: lockedTTL.Milliseconds(), - UpdateWorkspaceLockedAt: true, + TimeTilDormantAutoDeleteMillis: dormantTTL.Milliseconds(), + UpdateWorkspaceDormantAt: true, }) require.NoError(t, err) - require.Equal(t, lockedTTL.Milliseconds(), updated.LockedTTLMillis) + require.Equal(t, dormantTTL.Milliseconds(), updated.TimeTilDormantAutoDeleteMillis) - unlockedWorkspace = coderdtest.MustWorkspace(t, client, unlockedWorkspace.ID) - require.Nil(t, unlockedWorkspace.LockedAt) - require.Nil(t, unlockedWorkspace.DeletingAt) + activeWS = coderdtest.MustWorkspace(t, client, activeWS.ID) + require.Nil(t, activeWS.DormantAt) + require.Nil(t, activeWS.DeletingAt) - updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) - require.NotNil(t, updatedLockedWorkspace.LockedAt) - require.NotNil(t, updatedLockedWorkspace.DeletingAt) - // Validate that the workspace locked_at value is updated. - require.True(t, updatedLockedWorkspace.LockedAt.After(*lockedWorkspace.LockedAt)) - require.Equal(t, updatedLockedWorkspace.LockedAt.Add(lockedTTL), *updatedLockedWorkspace.DeletingAt) + updatedDormantWorkspace := coderdtest.MustWorkspace(t, client, dormantWS.ID) + require.NotNil(t, updatedDormantWorkspace.DormantAt) + require.NotNil(t, updatedDormantWorkspace.DeletingAt) + // Validate that the workspace dormant_at value is updated. + require.True(t, updatedDormantWorkspace.DormantAt.After(*dormantWS.DormantAt)) + require.Equal(t, updatedDormantWorkspace.DormantAt.Add(dormantTTL), *updatedDormantWorkspace.DeletingAt) }) t.Run("UpdateLastUsedAt", func(t *testing.T) { @@ -383,43 +383,43 @@ func TestTemplates(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - unlockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - require.Nil(t, unlockedWorkspace.DeletingAt) - require.Nil(t, lockedWorkspace.DeletingAt) + activeWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + dormantWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + require.Nil(t, activeWorkspace.DeletingAt) + require.Nil(t, dormantWorkspace.DeletingAt) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, unlockedWorkspace.LatestBuild.ID) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, activeWorkspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, dormantWorkspace.LatestBuild.ID) - err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: true, + err := client.UpdateWorkspaceDormancy(ctx, dormantWorkspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, }) require.NoError(t, err) - lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) - require.NotNil(t, lockedWorkspace.LockedAt) - // The deleting_at field should be nil since there is no template locked_ttl set. - require.Nil(t, lockedWorkspace.DeletingAt) + dormantWorkspace = coderdtest.MustWorkspace(t, client, dormantWorkspace.ID) + require.NotNil(t, dormantWorkspace.DormantAt) + // The deleting_at field should be nil since there is no template time_til_dormant_autodelete set. + require.Nil(t, dormantWorkspace.DeletingAt) inactivityTTL := time.Minute updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - InactivityTTLMillis: inactivityTTL.Milliseconds(), + TimeTilDormantMillis: inactivityTTL.Milliseconds(), UpdateWorkspaceLastUsedAt: true, }) require.NoError(t, err) - require.Equal(t, inactivityTTL.Milliseconds(), updated.InactivityTTLMillis) + require.Equal(t, inactivityTTL.Milliseconds(), updated.TimeTilDormantMillis) - updatedUnlockedWS := coderdtest.MustWorkspace(t, client, unlockedWorkspace.ID) - require.Nil(t, updatedUnlockedWS.LockedAt) - require.Nil(t, updatedUnlockedWS.DeletingAt) - require.True(t, updatedUnlockedWS.LastUsedAt.After(unlockedWorkspace.LastUsedAt)) + updatedActiveWS := coderdtest.MustWorkspace(t, client, activeWorkspace.ID) + require.Nil(t, updatedActiveWS.DormantAt) + require.Nil(t, updatedActiveWS.DeletingAt) + require.True(t, updatedActiveWS.LastUsedAt.After(activeWorkspace.LastUsedAt)) - updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) - require.NotNil(t, updatedLockedWorkspace.LockedAt) - require.Nil(t, updatedLockedWorkspace.DeletingAt) - // Validate that the workspace locked_at value is updated. - require.Equal(t, updatedLockedWorkspace.LockedAt, lockedWorkspace.LockedAt) - require.True(t, updatedLockedWorkspace.LastUsedAt.After(lockedWorkspace.LastUsedAt)) + updatedDormantWS := coderdtest.MustWorkspace(t, client, dormantWorkspace.ID) + require.NotNil(t, updatedDormantWS.DormantAt) + require.Nil(t, updatedDormantWS.DeletingAt) + // Validate that the workspace dormant_at value is updated. + require.Equal(t, updatedDormantWS.DormantAt, dormantWorkspace.DormantAt) + require.True(t, updatedDormantWS.LastUsedAt.After(dormantWorkspace.LastUsedAt)) }) } diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index c5a8aec6b8535..373b79c78d59f 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -217,9 +217,9 @@ func TestWorkspaceAutobuild(t *testing.T) { }) // Create a template without setting a failure_ttl. template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - require.Zero(t, template.InactivityTTLMillis) + require.Zero(t, template.TimeTilDormantMillis) require.Zero(t, template.FailureTTLMillis) - require.Zero(t, template.LockedTTLMillis) + require.Zero(t, template.TimeTilDormantAutoDeleteMillis) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) @@ -259,7 +259,7 @@ func TestWorkspaceAutobuild(t *testing.T) { ProvisionApply: echo.ProvisionComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.InactivityTTLMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) + ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) @@ -275,12 +275,12 @@ func TestWorkspaceAutobuild(t *testing.T) { require.Len(t, stats.Transitions, 1) require.Equal(t, stats.Transitions[ws.ID], database.WorkspaceTransitionStop) - // The workspace should be locked. + // The workspace should be dormant. ws = coderdtest.MustWorkspace(t, client, ws.ID) - require.NotNil(t, ws.LockedAt) + require.NotNil(t, ws.DormantAt) lastUsedAt := ws.LastUsedAt - err := client.UpdateWorkspaceLock(ctx, ws.ID, codersdk.UpdateWorkspaceLock{Lock: false}) + err := client.UpdateWorkspaceDormancy(ctx, ws.ID, codersdk.UpdateWorkspaceDormancy{Dormant: false}) require.NoError(t, err) // Assert that we updated our last_used_at so that we don't immediately @@ -315,7 +315,7 @@ func TestWorkspaceAutobuild(t *testing.T) { ProvisionApply: echo.ProvisionComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.InactivityTTLMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) + ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) @@ -331,13 +331,13 @@ func TestWorkspaceAutobuild(t *testing.T) { // This is kind of a dumb test but it exists to offer some marginal // confidence that a bug in the auto-deletion logic doesn't delete running // workspaces. - t.Run("UnlockedWorkspacesNotDeleted", func(t *testing.T) { + t.Run("ActiveWorkspacesNotDeleted", func(t *testing.T) { t.Parallel() var ( - ticker = make(chan time.Time) - statCh = make(chan autobuild.Stats) - lockedTTL = time.Minute + ticker = make(chan time.Time) + statCh = make(chan autobuild.Stats) + autoDeleteTTL = time.Minute ) client, user := coderdenttest.New(t, &coderdenttest.Options{ @@ -357,16 +357,16 @@ func TestWorkspaceAutobuild(t *testing.T) { ProvisionApply: echo.ProvisionComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.LockedTTLMillis = ptr.Ref[int64](lockedTTL.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](autoDeleteTTL.Milliseconds()) }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) build := coderdtest.AwaitWorkspaceBuildJob(t, client, ws.LatestBuild.ID) - require.Nil(t, ws.LockedAt) + require.Nil(t, ws.DormantAt) require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) - ticker <- ws.LastUsedAt.Add(lockedTTL * 2) + ticker <- ws.LastUsedAt.Add(autoDeleteTTL * 2) stats := <-statCh - // Expect no transitions since workspace is unlocked. + // Expect no transitions since workspace is active. require.Len(t, stats.Transitions, 0) }) @@ -399,7 +399,7 @@ func TestWorkspaceAutobuild(t *testing.T) { ProvisionApply: echo.ProvisionComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.InactivityTTLMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) + ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) @@ -417,13 +417,13 @@ func TestWorkspaceAutobuild(t *testing.T) { // Expect no transitions since workspace is stopped. require.Len(t, stats.Transitions, 0) ws = coderdtest.MustWorkspace(t, client, ws.ID) - // The workspace should still be locked even though we didn't + // The workspace should still be dormant even though we didn't // transition the workspace. - require.NotNil(t, ws.LockedAt) + require.NotNil(t, ws.DormantAt) }) // Test the flow of a workspace transitioning from - // inactive -> locked -> deleted. + // inactive -> dormant -> deleted. t.Run("WorkspaceInactiveDeleteTransition", func(t *testing.T) { t.Parallel() @@ -451,8 +451,8 @@ func TestWorkspaceAutobuild(t *testing.T) { ProvisionApply: echo.ProvisionComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.InactivityTTLMillis = ptr.Ref[int64](transitionTTL.Milliseconds()) - ctr.LockedTTLMillis = ptr.Ref[int64](transitionTTL.Milliseconds()) + ctr.TimeTilDormantMillis = ptr.Ref[int64](transitionTTL.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](transitionTTL.Milliseconds()) }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) @@ -469,14 +469,14 @@ func TestWorkspaceAutobuild(t *testing.T) { require.Equal(t, stats.Transitions[ws.ID], database.WorkspaceTransitionStop) ws = coderdtest.MustWorkspace(t, client, ws.ID) - // The workspace should be locked. - require.NotNil(t, ws.LockedAt) + // The workspace should be dormant. + require.NotNil(t, ws.DormantAt) // Wait for the autobuilder to stop the workspace. _ = coderdtest.AwaitWorkspaceBuildJob(t, client, ws.LatestBuild.ID) - // Simulate the workspace being locked beyond the threshold. - ticker <- ws.LockedAt.Add(2 * transitionTTL) + // Simulate the workspace being dormant beyond the threshold. + ticker <- ws.DormantAt.Add(2 * transitionTTL) stats = <-statCh require.Len(t, stats.Transitions, 1) // The workspace should be scheduled for deletion. @@ -494,13 +494,13 @@ func TestWorkspaceAutobuild(t *testing.T) { require.Equal(t, http.StatusGone, cerr.StatusCode()) }) - t.Run("LockedTTTooEarly", func(t *testing.T) { + t.Run("DormantTTLTooEarly", func(t *testing.T) { t.Parallel() var ( - ticker = make(chan time.Time) - statCh = make(chan autobuild.Stats) - lockedTTL = time.Minute + ticker = make(chan time.Time) + statCh = make(chan autobuild.Stats) + dormantTTL = time.Minute ) client, user := coderdenttest.New(t, &coderdenttest.Options{ @@ -520,7 +520,7 @@ func TestWorkspaceAutobuild(t *testing.T) { ProvisionApply: echo.ProvisionComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.LockedTTLMillis = ptr.Ref[int64](lockedTTL.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](dormantTTL.Milliseconds()) }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) @@ -528,34 +528,34 @@ func TestWorkspaceAutobuild(t *testing.T) { require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) ctx := testutil.Context(t, testutil.WaitMedium) - err := client.UpdateWorkspaceLock(ctx, ws.ID, codersdk.UpdateWorkspaceLock{ - Lock: true, + err := client.UpdateWorkspaceDormancy(ctx, ws.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, }) require.NoError(t, err) ws = coderdtest.MustWorkspace(t, client, ws.ID) - require.NotNil(t, ws.LockedAt) + require.NotNil(t, ws.DormantAt) // Ensure we haven't breached our threshold. - ticker <- ws.LockedAt.Add(-lockedTTL * 2) + ticker <- ws.DormantAt.Add(-dormantTTL * 2) stats := <-statCh // Expect no transitions since not enough time has elapsed. require.Len(t, stats.Transitions, 0) _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - LockedTTLMillis: lockedTTL.Milliseconds(), + TimeTilDormantAutoDeleteMillis: dormantTTL.Milliseconds(), }) require.NoError(t, err) // Simlute the workspace breaching the threshold. - ticker <- ws.LockedAt.Add(lockedTTL * 2) + ticker <- ws.DormantAt.Add(dormantTTL * 2) stats = <-statCh require.Len(t, stats.Transitions, 1) require.Equal(t, database.WorkspaceTransitionDelete, stats.Transitions[ws.ID]) }) - // Assert that a locked workspace does not autostart. - t.Run("LockedNoAutostart", func(t *testing.T) { + // Assert that a dormant workspace does not autostart. + t.Run("DormantNoAutostart", func(t *testing.T) { t.Parallel() var ( @@ -594,7 +594,7 @@ func TestWorkspaceAutobuild(t *testing.T) { coderdtest.AwaitWorkspaceBuildJob(t, client, ws.LatestBuild.ID) coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - // Assert that autostart works when the workspace isn't locked.. + // Assert that autostart works when the workspace isn't dormant.. tickCh <- sched.Next(ws.LatestBuild.CreatedAt) stats := <-statsCh require.NoError(t, stats.Error) @@ -606,9 +606,9 @@ func TestWorkspaceAutobuild(t *testing.T) { coderdtest.AwaitWorkspaceBuildJob(t, client, ws.LatestBuild.ID) // Now that we've validated that the workspace is eligible for autostart - // lets cause it to become locked. + // lets cause it to become dormant. _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - InactivityTTLMillis: inactiveTTL.Milliseconds(), + TimeTilDormantMillis: inactiveTTL.Milliseconds(), }) require.NoError(t, err) @@ -620,12 +620,12 @@ func TestWorkspaceAutobuild(t *testing.T) { require.Contains(t, stats.Transitions, ws.ID) require.Equal(t, database.WorkspaceTransitionStop, stats.Transitions[ws.ID]) - // The workspace should be locked now. + // The workspace should be dormant now. ws = coderdtest.MustWorkspace(t, client, ws.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, ws.LatestBuild.ID) - require.NotNil(t, ws.LockedAt) + require.NotNil(t, ws.DormantAt) - // Assert that autostart is no longer triggered since workspace is locked. + // Assert that autostart is no longer triggered since workspace is dormant. tickCh <- sched.Next(ws.LatestBuild.CreatedAt) stats = <-statsCh require.Len(t, stats.Transitions, 0) @@ -638,7 +638,7 @@ func TestWorkspacesFiltering(t *testing.T) { t.Run("DeletingBy", func(t *testing.T) { t.Parallel() - lockedTTL := 24 * time.Hour + dormantTTL := 24 * time.Hour client, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ @@ -660,10 +660,10 @@ func TestWorkspacesFiltering(t *testing.T) { defer cancel() template, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - LockedTTLMillis: lockedTTL.Milliseconds(), + TimeTilDormantAutoDeleteMillis: dormantTTL.Milliseconds(), }) require.NoError(t, err) - require.Equal(t, lockedTTL.Milliseconds(), template.LockedTTLMillis) + require.Equal(t, dormantTTL.Milliseconds(), template.TimeTilDormantAutoDeleteMillis) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) @@ -671,8 +671,8 @@ func TestWorkspacesFiltering(t *testing.T) { // stop build so workspace is inactive stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) coderdtest.AwaitWorkspaceBuildJob(t, client, stopBuild.ID) - err = client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: true, + err = client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, }) require.NoError(t, err) workspace = coderdtest.MustWorkspace(t, client, workspace.ID) @@ -680,7 +680,7 @@ func TestWorkspacesFiltering(t *testing.T) { res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ // adding a second to time.Now() to give some buffer in case test runs quickly - FilterQuery: fmt.Sprintf("deleting_by:%s", time.Now().Add(time.Second).Add(lockedTTL).Format("2006-01-02")), + FilterQuery: fmt.Sprintf("deleting_by:%s", time.Now().Add(time.Second).Add(dormantTTL).Format("2006-01-02")), }) require.NoError(t, err) require.Len(t, res.Workspaces, 1) @@ -746,7 +746,7 @@ func TestWorkspacesWithoutTemplatePerms(t *testing.T) { func TestWorkspaceLock(t *testing.T) { t.Parallel() - t.Run("TemplateLockedTTL", func(t *testing.T) { + t.Run("TemplateTimeTilDormantAutoDelete", func(t *testing.T) { t.Parallel() var ( client, user = coderdenttest.New(t, &coderdenttest.Options{ @@ -761,13 +761,13 @@ func TestWorkspaceLock(t *testing.T) { }, }) - version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - lockedTTL = time.Minute + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + dormantTTL = time.Minute ) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { - ctr.LockedTTLMillis = ptr.Ref[int64](lockedTTL.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](dormantTTL.Milliseconds()) }) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) @@ -777,30 +777,30 @@ func TestWorkspaceLock(t *testing.T) { defer cancel() lastUsedAt := workspace.LastUsedAt - err := client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: true, + err := client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, }) require.NoError(t, err) workspace = coderdtest.MustWorkspace(t, client, workspace.ID) require.NoError(t, err, "fetch provisioned workspace") require.NotNil(t, workspace.DeletingAt) - require.NotNil(t, workspace.LockedAt) - require.Equal(t, workspace.LockedAt.Add(lockedTTL), *workspace.DeletingAt) - require.WithinRange(t, *workspace.LockedAt, time.Now().Add(-time.Second*10), time.Now()) + require.NotNil(t, workspace.DormantAt) + require.Equal(t, workspace.DormantAt.Add(dormantTTL), *workspace.DeletingAt) + require.WithinRange(t, *workspace.DormantAt, time.Now().Add(-time.Second*10), time.Now()) // Locking a workspace shouldn't update the last_used_at. require.Equal(t, lastUsedAt, workspace.LastUsedAt) workspace = coderdtest.MustWorkspace(t, client, workspace.ID) lastUsedAt = workspace.LastUsedAt - err = client.UpdateWorkspaceLock(ctx, workspace.ID, codersdk.UpdateWorkspaceLock{ - Lock: false, + err = client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: false, }) require.NoError(t, err) workspace, err = client.Workspace(ctx, workspace.ID) require.NoError(t, err, "fetch provisioned workspace") - require.Nil(t, workspace.LockedAt) + require.Nil(t, workspace.DormantAt) // Unlocking a workspace should cause the deleting_at to be unset. require.Nil(t, workspace.DeletingAt) // The last_used_at should get updated when we unlock the workspace. diff --git a/site/src/api/api.ts b/site/src/api/api.ts index eefbcfba275e7..3567e4f977332 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -554,16 +554,16 @@ export const cancelWorkspaceBuild = async ( return response.data } -export const updateWorkspaceLock = async ( +export const updateWorkspaceDormancy = async ( workspaceId: string, - lock: boolean, + dormant: boolean, ): Promise => { - const data: TypesGen.UpdateWorkspaceLock = { - lock: lock, + const data: TypesGen.UpdateWorkspaceDormancy = { + dormant: dormant, } const response = await axios.put( - `/api/v2/workspaces/${workspaceId}/lock`, + `/api/v2/workspaces/${workspaceId}/dormant`, data, ) return response.data diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 39f5401d5ff29..7315caf7ded97 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -200,8 +200,8 @@ export interface CreateTemplateRequest { readonly allow_user_autostart?: boolean readonly allow_user_autostop?: boolean readonly failure_ttl_ms?: number - readonly inactivity_ttl_ms?: number - readonly locked_ttl_ms?: number + readonly dormant_ttl_ms?: number + readonly delete_ttl_ms?: number readonly disable_everyone_group_access: boolean } @@ -917,8 +917,8 @@ export interface Template { readonly allow_user_autostop: boolean readonly allow_user_cancel_workspace_jobs: boolean readonly failure_ttl_ms: number - readonly inactivity_ttl_ms: number - readonly locked_ttl_ms: number + readonly time_til_dormant_ms: number + readonly time_til_dormant_autodelete_ms: number } // From codersdk/templates.go @@ -1152,10 +1152,10 @@ export interface UpdateTemplateMeta { readonly allow_user_autostop?: boolean readonly allow_user_cancel_workspace_jobs?: boolean readonly failure_ttl_ms?: number - readonly inactivity_ttl_ms?: number - readonly locked_ttl_ms?: number + readonly time_til_dormant_ms?: number + readonly time_til_dormant_autodelete_ms?: number readonly update_workspace_last_used_at: boolean - readonly update_workspace_locked_at: boolean + readonly update_workspace_dormant_at: boolean } // From codersdk/users.go @@ -1180,8 +1180,8 @@ export interface UpdateWorkspaceAutostartRequest { } // From codersdk/workspaces.go -export interface UpdateWorkspaceLock { - readonly lock: boolean +export interface UpdateWorkspaceDormancy { + readonly dormant: boolean } // From codersdk/workspaceproxy.go @@ -1310,7 +1310,7 @@ export interface Workspace { readonly ttl_ms?: number readonly last_used_at: string readonly deleting_at?: string - readonly locked_at?: string + readonly dormant_at?: string readonly health: WorkspaceHealth } diff --git a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx index 5a1cfcc80060e..b16437ea62375 100644 --- a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx +++ b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx @@ -160,7 +160,7 @@ export interface ScheduleDialogProps extends ConfirmDialogProps { readonly inactiveWorkspacesToGoDormantInWeek: number readonly dormantWorkspacesToBeDeleted: number readonly dormantWorkspacesToBeDeletedInWeek: number - readonly updateLockedWorkspaces: (confirm: boolean) => void + readonly updateDormantWorkspaces: (confirm: boolean) => void readonly updateInactiveWorkspaces: (confirm: boolean) => void readonly dormantValueChanged: boolean readonly deletionValueChanged: boolean @@ -180,7 +180,7 @@ export const ScheduleDialog: FC> = ({ inactiveWorkspacesToGoDormantInWeek, dormantWorkspacesToBeDeleted, dormantWorkspacesToBeDeletedInWeek, - updateLockedWorkspaces, + updateDormantWorkspaces, updateInactiveWorkspaces, dormantValueChanged, deletionValueChanged, @@ -250,7 +250,7 @@ export const ScheduleDialog: FC> = ({ { - updateLockedWorkspaces(e.target.checked) + updateDormantWorkspaces(e.target.checked) }} /> } diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index 1d2eeb9d4811e..5e8b1345ee898 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -34,7 +34,7 @@ export const actionsByWorkspaceStatus = ( workspace: Workspace, status: WorkspaceStatus, ): WorkspaceAbilities => { - if (workspace.locked_at) { + if (workspace.dormant_at) { return { actions: [ButtonTypesEnum.activate], canCancel: false, diff --git a/site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx b/site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx deleted file mode 100644 index dc09566fc69e9..0000000000000 --- a/site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Workspace } from "api/typesGenerated" -import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider" -import { Pill } from "components/Pill/Pill" -import LockIcon from "@mui/icons-material/Lock" - -export const LockedBadge = ({ - workspace, -}: { - workspace: Workspace -}): JSX.Element | null => { - const experimentEnabled = useIsWorkspaceActionsEnabled() - if (!workspace.locked_at || !experimentEnabled) { - return null - } - - return } text="Locked" type="error" /> -} diff --git a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx index b65f3a5cdc28b..f49ec057d0878 100644 --- a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx +++ b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx @@ -27,8 +27,8 @@ export const DormantWorkspaceBanner = ({ return null } - const hasLockedWorkspaces = workspaces.find( - (workspace) => workspace.locked_at, + const hasDormantWorkspaces = workspaces.find( + (workspace) => workspace.dormant_at, ) const hasDeletionScheduledWorkspaces = workspaces.find( @@ -38,7 +38,7 @@ export const DormantWorkspaceBanner = ({ if ( // Only show this if the experiment is included. !experimentEnabled || - !hasLockedWorkspaces || + !hasDormantWorkspaces || // Banners should be redisplayed after dismissal when additional workspaces are newly scheduled for deletion !shouldRedisplayBanner ) { @@ -59,16 +59,16 @@ export const DormantWorkspaceBanner = ({ if ( hasDeletionScheduledWorkspaces && hasDeletionScheduledWorkspaces.deleting_at && - hasDeletionScheduledWorkspaces.locked_at + hasDeletionScheduledWorkspaces.dormant_at ) { return `This workspace has been dormant for ${formatDistanceToNow( - Date.parse(hasDeletionScheduledWorkspaces.locked_at), + Date.parse(hasDeletionScheduledWorkspaces.dormant_at), )} and is scheduled to be deleted on ${formatDate( hasDeletionScheduledWorkspaces.deleting_at, )} . To keep it you must activate the workspace.` - } else if (hasLockedWorkspaces && hasLockedWorkspaces.locked_at) { + } else if (hasDormantWorkspaces && hasDormantWorkspaces.dormant_at) { return `This workspace has been dormant for ${formatDistanceToNow( - Date.parse(hasLockedWorkspaces.locked_at), + Date.parse(hasDormantWorkspaces.dormant_at), )} and cannot be interacted with. Dormant workspaces are eligible for @@ -88,7 +88,7 @@ export const DormantWorkspaceBanner = ({ There are{" "} workspaces {" "} diff --git a/site/src/components/WorkspaceDeletion/index.ts b/site/src/components/WorkspaceDeletion/index.ts index d58af8e83ac57..a8c14bd12da69 100644 --- a/site/src/components/WorkspaceDeletion/index.ts +++ b/site/src/components/WorkspaceDeletion/index.ts @@ -1,4 +1,3 @@ export * from "./ImpendingDeletionStat" -export * from "./ImpendingDeletionBadge" export * from "./ImpendingDeletionText" export * from "./ImpendingDeletionBanner" diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 4ae79d145baaa..dd4535bc29b89 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -73,7 +73,7 @@ export const TemplateSettingsForm: FC = ({ allow_user_cancel_workspace_jobs: template.allow_user_cancel_workspace_jobs, update_workspace_last_used_at: false, - update_workspace_locked_at: false, + update_workspace_dormant_at: false, }, validationSchema, onSubmit, diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 9f9f0d231f9c4..9c43034905e68 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -31,10 +31,10 @@ const validFormValues: FormValues = { weeks: 1, }, failure_ttl_ms: 0, - inactivity_ttl_ms: 0, - locked_ttl_ms: 0, + time_til_dormant_ms: 0, + time_til_dormant_autodelete_ms: 0, update_workspace_last_used_at: false, - update_workspace_locked_at: false, + update_workspace_dormant_at: false, } const renderTemplateSettingsPage = async () => { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx deleted file mode 100644 index 6128700299e28..0000000000000 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react" -import { InactivityDialog } from "./InactivityDialog" - -const meta: Meta = { - title: "InactivityDialog", - component: InactivityDialog, -} - -export default meta -type Story = StoryObj - -export const OpenDialog: Story = { - args: { - submitValues: () => null, - isInactivityDialogOpen: true, - setIsInactivityDialogOpen: () => null, - workspacesToBeLockedToday: 2, - }, -} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx deleted file mode 100644 index a9407f6d5eab5..0000000000000 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" - -export const InactivityDialog = ({ - submitValues, - isInactivityDialogOpen, - setIsInactivityDialogOpen, - workspacesToBeLockedToday, -}: { - submitValues: () => void - isInactivityDialogOpen: boolean - setIsInactivityDialogOpen: (arg0: boolean) => void - workspacesToBeLockedToday: number -}) => { - return ( - { - submitValues() - setIsInactivityDialogOpen(false) - }} - onClose={() => setIsInactivityDialogOpen(false)} - title="Lock inactive workspaces" - confirmText="Lock Workspaces" - description={`There are ${ - workspacesToBeLockedToday ? workspacesToBeLockedToday : "" - } workspaces that already match this filter and will be locked upon form submission. Are you sure you want to proceed?`} - /> - ) -} - -export const DeleteLockedDialog = ({ - submitValues, - isLockedDialogOpen, - setIsLockedDialogOpen, - workspacesToBeDeletedToday, -}: { - submitValues: () => void - isLockedDialogOpen: boolean - setIsLockedDialogOpen: (arg0: boolean) => void - workspacesToBeDeletedToday: number -}) => { - return ( - { - submitValues() - setIsLockedDialogOpen(false) - }} - onClose={() => setIsLockedDialogOpen(false)} - title="Delete Locked Workspaces" - confirmText="Delete Workspaces" - description={`There are ${ - workspacesToBeDeletedToday ? workspacesToBeDeletedToday : "" - } workspaces that already match this filter and will be deleted upon form submission. Are you sure you want to proceed?`} - /> - ) -} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index 180d81df978b8..e13535d68270b 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -17,7 +17,7 @@ import Checkbox from "@mui/material/Checkbox" import FormControlLabel from "@mui/material/FormControlLabel" import Switch from "@mui/material/Switch" import { - useWorkspacesToBeLocked, + useWorkspacesToGoDormant, useWorkspacesToBeDeleted, } from "./useWorkspacesToBeDeleted" import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers" @@ -29,7 +29,7 @@ const MS_HOUR_CONVERSION = 3600000 const MS_DAY_CONVERSION = 86400000 const FAILURE_CLEANUP_DEFAULT = 7 const INACTIVITY_CLEANUP_DEFAULT = 180 -const LOCKED_CLEANUP_DEFAULT = 30 +const DORMANT_AUTODELETION_DEFAULT = 30 export interface TemplateScheduleForm { template: Template @@ -67,11 +67,11 @@ export const TemplateScheduleForm: FC = ({ failure_ttl_ms: allowAdvancedScheduling ? template.failure_ttl_ms / MS_DAY_CONVERSION : 0, - inactivity_ttl_ms: allowAdvancedScheduling - ? template.inactivity_ttl_ms / MS_DAY_CONVERSION + time_til_dormant_ms: allowAdvancedScheduling + ? template.time_til_dormant_ms / MS_DAY_CONVERSION : 0, - locked_ttl_ms: allowAdvancedScheduling - ? template.locked_ttl_ms / MS_DAY_CONVERSION + time_til_dormant_autodelete_ms: allowAdvancedScheduling + ? template.time_til_dormant_autodelete_ms / MS_DAY_CONVERSION : 0, restart_requirement: { @@ -84,18 +84,21 @@ export const TemplateScheduleForm: FC = ({ failure_cleanup_enabled: allowAdvancedScheduling && Boolean(template.failure_ttl_ms), inactivity_cleanup_enabled: - allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms), - locked_cleanup_enabled: - allowAdvancedScheduling && Boolean(template.locked_ttl_ms), + allowAdvancedScheduling && Boolean(template.time_til_dormant_ms), + dormant_autodeletion_cleanup_enabled: + allowAdvancedScheduling && + Boolean(template.time_til_dormant_autodelete_ms), update_workspace_last_used_at: false, - update_workspace_locked_at: false, + update_workspace_dormant_at: false, }, validationSchema, onSubmit: () => { const dormancyChanged = - form.initialValues.inactivity_ttl_ms !== form.values.inactivity_ttl_ms + form.initialValues.time_til_dormant_ms !== + form.values.time_til_dormant_ms const deletionChanged = - form.initialValues.locked_ttl_ms !== form.values.locked_ttl_ms + form.initialValues.time_til_dormant_autodelete_ms !== + form.values.time_til_dormant_autodelete_ms const dormancyScheduleChanged = form.values.inactivity_cleanup_enabled && @@ -128,13 +131,13 @@ export const TemplateScheduleForm: FC = ({ const weekFromNow = new Date(now) weekFromNow.setDate(now.getDate() + 7) - const workspacesToDormancyNow = useWorkspacesToBeLocked( + const workspacesToDormancyNow = useWorkspacesToGoDormant( template, form.values, now, ) - const workspacesToDormancyInWeek = useWorkspacesToBeLocked( + const workspacesToDormancyInWeek = useWorkspacesToGoDormant( template, form.values, weekFromNow, @@ -175,17 +178,17 @@ export const TemplateScheduleForm: FC = ({ failure_ttl_ms: form.values.failure_ttl_ms ? form.values.failure_ttl_ms * MS_DAY_CONVERSION : undefined, - inactivity_ttl_ms: form.values.inactivity_ttl_ms - ? form.values.inactivity_ttl_ms * MS_DAY_CONVERSION + time_til_dormant_ms: form.values.time_til_dormant_ms + ? form.values.time_til_dormant_ms * MS_DAY_CONVERSION : undefined, - locked_ttl_ms: form.values.locked_ttl_ms - ? form.values.locked_ttl_ms * MS_DAY_CONVERSION + time_til_dormant_autodelete_ms: form.values.time_til_dormant_autodelete_ms + ? form.values.time_til_dormant_autodelete_ms * MS_DAY_CONVERSION : undefined, allow_user_autostart: form.values.allow_user_autostart, allow_user_autostop: form.values.allow_user_autostop, update_workspace_last_used_at: form.values.update_workspace_last_used_at, - update_workspace_locked_at: form.values.update_workspace_locked_at, + update_workspace_dormant_at: form.values.update_workspace_dormant_at, }) } @@ -211,37 +214,37 @@ export const TemplateScheduleForm: FC = ({ const handleToggleInactivityCleanup = async (e: ChangeEvent) => { form.handleChange(e) if (!form.values.inactivity_cleanup_enabled) { - // fill inactivity_ttl_ms with defaults + // fill time_til_dormant_ms with defaults await form.setValues({ ...form.values, inactivity_cleanup_enabled: true, - inactivity_ttl_ms: INACTIVITY_CLEANUP_DEFAULT, + time_til_dormant_ms: INACTIVITY_CLEANUP_DEFAULT, }) } else { - // clear inactivity_ttl_ms + // clear time_til_dormant_ms await form.setValues({ ...form.values, inactivity_cleanup_enabled: false, - inactivity_ttl_ms: 0, + time_til_dormant_ms: 0, }) } } - const handleToggleLockedCleanup = async (e: ChangeEvent) => { + const handleToggleDormantAutoDeletion = async (e: ChangeEvent) => { form.handleChange(e) - if (!form.values.locked_cleanup_enabled) { + if (!form.values.dormant_autodeletion_cleanup_enabled) { // fill failure_ttl_ms with defaults await form.setValues({ ...form.values, - locked_cleanup_enabled: true, - locked_ttl_ms: LOCKED_CLEANUP_DEFAULT, + dormant_autodeletion_cleanup_enabled: true, + time_til_dormant_autodelete_ms: DORMANT_AUTODELETION_DEFAULT, }) } else { // clear failure_ttl_ms await form.setValues({ ...form.values, - locked_cleanup_enabled: false, - locked_ttl_ms: 0, + dormant_autodeletion_cleanup_enabled: false, + time_til_dormant_autodelete_ms: 0, }) } } @@ -398,10 +401,10 @@ export const TemplateScheduleForm: FC = ({ /> , )} disabled={ @@ -423,21 +426,24 @@ export const TemplateScheduleForm: FC = ({ control={ } label="Enable Dormancy Auto-Deletion" /> , )} - disabled={isSubmitting || !form.values.locked_cleanup_enabled} + disabled={ + isSubmitting || + !form.values.dormant_autodeletion_cleanup_enabled + } fullWidth inputProps={{ min: 0, step: "any" }} label="Time until deletion (days)" @@ -455,7 +461,7 @@ export const TemplateScheduleForm: FC = ({ // These fields are request-scoped so they should be reset // after every submission. form - .setFieldValue("update_workspace_locked_at", false) + .setFieldValue("update_workspace_dormant_at", false) .catch((error) => { throw error }) @@ -478,18 +484,19 @@ export const TemplateScheduleForm: FC = ({ setIsScheduleDialogOpen(false) }} title="Workspace Scheduling" - updateLockedWorkspaces={(update: boolean) => - form.setFieldValue("update_workspace_locked_at", update) + updateDormantWorkspaces={(update: boolean) => + form.setFieldValue("update_workspace_dormant_at", update) } updateInactiveWorkspaces={(update: boolean) => form.setFieldValue("update_workspace_last_used_at", update) } dormantValueChanged={ - form.initialValues.inactivity_ttl_ms !== - form.values.inactivity_ttl_ms + form.initialValues.time_til_dormant_ms !== + form.values.time_til_dormant_ms } deletionValueChanged={ - form.initialValues.locked_ttl_ms !== form.values.locked_ttl_ms + form.initialValues.time_til_dormant_autodelete_ms !== + form.values.time_til_dormant_autodelete_ms } /> )} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx index d36fcd85b021c..8480b933c44da 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx @@ -5,7 +5,7 @@ import i18next from "i18next" export interface TemplateScheduleFormValues extends UpdateTemplateMeta { failure_cleanup_enabled: boolean inactivity_cleanup_enabled: boolean - locked_cleanup_enabled: boolean + dormant_autodeletion_cleanup_enabled: boolean } const MAX_TTL_DAYS = 30 @@ -50,7 +50,7 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => } }, ), - inactivity_ttl_ms: Yup.number() + time_til_dormant_ms: Yup.number() .min(0, "Dormancy threshold days must not be less than 0.") .test( "positive-if-enabled", @@ -64,14 +64,14 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => } }, ), - locked_ttl_ms: Yup.number() + time_til_dormant_autodelete_ms: Yup.number() .min(0, "Dormancy auto-deletion days must not be less than 0.") .test( "positive-if-enabled", "Dormancy auto-deletion days must be greater than zero when enabled.", function (value) { const parent = this.parent as TemplateScheduleFormValues - if (parent.locked_cleanup_enabled) { + if (parent.dormant_autodeletion_cleanup_enabled) { return Boolean(value) } else { return true diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts index 346d371f02951..b04cf02e7d618 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts @@ -3,7 +3,7 @@ import { Workspace, Template } from "api/typesGenerated" import { TemplateScheduleFormValues } from "./formHelpers" import { useWorkspacesData } from "pages/WorkspacesPage/data" -export const useWorkspacesToBeLocked = ( +export const useWorkspacesToGoDormant = ( template: Template, formValues: TemplateScheduleFormValues, fromDate: Date, @@ -15,17 +15,17 @@ export const useWorkspacesToBeLocked = ( }) return data?.workspaces?.filter((workspace: Workspace) => { - if (!formValues.inactivity_ttl_ms) { + if (!formValues.time_til_dormant_ms) { return } - if (workspace.locked_at) { + if (workspace.dormant_at) { return } const proposedLocking = new Date( new Date(workspace.last_used_at).getTime() + - formValues.inactivity_ttl_ms * DayInMS, + formValues.time_til_dormant_ms * DayInMS, ) if (compareAsc(proposedLocking, fromDate) < 1) { @@ -44,16 +44,16 @@ export const useWorkspacesToBeDeleted = ( const { data } = useWorkspacesData({ page: 0, limit: 0, - query: "template:" + template.name + " locked_at:1970-01-01", + query: "template:" + template.name + " dormant_at:1970-01-01", }) return data?.workspaces?.filter((workspace: Workspace) => { - if (!workspace.locked_at || !formValues.locked_ttl_ms) { + if (!workspace.dormant_at || !formValues.time_til_dormant_autodelete_ms) { return false } const proposedLocking = new Date( - new Date(workspace.locked_at).getTime() + - formValues.locked_ttl_ms * DayInMS, + new Date(workspace.dormant_at).getTime() + + formValues.time_til_dormant_autodelete_ms * DayInMS, ) if (compareAsc(proposedLocking, fromDate) < 1) { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index dd03610717829..476d5085d0f4f 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -21,10 +21,10 @@ const validFormValues = { default_ttl_ms: 1, max_ttl_ms: 2, failure_ttl_ms: 7, - inactivity_ttl_ms: 180, - locked_ttl_ms: 30, + time_til_dormant_ms: 180, + time_til_dormant_autodelete_ms: 30, update_workspace_last_used_at: false, - update_workspace_locked_at: false, + update_workspace_dormant_at: false, } const renderTemplateSchedulePage = async () => { @@ -39,14 +39,14 @@ const fillAndSubmitForm = async ({ default_ttl_ms, max_ttl_ms, failure_ttl_ms, - inactivity_ttl_ms, - locked_ttl_ms, + time_til_dormant_ms, + time_til_dormant_autodelete_ms, }: { default_ttl_ms: number max_ttl_ms: number failure_ttl_ms: number - inactivity_ttl_ms: number - locked_ttl_ms: number + time_til_dormant_ms: number + time_til_dormant_autodelete_ms: number }) => { const user = userEvent.setup() const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" }) @@ -67,19 +67,22 @@ const fillAndSubmitForm = async ({ const inactivityTtlField = screen.getByRole("checkbox", { name: /Dormancy Threshold/i, }) - await user.type(inactivityTtlField, inactivity_ttl_ms.toString()) + await user.type(inactivityTtlField, time_til_dormant_ms.toString()) - const lockedTtlField = screen.getByRole("checkbox", { + const dormancyAutoDeletionField = screen.getByRole("checkbox", { name: /Dormancy Auto-Deletion/i, }) - await user.type(lockedTtlField, locked_ttl_ms.toString()) + await user.type( + dormancyAutoDeletionField, + time_til_dormant_autodelete_ms.toString(), + ) const submitButton = await screen.findByText( FooterFormLanguage.defaultSubmitLabel, ) await user.click(submitButton) - // User needs to confirm inactivity and locked ttl + // User needs to confirm dormancy and autodeletion fields. const confirmButton = await screen.findByTestId("confirm-button") await user.click(confirmButton) } @@ -140,8 +143,9 @@ describe("TemplateSchedulePage", () => { "test-template", expect.objectContaining({ failure_ttl_ms: validFormValues.failure_ttl_ms * 86400000, - inactivity_ttl_ms: validFormValues.inactivity_ttl_ms * 86400000, - locked_ttl_ms: validFormValues.locked_ttl_ms * 86400000, + time_til_dormant_ms: validFormValues.time_til_dormant_ms * 86400000, + time_til_dormant_autodelete_ms: + validFormValues.time_til_dormant_autodelete_ms * 86400000, }), ), ) @@ -217,7 +221,7 @@ describe("TemplateSchedulePage", () => { it("allows an inactivity ttl of 7 days", () => { const values: UpdateTemplateMeta = { ...validFormValues, - inactivity_ttl_ms: 86400000 * 7, + time_til_dormant_ms: 86400000 * 7, } const validate = () => getValidationSchema().validateSync(values) expect(validate).not.toThrowError() @@ -226,7 +230,7 @@ describe("TemplateSchedulePage", () => { it("allows an inactivity ttl of 0", () => { const values: UpdateTemplateMeta = { ...validFormValues, - inactivity_ttl_ms: 0, + time_til_dormant_ms: 0, } const validate = () => getValidationSchema().validateSync(values) expect(validate).not.toThrowError() @@ -235,7 +239,7 @@ describe("TemplateSchedulePage", () => { it("disallows a negative inactivity ttl", () => { const values: UpdateTemplateMeta = { ...validFormValues, - inactivity_ttl_ms: -1, + time_til_dormant_ms: -1, } const validate = () => getValidationSchema().validateSync(values) expect(validate).toThrowError( @@ -246,7 +250,7 @@ describe("TemplateSchedulePage", () => { it("allows a dormancy ttl of 7 days", () => { const values: UpdateTemplateMeta = { ...validFormValues, - locked_ttl_ms: 86400000 * 7, + time_til_dormant_autodelete_ms: 86400000 * 7, } const validate = () => getValidationSchema().validateSync(values) expect(validate).not.toThrowError() @@ -255,7 +259,7 @@ describe("TemplateSchedulePage", () => { it("allows a dormancy ttl of 0", () => { const values: UpdateTemplateMeta = { ...validFormValues, - locked_ttl_ms: 0, + time_til_dormant_autodelete_ms: 0, } const validate = () => getValidationSchema().validateSync(values) expect(validate).not.toThrowError() @@ -264,7 +268,7 @@ describe("TemplateSchedulePage", () => { it("disallows a negative inactivity ttl", () => { const values: UpdateTemplateMeta = { ...validFormValues, - locked_ttl_ms: -1, + time_til_dormant_autodelete_ms: -1, } const validate = () => getValidationSchema().validateSync(values) expect(validate).toThrowError( diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index ed810e36769b3..9684e156c8ce7 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -23,7 +23,7 @@ import { displayError } from "components/GlobalSnackbar/utils" import { getErrorMessage } from "api/errors" const WorkspacesPage: FC = () => { - const [lockedWorkspaces, setLockedWorkspaces] = useState([]) + const [dormantWorkspaces, setDormantWorkspaces] = useState([]) // If we use a useSearchParams for each hook, the values will not be in sync. // So we have to use a single one, centralizing the values, and pass it to // each hook. @@ -36,23 +36,23 @@ const WorkspacesPage: FC = () => { }) const experimentEnabled = useIsWorkspaceActionsEnabled() - // If workspace actions are enabled we need to fetch the locked + // If workspace actions are enabled we need to fetch the dormant // workspaces as well. This lets us determine whether we should // show a banner to the user indicating that some of their workspaces // are at risk of being deleted. useEffect(() => { if (experimentEnabled) { - const includesLocked = filterProps.filter.query.includes("locked_at") - const lockedQuery = includesLocked + const includesDormant = filterProps.filter.query.includes("dormant_at") + const dormantQuery = includesDormant ? filterProps.filter.query - : filterProps.filter.query + " locked_at:1970-01-01" + : filterProps.filter.query + " dormant_at:1970-01-01" - if (includesLocked && data) { - setLockedWorkspaces(data.workspaces) + if (includesDormant && data) { + setDormantWorkspaces(data.workspaces) } else { - getWorkspaces({ q: lockedQuery }) + getWorkspaces({ q: dormantQuery }) .then((resp) => { - setLockedWorkspaces(resp.workspaces) + setDormantWorkspaces(resp.workspaces) }) .catch(() => { // TODO @@ -60,8 +60,8 @@ const WorkspacesPage: FC = () => { } } else { // If the experiment isn't included then we'll pretend - // like locked workspaces don't exist. - setLockedWorkspaces([]) + // like dormant workspaces don't exist. + setDormantWorkspaces([]) } }, [experimentEnabled, data, filterProps.filter.query]) const updateWorkspace = useWorkspaceUpdate(queryKey) @@ -90,7 +90,7 @@ const WorkspacesPage: FC = () => { checkedWorkspaces={checkedWorkspaces} onCheckChange={setCheckedWorkspaces} workspaces={data?.workspaces} - lockedWorkspaces={lockedWorkspaces} + dormantWorkspaces={dormantWorkspaces} error={error} count={data?.count} page={pagination.page} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 9190db98c9575..cb3a8cd2f2e62 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -38,7 +38,7 @@ export const Language = { export interface WorkspacesPageViewProps { error: unknown workspaces?: Workspace[] - lockedWorkspaces?: Workspace[] + dormantWorkspaces?: Workspace[] checkedWorkspaces: Workspace[] count?: number filterProps: ComponentProps @@ -55,7 +55,7 @@ export const WorkspacesPageView: FC< React.PropsWithChildren > = ({ workspaces, - lockedWorkspaces, + dormantWorkspaces, error, limit, count, @@ -70,12 +70,12 @@ export const WorkspacesPageView: FC< }) => { const { saveLocal } = useLocalStorage() - const workspacesDeletionScheduled = lockedWorkspaces + const workspacesDeletionScheduled = dormantWorkspaces ?.filter((workspace) => workspace.deleting_at) .map((workspace) => workspace.id) - const hasLockedWorkspace = - lockedWorkspaces !== undefined && lockedWorkspaces.length > 0 + const hasDormantWorkspace = + dormantWorkspaces !== undefined && dormantWorkspaces.length > 0 return ( @@ -102,8 +102,8 @@ export const WorkspacesPageView: FC< {/* determines its own visibility */} saveLocal( "dismissedWorkspaceList", diff --git a/site/src/pages/WorkspacesPage/filter/filter.tsx b/site/src/pages/WorkspacesPage/filter/filter.tsx index 8d98c0b03182f..2a1b519a0ceec 100644 --- a/site/src/pages/WorkspacesPage/filter/filter.tsx +++ b/site/src/pages/WorkspacesPage/filter/filter.tsx @@ -47,8 +47,8 @@ export const WorkspacesFilter = ({ const presets = [...PRESET_FILTERS] if (useIsWorkspaceActionsEnabled()) { presets.push({ - query: workspaceFilterQuery.locked, - name: "Locked workspaces", + query: workspaceFilterQuery.dormant, + name: "Dormant workspaces", }) } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 2a77a174e8831..2e3438c1fe293 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -445,8 +445,8 @@ export const MockTemplate: TypesGen.Template = { icon: "/icon/code.svg", allow_user_cancel_workspace_jobs: true, failure_ttl_ms: 0, - inactivity_ttl_ms: 0, - locked_ttl_ms: 0, + time_til_dormant_ms: 0, + time_til_dormant_autodelete_ms: 0, allow_user_autostart: false, allow_user_autostop: false, } diff --git a/site/src/utils/filters.ts b/site/src/utils/filters.ts index 27f32130aafe6..8f477278b578c 100644 --- a/site/src/utils/filters.ts +++ b/site/src/utils/filters.ts @@ -14,7 +14,7 @@ export const workspaceFilterQuery = { all: "", running: "status:running", failed: "status:failed", - locked: "locked_at:1970-01-01", + dormant: "dormant_at:1970-01-01", } export const userFilterQuery = { diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index d3e9f7204e25c..1706d1af7304f 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -697,7 +697,7 @@ export const workspaceMachine = createMachine( }, activateWorkspace: (context) => async (send) => { if (context.workspace) { - const activateWorkspacePromise = await API.updateWorkspaceLock( + const activateWorkspacePromise = await API.updateWorkspaceDormancy( context.workspace.id, false, ) From 630ec55c48acf18af80d9aef3e7686960ffda08e Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Thu, 24 Aug 2023 15:18:42 -0500 Subject: [PATCH 239/277] fix(coderd): remove rate limits from agent metadata (#9308) Include the full update message in the PubSub notification so that we don't have to refresh metadata from the DB and can avoid rate limiting. --- coderd/workspaceagents.go | 174 +++++++++++++++++---------------- coderd/workspaceagents_test.go | 2 +- 2 files changed, 93 insertions(+), 83 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 3f90abb3a4b9b..c127b2342d4a3 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -6,7 +6,6 @@ import ( "database/sql" "encoding/json" "errors" - "flag" "fmt" "io" "net" @@ -23,10 +22,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" - "golang.org/x/exp/slices" + "golang.org/x/exp/maps" "golang.org/x/mod/semver" "golang.org/x/sync/errgroup" - "golang.org/x/time/rate" "golang.org/x/xerrors" "nhooyr.io/websocket" "tailscale.com/tailcfg" @@ -39,7 +37,6 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/ptr" - "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/tailnet" @@ -1528,7 +1525,11 @@ func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Reque key := chi.URLParam(r, "key") const ( - maxValueLen = 32 << 10 + // maxValueLen is set to 2048 to stay under the 8000 byte Postgres + // NOTIFY limit. Since both value and error can be set, the real + // payload limit is 2 * 2048 * 4/3 = 5461 bytes + a few hundred bytes for JSON + // syntax, key names, and metadata. + maxValueLen = 2048 maxErrorLen = maxValueLen ) @@ -1571,7 +1572,13 @@ func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Reque slog.F("value", ellipse(datum.Value, 16)), ) - err = api.Pubsub.Publish(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), []byte(datum.Key)) + datumJSON, err := json.Marshal(datum) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + err = api.Pubsub.Publish(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), datumJSON) if err != nil { httpapi.InternalServerError(rw, err) return @@ -1597,7 +1604,42 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ ) ) - sendEvent, senderClosed, err := httpapi.ServerSentEventSender(rw, r) + // We avoid channel-based synchronization here to avoid backpressure problems. + var ( + metadataMapMu sync.Mutex + metadataMap = make(map[string]database.WorkspaceAgentMetadatum) + // pendingChanges must only be mutated when metadataMapMu is held. + pendingChanges atomic.Bool + ) + + // Send metadata on updates, we must ensure subscription before sending + // initial metadata to guarantee that events in-between are not missed. + cancelSub, err := api.Pubsub.Subscribe(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), func(_ context.Context, byt []byte) { + var update database.UpdateWorkspaceAgentMetadataParams + err := json.Unmarshal(byt, &update) + if err != nil { + api.Logger.Error(ctx, "failed to unmarshal pubsub message", slog.Error(err)) + return + } + + log.Debug(ctx, "received metadata update", "key", update.Key) + + metadataMapMu.Lock() + defer metadataMapMu.Unlock() + md := metadataMap[update.Key] + md.Value = update.Value + md.Error = update.Error + md.CollectedAt = update.CollectedAt + metadataMap[update.Key] = md + pendingChanges.Store(true) + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + defer cancelSub() + + sseSendEvent, sseSenderClosed, err := httpapi.ServerSentEventSender(rw, r) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error setting up server-sent events.", @@ -1607,97 +1649,61 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ } // Prevent handler from returning until the sender is closed. defer func() { - <-senderClosed + <-sseSenderClosed }() - const refreshInterval = time.Second * 5 - refreshTicker := time.NewTicker(refreshInterval) - defer refreshTicker.Stop() + // We send updates exactly every second. + const sendInterval = time.Second * 1 + sendTicker := time.NewTicker(sendInterval) + defer sendTicker.Stop() - var ( - lastDBMetaMu sync.Mutex - lastDBMeta []database.WorkspaceAgentMetadatum - ) + // We always use the original Request context because it contains + // the RBAC actor. + md, err := api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID) + if err != nil { + // If we can't successfully pull the initial metadata, pubsub + // updates will be no-op so we may as well terminate the + // connection early. + httpapi.InternalServerError(rw, err) + return + } - sendMetadata := func(pull bool) { - log.Debug(ctx, "sending metadata update", "pull", pull) - lastDBMetaMu.Lock() - defer lastDBMetaMu.Unlock() + metadataMapMu.Lock() + for _, datum := range md { + metadataMap[datum.Key] = datum + } + metadataMapMu.Unlock() - var err error - if pull { - // We always use the original Request context because it contains - // the RBAC actor. - lastDBMeta, err = api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID) - if err != nil { - _ = sendEvent(ctx, codersdk.ServerSentEvent{ - Type: codersdk.ServerSentEventTypeError, - Data: codersdk.Response{ - Message: "Internal error getting metadata.", - Detail: err.Error(), - }, - }) - return - } - slices.SortFunc(lastDBMeta, func(a, b database.WorkspaceAgentMetadatum) int { - return slice.Ascending(a.Key, b.Key) - }) + // Send initial metadata. - // Avoid sending refresh if the client is about to get a - // fresh update. - refreshTicker.Reset(refreshInterval) - } + var lastSend time.Time + sendMetadata := func() { + metadataMapMu.Lock() + values := maps.Values(metadataMap) + pendingChanges.Store(false) + metadataMapMu.Unlock() - _ = sendEvent(ctx, codersdk.ServerSentEvent{ + lastSend = time.Now() + _ = sseSendEvent(ctx, codersdk.ServerSentEvent{ Type: codersdk.ServerSentEventTypeData, - Data: convertWorkspaceAgentMetadata(lastDBMeta), + Data: convertWorkspaceAgentMetadata(values), }) } - // Note: we previously used a debounce here, but when the rate of metadata updates was too - // high the debounce would never fire. - // - // The rate-limit has its own caveat. If the agent sends a burst of metadata - // but then goes quiet, we will never pull the new metadata and the frontend - // will go stale until refresh. This would only happen if the agent was - // under extreme load. Under normal operations, the interval between metadata - // updates is constant so there is no burst phenomenon. - pubsubRatelimit := rate.NewLimiter(rate.Every(time.Second), 2) - if flag.Lookup("test.v") != nil { - // We essentially disable the rate-limit in tests for determinism. - pubsubRatelimit = rate.NewLimiter(rate.Every(time.Second*100), 100) - } - - // Send metadata on updates, we must ensure subscription before sending - // initial metadata to guarantee that events in-between are not missed. - cancelSub, err := api.Pubsub.Subscribe(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), func(_ context.Context, _ []byte) { - allow := pubsubRatelimit.Allow() - log.Debug(ctx, "received metadata update", "allow", allow) - if allow { - sendMetadata(true) - } - }) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } - defer cancelSub() - - // Send initial metadata. - sendMetadata(true) + sendMetadata() for { select { - case <-senderClosed: + case <-sendTicker.C: + // We send an update even if there's no change every 5 seconds + // to ensure that the frontend always has an accurate "Result.Age". + if !pendingChanges.Load() && time.Since(lastSend) < time.Second*5 { + continue + } + sendMetadata() + case <-sseSenderClosed: return - case <-refreshTicker.C: } - - // Avoid spamming the DB with reads we know there are no updates. We want - // to continue sending updates to the frontend so that "Result.Age" - // is always accurate. This way, the frontend doesn't need to - // sync its own clock with the backend. - sendMetadata(false) } } @@ -1721,6 +1727,10 @@ func convertWorkspaceAgentMetadata(db []database.WorkspaceAgentMetadatum) []code }, }) } + // Sorting prevents the metadata from jumping around in the frontend. + sort.Slice(result, func(i, j int) bool { + return result[i].Description.Key < result[j].Description.Key + }) return result } diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index d1b379d3d74e7..48a399cee3db5 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1153,7 +1153,7 @@ func TestWorkspaceAgent_Metadata(t *testing.T) { require.Len(t, update, 3) check(wantMetadata1, update[0], true) - const maxValueLen = 32 << 10 + const maxValueLen = 2048 tooLongValueMetadata := wantMetadata1 tooLongValueMetadata.Value = strings.Repeat("a", maxValueLen*2) tooLongValueMetadata.Error = "" From 7ddb216d871d16f15f2b5b4394069e578137d30c Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 24 Aug 2023 21:26:30 -0500 Subject: [PATCH 240/277] chore: revert nix-related CI changes (#9321) * chore: revert nix-related CI changes - Reverts using nix to run CI-dependencies. - Running 'make gen' in a dogfood workspace resulted in inconsistent results for protobuf-related files making it difficult to pass CI. This PR imports the minimum changes necessary to make CI compatible with dogfood. --- .github/workflows/ci.yaml | 53 +++++++++++++++++---- provisionerd/proto/provisionerd.pb.go | 4 +- provisionerd/proto/provisionerd_drpc.pb.go | 2 +- provisionersdk/proto/provisioner.pb.go | 4 +- provisionersdk/proto/provisioner_drpc.pb.go | 10 +++- 5 files changed, 57 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6e02e3fd63fd7..66bf59853bb97 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -169,14 +169,35 @@ jobs: with: fetch-depth: 1 - - name: Install Nix - uses: DeterminateSystems/nix-installer-action@v4 + - name: Setup Node + uses: ./.github/actions/setup-node + + - name: Setup Go + uses: ./.github/actions/setup-go - - name: Run the Magic Nix Cache - uses: DeterminateSystems/magic-nix-cache-action@v2 + - name: Setup sqlc + uses: ./.github/actions/setup-sqlc + + - name: go install tools + run: | + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 + go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.33 + go install golang.org/x/tools/cmd/goimports@latest + go install github.com/mikefarah/yq/v4@v4.30.6 + go install github.com/golang/mock/mockgen@v1.6.0 + + - name: Install Protoc + run: | + mkdir -p /tmp/proto + pushd /tmp/proto + curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.3/protoc-23.3-linux-x86_64.zip + unzip protoc.zip + cp -r ./bin/* /usr/local/bin + cp -r ./include /usr/local/bin/include + popd - name: make gen - run: "nix-shell --command 'make --output-sync -j -B gen'" + run: "make --output-sync -j -B gen" - name: Check for unstaged files run: ./scripts/check_unstaged.sh @@ -508,15 +529,27 @@ jobs: - name: Setup Terraform uses: ./.github/actions/setup-tf - - name: Install Nix - uses: DeterminateSystems/nix-installer-action@v4 + - name: go install tools + run: | + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30 + go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.33 + go install golang.org/x/tools/cmd/goimports@latest + go install github.com/mikefarah/yq/v4@v4.30.6 + go install github.com/golang/mock/mockgen@v1.6.0 - - name: Run the Magic Nix Cache - uses: DeterminateSystems/magic-nix-cache-action@v2 + - name: Install Protoc + run: | + mkdir -p /tmp/proto + pushd /tmp/proto + curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v23.3/protoc-23.3-linux-x86_64.zip + unzip protoc.zip + cp -r ./bin/* /usr/local/bin + cp -r ./include /usr/local/bin/include + popd - name: Build run: | - nix-shell --command 'make -B site/out/index.html' + make -B site/out/index.html - run: pnpm playwright:install working-directory: site diff --git a/provisionerd/proto/provisionerd.pb.go b/provisionerd/proto/provisionerd.pb.go index 7d949b9c9c2f0..29a1e7dc505a9 100644 --- a/provisionerd/proto/provisionerd.pb.go +++ b/provisionerd/proto/provisionerd.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.31.0 -// protoc v3.21.12 +// protoc-gen-go v1.30.0 +// protoc v4.23.3 // source: provisionerd/proto/provisionerd.proto package proto diff --git a/provisionerd/proto/provisionerd_drpc.pb.go b/provisionerd/proto/provisionerd_drpc.pb.go index 058af595809b8..ed3155fb21eaa 100644 --- a/provisionerd/proto/provisionerd_drpc.pb.go +++ b/provisionerd/proto/provisionerd_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: (devel) +// protoc-gen-go-drpc version: v0.0.33 // source: provisionerd/proto/provisionerd.proto package proto diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index c334ad13a5ac9..f39e9731e6101 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.31.0 -// protoc v3.21.12 +// protoc-gen-go v1.30.0 +// protoc v4.23.3 // source: provisionersdk/proto/provisioner.proto package proto diff --git a/provisionersdk/proto/provisioner_drpc.pb.go b/provisionersdk/proto/provisioner_drpc.pb.go index d307402447c78..d8b40060cd376 100644 --- a/provisionersdk/proto/provisioner_drpc.pb.go +++ b/provisionersdk/proto/provisioner_drpc.pb.go @@ -1,5 +1,5 @@ // Code generated by protoc-gen-go-drpc. DO NOT EDIT. -// protoc-gen-go-drpc version: (devel) +// protoc-gen-go-drpc version: v0.0.33 // source: provisionersdk/proto/provisioner.proto package proto @@ -76,6 +76,10 @@ type drpcProvisioner_ParseClient struct { drpc.Stream } +func (x *drpcProvisioner_ParseClient) GetStream() drpc.Stream { + return x.Stream +} + func (x *drpcProvisioner_ParseClient) Recv() (*Parse_Response, error) { m := new(Parse_Response) if err := x.MsgRecv(m, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}); err != nil { @@ -107,6 +111,10 @@ type drpcProvisioner_ProvisionClient struct { drpc.Stream } +func (x *drpcProvisioner_ProvisionClient) GetStream() drpc.Stream { + return x.Stream +} + func (x *drpcProvisioner_ProvisionClient) Send(m *Provision_Request) error { return x.MsgSend(m, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}) } From 4bed4920129545e362ae2908c6aefd3671e6710c Mon Sep 17 00:00:00 2001 From: sharkymark Date: Thu, 24 Aug 2023 22:23:59 -0500 Subject: [PATCH 241/277] docs: ui option for adding licenses (#9322) --- docs/enterprise.md | 15 ++++++++++++--- docs/images/add-license-ui.png | Bin 0 -> 57668 bytes package.json | 3 +++ pnpm-lock.yaml | 11 +++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 docs/images/add-license-ui.png diff --git a/docs/enterprise.md b/docs/enterprise.md index f2a84adf86c20..60b4af4be19e0 100644 --- a/docs/enterprise.md +++ b/docs/enterprise.md @@ -20,11 +20,20 @@ paid license. [Contact Sales](https://coder.com/contact) for pricing or | Deployment | [Isolated Terraform Runners](./admin/provisioners.md) | ❌ | ✅ | | Deployment | [Workspace Proxies](./admin/workspace-proxies.md) | ❌ | ✅ | -> Previous plans to restrict OIDC and Git Auth features in OSS have been removed -> as of 2023-01-11 - ## Adding your license key +There are two ways to add an enterprise license to a Coder deployment: In the +Coder UI or with the Coder CLI. + +### Coder UI + +Click Deployment, Licenses, Add a license then drag or select the license file +with the `jwt` extension. + +![Add License UI](./images/add-license-ui.png) + +### Coder CLI + ### Requirements - Your license key diff --git a/docs/images/add-license-ui.png b/docs/images/add-license-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..03ff419d15a59d9b000587ad83cebbc58e2955a7 GIT binary patch literal 57668 zcmeFZWmr^e+dm8lC?cYyG?LQYskAVFq%_hn^bih1gCHW^LrOOSGju7PLwBfjOT$qA zaqoLS_kOnf!~605@NgUhYu38zjO$vzvj|gHRlvJXejg1D4Npl?Rs#(U(;W>BgZv&g z>Q4DLmt-`w`&G6wGU`e)GPLThPFA+|mS|{-VF^ihwKO*gy4M$vqaV?tqqBSxdM=8d zi}?a4ibBiBPqs?<`|4qz`m$lCM1~2HKq*`gYa8ZT@KMm35&Te>YN3h$_YfyRKbO!TUpn_{_rdW*Dg1&mggp1fq? zkzxOy%XA>t9Lwy=`TdxDo!@)S5V9zm$gs%c?$;FlDunk|Y|;2N2M|M~`ENw|sk=@! zVq`z?oKiJaSA%4qn1jC%gvY!Q&qXL*gsaXj@oUCIF`h$X74~@TEixE%6B&~){UjrA z9cJ`Cj8LvkXXo_?(N|KH;MS+65Efh5_Qn5vmH0knIaI{xF(qH$SHs(mS0p%ZKlN4X zX?~R#(I)88-C8-wQP4}HcqGtwn5Jl1u2nDP8IN^X+4N!HNuHF^fbJG)u0nT&YYT%% z-?Mcq`FD|>=WN%ma=BH$mHa(U>`qySdo%TZ^%cPt;;Bj0RHaRgrlr1>@nZQY6cwca zhBd5@ogL4XEy2EhhE=8aESrSc6gLrrQ5qS8?Z!Qo)sOqElb_xTdt&+RIa=@=8o>^K zLgzMg1M&C$ga|S(g)dSHg1Vea$_R5%B`0C^g~q1^%$l6$9KtqrL}U|z6MZP;@rWqT zTK^ZX_ea#*ad7jNcwYZ5I`)%mp3F@WH2J5}GU4h^5i|#QaKcCa_aB%BJbdDh^E!}2 zIFRjaAeWBIy6@T*uGFqs|nkaNsouoJlnnU>NVV|7Lh>e>lG-T)n?(hpY zV*LP}oXcVCV}38P<_M2$u3>b_Z(7d!!t`kzkwHh@@0JN%iiAZRpO!z-jV$S!_zra` zt4pm@omZY`7JrQBs6ME9tLDuuiOto4_{!B$(V_d}>>I^T>jR+!qjTRYA&m4ah0jml zJ~|J6`-1$9;HUTMs?&19FN78902@!6my4;Q z8ts%~k794_m@T>q)(PwhisFhVL?L8B{mEaRna1-y zUWix7H$gStK2bfPTfDE`T6m)pl>4Uqan{q(XXDeKT;y|7!_1-F*C{X7c-K_dM6B?` zd#X$i^vFsHCVh1%N=!6m#`O4fK#^7nYc7MT9BGO5BqnUiK=|bIGBN$k?#-y>NWv%s zo^r?e2p1YyH}KLf93BqehC2cMCf{shZdh!UPS}ovbHk_Llj+4`2A<)2A`3~&ddm{s zxZR{tQo=Va)Wz0iq(wq%PN9p$&FDUNu4#QzZsf1|kX(4M2i|zR5BeUcJ~<}i#cRf6 z46zJh#>=c0m@)JqX$3z;b-X-5P2JojR3%l%gOolD(80}Q=c+N^W4?Ev;dA&8f^RsJY_SMGg)wlg&dqf+s zo}%7Ocy&beG&uaoh%~?7B59SAgVV%l!Em+Qz1+t!-?7&*bzW4MT@}-saXnD%%L2kD z=91@3jk=r~Onq14<`{M|a6G?#VISMcaFClG*UTu- zq?$LtTdt+i8&a<`|6osYk4*|9RloGO75|3%2KP$sz`2Rmn5Tk6nx2*^i}#yLmW!#r zse`FswDNFMmgbk>Z^Pff}>ysfF21He;C&kIqDS5#Q zE1{YL`HBVnAu_^J-bUv+_rl-nJ#JNHREXTUSBq zedWhj9k32wvp6%LnN-KYkC)4$%gkL|?_1NdQfZZJUNSItG3~viOeW!WxQ1mgB;{u3 zij8sPBT{@_LiRqEl6712THUvze#ykp&A1P^p}R+_g^R6m&XTHue0TE|?<*;t$*Yt? zwH-!I;q?RKt{$$Z zuZlwWHN6D8X{}XN8wccd7`D?^d4SiYzTiS{UCvlBiCUJZcX$fAM2R10h>R{4FSIXc zthY_Yn~k6IHy}4=_OZvwv6e`{wgOC z9z?RQ_rfT)eq~GJGA~bgi6h)#(y-8^uI}??XcmoKxK{Xz996$WlGF9$+nkl|8e&y8 z!SZt*cGeaxGZPKtL(4qp)x1nf-{SMl%i?iuhY}-QCp{TMdLxi^8DdrAHScMo~yXL?O!dl@%%obu_F#P`311-DPbpMgqK2b?lL!^eDhWlK`o*FG7=9aXq4QiW>OIlXYDAg(7-Y6e(c|HuGwioFT(ej+$ z`I%2YK;Ma{*ghX}({m=aFiC|G@sfFY=H(rw||3v`)#N+{&*nAXzU2)-e?om`#MbgW$ps|VH67v&t4 zZ|qe=^v;Zz5Uo<@rUx=#+kYt7MfK5aOO)RXZq*`Hr-qh|n#$SKvNOatNIY84CC<*c zB;KiQs`W$gAixX}NwG=NY0v8m*t!Il1sApn$@Qn3?tIJV<^@vYU}6h+R6Fd1-)il5 z%SqUS)t-a)(7?uywJmi4?HChn%^htE7L(#N9UXIv-i~=2rm(j6281;|g8oPXpLU0} zt?Q+IZs#$X658Vj1N%6IadEB@L#^%A$mpWoJJY8_if1Sst~R8 z@0pjaXx!g$h{Kv$D^MU-$MUt36##(7in_jsh8}8*hKagDM}5dqA2hT(u_0)8QGW?g zAK5I7zn)^cXWjYh8sm?a&oyO~lu(T{&0Q@m9YHouZg&}@m{CJb*=oIZdks(#HFt90 zd}HBcX36>1!5P&O4Nd&5DC*L|((MiHTL*hbkmy?px<6WoqOO14=Axthqlp_(g6=gy zomR%l)sj|_lbe&9PVzo2Ev>k#g_Wp=to(nvqrOSd*|@nmi*j*!dU|qt@^L!3T66J; zh=_1;^K$X>a-dppfWVG!Z{Bh^g6RJoyX#|8)NQ!~b*?=lVtZ z-&pY{pZ~avVzlIaajyRnn&f@igMCsoG-)&?+2>ks(RUW^dJ}b$wjC%i2QNbIV#!9+ z397Sa6;3~TMEof4S)kc-bh+H=3$|w;h@NA~eWuH%ZLS^8JzIp}C|)B{>$(?RJe9ur zH?9bIf8BLh+IQrvb+TT}?TOEs*84>3kA`vYAHQVdrTgB`8hDVPV?L5b``^FX?mrs; z{^Fl^QH|)-{j*dtGx$Bo{&$C8{b>u^1^#WGEVSq8_rz^?cz;_0t+aY5@E=qE`ubiP zJy7)V+tF#u-FHUVrU-)U|_n_{miZG|0kU&vY%6x|Gr#lS`4gvTe9Dj%#$Af zMrpu!5;SWTP^(l_(hm^?t@J$AYxJrw<&#o~qCEYGel#HX z6*5w%xbp2{sC!Zc!whIGae^(er%6xK@~nK+dI&^a}n8xgNB-hq$S_~`a#&?lw4cb{@-tcH+#r0!V?IJ+Ho;D~_MV6xT&Hc60dJ&Nk6 z$||c9-ujy^gFL;Gq?OHeV|aL(alNc#^O3RbNKFt#fMBUIg~Z%r0e{koj`3wuf@S!+ z@)p1CYM2#bE-SWtr?6KEW^-fTXpCpS`SWpRTg;R^IOw;%qNMTd1Gf9=-V9^6ifOL% zNGwUM>31{N{UAKcHtYTVs3rXPFu$l+gjWkkqlaVCCAHVUHYO3L&P${ZGUFq1RqO(^ z;(T%3Udy1n=dDpad2M_o;&h*wh0~qd=9$c0AI|ocO}6FkkMWB%Ziy0&o;~J(%Vb(Zj60};1)MXpI}3y^6Yf$j$!ng$)V&| z^aCyl(JCp`)13q$d-A%At?(#|(K@NzC=uVXcRBEPFiP>yCi!aAY?YM?mb1B)9L)W{a)EZnObzv~AZHYnm5uJn75xFw7s&+Xb3MIusRsTIN(H=~QgFdN-f* zME;*=Uw{Rtvz+Ue@Y)C&U)b8jOw&_N8kK+-asY?arbEYyFGK7ZQ)2~ckPvs%vmq+u z{pBtPAptAaG$CUJB5t_9$L_SpBFspv2Fhe$%jmi2U1dH;64z)IYs1HZN9SPJD$kmt zFUC_{0L|C6Zc3b3d)?9rq(OM80R}3m_~6d-t4qt>jF!c0Ud8i`?cBee*TDg8#1TX` zyToNw)wbq_08$72+<>G-8jr{%Cgn-{^}(EF3;{XO&rVUgd(_t72U4nMe1z-#GO6+a z&4SR5Zt{VV{5P}qb{o2xBGo28Q5@L~6#5$Y&z(sdZI4<0`iO0JrudVRe$z9nL}39< z{^Zy}298O6p4pY&i{3QG)H1-bB-`u&tB+Q^wN|lKk$vp$gl-LRoM}{!Q11 zW7`RhHu##209u7R!m25J=2bTMhEN0R{``|FhixY7QLI*`wP^y_W5Nf@>BtXVD+I9A zf#BbEOp^S!Dopp$JY_D%3RY`B$GTMyTMGih*5Q^^`;ij*%Z$;ymiNJjn>95|fW<2;2YS3oL%7f^D2u+K8wa=Bq* z6lN*LVwB%xF_a>jT6S6Vdj^!!$&<*^{PFMU^rFGI1!xjYt=Y?C&wK`SU#G3C`+C}!7fVRe!@CzQE z2}zhpOs?qJ-dq6*h!dWxrtWnYs9hM`a7zH{l?T!`W4|7w-?$c>F?vtS|;g}*rxFVsxeI+wF=B(9K8(i?_0JCOT(h?)c|vz(_6}DY`ZE;1PeK) zD5y(J=`g&)Q%JyTvJcUYJ)_S`o;8xMkHaym#U^TQCF17n*Ct0U%Goej4jH@E}cVk3v1cyG3uhf8#n@KpP4~Va#!3 z{evvj*LwyiNcIL|H_h`eLw-gfyf#6V-x!RJ`Bevn7pF7GJs$kaknGax%%AZz|79JY ziD?T7YYIZBWd12LbW9A~N8>p5?L_~wj{hU=A3*k=LBP1&OYMhq>E{0;Zgk8m++4+U zL1gKz(nq=9IsNFKE;`iY-FC{G)_;)^ElO7czj5RIi(qw8y7Jx2YMSd`Wb_`TE40EY zFMglyt2Rnk>N3bqasOpVI+U*5dl>gG>u~<1E9KT9*Z(qP;60SC1hzB$%Q_5yZG-s&!7*S0j3iaxXKIm_tDs(dOd778+`?29wj>*=J%x zTqsBxmeCE`wd9{NzF*!fdnd#+px-5fuqu^BqaZ>uHJU8t7y5Z`5pbWlJb_Zg_3gtq z*GRl7-)k?#W$;6{{Y9hM8VB>6xA~v@mz>)p2q-90hdWd7|D#f|{?ZMFDjH4p?jqC> zbq^;K4h8((glcP5<^cNTM=zd6(^;~H4W-GruCj4#@>#^X_viPuc1H%AO?-uu5_CnE zu4rVeckIwUPw%kd;lj8lTiW*iG5yM3BM@?WdwsT)w6_3xyU1)oYfxifWjFCsDO0l9 z`jphfp83w*hsRCUf5|bnMLMO=U4`T0Nj^!}VXoh2%I|VG9TSBtbp7TxO@$}IxS(AT zg6}8fTSsZhIXM|RX0T*Zbj(@-`!6kB+qN zxW1F$Z*0$@WGKS)St;fV_Y6XaI>SrV(kDms{?b(>Px!1pZm6Nmq?Kda*&*ib@PHtN zUp!Du#CXNTvpdobiCwJ(4U;lC9}iQj!x%#zb>+VS&omCvOI0^)Z^z-~NxyQ0k@^{Ql5 z(}ce}J#JlgUF~CZ-<~Ai)sQf0X{xf^*3qleZ{m zpY8a{@9q;bC?~S>`uToT$&{2yW6F(|5=+V(`uJKOieIe6oi<~; zHF)eYHG7>LLiaA2Kx+fp&F&Kww}rWY)Je}OA1Uv1XYlpO!z##`W9yjD8ASRPT_acV zF`1ahM;q==mI!hQtGhz>)23dyjS4l;wpUr%=y4UT)ZU8+_3JcZr%s#Gl{uw`^`jEA zKw+Zp0=97`7b?*v&WaXfajwc{D_t?t+pGz4wzUh#Z~7Z(rzOC;^4pH+pnsEz5_14I z!EoEx&u^CQ;=Q%;&quB0u#?mT^T8w$hrSk*$`dX%@q@Q*;N#ERQx#6YnR)M{K^%?Y zy%~j%Pxgq1UAPM8s6-BhdQemVy~cj)ew`uYw5aUsE#Y+>o$A=oTP;{*VIdfK1JQN1iM zJyvcc2EN?o%($TT+ISi_m0!TF51Z!O%S$qj1y(dq9^HKV62w$6bSGba$Qx1jk&Lg; zsrA|pbh@E9SUdK~{O;zJl{G^A2D@0RoLw_tC8ox~guC8qa#oKYe-PERDNs@WNa1WM z-9#ZZz*dvFU?5FI6Gf(P#g()KHJ_Xza;Xs!o+T`L%dcU`a4qd)7Ei>B} z;~@G84SEhT*%&hb*pRd2sig3t{I(&AGe)O`0Sp{Jd+4JSPj_Y{x}Q)`0HLZ9n>ZUA zn0V3NR|nlp{?$EvnGbCqQWR{gkMJSHcbnkN4ihD3g}TxT%+w;T9Sowr9c*WVM`Z@e z4Q`v_{YZ*ow6UD1$umqhH)_EA)wt&1%EuA~d_aa_(_5DcZ6a18(n1&cA*jS0TUO3; zt^Ew0!z?U@(`Yd z2JckLX9qCf|I;2<0!XJaYzIM6bTMuWAFd`Gijfj!1tH?1zejt0;w?+xV29MX3eJ?McT^(v5Oq z?ybFPc;gV82dHehs>Fkw!6z++ z%1NAGtdxH8JFwnt7PwH6*z9NNhwGTIB$;G+oov;74b~|frYYDs?})$LYeX1>ycRa2 zPO}@{w0q5g<;*5?HnEJoiqb6xp`E!;gl{2sW|PD=#y*9@;KN+$IC@v_*FvqMd(PKb zj&5njZeEp}&SqSqOMLDp6S{NZ7jbHmJk7($HA2+nVbsFT{ttx0WcTZ?A68IcFRg;3 zP!1nqjJ-7tQO8O(2TE&6pL7Six{at-jZa_et#n13-`jQ_pD#yUrcOjEr;A2R@t3(5 z`l;VZs0XcMK^Z87?2qq{o=Zxk3cJLX?fxj0&U`zN!Z+vkPSWTG3H&&aDui&GYmttm zme?9_T?9Uh+CM*7Uc>EBabXVgJ?JF;6_Yqboh7rT(@kKWD#wL}qtl?rfBIU3gZx1< z*F$3a#^ZY{d~jXhc;QoITft3E)XSvWhPMKrS_%_P0^cb_Q9IaSCf%EctTRqCl<;GC z-Ihv(G=%FE4~IIc9(dC1so);#*E$--iMa-$bU=n&(B1~5f|{yR@*6JfBjn1VcERqD z`yot(d|{dFA?KsX=jqaomJ&E$ zci;i_XfA0U7%zOaz=U!ABfJF#<5tn3e)heXjE)o;D)o`C<>>d_q>I;zdy!%vo ze!aBa+~vTMUlfRG&XDrXU0=p1Xd1B>;S_9Bv!tvxSZ9M&wBf-4dKn+rdX`M^FI7VQcOTx5DMiqM^I?rfJ-6QnNH)nz%df zFoT#e=->erG98Ck;1H@7#Hwm4mnv;LNLTha_h>6g*%RmF01iBh1yE8^`INI1uL;>L zHq~E`H~=H40b1AX`0dw%2-kg@_CMtzo9(|S(=mbVJL6%PCA8Thw4MMkZdL%aF{@?F zA6}oeUs12!!561Jn+HV|t$K})7mKuxVJ^Lc5n^4Z8o(4E&)C@=Z$;D#Zbw#ZxX!X8 zgYYFE4L&c-lMN%tx4mfBs5HYefV`f9qh4c(Vwia_WWQPW0}+dTEAV)GYH93&;gtLF z#^bQC6sP^}(Oe~9gToep_84{^5$Ll7Cu3HqEOSwFr19a2b zuAVLV)h>yo`%LmxM$g7^XCt@`rUqh=r7v6 zwl83xYH~5BN45TNLzwi_{$1@X%R3RR@UAkZy2E8~+)0v<=bm_meSEod{%A;SXX)qd zb;UZkUinzwvnSfgA#wy}yG*Tdc^SD}`kKZL7)P;7^d=8HT~=Y<9eLoE_-$Z)U;9>2 z>h!gYS|6^1q~x2gZ*7OOT73K$+b9w7EOByoc=Y+Fb1zM*EO2Jvz$Dwgo2skhv8W7c zg_FyWo3j0hZNfMc?&e3@ zwC1Hbm~HhqPXGr1@m-~sPww2$OR6k@V%>OFgwnd{NRp^xFH$gY#*Xl!8FJ<&SkyiX zTdbFCLE2?$mFdf!?#?-_k5GnX`XY7~|JohrL}M9uE1=?RY}7jv;sPsqNz;7unjr~(+(evct3qrBXo0v&vPo&#U&75Pk z36xd0+{}(GvD&DzajGD*S|;>Cf}PO3=}ny@ERM7D!1jn075(TX-Ii3>bre)PJnk>? zefpE*mjTf-KwSfl@Gdx@{?oMVf0}jnNY%wrcwyPwsUaPFr`hgs>TtEczZxNFy{`>p z3=|9)q4b^A(o;qq_zu?C6D3oh&)ASc8Z<4GVjcskQ=!u+F#K774VH6DpK$kN&o{jWOLLI0KMC77O$Jo7v#yRay>keG!J{ngJMfJ|@>LJdW(wkj zFTS5Q?iP>nebl+*M(VJYrKAY=(mVLkX}>Z}b>fETM}>3^MFLP(&@>m_9wk2#i|zGp zj5MSXWs6rRg#&cg!>-L%;DO4=YfFB&0%~<#(=-dgI-V12MI6xD=*Io}xoT~^j9QzK z7bwHlqT8r%dCac08Q{x*J(4ZKZR4a1eS%2$)-MU$wr;$k%K9_o=+Pzyo=O?zHD@;9 z70kwg>Us`xw&2-c(RyPC6woSt{R)ZyCkwn^#&n0t1!KFxN=;gg1)Ub{sn3Zj_pJw#SwbIDRdLX_2n|;8146Jv zU>m0b!nyq|$D_&~X*UHD5s5z&CVd#6i!Zd9R_6HIuwKZ%jon+AV zllJX#P{+jx+7ON3$C|Mdsj~WIPOmS*YP;@Wj-e_fRLW2CD7P^)f| zWaWcxY2*n*s+-o;t;UUMn@x0oq(V8fU>!e)ALRrPGk1saaq`Sfw%T&Rf!0p;s6*K_ zko(f1n$Ad}+DdIk+m<8k0U2-4r=ZX#9iNTi`cMk}_80wMj@Ay0yP7cok&P%9-dwo$ zD@}vb7eRNB(MG^7O7U;$p@D0~X*vHh(Z*e9@5n>Ib0rVW|%g~l(>`vRzvIwb}eZ-g=bD4iI5 zc8~T^1_rY8MXHTxp2x1fAdbOOi-~B?PKj^P=#u4nR9YmZz!pA29BSy?`GNQ}DkgGV z%2c26^kk|T!a8hAel=m22US4VjSP0b#ZH)J+AYO4qb3WvhQq{}aJ)%K3V)4fFA z>5E2UR{Vi!W7xlqw>;r73oiS#OsO12y#TOgtNnltJl$V%BE$eXJSo^%aw)Kih`e0S zWb}v~TAq*I_I%_Z&j(s#x8|vsLd`L~C=Tz9z1f}EzdL)(SSd%7xb2zAy7oM=(dPbx z!Sr>Q)yCL5UxjxebiXS-smP-ydbdTSeuS6|H_J zlAwkwv&scILZ9iesoJ287^SWGk&-8mSxf<^$`i_s8le_b_)4Cj>jmQ@4+B1St(qbC z;XGwh7dzkWGfjzFa@bgQkrsb*7?afGQNfZC{4`-wRQUrVo!!=k+$!YDyF&}Nt>X{} zlDvsdm`IW@_c(8FH7tp!taxK3|NLUzL}jeB;S^gwNQ7Kz5?|H96B?b}Y-UR#l@QTnRRi3wc@@gNDlo z<5TZ9{-=$;`8I~M3N4Df$V%mNLlkXzkZ5S z7~R#{qA=14WyI8^ln~N3ep%TY2|iTSkXukIt145on@6WAbKN_JecvJcQ~vi*HVu=k zq!<6>3IljiP+(+@33)WGsWyWWCRunQ3aicJyAETvBe@kJ>8z-5#d!XlqoqdN*j5~ zT|Ah5e52B;Y0JmrgI4kUJs!p696u?}%=^<0nCt5OR24noIx0~}>A1zQJCe%dz^Ub; zYkbFN)WcVE`dGF!EXDoCBcDV|gH_)^;Z6Xu4S)ythlZs6Rz{(ky1G@=irZ_P^?NC(}``5Ux6e)QeLD+ zh}(g9@HiJ72@+9xaNoXS9V>sh>Cm+rNdBH+&en1PXL`>>GF~7_-ZYUtkv<0lFW#IR z7$=P&h?fxP4)^s7RH=?Gh|c6xk|x}@qkKtF-qq2ogAa2dkY=Ms4>2dBy-iZF(&v=B zMqhD$Y$Q?duD)nhaa?Rtf-y_uao2}qci+XonU&}hbjL%1PdYE3!yh+><6I@k9JTOc zoCwYI`5AQhB(od2O~Ieoz&Q_#a}B*la(r07+RMDWhX#ByFRf$8rl$E5b}9OT!;!c$%NotE{^Y{H~>3N zHx441t<|zDF(Rg*gGeC*$Tu&P7gaDRY7LQcY;ts3kYUG^>9^QD`E~g24s^S~s4>Z& zIzf8dEoy3>9ZGu!pPHSct~$OcRm(&;C=4n?7ZFq)1>Wz1M#*8Gknp+l$rtuOUC#w3YjS8sw|b;^7RkOf;R6lSfiI|7cA&A z)!3*PcjcTCfM~mQz;(_)MyrXdvm#)heY6&y1N#JM)rrZ#t zKM0Z2dZ{l~fU@TIMtnn;2`+7?GNjUlBtBi1;rgB}s9(kp=!N;FAW}Mq7^4h#@!p4f<#4i(xx~{T75JNNI3MpV#O{~LtXy#cVH3}de0FO z@YyP6^*-iV?5)TV>Ipy{M91Gl$3Qw+7ikCjGCEVFKn%uD&%E|?;Qrn;UYiZ@wK73m zklnOl&nZkkv|LJ^{6TuTK+=l@Cq|+vz_Ur0r#hdEnY3!4n!G;9LjSXIq^Vkv_#P44 zCrOf}6QeND%Js7eZm5WG>Fc>bqSpB`*%JY~)u%&nO_jtFKBQI^^vARit%*?L!p zBHy1zG;Bujh|a$)k97F7W!4~wX~W&C2IQpON^2wZ0S?GwK_!T-Pc8ZQ%x2*%!F2EX znTYdGi`h@(=WiXH_}&rS+YPaMi}H(kK#=2zr6i#;^rDMCx205m+by9iLKJxR8Lqj4 znpns5t}Yvl1Ax0Wns8JwA;mwE>(TxnN$If({!D}qNrqoN7!*qy_KzHDMR{;qmvG!> z-w|EktZ-%JY>(;>krED%xNjWZwCHZ~bBIc4p{Lgg9JbOwVl`-{!S_%r8{@Q=?iOk3 zzb3B{aOV`loconl#hKtgX*JUEbyh_QVl`Uug4|S55pBjZ6Xwb6P*JYd?gj=>7NWA_}!D5tkQftFI4?FYbeFv zs*`XT+xu~P7=6+=F0ldvUR>8t6BI9x6+|I(>W5Ll=YKNn8MFgFJNPDOeG%dOTU6z* z5H}GjHR;8t9?^fv7V1YOovrf*Rg3(8>_)41FHHg>uaUbV$f}pN^N7j?BLbL>X+=S*A|7sjKS+=D z0)dFT3XzLru;LuKmG1kJo<0kQ!K~W8~}J7R86eD-FZIK>;tEi zfK9Eah@=~e2s_dQ5HW?Sx~);hvYFTt2KO>-Cjt`uqNom1w!bdA?iwY}<-OkeBV5n* zSGfMalBE5a=`Wd%$Y?B-m9u69^Xk4+kdC7RKBRJRCH_DznnB!%Ek0!|58BPkLXQkz ztHLvmJW7iWdRiFwh;i+2!OK76(I{`Oihs0ma{42Wf97VQ2vYo&d=~tS2mfDkCaq99 z^K~cqv)JFc_@}-}zraLENH*;EJXKVXln|ADX2l%5@Gnat_?5Kw^yR-~=H3fVk=|0@ zSKB-UtTv>I&P=G%OsQ++(mE2^nNNncZs)Qa8t^nl1ceVWdwV z9llp?c~Se;Bh)`KG!PXOm6!H&%E_*NxfiIbJnJ^{xO(Y}2ftHGU@WsqfPb~DoMkj! z=SwA2NbJrK;U3|yoc+x)g_(uY*BA0EuZmUb_FX&nOv>jk7=Sf9PiJoVZO3MTT%Xwb zp&X&B3v`R^Wd{PhgbbhN~_xlADVg#3iHmt7TQKk zau2M|tU38Z_dI%99OGW_3+W)Sl1)88K0pzD|y@ z%1G4@$!8K?r%42P%NQb2f?oP|OP7irI}kGunCb#D`*{MJUWVPw(D@{8FXsxE`m`|w z$4D>zE_qLArVqW*;`Z{%(YKrk!iM`rUgmf#kU@8x45@fNtM^0C)TGXe<11bZ#Fw0u zDpy=GLnXK%p8N655D##d$$+%5A&9j4mOcDGsk%xAv@3%Z*?hz=XU!<7FnSefeL@D5 z)@)9!4ilkp%lXV!FDgxx8+bKx_6!mcek_zeR~wHir;$azIbXQ-VlstW9%0Wk0~?n% zd*7@`;jX`|RdwJxR|k$a8Pqr|u=gjj*L`z0+z6~xi0X-p8jl|V z{AzM|E#wn4l$0E12nri9GyczxteeMpioa#JcgGZRkJfn;GggDJ4{uwicj0V8jH?jT z&QYsuzw>Hre4uet`+GLy&Xqo!#d>7f;U(8n@6fIR$7wUZVEpc!pfU`HNW1w{$gd&_ z`6x=xxmw4=w2!9!+bXl(>osEgaT>ZN^bJm$gAp6;c+kG}Ot-Vr6>%+WP5=CLJkqvwM1++SoC+9%YF%W3dM}33(JE*5f77{2WwIe1a3<* zQDKv!dd;pyDj7cuc0d!c!)s2clA?Y=oH>|F`qj;tx`^vabamal*0O4mMp;9eIQYfd zUErqzE!rH_1M@$2t80j9|z zAPysI?iJ=J&rr4a^-lkxT!p6!b&+3Zit%p#oXT!N6{^xp|HA!T$o1;H@@O)To}kKS z-EgfZMLF@NTDt2avNtXK=#CRoY^qR+ZKfVH2c9Zkin#pvao=0O4w*LZ4diIl+7kz_ zZr?PowB7hH-7eGv8MKSFn7Dc6Nd{8C>or0*B&X`G9edMJxj)_oIl`@XU%eV}ZRZfM zoQmA#br?0N^4(=^OsnQWYBiQ9~>_}`GGJinUeFj;X zd2;8k?dz}nOZ^SEtsbF{&U+LAIOYm7Su#Z>ec;_VY_DUh2_sO}>#)(oF9l04?KH!! zyT)AvZxzm>kg08IqK)?ThY2eO`DJ-8@+d5Ne>`E)Ib_{LJ~vT2DnCzDb81mWboDZZ* z407g7>E$pXp!qiV{ZhDV&~2&a%KiQNQ#TX4MfXhcsXYM2Flw`f84c~y>|AwwI?N{2 z-+;<-)~8W{W_yI~=WVg+eY-4Rx%8*(Q_m&1ZGEvl7U4!1@aD-zUCHA(b}5;&r~Y-xt9G;?zGe@#yD zn1}V6uWo-|&V7AQG61+R6Wbg9a($?WsG~R{i|g*qqUrio_&3MIalm0xGSn{k{#bS0 z!xedKdvS>{Q5nw7A$q3OQH}4Ql0Bui=r>l4ug{ma6fKn-T}1VHX52%`hDEEc4zI0g zx+Pe1+Pujof5W zWuGfvgqWk`67N0`#g*z+E1PKc%K@uYQ1xqlMg~elP(OWgAtyG}JDX9a1v$_{Bemv9 zI8J5(ZIYn%gU$koOXh6~fBPZ_7H4ZFX(rS_9S@EOyW$>J3Sbp4IZDW0QMt6)9-bimowo;cE49LU` z;2`Gc21FMg@HylGyVrLi0+*=5AVkYdO!;kK`fadbYPojr&L$y3Oc0M8tFdMI(&)r< zu)dbeX8NpdnJ;qx?Afr#lRx8^sJAVu+L#=bDP%$abE=SLwOI|j_Zyc~&7dEDQL*5} zugbclFJJClT6B)St2v|eVKo`P+Q6%8?9R|;`(a155jY#Km`c3LJE+mI!V=~L_Q|e* zQ}Jgl$3Ho`nH1ZauAS{*3CD@wh%o;0RW4m`rRRH)UEE9#BF5 zXy?%`|8>O{UTvR>=A2ThwTN7*Ffoh%XiK_?dlT&Kjr+(qe<-^#_Z*?2>l*&4K61;o zCM5-Buz>|WA4l8vt#REWv^UzbRV^c+s+~8))u(4pwY940X87iwbY4>-kj!g2v}Jq0 zpuac?duM)ihIENLpuFMmJV;>Wyz=!~?oo&UR|H|33lp`3>oggxB48+KO69hh#m5~S zCMb~RSYK9SU1hwVQf*N^xbSdi3D{k6$z`*W())m2a1bNiKQc0t&pg)QLvFk3!2+r( zumxM}7N=QoVf%Z1I7>t|&PKRcNvN)`0S zf?2G~o3aD$71(e*a#+i;JUkz%(RqK$oht%z6U+1+@5G$=0mTvN$Ml+5Hn0xV*~|XI zwcx0Qd|+G;nLp|f@}A{CbM4b(-aPi}-uXB+bzYO%xs&N-S6yQ&cPEs5XSD6Z?^XOC!9Ry$+Rdgz)tKE3=yRrvJQ@qpyCMBCVjI$Mn&q zSp0sGr&gdJ9WDkC%Y> z1qJCgf^=y?I*N*vfYL)%dhbO_Ktx5QN++R7@1ge)6%Yc1PC{q_=`}(qhWak||2%u| zb3Nz#`;AKsthJbPtTDzt?%yQM8V6yEtg}$thcZ9e`((<%($cAwFOMA9Cw^Q1#+ykh zON5jj*i5De9N1E?B4!xHIKd6>*^u_ORyKYF0E(nr>OsgCk zAYa$G44QRWjoE*sMG)kVmOkGG?P;CXip+YG_i^l9BCy;S#UNu|u}Gzxgj(yk$p|h_ z`?wncr4_zv`AGe_Npf&uZ^SP0It-Vody52`NaP26PT%z6a5lSd0-Mw9p1S&3V5KTdAK%Nqb6x6?7}`r`tdQqRdE$4CZjz*aM+I2&bU zKVa1q3NhpMWx5wUzRQ{aF{nm5dLe6o8ar0V(@0qgfs@JT0D^}83h@GL^&@n#sW1Fuk52W8&4>7%!&C%Emm*T z&}WU_l|WG%8v(yW$3s7Z8p`km(64^8h)cxX9TSJ>XK+D7M1y_#+?Ra_L^G8ObS|bC z98674soNw}z+^8oKeC41mJp;&S ziFB~U-e=k0v#v*Y-2x}1X$}4m(im09bh0H?OmzR&d(v)(X#DmYcbP~kDr?i{ zQ*%M8FCEnHf3$#|4&nkEdca-U&`0)J^c25XE2&BUrY3>CG~yl%h)AU$gpqY%KMk>N z@xGcm)B?TRNc_Og4#iw5uG$9~xH}>uww=TWzLYJ=w+)gIeH_Q)E2SC(*vcK_q=S!U zsfUV_K**yE(@3!D!Ecl%Sahpy!4SN)r##ZE|M|`+*kBT0=8IvvY8Nx)y!X zh@3c3Ql@N;KlkBs?iwLaGUko%EchJ3#Z}2nk-uGDeD$`|czR)o2nx_s7Pp z2Oqm%Bs2vi12lC<})*yUaH8fwDz38~DfSVmrPO zm{BCvql36#hF&HfN_+#+0hy+82RJL_-GkPXL{c!qf$3#1;XqzThgW!}hIlRIb8r;L zP2c3#08@gr%VrgKt;sR-tWhs3kW-qk)fA3@`0LfOA`^&g5JKR_+lbL{wqVr3iQOA- zX{@`}AoNV)?}*t0`9T+Aamuv7--gs**PecsarXM<>eQm%Ms9h~T`xx~Gaj)S`7;H9 z0UC-MR0w-Wp=5toI_{ZH;X2!=@7+J_3!nxix zDdks>K5{-;I0rtp(;5>5u8Z-xnci<>(6Sm%IyCR8S#}Z_m3ebiwz73xCe7|8j)z%9 zODDT$5^o=I^b?Df(8j%@Tc7@?H-XW5ohpMNrFymj!AU73oFRwWsO2q&GWbPZi}Ky; zOc_?Jnl=VJ)4!1i3t#v~Tg^rEn+@P*&w<7X7n_Ed+YKPFzjwt)y@=Bf%a8gqBhgC6 z7NIR>2Jv}^i{4$c+(;~vQmg^C}ASOQFrciT}HSv;L(R%vUaUf}bDNhCO# zCDp6;+M48F*U8JRU%n&8@IS4?*nFVdHs?1b?Ld7AV3xq+lo99b)7jT&8~|@np1=}j zYyGPha@wrIp85)JCiculMEZ|^^>__5ff0Fw-gKecmo8Ib8Yp^?Qg8TN`FC3~wp8g6 zk6KHM+B>U&tov^NZdm?5TjN)uOP8@oiDa@#_YV(P!Y3c%K_Dw$M>V5`b~UXX_Lctc zNb#SV_Y{@tgBvBGG8->?Q~l_2?a4_wMYLtJH&&`}8?KVYeux97#94zLJ?YP&xJ?w3J`ro%UEk^g&8N%u8 zkQ;(fs&Px*^n@^Q@&5MYTVv#w<{xp8yOFP9Q2l?Iq)n*5gA1~i*ve^jjp`gMc+Vnu z`zt?k5-VVitXq>Jl`gHIrUEd&$kP|X7vRkYMfsot#kXyxy_U*tYw&u_Pc|Blm@!Er z(LL!^N>2_Y)PP#aGGB3Xw6)TntUth}Bk#J*><20|1sE^iyPwlz0~6e2ew9+eA(4<;d2DRHf+*tL#s=j%2!9HM3K)*D>dsPY( zM)HdFzc^AcG?b$%)EX8fOxc=?RqW7=rJ|6#7E%5mqW(wp{LkwNSaeE*S3cA?!FV%g zC9ob2YbIqwTMkW$VnIK>mIr4on=l!{2kKL=P?1+f%nTEehPUOXTttxG{&<}Ln;v4E z^`SXY9fNEEgczw&>)mz5yJ=JVUl~s%eJVBBPqO2Mbkr;sPHxvt=spfQf<=^~vc=m3 z4R7UZWz>?HMf45Xa82eba>Kg28b(r#*>PPz({Df zIjCHx#^bDX0Is(y_@BMt3P{t^yV2TmlE73O=oNnNPnu#x_0l+(q@}*mk)GY3(P1dm2MF#%ybRF(Sye zPDL@BiL1*KGiNcEuJP#PVG~oTEaV_{{3gcwV%A-Ey&dq|g4_uTD67825o*uM?xGP# z>uwyU*y!-#z>7FxMJb!lwq`W{5;FeZ%DJ(8{;l&fm5()rgNq<&CFr3~qo}XLQoU9P z4NO>NAP@|#vi{r}7QH`dvb*LEsg_ubYKlPIgY2$NF0#%KH@iPcmG;#4nlzF)*IP8Q zI=}h;VwZ86S#^z+NiijpF@+KW~lZ7|e z&4cuxL~`-EdWWw*?m^N2-7xu|=nlNe2vjfGhuz;u?-!ZE9ba^qE2(&{F8%I)-V689 z7;95Qs0l%92dgl}YS)q`L3ULRBQor7S?~Q0AXHe^**1bCb{Xbgf%fTh)Kau@B~X#H zrrqk0^r$@9okb%#K5Rw+BvHs*2vO&hv_ab070ms1Bl4WRl1g_L#W4m&rB0Pnsfj=ZmE|17_XDB>5NNxur-h6QHlC7=EsuUh8deskK=K)n2+bv zc{;wB9!o+mTb@x$C~JBGI@}3$rpcqb%Ac$?mKmL8~|Jj0<`7QyHsP0f< zsb#1X482R`v$bj`l>RI}u}@BTe^z*ht){ZZa$+_J`~Z-;s?>>E#LEoS_I0yqje#$k z`0$jeR|mJ3k8uK9i>|~E-~t)u5nR$eu=nSaMxIV$I?{p_kk|{&1#k%sFMMf zbQ6WsQeD*l^&0qaKjdoR(37xg*BlxlgMdpm5w3HrE&U;@#k%DXq?cc5g7_YKOcsy0 z(*@|-Msfy4cIybGZ*0y_awjuMV`=3tqDZ2453QUludxU8_{PGJlHS$qJ7>;aOp`K2 zSlLh8@1?`Q<(^aY5lS~M|H-W&Z4*8J$wB^S`@1zze^)4&Rk|v^$4wX;?YGD+RD5$&kF#oTO8bWvZ{P-hG z2;=+Lnm1T~{MV~m$pZ+TnVKFl(i{T+ho1KoFbn=)A3H_$@(*_;LW{BK{eLZo^VC33 zgX!t}&3|9-|Fk=<|KX0z#BJkK~?$^xFTye!2AYhIb}y#2@=^Y?hL)mp&#oJJYH&UU@Jl zG8TBN-f3^0OaAwxoWAl$!`9bKzYnSi+w1y(`)z0%Zo z{L>$T4fDUg$yeHT&`5i4)h~sK{hfOM6*`}Oc&%_%GYy0S#M;kP?E;w3p-omDc|5ex z?bse=aRz;g{wRXlwXsz?u8s<=+-BA+NC=wL){_&-0BTC zD0M1a#$W5p9^C= ziN=uK8pm!A=}lLsHttf;V;qN!M9rt)I+)Y{x}{5rRQ|{>dmR2G_XhP(!}i&jEB*~5 z1TJ8P4;=Dy-lazMv!t^8|9-UfSEqn z-t~C=zh!1%hGWWe3Dq$#OzkaPcORJR2?}1$tJEOW7^Vby2KR*s2E^PgwHXcjQo&Wd0 z{`HG*umadh)%Uz<|3BSWNdE=ENeJX)293bgfMWSCZhLBCxA^$UX{`My4(nvtaZi2+%&py zRd7yL<~hlmQZ(XH*$p`Ra9rkD`d@p_-(d=wM(<##Ls}pG^X#WBs5crHgZzs89MQ#g z_+v<+e=9a9U-U}I+Y7n{hEN&b17O(Tsr%Hex|Hc2I;2-DAX=wNVdZ}=tkkKJ7tN}( zC4LR&Kn3`GA!qjl&05*E?e}{;&1O@j3bk`%eFJtse}5M)lP!mw7toDmOe1bKYjr9K zk!9x?jV8Uiowr8guxnX<dXyA z8V%&#qlFbo>#@1b#mI}`Tj1RS30G|Z?w5AmskR%u{!N8V4UmTA$Js^KjQw`r>8I}j zj+xiNA;ZTi;{OX_D*Wy>c%%qMI+KO1#Fd%x4CQIW93AZXgZ1`TD)R^!EO58N&_=7z z8ov*;v>51186<+%>>4n`^_>X<(SiG$0X_{5Qu;}&MJ6Kon(6TrQBSMm326F`Xp984 z#Caxh+eABt#B1G&uNc)3Ith2Z69RWJ2R^Cblp?+SGUqa{9zk*YLqllG8h78}M#vAW zQCIT7ZR>Fy*2n9n&gv6nshl5KwP%8`0TpqmuO(!p$<~JB7>jgk;2eP3^W&J(v^Ls; z1rz-n+sU!*hh}p4-D@fGo<)J=ktFSw@7~%akN8+A0t#iL- zl(hk-KulUbkQ+a(bs{{hRnY$z&F=N>7#VMe<=r3=%u6b8Ri0J_Ma^uv|FKtd;-`e7 zELHTS+UMUR96LQln)wU0^D4iwr)84mD7tjeI-7(NqXxi0 z{|8qo*L^R>i#iWJc7MdBNVrftEdY#ske<+;)U{zM%LCx~y!`U><6DX%063g6pg@SM z6PNfOhbL`^XX14L=lyy$)mggn1=E26R%^`9ciKld*|`Na$s^&U(a+Xh2}%2r@KGhK zho>nEpFZF_@zeT4;>*`cOKhiG9M=;zyMDVn@yFTBEtb1h3&{9Uh^QhJMss>1|G`_% zMj9X^UwX0Es2qj|5D$ecFlxY>6)Wv&(;ahDd5&OvRL6;0iY)&p0)iyD{T@& zV2TOBC-TBoHW~?H*4^END)&1=#&vN3w8GBC!*cC>X-)q@H`3Ypoe)B5%v}c*88>h{ zc=x)QqerPmn#2??u>Z4vXz)UheV-RiY}9Kr7T}s=FW~hODImM-$Bwv&<0D|0_H-<% zh4n6-#X{HeQ2tsyKz{42xh8;l@9nsDpmZYns<}`jO|BOgJK&e6acJxx_}e~{z$Aa% z|1EUOIwN}B!+$b(bHo=sTJ1r(GETj=3F=EWa%8ezR-|J>xiBl;kfwWN^{j)le;`f@ z@U+LIwgPoWT!Mf>7hn;+D}p|(Z2Q4Qhw+Ef{wsIkFSnodC)9Z6d-)%jrpV2>LhlEc z<=aQ>rY8`F;r(B=j;YSdzffW1W_D%<>@_&#PKz|&L;$N%RpPZgG`=GQZT>oB(p`0h zCid-X(8F|5=U9e0U>No3z|Y!Ryc5%%0gtUY+94;0#KJ-y{R=EYHi+kdX7;ew<)fq7 zHTp00_AA?XZnVFRwDlVmb!MTM=Dd^_ce}<$1mudP{kK0fq)Vm1`kddyq!0YD&7D2} z$mCgvR3;f97j(&zngdRUZJ<8be0J^645@ZKfqSj**G~8nVPb*50i#_V(M+eTVS$ZF z2`-F9C|cek=35UJD9!3@h86y1SgRTH=@S z{V*H&!?n{s($!2;zt;c!A+|e?PY1-_^Lr+2W^2?kve^o-_6tD}-~=tjbs^yEgi_-= z`0JjU_-P6%E}S|TwTxCpnxQnPs!JDZ3D3NTZ>elfk}9)N<)h7aS~2l{TV>lfnx{=_o_s!r9vz%W#>Z>tc8?m#`Ntg|Aa7g!dfg|lNMzg} zoXTv%uX1Uoh~+woK1e%lz8&jRioj%@72W!kJw>#hB#UzpsMw`S#A{Hs>VS)MEC<6j z=iU_wQqgo;yX;qLD!@97jy{xJF&H=LPT^Y83ylP3aFQ%Q_{b(ZNT$-`BxsXOuE~17 zR9SJC+3jLed|;;c?rmetTJ8&k=mj5e>o5zOSP@lJ zR1gapuBc6;ejarby@5N4idVe2f|(?r#`1gf4_USgOTQTV9_F|_fU3I6Z>SW-ePCd= z`dhz#19SkS`l^OX_?5j1+ zaAai1)_Ij;(BkeB$*-dI5Z)sJB^9*8C6$mPm-@o@CZ6lw56*mQG%Mw-C7K|NvNf23 zh!(K|MD1ChWr|tStGea%2LZ2#HpUDPyM}PrPXI`jmgz%sX4HY(!;!fzsO@|D=b&d> zujTgbV6qNJ`#WO6->3?SnJwEJBjYI$T&eLQz>axZ8ksHSz;=B=DU8_{@BqdZNV-(> zB_;ylTMd|Q*KkHK@1l;AgpBq149Zer$PoBIwhDl)89P)9eTp{=D5brc=9pd+Nb%58 zQejc+6qCuI$xO|v3FLZ}Sq3XUsD8C+0enZr;hAc1^^#ZV1ry}dZQv)e0H$0pr7Mm% zdQHOlJKy;`ydeb69Azrz(A-bVkgS7^o*P;GVR>O*jMt1E+AVA{#_b=VFUinMs8% z2ti#vId#xBbt)sFO3cblQL82OI3xHL7V<3LmUMbIAWvaw$VfJXO6JqadS=kz79}YM zH@Sy^XWMIdA`%7{<1XG8Q8j$gXkUr{^)X+AF_qZ7#fwFVx8kOkhk^_I`rS1E@9m3N zP@ihdTOa2#R3(bAekQ=##So3du?g5Ws$bNVNU2Ob$K`UPuiC*eOwyp2m)l3`Ti)4S z*u#^9&9Ix`*U;6CgPeIC2ByaATXTQr7WJ9uw4AAY{PLfywSIxT~OZ9z+0RGJt zAsPuFrWTc-XC&*EYPSm%cy`_?^khM)$~YVwHZ>{Xg>jSf030fP8DMJ|`-#S9i$#c6fJO5{;baT!V17(ZD$FbGb7f^l5EtLR z@}JVbEa&TGvV8esfNMB7Y|zFa0D|Ro&iZNmo`Lm_B0Z-d(73Mq#< zi7ZnoYFhZjmZUJ9T~K?9k=q|Ye-z#_^}lxd!P&x#&rdJkpt^PU21NJLqpQpO+SJQ8 zP@%7Hq0~vJy{O+C4KEWXW8Nw~>{i-`Z1e;&kvlOBi(pqn{86?kj;d=?!Cs&O z@?g{v~t+Uvl~;On%k;c!xuR`a44D{o1=b`l-LKC_eWHSSD;G){T_-2hG%KAOyqoixWC4ifgOCXGqyJCE& zJ9ho&X?f^go z(D+vQVTDIc4_uy@Z9XY&`D0tV&WC}|w5Xo8C0~p+_P}cCSBCX`k)pGC;GbQP{zT~{ z((8DFaO&cKQWVL&e)3|!0@c_?3$m&t<$(mXt`eFhedpjoFzSaR0f{QbOjQo43tCqYKek89nsvrY1&EN?|DmWfVxH^le~k6 z#*fZlMZ1N=CvGxrT9Ry3*|Mzr-y3|$QVnSmbUQxEXg~}9nlwTL!fB_N92;sav zpJc8E5GAqZaa^4rF1N1t7#q<&ty?vZs5bOK2OX`uA^Q|o`R5_ig(eLiLB~57RB+fWJ48+DW~n_*1bW_-$yVBiwtY+*W!664JC<(P<`1U9&b}#}_KAz7%1Zk_^e1Y@tSs4Tro#ay~vqWS$dE* z=1S-byT~osVZ*{8hB8K%#S0$59q&4hNfgfSEu&S7;`yB14jL(8ZWNE)_2R`vn3G$_ z2p`?Qy6bOmhIGDMGBQcZI?c(e-T^ICE>Bk(T5k6xO|5@+o=vsgnp3se-y8!Yp5TF^ zqL=#q1_Hgy6=T|TT!DVpeA2@AYyr2wM{=d(b$rrWM`Vlv05eS&uCA^2Lugd= zH&^LdGaTrlm;b}5nS|6izFhc|@dBK8=e*CoQzI>~re_Da!$X9q1qerPC097O* zK9A90{X{b6&Eu}FeFj7-WEb^azgLUyYPOd3Yk1|SbGNb{CBo2!FF3wy4f|f7;iP1{ zK=R~iYKUHIxS-mN<0hQaTGUdg#LwWR4a5G`;I^ZCi_zdL<;`v_1vaxiOdo0Hyt48; z=ir=FvgS^y2pZ0a8B7WoQVIt`Y{eixaAb7g$Y z|AxOV6|z7U5?9tgW1M^+0DX9@4D;c^rq>k$-@+P@frFw(U!fg!jdc|AA|m1orA$wz zxyYhj&1c0MvNIy{U;~n`iPSBuT#{UNGG-CkKOC8WaRb}cC*%Nhs<;Yj-{eCnS+aE= z$m$SNjy$fZf=WwX&g%F@yJGiwPM6iAtK!Ibt}$huKPc;J?2q9=)p)^Dt4_0Lj3hJH zgY#t~Y$bLhS(}!VkmUQ1gEHoInz{)E1d&Pq-x02go(m-roQeaznF&KyRgc1%M`+5^ zU`PH)J1VKM#=zYMS{m-EZrKw8kK(c1lg7ZYZ;XC$10Gq|~jeE{N1S#f|Y}$EVs(i+I@e!iRU3UN8wUQzCScjkI zObfr~Nd46k$`!Oz+L}e>z#{yL8Rbbd2=i>Xxa>h(AZ}H0%r=Rj-{@`}*x{mD?9M-u zIL+ykdpfO+Z?MTlUO$z(wpKUw)ki0sQP1e6bLQgIq%o*Sq|>sJQ!njV~^)*!idvgA`JN%jhKOntVv!Cw57D>x$DAF5|VFyH$+EgVXD% zfMc!TGH)NReK+S;nu{^&F6}tU+bRHH7`oPUki;qL+Yr2MICbcDJ)z2D7L>s%?)6kx zTRdmwkfNd}?wdSKi5ue)XDFws1Rz+#89ojo+|>>sM6HwLgB|NUmwqb*Z4T^ePX^v6 zcMtXIn?|%t9PeHsZ~XeZ4pO_Nio(D3B{iy--~uK!uH$rl+ls9;>DhqA*1XJoo8JCQ zN?)6(2d%LJe1kTgle&o$?IzugnUXKIn1hw0I7j(;8C|4?k`ym|Fp*o0YyZjNMmeNq zA0FWA+sRd~ep9^MmvLF~_)5VBm)xsBaH0~YozEw3()0INj86W1Y+?TFRCVXCcJ-tQ z1A;Y{dK)>}uo_31b3(%wqpIvyqZ<^G-@V>>_M9DI_CicyFX#ZPzZr`4V6LE{qAvj)j|Dv1&yEk*xH-ELN)sDYhDz*beJHM zK|VY5XI%KwAXR?UTI}rrsnH>amvd)iPb!gdVX&1}yVKUDzDJ+5p3fNLMY>1AZP$)v zuKCB`RoL|8{GO|fthP7h%BP7oz5E1y-y7}q{%-!Vx9dcGdyvymV+tRLyPa^ye0Kvx zw$(~t#)26-AFNUHPZTEIQ!0UT6cRuE6atVm%L&|k(o51D&nT`Oqr_%K+ zzbKH~^rn}R{+wX)_VMY`FWKw39f7j$@N0WBjqK4lY4(dGsE7!&ALJ*G2Iy=zF45Q~ zW7M5#42s;CY_NXwh&|0TbludkFI8+P_h4j;g`X!M@?b9C*%9wtUZrUNYhkA-A!pY=wz^?y=ZqAEx5HK5$T|=}j2EGG6bJTX<5X z;$hWi6E5i(nYkg~;$IY$ns!2{F$VgyciEZ;jHHVVke`8A1hiP+tqY?esSZRE@dV$K zx$`B0Rnnx<=h(0a1=ZUnDcaFVE2jNA`&r0WSXQP8wSyILv8O_Jfws?)omNk_M|+j2W`_g>!mm~msq zGAVFhKvj`5|W2BY3fgJ1dGy z;rev{I#4Yl6n~k-4dqkq5$7y`ph`1`bI-ISzQ? zuw(*T1G&JR(wrR^x)Q?kop11JHn(k&jt)GQC)^1QFF(_6$p!UTrpdKlI8uo)7dd)7 z4>jtWf>|ffepuRsR#_z;Qp_tSmtqRWyopR{ANgG-?r$xyBUO7j2-XKt!E-wf-p(tZU-#8`KF5bcX|C5fDXG}5{Hatl zUR@s^Xh{+@y$ONWRkT)Yv*fLe6sc*)3FYkVI?S(>Wp(v=aB={Q_LnM|L8vh?SQm1z z*&fAQ@T9-N-6 zTNjp`^4DqfUBBADaqKe0dJesx)f6xIe0U@7?#qYvLyZkblnv5@kkJ@%TQKy#uhEU& zi0e(w`(x~b2dku9PvBHkvxH(@x~ocb7^4w98)_$W~viO`AiIRMb$u zY?!*x;KUybC*~Oo#2Y+n32`&%Ll8;Qs?d-ZBeHCZCmx-PawwKd#x(!L;CAh zzYB;tUJVBFwfTK9sx%DTx4ElN9*w~!^jg^W!*kTqaF*PQp)?r^B-v+Ufx`r7yDZg= z=4*{Kx4q8#7r=^VIGy&bE2X_l`ee6;oc36eC^=f?ID6&eRq0BhPl1cLY^tsxu3r%X zVf07Ox34AV4JFI!q%@I`A&9xcszzjGx6G<8R)+M6QA;i6MoWiz-41e+1a<;_#vbKr zW$0^WC78u2|o3J$^X2*=hr(TgnYQo4ej&`sYBW zvXXQ#H;kXW*c&e`4y*K7ZJB%quW{i;HTu)&NRAG-S*@;6(_j33a>%QY5x+oux{2k! znC8<!hZA@U~rM9J9jBY z0Cv__cE-_0;A9`|Xft!6EMSwwQ38Fy)%`X3v<=T`$@wp zZ_|8~Dr)KPS3I^FyT_B^$f{rwzjO(jZ~sfZikohsPe9gZBNmM-@dR&joj=WR#}3r1 z4!U=_{qXr5azwyt)y7=8600z8R(OZ4{J!7MY~0V>F1V2wpVfq2h7-aa9kG4=f_~FfK&9`V;sRW%KFXV!ah=F3TkGt;j%>yL zIW&+;o|Wp_uqs?41x3TKWY^?NDr5bLG6E#dnbhxAXYkc~8}zGVwbt3dA)2cEy8fm= zqA3H1m-+Kj<$^R4g@U%j>P7^TR7V^noqogw$hU4?<5!PJ7Pa2iQqJ`vTqyG_pRW5F zR;Z&c8HZV$iXt`$prW}FMr3zF*Hcn_&a>b5s7EAwz`FGZR=df@n{{QkyCFbP#0q=Mx;wtI z_{trP7oh^E02rugZ=(ubuF0XO_T=ckVfBx(#M1E`+d8dW&C-=J;cXMP_yNJ5c>#UG z&i-+qqJpw%O;Fz&D-%xw?R6ou$_bpR@jX{K>+zML4R%={!4!!Z7Cz@s1CE=NVGtc4 zb^15%+s4-QbnvpVx4SLe2J!(%0lHTg_(>Z`)-79cfj-t%j$=+%j}ecL9K$l@u^ zt$3jTVfH0=;5AUTC$_NolB)?gs;~80{~!aOPX1@Wp6fWdPHY;dN2*yyH+e4B(yyJo zn~-4rsu^%IK+{;HLq0Pb`oMcdDn8>qOe-PWwr7)UEcQ3fYz<*uK=X9=g36H zcip=39M@6oVvrjU_=x67v+dymer7#9!DZ0ZfFW_1S-o9hpj78#5&@QHcp#$4P;qv4 zXVFSY$iLcd@B~3}manRq4T@t37GWvnTcEERxtWVJC>2C&Ca(=dGO{9Mob#$($Ge(O zHrRqjW~;QU2IH5v+6@#=mlHvdIErW9CuTz~$$bMq5|Dm<;ol)T-ZHW?8?Pe<^1vsR z1MbA!v;-L&FAT7K1~yU5tIb*Ucc1j;XKNwzUGv>O`Nzf6N8P0w?p;IhZkbkKMhGkuV@apS9Wz~KK~uS6P7nc{o#Mj?Em z&xf5VvjtCgV_B))=3w5vO_%X#M_xtdu6X3#gyLrVGxtKz1qlJV z)}|$r7ujY0%Nj6+Cwn>JCpJnR@RTAw=RYl?Spn?-?Rg#af^8TkHb3TM#PyCM!^xCo zKosGSx$3SE6m?)MDtc|Gv*U8wr*gF-ibw85VKSO+sP*#{bs?`Nza} zXW;L&G1~?JkC=i_J<*E0Ry{WptT+#GJJ8cfJuJ5Dm>X-o>BZNx=hGx%KetE6;-n~J zRpRioO}q1OqQxP|1tJry=|gO`Ms^stTpHd?Gc&!)J40zeAyi-)L;d+&Ppi7vjPl#P;aO<u{^qM#)|W_MWb-hd!*LR+)~R zbbb8O6#1OfX`41bBbaOqae3_c+!ETxW^1u?#0g3?!}L|D5D3G>#Nl2Dhvb7VjUdlw zp+e8^#BzY4`Xb3+zKlT1RrVBrm1|lqdM)SVEnB9r(y)zC2h&*f-cd|b5K>Ie@a2U( zTZV|=S#uR{njnF$=agDxJ=%H?-D}0fT^XWx=Wwy1*Knbd?-n-yELdUDHOC>HgK0Uc z>=km`F7f5okGfsJq@PZW5Ba-WcFBnft~yjdAUimHlo%^u%Q)`pLN2RPuUOTL`R%2y^2yu^nMuX!>K6#fJa;B-}HS%};;P>I`fh631;St(*9$U9E z+H+gcch@qPF?Yr8!W}I(MI$ic%L~mWmmqii@>CoMve9*a(D`G}vm)_)&HGIRUxq~3 zL~cGn@Olc0I%%(goO#HAsE*|cUl_aT(0DT~MMwaJDI0`VNgz}mDgB4cPaWReW_vXt zt*7%S8Wf4WhYnacuvu@-U^N}gj|&A|AL^vX4wk^{LFzU>8zzHyA77yCzU>oNb6Sx- za^2Hxcu5r;sgoBIt)UMQ(Mhu1Y9Pnj}2UPYXg)Re*8s%7MTPyRRw-eReG@MJX9CjYgs4m&8rhvLyZy&ieP;B}3rL0()0oDVx_%f?-Mn!w> zMZ>*4F>AG_#6i6k3FIkRyX zp_ zEs9xfaWKY<>gY>bIQ;sJmk@u7z)ec~;%vd-w4!nQW$dq8X`)5mL%xsB+#cgknRy;! zU)*zvN9ZnxfWFhf&#o46tBHetXwk8#c(A=QM}C^f$UD(fh+l{$xx1J91DG zFgYk!d$KvJa>q5~VJ$eON;LS}EswACbe=|6nV$9+_UssiG4B7!zTe2$G5Ez6saNV^ z?jOPAG-SUN;Cs@R{XTk#mlREHv%l}!Qi$=Fwy1R3R#8=r`{+rauW@jA!>2hk4Pg|& zinEv5R>Rwm2d~z`h+A&2O1J{Ls4WNKN)cd&6}fvP&&l`8eT}aMXuQqD#u-yi4LXr2 zXLRP6r*i(o0%(Jtd0NraH+twks}_A*Wqp&Pfhwd1JV8&X3$tJ7hWG{hfmS{ z$9DN=z!rJsx>Qi?oPXhm4#$;kqILiD3^+4eiai+?n4Ke zv!sC@;Kd3}2J+97QXA;zclZFoBtcLx8a*wy=lQ&|_G? zbclsA28sOl76A+CS1=6?VW|_rkHvyvN-=ujkF1+>TRCB$ZQFaIQD_diwe0 zaj*|~V4if1tY&NTWiU}pZ$;^xH?9n7Ee_?^E#haJrY*3ifmn(^el@nux<+rzDsGz~ zRfLb-aXj(jOq2?KfQcQky`SMY-+ET)_aVmCywgR;&96Q7ozG|zqq_PFv9?sOeRB%m z#?DICj*kSpO1Qiu&A?#;dfaqa*YWDI;7j!3{o!Hsr^OYOszu;v_*+8k==IG(3yh4M z27cAavikMeqU2kc{XwOkYfNtmE^lndERtV;0%L1cUB$9e@nG@dWgoH!JmALsOAP{H zdo3!#0cSXdFA?-gExKKgr=+&!7&WC+h`VMPk z8(GaZhx#nB!%N-k-T*1ubPP5O_1{^#rK~~IX04O`v%BTYqkQXpd8m>tun)prYJY8( zKS!rzMs~95uv@iFpXMUm5eH_k;WqC}!0UbKkT z3Wy|{?^DIAanNd8Jfp0jdS7;m>bX5q!WZJ6r}d5HOGUA{-Q%l=E=zZ#SJy{sLRmEz z#N+!d&u~)-xlPf(94z5*Mn!Wb6iR!liHjRgy`JTrtEFvTdR8C=>Kr1!%!}B_f563X zET=wh|K@=ai;CmSeW<($oT_KcM~)-!)em~xk^TGEn0T%VeQN%GXyWk+w7N;FaSz!& z)4F^8=t|$3XSc~JjOlPQk(tk7`~H-t)YO)4{Y4JqurmL8a-KFXZh+xW zk*7p`>AL8eEVRidG>w73ysnrazo>!w&~(vJhI+R?W>l-n%gZpC`H_z+pU#~mvd!-G zlQ#x@J-fifpda<^iD+Bt7pF_tuV6=ITfZER8H?+drA#AqY%xe!AaZE2@bn`L(Y@YR9BQAEm1%f4*uS6?#MrX>2joU>|g4qp$t_OL&d=5M#cjYGPn5(AmD zlp%?<1b#spuWc@gQ`52leN__CiGCgBqG(y$gbx|;uKr4AhtpI#&AG0cFCPql9mO>Z zI5+E->7N@N-Y@Ti&@m?o?-gC;)7NGeNiH>|F0wxfI_njC2Vzt8HVXc;{oBC#CF(GX z{{8D#Hpzp_or)5Y&ef5UkH3edoYExg7Fs<|XUPrD(-3Q=4(mwS=^K=oed4$~AuieV z#5&+xua)=AvAn$tP8zrXgZ%sW?>z>PC;j|P8XjZ%agxI`(+~KZE<`$hTX46r5oS=3 zmh??M`hFmrA~SPD>v05R0k2P`$ef-o|NBtqUj~^$p?R@b4RwWpg{#qg-;U&`Y1zH% zb)V=^`=e5_(iV=WFd8D2zvfo_)nlp0r1$@8@4KR++O{Ya5ix)$ih?8)hzdxCA`6IQ1Qp2! zauCT9iXc~!j3PN_L4xEADgqL!$P^isOjQ9&&b{is`|k4=cK>yc?jFw%2ZEg1XPS}+x`Zm% zf7saW{!upJNB@8S*8TlJOc(b#!FHVPANT*kmbz_3Xwfk!_%X=fYQ-!--R$L|!3r0J zq#J&;Nt0s+zy5csJkmu+%B`XEDQ6^OgJ~wARg>x&$*Zx6qJ`sEl%G+Fu%`cNtnK-d zC85T4YL4;TS>ZqZ7&nwh-HyGyPojZl3V(X=H8<&%XQGaee`)mb3_jkLsIe~Wy*+!Z zBOpQfrk|}CY-zVVwFNjEm#!FJxRq~r)>%*aAKjS8q$J!iyY7Pgu=MLr*q8$r}R$0Mtjdc;+-AR%=h zF~fC{;{p#3l{t)84whTX%#KtrTs}Edd+B)iFFkX6NK~lWBm!wF8q;AGAqbNrbsZFT5d@>yV3CmAk05c+X(=Y4hl~*nb+H zsL1#3jV;zOE-SZQs?;faqWE~El-_JFLzV6`1ld`zXSBih^9i}@UAZG}b}LEx70`&9 z2yF&nNE5{WN89VTjm7Csn`yPShVeRoTJ?M*)Ma4@9C_lq6(ML%?~~ClFhV(VOezI4 zdxC44RDHl1)|I&tcaF&fb*tm^2XPUF3`&FTxx;q0gNOeH2?<3#4~YvQuO3m`^7630 z;kJtzIMOK@%b|-H!0Hs4E!D2vLE9g|Zh+m6!Eogo!5A>e4f3VSV4w2Fb?s-f=e`3X z#iDaZIVXXM2PZ(p~26U5%UXrm8&X4m`?hy z8KW`l$EDa z*X~=1ucW*Yqqu3UCwKpta_OQH)n-;n`xt|cjrBlZ#@NJ*A;;e3u9N16&sPl$SYblI zuJPv5On0i3H>J(OM0R?4B8cooj;@qHL#Wt4p>kM84i8t=1Xm+#D6a>_d9Tm9_vMej zM%dGpInKpR2D)DVpzrA|Zim;N+TOFga=h=CIw?#e>2>B7lpDx-vYq+dz9-Nq6s%^u ztFgwQSl=h?Sz0ogui#0t29`z#zHrv~zkWe+Mn3Cap3Y?ZGdaWwliv4<(7t%B+U@GxOugW>}`-;0x8{lMTJBdt5F@#kW_JyK?pQlVpQt`x)CrOC25(E2DTm zVWygRhKLn*zx?iQc0R2T-G?#)@4IqO|AXds-H|+x!;2UlHh) zXJo~&a#_J?h5KVbqgw9p06@3RVM=;Vsg(`oNiu#nw>S4I);A$F%k6xIiVYgo%%`hI zkBV%@u*XpVa)9NYx<0U#^`UkTIU|tM+!XpwQ8hi!|1{U9zN>Wi2^}GCICp%f z9gAqymR`5S3OA6xez@VHH-gd1P`en3u=gFTMk)XkofQ?uSM>3WsN7(wHNNN+o7!he zZ(-y_A=nh7j<|#D>CTK+?(mKdX0_iux6h-kYGVaAUj*i>&ZJ0^@&g*ISAQXa{JHw5 zC`mt!&A$^vuKT*w2ZkgjOXbF_BdRSGLN8C#rH#92KSB1`ntZ6IB`LO>R?`5acHiT{ z(!3LwR8`S&qUI`KHmMw4#ADQQdP7L7(Bx+$Yy6i9lr~9a3ixL>g5jj4^#r(54@kT+ z>NRN}W94Ibk>bgWDsI=}xpFsaUeMr!$KJGJ^oYYyz!`y5<=FltsgyzrCb1TyNt0_g z2cQGi0F&bm|0`w!8LsOHbFDs_o~j!shAaoc_Yb7HKO6Pkw@!X_!tb5%GvWecA~dN( zTHhVpNpuMCu=OQw@Ima_ z!l|3HgPi?|a$vyw2K?M2EP|DmyTFE{9wjPe0#Erh*P|N)mM@ObUI6ns5T&8SESZ0hH6Ct? z6%NOC(J@>1gSDUKdse9zi|EWfBTG7?z z;*63aHKQ0(_hx_K#q0Ap8lj@_{aauZ%YrpYWlzCn9cdC3M9v2i-!X9F$`l#4WQI-A zPp%*_UjtnB8X7ZYS0qct@t$7H!UMvc#;@GcKIQn&F)XX4M;Q+g3y6OZRr@B04OU5C6|^Ga=Au%!Go$ z64BB~J2OUV$NM1}%lmQ^m%d@vChK=uTtU0WtqOE>HuI$HV`(mn!I@+pO-5emkymYJ zbUFJK&x22-eZc&t7jx}BvgLGpkER#HddCOZBxxTVBxzy7pS*GdU)mKLe3*z6_ZnEM zTJ$u5P0<)wQN9*Kk`1i5@2n{{X3_FvdqkU(+P(JCn&(ZnCqAUZofe!I7INDx#SY#| zzJa_NJla3i`V970+s`j)Yssp4?T@0BxZC%^vcJX1HBjphB_JL^@di5L2&PwE@ntbH$FD|))ENf5Kc zbiD`V!vdF6mvj%&Ts7IiJh-Wqa?d!XW1S~iHpn4X`S9V^2(F>W7#uCCsXZ$MN|#Xx zK66Wk)9hhc&%Mse23nW_Rbi&|>R~$;1u&9N(J9L(^empa*Z$SFn%}bjs^2M2uKk|a zKJ)fg#F(6U=M+vQiZ2C(A4@`5Rr)1D^%U+$8I>wV^XnF}1V9Ubk{=zDnCqps+W5r? z5A?gqHZ1`<@=lj-4~>mwyi!_cYmc%ySt@8Y8Ffz;`<~Pf1&?@>k{lY<9fYgG1{3d; z3pR--B;yt|_I}Qw9-Dc&|CjFaKf|t1WfBpGXWph8eK+X|s3s6Xz1R^gO^yB5Eqr@Y zlt%1pj4h)i)l%h8o*{)jrQo1qOz`&BMqk#+65}6u?G^uJ!8+Cw~`xv(AH4L&7C`)t9$rlLr^^Phsd;t zpI<&Gut&#>&rL@13A2E0kj;_FjOXMVsnm%&yP!kf4HlBRv*e z`ES!XCX_CW)N%7F>~PuMqvN&mxe9 zfRK`i7W&Oy|M#I0P4oZr4~;4ZWV1m+0n$V77u3IdcRsz5=(=Xlw^zrX%ogG3cA{-< z=AGDH9ExT4V*F6C|I?kcz=cb^r3)h54(DV8uR?Gi4k;j*l$geLI#t$yZToAeJO$Z% zZUu`W5$+ol7EH4n<1LZu4;upU)iY(BtT%%t==jVo+)5U!d3s;fKVEnbE3tYFvp@MC zYYBm5R$%c*$Tz~okc@_tq>lH~@8vzrx1FTatXQ_96?ys|n%Q;1BJ)z?>x;Icnb0b( zu(Q3Dsu0*qbi{KKd{q0`qo&mgO>Vg_|8%#901MjNn>K1{ZTr7(R6@6VcvnW} zmsjUypN;!hZ>(6-Uu8|CCVotns?73Zm)c!k)NR-tof5>9I4_w&LjShW?pS+LWB;PA zxH(H}LGf8BvSvaslpKWkf*&Zc6rj_&SNbGz5!WXL=hp>@sq?W1j;!9GWIz~r-=bhZ zD8jLO5rShbTG2+l+qSM$IESub?TdhXi2a-hiQo3-T8=Is)(|obZkJ^X41!{l*Q`qw z3_sZ*ErZN8>ZZ@}ix&VlVTm@$hK#D#=-}4&##KVRUTC`DIfjbXfL@N7@&kCq6|4Tj z4A*%{bLy-n!@15fSq7u!`ZHEv+;@Cjf6B3Ts@QkV*$aXDZWUz>`kiME=%6(`##ib4TYnQ{H=hM?<50yjmrz?1BND$6GkOG@|34Rnk@lJ7?8*3GvikB zi_iYFokh`P@-y8V@z??mu&TNR2GLo51lR??0nZsP1nbpQ92?HaGIc`SO=NAMRb0lK zdpeja-IO98n(Ct@ZklPnzn=&gf-IrMjpRxmjsqg25CD^+JF@CHg^NvCV-iq3P26ALxtKpWBZF{drSz-(MY6GT25-#TGVJ_X)i1})`XMMzvP zLQq}&JV_r}ltl59)J|_8agE!eSJf1g6MGP+2&|hZ>~v3dG}~8tg~!8{7s0FdVL=G= ztD2THykwMuesfKWQ^vyZj<5sOzl^^6knej22 zHtBpIr>@BFpK~~{*lo*ML*NX*MKDCZk+`r$hghRoldUtTH|^n+`W^mJgJrg5fnaZw zPBA5T1+*nc5yP63BQEv+v=^XyH6jL5wDH*6kYVKQk1V=W(Bn-&8_fqgIZ~9Ods@Qo)GK}*mf_H= zysys`Nm~H#!x&dVPv4Z*9syo6!dDvD!Zx$^GNkLHl-m)Upe6Hz zT-8L0E?a7JqY`b6c9D<_205DkPfZA&mMYWO)3Ix&UxE`I1j8P8u5+QY)DY*x=V;oT zT<6|>WMF?Q0zQ#z_!3!rhol<;m#e*)tAm!d`apNm$ecLW{mnjw{<_uND~#=WRh30ky%8v8MCr+%_eCGSUW~*TOSK zMUGJ5Oy1X5O(Pg=QaRK2^Ccv2f!Ys6_R&)az-Vr4AHcDnJ*e7TlSBp8+kDLb|;x z@Ra-R`_Noo=KS*A+GFk7TrFfCkB4_k(KIgB;{14mbdI-MJ(=t zR`ku>cU6to>G)=hg^%DsUhO!$7OyZiY&kdbI=j|WGJBF4{h)T2sUAEJPXR`uIJf}q z(A{-p&gSUtX}7-&4ga$xor{u)pqQERnrG;3Ki33jbtypR-r1eu=2YVV4E}89Om2BI z>^%n7^`V*tM$vtVQGyks%Q^dl?47M*x#aiJR%?R8p2P;PH-DH^r^ zFwJUS$;i8ZC5kU;0(!7wz>JTrd#6`bVhQc3g9XN%qpKAMO&EA2^Mxk(T1TZ-X58Vt zOgp{sMElbE;P__5N{jx6t-B<7}jN>B? zBUnBKRsiW4_j@DfsC!!xL#0L^RxyQB@5w^qicMJpjP{A*+pbKCs33BU#-_w3TjL!m zt*hRt_?}?1NPFFb`|(aR!(9{OreogE`|x0b;Ci56Lk(NSUOa`v-TclRgdDBgv5K z^k$HJ*FdE&KsI%3+SK+h7v;Y{3Cf*(gqr5WEt~VZ#z^%Fa#`t=bX3)@>G8loR`(-H zw((CTkbnH>9(9&g zIVKpQAkkv>tgM?cf_a~VnDi-`#OUvG_+W{`O>m-ucFr_!ePAaGeDDY)bFqt0D2DL) z3sjOWKr48VJ)be!!kYUzer_sDwEp8=Bg`z-1nDOW zh~4a552!;;=i+*%T52Y$t=(}*c7?*M8Mvj3(+hW0D~?gTuly;$#=IhRVAnM~BqT^4 za063}uI7c#(sU<=%#QbcGV0*`RCV<}iM>OG`U5wD7}@QyU7@}wudEmK6)AfQ@qq+g zeIUd6eFxmGKrnA>i5;$r21OHffGRHlvikba~piQ&!$!Qw!jy9Cy8cs zp4;;pT^HN*N1VZw;79tE0Jm)05yswe2uT~$Sp-vRBX}=;*XaD(lp;r+YSx{ZZ_#H{ z=CTsUHdraotD20TtHJcIz(AWo`f&_W1Vyxs#IW~85Jey zMTWHEHW+ro1w%1b1!BZ=gvz%2&KBlzjr&eD!}b!$6E;G@(PFs;O6H39jq-=YtcFFV z2TF2HxJCgymnTSrv>6sVxr5q+f2fGl&zqG`e;CyHo^)`hxNI714A#TT3uBGyAW62Q zR_!Wu=4MCe#tJ?hO38irS$zNnO!grF-r&b{)gh^O0l&;&4gXv_eV6)UBC}mjN%edU z=;^e!Lf@X8#l{(yEgfL`0x^Y>>ZB#DxZ5+?Q&9gZu8p;u3h6w4QT`H?3LV76mfaSo zxcZ9B!zr4sYUXztE%BQ7=!9|Vv%vdNOe;*+R%VY41E}H_?=>Ft9t#ceQye5yZj!1nTr=?MY2&1urhhl@@wiR}P?hGE~3aAqaYH zu1)GEMT&s)K^V#YLlFte`!PS{LRgAE#)9z0Q#)(O>)I`qAwz61PQ+wjRN_R~Ah<8+ z6GvB9?n0bB91jtcCcL%0_@Y9SJsC!xhs0WYiFlcwGu_&IldiF3C+JKwg@J$Hf zWlJ?r;(0AHBiWN4g>HYKp-dAvXTdO(#u*AZMU`YQ^J*35+3#b zgYWa_KKD(7S&_{CjXIANZz)Jcq3W&0D%6Gh5FaB`M;G0<*6pe7Odp$f31Xr0&jEG9 z>Z-WWhM;CpV;P658<{P5qB$JsY$(3a3p?Bfq|6WlZgR$%aFY>)qF#=6fl`m+bRuFjv0MybEMYpLKS&;PJ^!Phv;HcnFwmwi4n569gkuA{sowpD7HGQwKDBzq>xvA*Vr7Si3n{^ zk_job9`)_%O#GF{^4GCjz5kP|YD3xmbcaB+f9mr$MW!rAe`R(5HIA1rA(VYfeT<26 z#s41%_MfNrDl3(d`OK>0N9gq2wU1Fzoqb#DxOZ37$xKBFrHemmz3VUTTJ3uv>Re?c zngPN;KM|z8pRBf(?w0nJ@JOH>+3gtNAt6X8T2M04h5u&cU;^p#n<0~X!vH>glNx=! zyTAakvg^@*266vdNvaTg3%Z)*rkfT7lP|~(SMSC z{`}BCq+kHw<`b>KdTQ5?B^jy@5HB2vES?ZbOY~yA9KWbX{ChaeZ);a}Khgg_oc}8x z4!MA2EI`(cl}@&Jh45W?2q90Td3l1*IcDe6J`XaxLG94XcZS6Cf3wh~FL^!*$ylh2 zlZR-P;Q!1xBlI@9 ziv3Ji>z3A%6QJQY0pnR8Y^^&;ywb?AgiWF|T}9veh3>S?lOm8W`||!ltw{M5oznMN znhW8DmK%u;>kvA8ng3QR3LoVSB%+(6^>gB=vkx3b!u6NAqWG|m?U-UqZNt{MAvKU7 zn{+?{+z{-xRUHPh#v&!QZCu1J*f$x%CeHh`GgZO(XziVMcYVlYAed(_#|>}xk04zn zngJmT0pTn8#i@4PicnD8+}>KzZ+Bd|n`TD~SZc;bCV7=B94C!2LVieyL$wfgtM@=b z*#n4ci)E!(wTlN0NK+x)2owq5o<8yKm*=k?%5(k#fmjA4pLL;F;f+e+2)a@>i29I1 zw8G`6nyuK;xW#IKIGyIddY-s^`%95IM8sp0@$O*+5lD!)aLx>`?<5%pOwg`Vbm81bRefDp2m z^>inF4BCHqLSeX)vrHLyt4eFr=}Z^R-n=y$Ai+2rp9Rt=OB+|Wh4v+I5-Ezj?!KA# ziIk%wYN_)gFs8VHSD``nYFWUn>k@Tu<2j%!hC*sK@IXbm4Jt=^$3^vWEhk!Np>lZ0 zMJexLU4D9(;syL%c1dRR*4-#(xg*i`Z&+0rD|E42SG5Y;V#JH8SafS1*4bDGI_7N1 z%>ivT911yoL%ifGAOWbva+@ns+`W3R(y4P$gUoizPsu@rg?f+HgZOVTLIsw=ia}73 z$~{82ScN|i$>z@?$C1WwwX)I$&$`O=s&>jazR7_aVp*-Kvr@!J9mewaghik)~qzH=}cQOmNvL;2`w=0jFzV^W&qjIMFkAu`K5qs*6 z=Cq=pk=;*IiGQ4LDaP#?sp()JNH1hj-{j5YX5z(EuIqJfXKTg8CZ9l>g`5Jv4Mt$0 zb$+LS-m?&gux}z2nd$QgnoaNo!3jzqGGhY0QA_ydVG0n@Rh}Izd$b`4mT`?ejb{f+ ztiplE9DOAAU`Mj7N(G)E{wx`9_>-X54&_IDIV-aL{=MfKmwT_W8bghASpn*RF+to< z=Z|tb&g!hq*A_brfjWxPi&l$A?Pvowl@UdeWAI5^#Qkm)ta^%BR zPTlgZm<5ICP&F`orEd9=5uHrSrCZ^UrwecI4H}Nc#?tmgEkxJNeIm-yV#pT^bg7z` zqqdSQp=y9O!ngpsiwe%;m~ho^1pcEA))CkHz`U-7PxEF?;6}F*Y6@8{u;S0ezzvGH zu57BzHACZ2Q~8O}H&1~B=C!B`e`6SX^k;5OSu_eZ=d%yB!s`oixPs0j^y!wv`F)y&b7q))!z)lZ)j~yZ zsb}7C>CEZeFk6s3L%kaLhymV8ycP3=97Y7{a6B>Q32+6Bm~a(GcBnKl5d7f`>8HW@ zD+jtoC6b$?ijq9(kwTHevm51eb6H1m8<5bB0a+AWMUJ(94Nw;_hNP8WFsXpul*`Z< z2@=!ekhwDs0U6q6>h_4TZEL=D&z&3{IsV6k7a&bO9(!)cl&fcCj4ZO;&t=5uazwZKH@@1dl834|DgbiDr$QQn3n7 zY2Oo#dM^EirWasS$iV>qvvowVjA@|C1x_+3GL*>p^S%5&JV>!9Lbq9FH=VS>(W92z zYU-`{;uTrF-D>yt)`6IA#yQWj37}j>Mu~+gzUN4ztzW8Qc(GLJE9jy_&qTbS$oKwP z3c-k)*ZcB(Zpy`f%v1KJGYz0eDYoAaEQV?JA=7|E>?Ty9Ot4i`>wqiCRlADOa<6Mf z_YAgcMsny^;|e~-=Z?581klR&U<^<#Jhf3bBG^8RSX1P6d)Ow8K6M|fl6c5CF4k;v_Z!gI7sc~U{ir)trMm%@NEvWU!>(r? z278H9?8>h5UnRA#-1N5FS{{(bh?TP^e3RFjyipKIGQj(xz#eMk9lBLV%VIV z#X@l$KU_7ijuizmD7q-OAOUHLO!mmp^wZvf(s9~p+cUS0W@ju+r^l;lG)RsSAt%f_ zhyUnPWVq9vMbG{kDqIX^5<$*F%ZyvvO!;g%8*nJ17tzRz` zL|K%nRpe{(;~|CXRMu_aTyX7uVU6b4uEqwlBdZqB6+fZK676y8Kt(PMHEb;5>=&}G za2vH4_%WK_Z!|^l%6n^@lKD}Jb|lK7sW`It8F=eNe)^(yg234<*5pFx0e?OOM7@JWpXg3&{uG!DB>MlS5UWw+GfVQ3HVfl2W=;b^M)|&O*^@wkh z%q>PUfP~3(CEls_)+jr5yC+do85wIV9tqBYVcEbMkMXlb|suj9k+AQ9u`A>fwr#1Ga#vZu~XMo}f3LSVun!D4L)y3R4 zpK4d!;PJNWw?*A;Uq%V+!{P1%o3sebaNQ7DEvC+>0J{Fq1#&uE`vYYAu74^A zD(EAN8YnC(BXf#8?Z~dJAjFjQC7qZA9>=hgXRF6xo$MxZ9nx;*qb1Vza(frWg~H%7ycM&e~q3e>QKyb~B;qKJSMlsq|;b)7Q?jKhBC2m|ZdnWE6df?Ro26 zFqFFhVWK?n+82S zA|qs&-N@HRh+w@CAMN7p{?niTcTn~;R3aj7tLa@z8SO4yLW21;fq{6QMfW%8!r6e*V6go~??@fbtvhn8fR^GvmWl-FDe_a0P)(VQo&;4^;xMG_vbb=5m~C=PBzAij}*{1asA^Gge!g>CwTzAn@37!I@5Da zuF2|`)5l2V0aAYfC^f-KX9VO&s*ykfMCH5I*~mrnTfP(fR;gOk1T>!A^uJ8!9gy4Lr8wWKlJz#1oQJ`h6rbKIEB5EP_D~i9hhm6$zJ~ z$Dh1}O;C;sP}ecK1DY<@LU%LIh&)xTlAVLA_yN(R0RoT$<81vJF^&og36USthK&V5 zz_~chZ*fm#ed>IBu6~UPbW&z79Ti=?zc@D{PRGCb+HvfQa1vA)y78U31g=N*(f|+? zAPAlSFGx5 z<=A6%laqkzQFqSxKeO@YwA|-BvC2K8YUHlNC1f*m*OJH9*AP(YOvjF~^msP}*^$z$ zYf_WV<9DGbXDzbSrQUrRzVCeY3y+xCh6QqST7~DZWNdsrSYhbvjzGhIgy4_H-a^wy z(3~dVwp_5S$(+`k0E+0Zo^jVDJ1tDKm&}RF!pbN}y1i2|5)j~f<0Ka#6j@oaL5$Xm z?L!YCx8Xvr7iK|+=X5tCq*mx>T73Hi!*y)rQj7t!s9L@Jo11uhX{CmZU9!LXmBXjZ zIWDs<-MkSu_J=_2{L!OrRo9i#ddg_l-!C)s=sPCl2y(@!z*bH5A_Loj)+Cu7>hm|~ zH3d0+UFND-bINBjuirOTySsxcXsuHc7R);*h3d2)CU2m%E%?{>1IG3xT>^;LY~jjLkqb)u9iiUFcvNIw zA_loE^kX4jpBlihmY)+T9?A%)cASP3{^?Rpp#_%HUGvxh20K-Y{!%tEFr{)v?$}>uZdhx-<=4?QnH0~j~uWpn)`Sz(d-%#P~7h7Wj+SQ$_;Q8iu+-rupf;lnG_mj|} z+B~CvoY2dBs#sP(c)v@BMFQ`rG4Ka9k>MP&x5d*r#2s4PW8z94)V>fL_2>DXRe<(a zR~9Pb2(q=v0~^=R?{!Sluqo!Rv&P4qPL|!_?=QBD#*5fPDY;?J zeMjPK!_<+K?ewC0JVw;*J0k1R>}n~YH_j0&OZ)v z9N8Lq3ZLkA2Ti=J^sWX+Cn#GVSsuF@`<(~cxI@_Wofl9$fi>IqhG(beoAaRCvUL8F zI~%`GGiQy+-K^D=YYP)g;2&rDVDjC@_bBrmpF)Ex3>rGtGARrNdM^0x>s$F2#_y_; zoNx6G@jacV(5wj8i+Y9k4K>>8S}Gk6bLYf$@G&d2=`0Kvl49GWj~ct9 zZ_+L@J9!1z#n}k$%uu6k*PaL;4Qm;HEa-#$%|MlDRX`k`ZkUaE+172d-s{$tB0 zCCAl~1kmxDai`GlRA**pe&SAh3Dj_0zRNzcBZKGn#xA}-@kOr8$|3Z}T-I{tkvp%YYH zjo%Wcxrwg>6f^ zYIBMCZAh(Try8SKXhrm(shESM_DaUmQ$}$RPckW~z3aZe@9(cKJn0XS&x}23 zvx_8p>6w||j-DZUurTMUFz4Q0Y0bpf*(=E~LVSh)PWk`VDfjfXxhOcD5;D_O1PDq` z1`<^d_=rE8f^|T&H>Z)6k&yv8_KU+#ld-)}N0S@El$i{b+XL;Gn}$=bvU31@9lzXz zlhUI1{eyJN+q)JX-ECge>Z-xhY7&!WlX2PVB`=OLF#=YY+bLRC-zqL%G+!z1rcjhlGDGG5rAISW`_x>%cc`rAL#rl8p1pl#+d8X!;)B(HpIAr9Mg zCD)&6`@Ku$&i01&WNh+7VB6Pe2=030lCB!hq(lnVU6dCNyme?QBX?_4ucZ%Lw#&DdP&uS@}_;sW*6jEnJ z)n}B&TpkaXTeDGfLa0%(jt^!;I;QtdU;|C##j29EOL8uj2tXh<<~-Gw$OR}*>Dt6u z&9z5CqBI~ma$U!+U4{!vO@c9{XB)*lyVnJ^1P&5<}XqKI@wuxENT|hRr!@ z*T)KRmt)Pz+#D*?b1)DK_(4td&0v^ACIXvPklEak718|UTV2e^J1K9oi7TVdTGjL?rS=Yvj=2}xXs2;*vYw$GLpsI#0TIus<>NeD~sqNuWy9n`C|6~fIqq1cvD<gic{|92Dp-bWysO}f%iMMzQd1ekkX0~sZ@9#1tm;MI<5 z?E1bAIUMB=BkW!ik4p72^IP?qZ@$Fe4?YZ6(m4{MOdP?H%0qx6FRHWa#%<0*DB|~^ zLykBUk;s4mD8u{8uJ3ce8EC_$h>#=R;xC<~>S9}ZB18QBFVEph953WpcZE`^Fomg% vIYh6^UXn5hn;Pq`O= v0.9.1'} + deprecated: deprecated in favor of builtin child_process.execFile + dev: false + /prettier@3.0.0: resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==} engines: {node: '>=14'} From 60d5002eb68e6cfc1ccce578a7d00bbb5d11c6e5 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 25 Aug 2023 10:10:15 +0400 Subject: [PATCH 242/277] refactor: change template archive extraction to be on provisioner (#9264) * refactor provisionersdk protocol Signed-off-by: Spike Curtis * refactor provisioners to use new protocol Signed-off-by: Spike Curtis * refactor provisionerd to use new protocol Signed-off-by: Spike Curtis * refactor tests & proto renames * Fixes from self-review Signed-off-by: Spike Curtis * appease fmt & link Signed-off-by: Spike Curtis * code review fixes & e2e fixes Signed-off-by: Spike Curtis * More fmt Signed-off-by: Spike Curtis * Code review fixes Signed-off-by: Spike Curtis * new gen; use uuid for session workdir Signed-off-by: Spike Curtis * Revert nix-based gen CI task until dogfood is on nix Signed-off-by: Spike Curtis * revert deleting dogfood Docker stuff Signed-off-by: Spike Curtis * Revert "revert deleting dogfood Docker stuff" This reverts commit 97621581670e21945c78ac46955c1862727914c7. --------- Signed-off-by: Spike Curtis --- Makefile | 8 +- cli/agent_test.go | 18 +- cli/configssh_test.go | 21 +- cli/create_test.go | 95 +- cli/gitssh_test.go | 2 +- cli/portforward_test.go | 2 +- cli/restart_test.go | 42 +- cli/server.go | 13 +- cli/show_test.go | 7 +- cli/ssh_test.go | 8 +- cli/start_test.go | 24 +- cli/state_test.go | 16 +- cli/templatecreate_test.go | 77 +- cli/templatepull_test.go | 10 +- cli/templatepush_test.go | 31 +- cli/update_test.go | 55 +- coderd/activitybump_test.go | 23 +- coderd/autobuild/lifecycle_executor_test.go | 22 +- coderd/coderd_test.go | 2 +- coderd/coderdtest/coderdtest.go | 20 +- coderd/gitauth_test.go | 30 +- coderd/gitsshkey_test.go | 2 +- coderd/insights_test.go | 16 +- .../prometheusmetrics_test.go | 10 +- .../provisionerdserver/provisionerdserver.go | 6 +- .../provisionerdserver_test.go | 8 +- coderd/provisionerjobs_test.go | 16 +- coderd/templates_test.go | 2 +- coderd/templateversions_test.go | 88 +- coderd/workspaceagents_test.go | 88 +- coderd/workspaceapps/apptest/setup.go | 8 +- coderd/workspaceapps/db_test.go | 8 +- coderd/workspacebuilds_test.go | 65 +- coderd/workspaceresourceauth_test.go | 18 +- coderd/workspaces_test.go | 108 +- enterprise/cli/provisionerdaemons.go | 16 +- enterprise/coderd/appearance_test.go | 2 +- enterprise/coderd/provisionerdaemons_test.go | 6 +- enterprise/coderd/workspaceagents_test.go | 6 +- enterprise/coderd/workspacequota_test.go | 70 +- enterprise/coderd/workspaces_test.go | 40 +- provisioner/echo/serve.go | 291 ++-- provisioner/echo/serve_test.go | 306 ++-- provisioner/terraform/executor.go | 122 +- .../terraform/executor_internal_test.go | 4 +- provisioner/terraform/parse.go | 22 +- provisioner/terraform/parse_test.go | 210 +-- provisioner/terraform/provision.go | 252 ++- provisioner/terraform/provision_test.go | 521 +++--- provisioner/terraform/serve.go | 2 +- provisioner/terraform/testdata/fake_cancel.sh | 15 +- .../terraform/testdata/fake_cancel_hang.sh | 12 +- provisionerd/proto/provisionerd.pb.go | 390 +++-- provisionerd/proto/provisionerd.proto | 16 +- provisionerd/provisionerd.go | 9 - provisionerd/provisionerd_test.go | 452 +++--- provisionerd/runner/runner.go | 406 ++--- provisionersdk/errors.go | 19 + provisionersdk/proto/provisioner.pb.go | 1417 ++++++++--------- provisionersdk/proto/provisioner.proto | 183 ++- provisionersdk/proto/provisioner_drpc.pb.go | 124 +- provisionersdk/serve.go | 27 +- provisionersdk/serve_test.go | 50 +- provisionersdk/session.go | 318 ++++ provisionersdk/transport.go | 2 +- scaletest/agentconn/run_test.go | 8 +- scaletest/createworkspaces/run_test.go | 34 +- scaletest/reconnectingpty/run_test.go | 8 +- scaletest/workspacebuild/run_test.go | 18 +- scaletest/workspacetraffic/run_test.go | 16 +- site/e2e/helpers.ts | 201 ++- site/e2e/provisionerGenerated.ts | 314 ++-- site/e2e/tests/app.spec.ts | 2 +- site/e2e/tests/createWorkspace.spec.ts | 2 +- site/e2e/tests/outdatedAgent.spec.ts | 2 +- site/e2e/tests/outdatedCLI.spec.ts | 2 +- site/e2e/tests/webTerminal.spec.ts | 2 +- 77 files changed, 3426 insertions(+), 3462 deletions(-) create mode 100644 provisionersdk/errors.go create mode 100644 provisionersdk/session.go diff --git a/Makefile b/Makefile index 62528464cb419..56acd83ff70c8 100644 --- a/Makefile +++ b/Makefile @@ -456,10 +456,10 @@ DB_GEN_FILES := \ # all gen targets should be added here and to gen/mark-fresh gen: \ - coderd/database/dump.sql \ - $(DB_GEN_FILES) \ provisionersdk/proto/provisioner.pb.go \ provisionerd/proto/provisionerd.pb.go \ + coderd/database/dump.sql \ + $(DB_GEN_FILES) \ site/src/api/typesGenerated.ts \ coderd/rbac/object_gen.go \ docs/admin/prometheus.md \ @@ -478,10 +478,10 @@ gen: \ # used during releases so we don't run generation scripts. gen/mark-fresh: files="\ - coderd/database/dump.sql \ - $(DB_GEN_FILES) \ provisionersdk/proto/provisioner.pb.go \ provisionerd/proto/provisionerd.pb.go \ + coderd/database/dump.sql \ + $(DB_GEN_FILES) \ site/src/api/typesGenerated.ts \ coderd/rbac/object_gen.go \ docs/admin/prometheus.md \ diff --git a/cli/agent_test.go b/cli/agent_test.go index d33bb55d00b2f..7073f7c0f18ca 100644 --- a/cli/agent_test.go +++ b/cli/agent_test.go @@ -75,9 +75,9 @@ func TestWorkspaceAgent(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", @@ -127,9 +127,9 @@ func TestWorkspaceAgent(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", @@ -179,9 +179,9 @@ func TestWorkspaceAgent(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", diff --git a/cli/configssh_test.go b/cli/configssh_test.go index a592efa424300..44246da2596e7 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -82,9 +82,9 @@ func TestConfigSSH(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -720,22 +720,11 @@ func TestConfigSSH_Hostnames(t *testing.T) { resources = append(resources, resource) } - provisionResponse := []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: resources, - }, - }, - }} - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) // authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: provisionResponse, - ProvisionApply: provisionResponse, - }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, + echo.WithResources(resources)) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) diff --git a/cli/create_test.go b/cli/create_test.go index f3436c0268f3d..bdd229775ec68 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -29,11 +29,7 @@ func TestCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - ProvisionPlan: provisionCompleteWithAgent, - }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, completeWithAgent()) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) args := []string{ @@ -84,11 +80,7 @@ func TestCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - ProvisionPlan: provisionCompleteWithAgent, - }) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent()) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) _, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) @@ -141,11 +133,7 @@ func TestCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - ProvisionPlan: provisionCompleteWithAgent, - }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, completeWithAgent()) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { var defaultTTLMillis int64 = 2 * 60 * 60 * 1000 // 2 hours @@ -240,6 +228,22 @@ func TestCreate(t *testing.T) { }) } +func prepareEchoResponses(parameters []*proto.RichParameter) *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: parameters, + }, + }, + }, + }, + ProvisionApply: echo.ApplyComplete, + } +} + func TestCreateWithRichParameters(t *testing.T) { t.Parallel() @@ -258,27 +262,12 @@ func TestCreateWithRichParameters(t *testing.T) { immutableParameterValue = "4" ) - echoResponses := &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: []*proto.RichParameter{ - {Name: firstParameterName, Description: firstParameterDescription, Mutable: true}, - {Name: secondParameterName, DisplayName: secondParameterDisplayName, Description: secondParameterDescription, Mutable: true}, - {Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false}, - }, - }, - }, - }, - }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, - } + echoResponses := prepareEchoResponses([]*proto.RichParameter{ + {Name: firstParameterName, Description: firstParameterDescription, Mutable: true}, + {Name: secondParameterName, DisplayName: secondParameterDisplayName, Description: secondParameterDescription, Mutable: true}, + {Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false}, + }, + ) t.Run("InputParameters", func(t *testing.T) { t.Parallel() @@ -427,28 +416,6 @@ func TestCreateValidateRichParameters(t *testing.T) { {Name: boolParameterName, Type: "bool", Mutable: true}, } - prepareEchoResponses := func(richParameters []*proto.RichParameter) *echo.Responses { - return &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: richParameters, - }, - }, - }, - }, - ProvisionApply: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }, - }, - } - } - t.Run("ValidateString", func(t *testing.T) { t.Parallel() @@ -626,20 +593,16 @@ func TestCreateWithGitAuth(t *testing.T) { t.Parallel() echoResponses := &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ GitAuthProviders: []string{"github"}, }, }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, + ProvisionApply: echo.ApplyComplete, } client := coderdtest.New(t, &coderdtest.Options{ diff --git a/cli/gitssh_test.go b/cli/gitssh_test.go index 9c38ef945ba7b..3e5045acf0288 100644 --- a/cli/gitssh_test.go +++ b/cli/gitssh_test.go @@ -48,7 +48,7 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, str agentToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(agentToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/cli/portforward_test.go b/cli/portforward_test.go index ce480768525f2..030133a7ae317 100644 --- a/cli/portforward_test.go +++ b/cli/portforward_test.go @@ -302,7 +302,7 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) codersdk. agentToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(agentToken), }) diff --git a/cli/restart_test.go b/cli/restart_test.go index b68a3f843ba51..43b512c1bc30b 100644 --- a/cli/restart_test.go +++ b/cli/restart_test.go @@ -20,30 +20,14 @@ import ( func TestRestart(t *testing.T) { t.Parallel() - echoResponses := &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: []*proto.RichParameter{ - { - Name: ephemeralParameterName, - Description: ephemeralParameterDescription, - Mutable: true, - Ephemeral: true, - }, - }, - }, - }, - }, + echoResponses := prepareEchoResponses([]*proto.RichParameter{ + { + Name: ephemeralParameterName, + Description: ephemeralParameterDescription, + Mutable: true, + Ephemeral: true, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, - } + }) t.Run("OK", func(t *testing.T) { t.Parallel() @@ -187,10 +171,10 @@ func TestRestartWithParameters(t *testing.T) { echoResponses := &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Parameters: []*proto.RichParameter{ { Name: immutableParameterName, @@ -202,11 +186,7 @@ func TestRestartWithParameters(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, + ProvisionApply: echo.ApplyComplete, } t.Run("DoNotAskForImmutables", func(t *testing.T) { diff --git a/cli/server.go b/cli/server.go index 1cd6ec475747a..9d6b4f975cc79 100644 --- a/cli/server.go +++ b/cli/server.go @@ -41,7 +41,6 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/spf13/afero" "go.opentelemetry.io/otel/trace" "golang.org/x/mod/semver" "golang.org/x/oauth2" @@ -1304,7 +1303,11 @@ func newProvisionerDaemon( defer wg.Done() defer cancel() - err := echo.Serve(ctx, afero.NewOsFs(), &provisionersdk.ServeOptions{Listener: echoServer}) + err := echo.Serve(ctx, &provisionersdk.ServeOptions{ + Listener: echoServer, + WorkDirectory: workDir, + Logger: logger.Named("echo"), + }) if err != nil { select { case errCh <- err: @@ -1336,10 +1339,11 @@ func newProvisionerDaemon( err := terraform.Serve(ctx, &terraform.ServeOptions{ ServeOptions: &provisionersdk.ServeOptions{ - Listener: terraformServer, + Listener: terraformServer, + Logger: logger.Named("terraform"), + WorkDirectory: workDir, }, CachePath: tfDir, - Logger: logger.Named("terraform"), Tracer: tracer, }) if err != nil && !xerrors.Is(err, context.Canceled) { @@ -1366,7 +1370,6 @@ func newProvisionerDaemon( UpdateInterval: time.Second, ForceCancelInterval: cfg.Provisioner.ForceCancelInterval.Value(), Provisioners: provisioners, - WorkDirectory: workDir, TracerProvider: coderAPI.TracerProvider, Metrics: &metrics, }), nil diff --git a/cli/show_test.go b/cli/show_test.go index c77e5dc0171cb..ccbe182cc7ed9 100644 --- a/cli/show_test.go +++ b/cli/show_test.go @@ -7,7 +7,6 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/pty/ptytest" ) @@ -17,11 +16,7 @@ func TestShow(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - ProvisionPlan: provisionCompleteWithAgent, - }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, completeWithAgent()) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 5e670d44d0689..971dc2873ffdc 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -56,10 +56,10 @@ func setupWorkspaceForAgent(t *testing.T, mutate func([]*proto.Agent) []*proto.A agentToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "dev", Type: "google_compute_instance", diff --git a/cli/start_test.go b/cli/start_test.go index e92d70cd71624..dff4048f3e765 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -33,10 +33,10 @@ func TestStart(t *testing.T) { echoResponses := &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Parameters: []*proto.RichParameter{ { Name: ephemeralParameterName, @@ -49,11 +49,7 @@ func TestStart(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, + ProvisionApply: echo.ApplyComplete, } t.Run("BuildOptions", func(t *testing.T) { @@ -151,10 +147,10 @@ func TestStartWithParameters(t *testing.T) { echoResponses := &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Parameters: []*proto.RichParameter{ { Name: immutableParameterName, @@ -166,11 +162,7 @@ func TestStartWithParameters(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, + ProvisionApply: echo.ApplyComplete, } t.Run("DoNotAskForImmutables", func(t *testing.T) { diff --git a/cli/state_test.go b/cli/state_test.go index 6273135d39c84..a240a6d2c81ae 100644 --- a/cli/state_test.go +++ b/cli/state_test.go @@ -25,9 +25,9 @@ func TestStatePull(t *testing.T) { wantState := []byte("some state") version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ State: wantState, }, }, @@ -53,9 +53,9 @@ func TestStatePull(t *testing.T) { wantState := []byte("some state") version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ State: wantState, }, }, @@ -83,7 +83,7 @@ func TestStatePush(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -108,7 +108,7 @@ func TestStatePush(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index 1c3d6d6c954bc..ba5dad7b4ac6a 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -19,26 +19,52 @@ import ( "github.com/coder/coder/v2/testutil" ) -var provisionCompleteWithAgent = []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{ - { - Type: "compute", - Name: "main", - Agents: []*proto.Agent{ +func completeWithAgent() *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{ + { + Type: "compute", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "smith", + OperatingSystem: "linux", + Architecture: "i386", + }, + }, + }, + }, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ { - Name: "smith", - OperatingSystem: "linux", - Architecture: "i386", + Type: "compute", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "smith", + OperatingSystem: "linux", + Architecture: "i386", + }, + }, }, }, }, }, }, }, - }, + } } func TestTemplateCreate(t *testing.T) { @@ -47,10 +73,7 @@ func TestTemplateCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) coderdtest.CreateFirstUser(t, client) - source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) args := []string{ "templates", "create", @@ -85,10 +108,7 @@ func TestTemplateCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) coderdtest.CreateFirstUser(t, client) - source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) require.NoError(t, os.Remove(filepath.Join(source, ".terraform.lock.hcl"))) args := []string{ "templates", @@ -128,10 +148,7 @@ func TestTemplateCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) coderdtest.CreateFirstUser(t, client) - source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) require.NoError(t, os.Remove(filepath.Join(source, ".terraform.lock.hcl"))) args := []string{ "templates", @@ -167,10 +184,7 @@ func TestTemplateCreate(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) coderdtest.CreateFirstUser(t, client) - source, err := echo.Tar(&echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + source, err := echo.Tar(completeWithAgent()) require.NoError(t, err) args := []string{ @@ -196,10 +210,7 @@ func TestTemplateCreate(t *testing.T) { coderdtest.CreateFirstUser(t, client) create := func() error { - source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) args := []string{ "templates", "create", diff --git a/cli/templatepull_test.go b/cli/templatepull_test.go index f6d8ececc9356..95b0a6cf9aa30 100644 --- a/cli/templatepull_test.go +++ b/cli/templatepull_test.go @@ -205,9 +205,9 @@ func TestTemplatePull(t *testing.T) { // a template version source. func genTemplateVersionSource() *echo.Responses { return &echo.Responses{ - Parse: []*proto.Parse_Response{ + Parse: []*proto.Response{ { - Type: &proto.Parse_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{ Output: uuid.NewString(), }, @@ -215,11 +215,11 @@ func genTemplateVersionSource() *echo.Responses { }, { - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{}, + Type: &proto.Response_Parse{ + Parse: &proto.ParseComplete{}, }, }, }, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, } } diff --git a/cli/templatepush_test.go b/cli/templatepush_test.go index 4f2e00a359be2..4c41597802bb2 100644 --- a/cli/templatepush_test.go +++ b/cli/templatepush_test.go @@ -38,7 +38,7 @@ func TestTemplatePush(t *testing.T) { // Test the cli command. source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example") clitest.SetupConfig(t, client, root) @@ -82,7 +82,7 @@ func TestTemplatePush(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) wantMessage := strings.Repeat("a", 72) @@ -121,7 +121,7 @@ func TestTemplatePush(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -168,7 +168,7 @@ func TestTemplatePush(t *testing.T) { // Test the cli command. source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) require.NoError(t, os.Remove(filepath.Join(source, ".terraform.lock.hcl"))) @@ -211,7 +211,7 @@ func TestTemplatePush(t *testing.T) { // Test the cli command. source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) require.NoError(t, os.Remove(filepath.Join(source, ".terraform.lock.hcl"))) @@ -248,7 +248,7 @@ func TestTemplatePush(t *testing.T) { // Test the cli command. source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) inv, root := clitest.New(t, "templates", "push", template.Name, "--activate=false", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example") clitest.SetupConfig(t, client, root) @@ -293,7 +293,7 @@ func TestTemplatePush(t *testing.T) { // Test the cli command. source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, @@ -340,7 +340,7 @@ func TestTemplatePush(t *testing.T) { source, err := echo.Tar(&echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, }) require.NoError(t, err) @@ -619,10 +619,7 @@ func TestTemplatePush(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) - source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: provisionCompleteWithAgent, - }) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) const templateName = "my-template" args := []string{ @@ -665,16 +662,16 @@ func TestTemplatePush(t *testing.T) { func createEchoResponsesWithTemplateVariables(templateVariables []*proto.TemplateVariable) *echo.Responses { return &echo.Responses{ - Parse: []*proto.Parse_Response{ + Parse: []*proto.Response{ { - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ + Type: &proto.Response_Parse{ + Parse: &proto.ParseComplete{ TemplateVariables: templateVariables, }, }, }, }, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, } } diff --git a/cli/update_test.go b/cli/update_test.go index 57e49d0db3766..0efa1f997cb69 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -57,8 +57,8 @@ func TestUpdate(t *testing.T) { version2 := coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: echo.PlanComplete, }, template.ID) _ = coderdtest.AwaitTemplateVersionJob(t, client, version2.ID) @@ -100,28 +100,13 @@ func TestUpdateWithRichParameters(t *testing.T) { immutableParameterValue = "4" ) - echoResponses := &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: []*proto.RichParameter{ - {Name: firstParameterName, Description: firstParameterDescription, Mutable: true}, - {Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false}, - {Name: secondParameterName, Description: secondParameterDescription, Mutable: true}, - {Name: ephemeralParameterName, Description: ephemeralParameterDescription, Mutable: true, Ephemeral: true}, - }, - }, - }, - }, - }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, - } + echoResponses := prepareEchoResponses([]*proto.RichParameter{ + {Name: firstParameterName, Description: firstParameterDescription, Mutable: true}, + {Name: immutableParameterName, Description: immutableParameterDescription, Mutable: false}, + {Name: secondParameterName, Description: secondParameterDescription, Mutable: true}, + {Name: ephemeralParameterName, Description: ephemeralParameterDescription, Mutable: true, Ephemeral: true}, + }, + ) t.Run("ImmutableCannotBeCustomized", func(t *testing.T) { t.Parallel() @@ -313,28 +298,6 @@ func TestUpdateValidateRichParameters(t *testing.T) { {Name: boolParameterName, Type: "bool", Mutable: true}, } - prepareEchoResponses := func(richParameters []*proto.RichParameter) *echo.Responses { - return &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: richParameters, - }, - }, - }, - }, - ProvisionApply: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }, - }, - } - } - t.Run("ValidateString", func(t *testing.T) { t.Parallel() diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index 15965f5ab1fea..8ce018a4e9a20 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -17,7 +17,6 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/provisioner/echo" - "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" ) @@ -60,25 +59,9 @@ func TestWorkspaceActivityBump(t *testing.T) { ttlMillis := int64(ttl / time.Millisecond) agentToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - Agents: []*proto.Agent{{ - Id: uuid.NewString(), - Name: "agent", - Auth: &proto.Agent_Token{ - Token: agentToken, - }, - }}, - }}, - }, - }, - }}, + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(agentToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 356926d9bbff9..7159f3b7d5665 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -683,8 +683,8 @@ func TestExecutorFailedWorkspace(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionFailed, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyFailed, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.FailureTTLMillis = ptr.Ref[int64](failureTTL.Milliseconds()) @@ -733,8 +733,8 @@ func TestExecutorInactiveWorkspace(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) @@ -766,22 +766,16 @@ func mustProvisionWorkspaceWithParameters(t *testing.T, client *codersdk.Client, user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Parameters: richParameters, }, }, }, }, - ProvisionApply: []*proto.Provision_Response{ - { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }, - }, + ProvisionApply: echo.ApplyComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index 1924c68439508..6edf4657cc903 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -181,7 +181,7 @@ func TestDERPForceWebSockets(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index b915b9ffbd3da..18062a549a8bd 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -37,7 +37,6 @@ import ( "github.com/google/uuid" "github.com/moby/moby/pkg/namesgenerator" "github.com/prometheus/client_golang/prometheus" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" @@ -469,10 +468,13 @@ func NewProvisionerDaemon(t testing.TB, coderAPI *coderd.API) io.Closer { _ = echoServer.Close() cancelFunc() }) - fs := afero.NewMemMapFs() + // seems t.TempDir() is not safe to call from a different goroutine + workDir := t.TempDir() go func() { - err := echo.Serve(ctx, fs, &provisionersdk.ServeOptions{ - Listener: echoServer, + err := echo.Serve(ctx, &provisionersdk.ServeOptions{ + Listener: echoServer, + WorkDirectory: workDir, + Logger: coderAPI.Logger.Named("echo").Leveled(slog.LevelDebug), }) assert.NoError(t, err) }() @@ -480,7 +482,6 @@ func NewProvisionerDaemon(t testing.TB, coderAPI *coderd.API) io.Closer { closer := provisionerd.New(func(ctx context.Context) (provisionerdproto.DRPCProvisionerDaemonClient, error) { return coderAPI.CreateInMemoryProvisionerDaemon(ctx, 0) }, &provisionerd.Options{ - Filesystem: fs, Logger: coderAPI.Logger.Named("provisionerd").Leveled(slog.LevelDebug), JobPollInterval: 50 * time.Millisecond, UpdateInterval: 250 * time.Millisecond, @@ -488,7 +489,6 @@ func NewProvisionerDaemon(t testing.TB, coderAPI *coderd.API) io.Closer { Provisioners: provisionerd.Provisioners{ string(database.ProvisionerTypeEcho): sdkproto.NewDRPCProvisionerClient(echoClient), }, - WorkDirectory: t.TempDir(), }) t.Cleanup(func() { _ = closer.Close() @@ -506,11 +506,11 @@ func NewExternalProvisionerDaemon(t *testing.T, client *codersdk.Client, org uui cancelFunc() <-serveDone }) - fs := afero.NewMemMapFs() go func() { defer close(serveDone) - err := echo.Serve(ctx, fs, &provisionersdk.ServeOptions{ - Listener: echoServer, + err := echo.Serve(ctx, &provisionersdk.ServeOptions{ + Listener: echoServer, + WorkDirectory: t.TempDir(), }) assert.NoError(t, err) }() @@ -522,7 +522,6 @@ func NewExternalProvisionerDaemon(t *testing.T, client *codersdk.Client, org uui Tags: tags, }) }, &provisionerd.Options{ - Filesystem: fs, Logger: slogtest.Make(t, nil).Named("provisionerd").Leveled(slog.LevelDebug), JobPollInterval: 50 * time.Millisecond, UpdateInterval: 250 * time.Millisecond, @@ -530,7 +529,6 @@ func NewExternalProvisionerDaemon(t *testing.T, client *codersdk.Client, org uui Provisioners: provisionerd.Provisioners{ string(database.ProvisionerTypeEcho): sdkproto.NewDRPCProvisionerClient(echoClient), }, - WorkDirectory: t.TempDir(), }) t.Cleanup(func() { _ = closer.Close() diff --git a/coderd/gitauth_test.go b/coderd/gitauth_test.go index 6aa4d4735b06f..c0ad89a1b53cc 100644 --- a/coderd/gitauth_test.go +++ b/coderd/gitauth_test.go @@ -23,7 +23,6 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/provisioner/echo" - "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" ) @@ -227,7 +226,7 @@ func TestGitAuthCallback(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -256,24 +255,9 @@ func TestGitAuthCallback(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - Agents: []*proto.Agent{{ - Id: uuid.NewString(), - Auth: &proto.Agent_Token{ - Token: authToken, - }, - }}, - }}, - }, - }, - }}, + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) @@ -342,7 +326,7 @@ func TestGitAuthCallback(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -400,7 +384,7 @@ func TestGitAuthCallback(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -443,7 +427,7 @@ func TestGitAuthCallback(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/coderd/gitsshkey_test.go b/coderd/gitsshkey_test.go index a406ed6a1d5d2..be1f43c52eb2f 100644 --- a/coderd/gitsshkey_test.go +++ b/coderd/gitsshkey_test.go @@ -108,7 +108,7 @@ func TestAgentGitSSHKey(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) project := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 351905e9b698e..83498bbb365fd 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -48,7 +48,7 @@ func TestDeploymentInsights(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -134,7 +134,7 @@ func TestUserLatencyInsights(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -498,18 +498,18 @@ func TestTemplateInsights_Golden(t *testing.T) { // Create the template version and template. version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Parameters: parameters, }, }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: resources, }, }, diff --git a/coderd/prometheusmetrics/prometheusmetrics_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index c6d65418cb4b6..bf6f475ad1be6 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -268,10 +268,10 @@ func TestAgents(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -494,7 +494,7 @@ func prepareWorkspaceAndAgent(t *testing.T, client *codersdk.Client, user coders version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index efb942f8a32ae..f61606a1425b9 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -280,7 +280,7 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac RichParameterValues: convertRichParameterValues(workspaceBuildParameters), VariableValues: asVariableValues(templateVariables), GitAuthProviders: gitAuthProviders, - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: server.AccessURL.String(), WorkspaceTransition: transition, WorkspaceName: workspace.Name, @@ -316,7 +316,7 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac TemplateDryRun: &proto.AcquiredJob_TemplateDryRun{ RichParameterValues: convertRichParameterValues(input.RichParameterValues), VariableValues: asVariableValues(templateVariables), - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: server.AccessURL.String(), WorkspaceName: input.WorkspaceName, }, @@ -337,7 +337,7 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac protoJob.Type = &proto.AcquiredJob_TemplateImport_{ TemplateImport: &proto.AcquiredJob_TemplateImport{ UserVariableValues: convertVariableValues(userVariableValues), - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: server.AccessURL.String(), }, }, diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index f53933722458c..5a317cd531530 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -267,7 +267,7 @@ func TestAcquireJob(t *testing.T) { Id: gitAuthProvider, AccessToken: "access_token", }}, - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: srv.AccessURL.String(), WorkspaceTransition: sdkproto.WorkspaceTransition_START, WorkspaceName: workspace.Name, @@ -359,7 +359,7 @@ func TestAcquireJob(t *testing.T) { want, err := json.Marshal(&proto.AcquiredJob_TemplateDryRun_{ TemplateDryRun: &proto.AcquiredJob_TemplateDryRun{ - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: srv.AccessURL.String(), WorkspaceName: "testing", }, @@ -391,7 +391,7 @@ func TestAcquireJob(t *testing.T) { want, err := json.Marshal(&proto.AcquiredJob_TemplateImport_{ TemplateImport: &proto.AcquiredJob_TemplateImport{ - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: srv.AccessURL.String(), }, }, @@ -434,7 +434,7 @@ func TestAcquireJob(t *testing.T) { UserVariableValues: []*sdkproto.VariableValue{ {Name: "first", Sensitive: true, Value: "first_value"}, }, - Metadata: &sdkproto.Provision_Metadata{ + Metadata: &sdkproto.Metadata{ CoderUrl: srv.AccessURL.String(), }, }, diff --git a/coderd/provisionerjobs_test.go b/coderd/provisionerjobs_test.go index 0bfd00e46a143..5d1715a9fe52d 100644 --- a/coderd/provisionerjobs_test.go +++ b/coderd/provisionerjobs_test.go @@ -20,16 +20,16 @@ func TestProvisionerJobLogs(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "log-output", }, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, }) @@ -59,16 +59,16 @@ func TestProvisionerJobLogs(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "log-output", }, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, }) diff --git a/coderd/templates_test.go b/coderd/templates_test.go index fcdb7e64e2e78..403370b5da670 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -1203,7 +1203,7 @@ func TestTemplateMetrics(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index f81b29ee82d4c..d06e68fabb368 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -136,8 +136,8 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) data, err := echo.Tar(&echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: echo.ProvisionComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: echo.PlanComplete, }) require.NoError(t, err) @@ -245,8 +245,8 @@ func TestPatchCancelTemplateVersion(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }}, @@ -284,8 +284,8 @@ func TestPatchCancelTemplateVersion(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }}, @@ -346,9 +346,9 @@ func TestTemplateVersionsGitAuth(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ GitAuthProviders: []string{"github"}, }, }, @@ -400,9 +400,9 @@ func TestTemplateVersionResources(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "some", Type: "example", @@ -439,17 +439,17 @@ func TestTemplateVersionLogs(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "example", }, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "some", Type: "example", @@ -610,15 +610,15 @@ func TestTemplateVersionDryRun(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{resource}, }, }, @@ -677,8 +677,8 @@ func TestTemplateVersionDryRun(t *testing.T) { // This import job will never finish version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }}, @@ -705,15 +705,15 @@ func TestTemplateVersionDryRun(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }, }, @@ -776,15 +776,15 @@ func TestTemplateVersionDryRun(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }, }, @@ -1040,21 +1040,17 @@ func TestTemplateVersionVariables(t *testing.T) { createEchoResponses := func(templateVariables []*proto.TemplateVariable) *echo.Responses { return &echo.Responses{ - Parse: []*proto.Parse_Response{ + Parse: []*proto.Response{ { - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ + Type: &proto.Response_Parse{ + Parse: &proto.ParseComplete{ TemplateVariables: templateVariables, }, }, }, }, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, } } @@ -1418,10 +1414,10 @@ func TestTemplateVersionParameters_Order(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Parameters: []*proto.RichParameter{ { Name: firstParameterName, @@ -1453,11 +1449,7 @@ func TestTemplateVersionParameters_Order(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, + ProvisionApply: echo.ApplyComplete, }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 48a399cee3db5..43694e95e67a9 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -43,10 +43,10 @@ func TestWorkspaceAgent(t *testing.T) { tmpDir := t.TempDir() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -87,10 +87,10 @@ func TestWorkspaceAgent(t *testing.T) { tmpDir := t.TempDir() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -132,10 +132,10 @@ func TestWorkspaceAgent(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -188,10 +188,10 @@ func TestWorkspaceAgentStartupLogs(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -252,10 +252,10 @@ func TestWorkspaceAgentStartupLogs(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -319,7 +319,7 @@ func TestWorkspaceAgentListen(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -360,7 +360,7 @@ func TestWorkspaceAgentListen(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) @@ -371,10 +371,10 @@ func TestWorkspaceAgentListen(t *testing.T) { version = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -419,7 +419,7 @@ func TestWorkspaceAgentTailnet(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -471,7 +471,7 @@ func TestWorkspaceAgentTailnetDirectDisabled(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -548,10 +548,10 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -806,9 +806,9 @@ func TestWorkspaceAgentAppHealth(t *testing.T) { } version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -893,7 +893,7 @@ func TestWorkspaceAgentReportStats(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -942,7 +942,7 @@ func TestWorkspaceAgent_LifecycleState(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1014,10 +1014,10 @@ func TestWorkspaceAgent_Metadata(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -1184,7 +1184,7 @@ func TestWorkspaceAgent_Startup(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1238,7 +1238,7 @@ func TestWorkspaceAgent_Startup(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1293,7 +1293,7 @@ func TestWorkspaceAgent_UpdatedDERP(t *testing.T) { agentToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(agentToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/coderd/workspaceapps/apptest/setup.go b/coderd/workspaceapps/apptest/setup.go index 9d9a80490146f..6ab541f078072 100644 --- a/coderd/workspaceapps/apptest/setup.go +++ b/coderd/workspaceapps/apptest/setup.go @@ -288,10 +288,10 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U appURL := fmt.Sprintf("%s://127.0.0.1:%d?%s", scheme, port, proxyTestAppQuery) version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index 1783fcec17cda..163247f6d4e4f 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -94,10 +94,10 @@ func Test_ResolveRequest(t *testing.T) { agentAuthToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index cd62f6a75046c..0ee810e3e3eda 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -376,12 +376,12 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }}, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -401,11 +401,12 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) { require.Eventually(t, func() bool { var err error build, err = client.WorkspaceBuild(ctx, build.ID) + // job gets marked Failed when there is an Error; in practice we never get to Status = Canceled + // because provisioners report an Error when canceled. We check the Error string to ensure we don't mask + // other errors in this test. return assert.NoError(t, err) && - // The job will never actually cancel successfully because it will never send a - // provision complete response. - assert.Empty(t, build.Job.Error) && - build.Job.Status == codersdk.ProvisionerJobCanceling + build.Job.Error == "canceled" && + build.Job.Status == codersdk.ProvisionerJobFailed }, testutil.WaitShort, testutil.IntervalFast) }) t.Run("User is not allowed to cancel", func(t *testing.T) { @@ -415,12 +416,12 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) { owner := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{}, }, }}, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) @@ -452,9 +453,9 @@ func TestWorkspaceBuildResources(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "some", Type: "example", @@ -494,16 +495,16 @@ func TestWorkspaceBuildLogs(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "example", }, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "some", Type: "example", @@ -548,10 +549,10 @@ func TestWorkspaceBuildState(t *testing.T) { wantState := []byte("some kinda state") version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ State: wantState, }, }, @@ -764,31 +765,31 @@ func TestWorkspaceBuildDebugMode(t *testing.T) { // Interact as template admin echoResponses := &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_DEBUG, Output: "want-it", }, }, }, { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_TRACE, Output: "dont-want-it", }, }, }, { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_DEBUG, Output: "done", }, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, } @@ -831,7 +832,10 @@ func TestWorkspaceBuildDebugMode(t *testing.T) { if !ok { break processingLogs } - + t.Logf("got log: %s -- %s | %s | %s", log.Level, log.Stage, log.Source, log.Output) + if log.Source != "provisioner" { + continue + } logsProcessed++ require.NotEqual(t, "dont-want-it", log.Output, "unexpected log message", "%s log message shouldn't be logged: %s") @@ -841,7 +845,6 @@ func TestWorkspaceBuildDebugMode(t *testing.T) { } } } - - require.Len(t, echoResponses.ProvisionApply, logsProcessed) + require.Equal(t, 2, logsProcessed) }) } diff --git a/coderd/workspaceresourceauth_test.go b/coderd/workspaceresourceauth_test.go index feadc62ddecb9..fdf1bd2335034 100644 --- a/coderd/workspaceresourceauth_test.go +++ b/coderd/workspaceresourceauth_test.go @@ -26,9 +26,9 @@ func TestPostWorkspaceAuthAzureInstanceIdentity(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", @@ -71,9 +71,9 @@ func TestPostWorkspaceAuthAWSInstanceIdentity(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", @@ -157,9 +157,9 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "somename", Type: "someinstance", diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index ebedb8497deae..8da37158b1e3e 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -174,9 +174,9 @@ func TestWorkspace(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "some", Type: "example", @@ -214,9 +214,9 @@ func TestWorkspace(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "some", Type: "example", @@ -258,9 +258,9 @@ func TestWorkspace(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "some", Type: "example", @@ -1248,7 +1248,7 @@ func TestWorkspaceFilterManual(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1276,7 +1276,7 @@ func TestWorkspaceFilterManual(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1316,10 +1316,10 @@ func TestWorkspaceFilterManual(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -1374,7 +1374,7 @@ func TestWorkspaceFilterManual(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1418,7 +1418,7 @@ func TestWorkspaceFilterManual(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1457,7 +1457,7 @@ func TestWorkspaceFilterManual(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -1575,7 +1575,7 @@ func TestPostWorkspaceBuild(t *testing.T) { client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - ProvisionApply: []*proto.Provision_Response{{}}, + ProvisionApply: []*proto.Response{{}}, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) @@ -2138,10 +2138,10 @@ func TestWorkspaceWatcher(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -2231,10 +2231,10 @@ func TestWorkspaceWatcher(t *testing.T) { // Add a new version that will fail. badVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Error: "test error", }, }, @@ -2299,9 +2299,9 @@ func TestWorkspaceResource(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "beta", Type: "example", @@ -2367,9 +2367,9 @@ func TestWorkspaceResource(t *testing.T) { } version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "some", Type: "example", @@ -2424,9 +2424,9 @@ func TestWorkspaceResource(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "some", Type: "example", @@ -2497,10 +2497,10 @@ func TestWorkspaceWithRichParameters(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Parameters: []*proto.RichParameter{ { Name: firstParameterName, @@ -2521,9 +2521,9 @@ func TestWorkspaceWithRichParameters(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, }) @@ -2590,10 +2590,10 @@ func TestWorkspaceWithOptionalRichParameters(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Parameters: []*proto.RichParameter{ { Name: firstParameterName, @@ -2612,9 +2612,9 @@ func TestWorkspaceWithOptionalRichParameters(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, }) @@ -2681,10 +2681,10 @@ func TestWorkspaceWithEphemeralRichParameters(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{ + ProvisionPlan: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Parameters: []*proto.RichParameter{ { Name: firstParameterName, @@ -2706,9 +2706,9 @@ func TestWorkspaceWithEphemeralRichParameters(t *testing.T) { }, }, }, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }}, }) diff --git a/enterprise/cli/provisionerdaemons.go b/enterprise/cli/provisionerdaemons.go index 82d853503ace4..e46756578d542 100644 --- a/enterprise/cli/provisionerdaemons.go +++ b/enterprise/cli/provisionerdaemons.go @@ -70,6 +70,11 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { return xerrors.Errorf("mkdir %q: %w", cacheDir, err) } + tempDir, err := os.MkdirTemp("", "provisionerd") + if err != nil { + return err + } + terraformClient, terraformServer := provisionersdk.MemTransportPipe() go func() { <-ctx.Done() @@ -84,10 +89,11 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { err := terraform.Serve(ctx, &terraform.ServeOptions{ ServeOptions: &provisionersdk.ServeOptions{ - Listener: terraformServer, + Listener: terraformServer, + Logger: logger.Named("terraform"), + WorkDirectory: tempDir, }, CachePath: cacheDir, - Logger: logger.Named("terraform"), }) if err != nil && !xerrors.Is(err, context.Canceled) { select { @@ -97,11 +103,6 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { } }() - tempDir, err := os.MkdirTemp("", "provisionerd") - if err != nil { - return err - } - logger.Info(ctx, "starting provisioner daemon", slog.F("tags", tags)) provisioners := provisionerd.Provisioners{ @@ -121,7 +122,6 @@ func (r *RootCmd) provisionerDaemonStart() *clibase.Cmd { JobPollJitter: pollJitter, UpdateInterval: 500 * time.Millisecond, Provisioners: provisioners, - WorkDirectory: tempDir, }) var exitErr error diff --git a/enterprise/coderd/appearance_test.go b/enterprise/coderd/appearance_test.go index 2cf7259044e83..8ee2c071377d0 100644 --- a/enterprise/coderd/appearance_test.go +++ b/enterprise/coderd/appearance_test.go @@ -111,7 +111,7 @@ func TestServiceBanners(t *testing.T) { agentClient.SetSessionToken(authToken) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ProvisionApplyWithAgent(authToken), }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) diff --git a/enterprise/coderd/provisionerdaemons_test.go b/enterprise/coderd/provisionerdaemons_test.go index d9b688fab8158..e190a3df90e20 100644 --- a/enterprise/coderd/provisionerdaemons_test.go +++ b/enterprise/coderd/provisionerdaemons_test.go @@ -139,9 +139,9 @@ func TestProvisionerDaemonServe(t *testing.T) { authToken := uuid.NewString() data, err := echo.Tar(&echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index 52e59858f5354..5b7ecbe1bcfd8 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -73,9 +73,9 @@ func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.Cr authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go index 3119168696a36..69c9a4bc3de88 100644 --- a/enterprise/coderd/workspacequota_test.go +++ b/enterprise/coderd/workspacequota_test.go @@ -89,9 +89,9 @@ func TestWorkspaceQuota(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -183,39 +183,15 @@ func TestWorkspaceQuota(t *testing.T) { require.NoError(t, err) verifyQuota(ctx, t, client, 0, 4) - stopResp := []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - DailyCost: 1, - }}, - }, - }, - }} - - startResp := []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "aws_instance", - DailyCost: 2, - }}, - }, - }, - }} - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlanMap: map[proto.WorkspaceTransition][]*proto.Provision_Response{ - proto.WorkspaceTransition_START: startResp, - proto.WorkspaceTransition_STOP: stopResp, + ProvisionPlanMap: map[proto.WorkspaceTransition][]*proto.Response{ + proto.WorkspaceTransition_START: planWithCost(2), + proto.WorkspaceTransition_STOP: planWithCost(1), }, - ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Provision_Response{ - proto.WorkspaceTransition_START: startResp, - proto.WorkspaceTransition_STOP: stopResp, + ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Response{ + proto.WorkspaceTransition_START: applyWithCost(2), + proto.WorkspaceTransition_STOP: applyWithCost(1), }, }) @@ -258,3 +234,31 @@ func TestWorkspaceQuota(t *testing.T) { require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) }) } + +func planWithCost(cost int32) []*proto.Response { + return []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + DailyCost: cost, + }}, + }, + }, + }} +} + +func applyWithCost(cost int32) []*proto.Response { + return []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + DailyCost: cost, + }}, + }, + }, + }} +} diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 373b79c78d59f..db14ae96f1100 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -120,8 +120,8 @@ func TestWorkspaceAutobuild(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionFailed, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyFailed, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.FailureTTLMillis = ptr.Ref[int64](failureTTL.Milliseconds()) @@ -166,8 +166,8 @@ func TestWorkspaceAutobuild(t *testing.T) { }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionFailed, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyFailed, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.FailureTTLMillis = ptr.Ref[int64](failureTTL.Milliseconds()) @@ -212,8 +212,8 @@ func TestWorkspaceAutobuild(t *testing.T) { }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, }) // Create a template without setting a failure_ttl. template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -255,8 +255,8 @@ func TestWorkspaceAutobuild(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) @@ -311,8 +311,8 @@ func TestWorkspaceAutobuild(t *testing.T) { }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) @@ -353,8 +353,8 @@ func TestWorkspaceAutobuild(t *testing.T) { }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](autoDeleteTTL.Milliseconds()) @@ -395,8 +395,8 @@ func TestWorkspaceAutobuild(t *testing.T) { }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) @@ -447,8 +447,8 @@ func TestWorkspaceAutobuild(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.TimeTilDormantMillis = ptr.Ref[int64](transitionTTL.Milliseconds()) @@ -516,8 +516,8 @@ func TestWorkspaceAutobuild(t *testing.T) { }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](dormantTTL.Milliseconds()) @@ -578,8 +578,8 @@ func TestWorkspaceAutobuild(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: echo.ProvisionComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, }) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) diff --git a/provisioner/echo/serve.go b/provisioner/echo/serve.go index c057254704f3d..9e22157bc381b 100644 --- a/provisioner/echo/serve.go +++ b/provisioner/echo/serve.go @@ -5,25 +5,24 @@ import ( "bytes" "context" "fmt" + "os" "path/filepath" "strings" + "github.com/google/uuid" "golang.org/x/xerrors" protobuf "google.golang.org/protobuf/proto" - "github.com/google/uuid" - "github.com/spf13/afero" - "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/provisionersdk/proto" ) // ProvisionApplyWithAgent returns provision responses that will mock a fake // "aws_instance" resource with an agent that has the given auth token. -func ProvisionApplyWithAgent(authToken string) []*proto.Provision_Response { - return []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ +func ProvisionApplyWithAgent(authToken string) []*proto.Response { + return []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -42,23 +41,36 @@ func ProvisionApplyWithAgent(authToken string) []*proto.Provision_Response { var ( // ParseComplete is a helper to indicate an empty parse completion. - ParseComplete = []*proto.Parse_Response{{ - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{}, + ParseComplete = []*proto.Response{{ + Type: &proto.Response_Parse{ + Parse: &proto.ParseComplete{}, + }, + }} + // PlanComplete is a helper to indicate an empty provision completion. + PlanComplete = []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{}, }, }} - // ProvisionComplete is a helper to indicate an empty provision completion. - ProvisionComplete = []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, + // ApplyComplete is a helper to indicate an empty provision completion. + ApplyComplete = []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{}, }, }} - // ProvisionFailed is a helper to convey a failed provision - // operation. - ProvisionFailed = []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + // PlanFailed is a helper to convey a failed plan operation + PlanFailed = []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Error: "failed!", + }, + }, + }} + // ApplyFailed is a helper to convey a failed apply operation + ApplyFailed = []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Error: "failed!", }, }, @@ -66,120 +78,116 @@ var ( ) // Serve starts the echo provisioner. -func Serve(ctx context.Context, filesystem afero.Fs, options *provisionersdk.ServeOptions) error { - return provisionersdk.Serve(ctx, &echo{ - filesystem: filesystem, - }, options) +func Serve(ctx context.Context, options *provisionersdk.ServeOptions) error { + return provisionersdk.Serve(ctx, &echo{}, options) } // The echo provisioner serves as a dummy provisioner primarily // used for testing. It echos responses from JSON files in the // format %d.protobuf. It's used for testing. -type echo struct { - filesystem afero.Fs -} +type echo struct{} -// Parse reads requests from the provided directory to stream responses. -func (e *echo) Parse(request *proto.Parse_Request, stream proto.DRPCProvisioner_ParseStream) error { - for index := 0; ; index++ { - path := filepath.Join(request.Directory, fmt.Sprintf("%d.parse.protobuf", index)) - _, err := e.filesystem.Stat(path) - if err != nil { - if index == 0 { - // Error if nothing is around to enable failed states. - return xerrors.Errorf("no state: %w", err) - } - break - } - data, err := afero.ReadFile(e.filesystem, path) - if err != nil { - return xerrors.Errorf("read file %q: %w", path, err) - } - var response proto.Parse_Response - err = protobuf.Unmarshal(data, &response) - if err != nil { - return xerrors.Errorf("unmarshal: %w", err) - } - err = stream.Send(&response) - if err != nil { - return err - } - } - <-stream.Context().Done() - return stream.Context().Err() -} - -// Provision reads requests from the provided directory to stream responses. -func (e *echo) Provision(stream proto.DRPCProvisioner_ProvisionStream) error { - msg, err := stream.Recv() - if err != nil { - return err - } - - var config *proto.Provision_Config - switch { - case msg.GetPlan() != nil: - config = msg.GetPlan().GetConfig() - case msg.GetApply() != nil: - config = msg.GetApply().GetConfig() - default: - // Probably a cancel - return nil - } - -outer: +func readResponses(sess *provisionersdk.Session, trans string, suffix string) ([]*proto.Response, error) { + var responses []*proto.Response for i := 0; ; i++ { - var extension string - if msg.GetPlan() != nil { - extension = ".plan.protobuf" - } else { - extension = ".apply.protobuf" - } - var ( - path string - pathIndex int - ) - // Try more specific path first, then fallback to generic. paths := []string{ - filepath.Join(config.Directory, fmt.Sprintf("%d.%s.provision"+extension, i, strings.ToLower(config.GetMetadata().GetWorkspaceTransition().String()))), - filepath.Join(config.Directory, fmt.Sprintf("%d.provision"+extension, i)), + // Try more specific path first, then fallback to generic. + filepath.Join(sess.WorkDirectory, fmt.Sprintf("%d.%s.%s", i, trans, suffix)), + filepath.Join(sess.WorkDirectory, fmt.Sprintf("%d.%s", i, suffix)), } - for pathIndex, path = range paths { - _, err := e.filesystem.Stat(path) - if err != nil && pathIndex == len(paths)-1 { - // If there are zero messages, something is wrong. + for pathIndex, path := range paths { + _, err := os.Stat(path) + if err != nil && pathIndex == (len(paths)-1) { + // If there are zero messages, something is wrong if i == 0 { // Error if nothing is around to enable failed states. - return xerrors.New("no state") + return nil, xerrors.Errorf("no state: %w", err) } - // Otherwise, we're done with the entire provision. - break outer - } else if err != nil { + // Otherwise, we've read all responses + return responses, nil + } + if err != nil { + // try next path continue } + data, err := os.ReadFile(path) + if err != nil { + return nil, xerrors.Errorf("read file %q: %w", path, err) + } + response := new(proto.Response) + err = protobuf.Unmarshal(data, response) + if err != nil { + return nil, xerrors.Errorf("unmarshal: %w", err) + } + responses = append(responses, response) break } - data, err := afero.ReadFile(e.filesystem, path) - if err != nil { - return xerrors.Errorf("read file %q: %w", path, err) + } +} + +// Parse reads requests from the provided directory to stream responses. +func (*echo) Parse(sess *provisionersdk.Session, _ *proto.ParseRequest, _ <-chan struct{}) *proto.ParseComplete { + responses, err := readResponses(sess, "unspecified", "parse.protobuf") + if err != nil { + return &proto.ParseComplete{Error: err.Error()} + } + for _, response := range responses { + if log := response.GetLog(); log != nil { + sess.ProvisionLog(log.Level, log.Output) } - var response proto.Provision_Response - err = protobuf.Unmarshal(data, &response) - if err != nil { - return xerrors.Errorf("unmarshal: %w", err) + if complete := response.GetParse(); complete != nil { + return complete + } + } + + // if we didn't get a complete from the filesystem, that's an error + return provisionersdk.ParseErrorf("complete response missing") +} + +// Plan reads requests from the provided directory to stream responses. +func (*echo) Plan(sess *provisionersdk.Session, req *proto.PlanRequest, canceledOrComplete <-chan struct{}) *proto.PlanComplete { + responses, err := readResponses( + sess, + strings.ToLower(req.GetMetadata().GetWorkspaceTransition().String()), + "plan.protobuf") + if err != nil { + return &proto.PlanComplete{Error: err.Error()} + } + for _, response := range responses { + if log := response.GetLog(); log != nil { + sess.ProvisionLog(log.Level, log.Output) } - r, ok := filterLogResponses(config, &response) - if !ok { - continue + if complete := response.GetPlan(); complete != nil { + return complete } + } - err = stream.Send(r) - if err != nil { - return err + // some tests use Echo without a complete response to test cancel + <-canceledOrComplete + return provisionersdk.PlanErrorf("canceled") +} + +// Apply reads requests from the provided directory to stream responses. +func (*echo) Apply(sess *provisionersdk.Session, req *proto.ApplyRequest, canceledOrComplete <-chan struct{}) *proto.ApplyComplete { + responses, err := readResponses( + sess, + strings.ToLower(req.GetMetadata().GetWorkspaceTransition().String()), + "apply.protobuf") + if err != nil { + return &proto.ApplyComplete{Error: err.Error()} + } + for _, response := range responses { + if log := response.GetLog(); log != nil { + sess.ProvisionLog(log.Level, log.Output) + } + if complete := response.GetApply(); complete != nil { + return complete } } - <-stream.Context().Done() - return stream.Context().Err() + + // some tests use Echo without a complete response to test cancel + <-canceledOrComplete + return provisionersdk.ApplyErrorf("canceled") } func (*echo) Shutdown(_ context.Context, _ *proto.Empty) (*proto.Empty, error) { @@ -188,29 +196,42 @@ func (*echo) Shutdown(_ context.Context, _ *proto.Empty) (*proto.Empty, error) { // Responses is a collection of mocked responses to Provision operations. type Responses struct { - Parse []*proto.Parse_Response + Parse []*proto.Response // ProvisionApply and ProvisionPlan are used to mock ALL responses of // Apply and Plan, regardless of transition. - ProvisionApply []*proto.Provision_Response - ProvisionPlan []*proto.Provision_Response + ProvisionApply []*proto.Response + ProvisionPlan []*proto.Response // ProvisionApplyMap and ProvisionPlanMap are used to mock specific // transition responses. They are prioritized over the generic responses. - ProvisionApplyMap map[proto.WorkspaceTransition][]*proto.Provision_Response - ProvisionPlanMap map[proto.WorkspaceTransition][]*proto.Provision_Response + ProvisionApplyMap map[proto.WorkspaceTransition][]*proto.Response + ProvisionPlanMap map[proto.WorkspaceTransition][]*proto.Response } // Tar returns a tar archive of responses to provisioner operations. func Tar(responses *Responses) ([]byte, error) { if responses == nil { responses = &Responses{ - ParseComplete, ProvisionComplete, ProvisionComplete, + ParseComplete, ApplyComplete, PlanComplete, nil, nil, } } if responses.ProvisionPlan == nil { - responses.ProvisionPlan = responses.ProvisionApply + for _, resp := range responses.ProvisionApply { + if resp.GetLog() != nil { + responses.ProvisionPlan = append(responses.ProvisionPlan, resp) + continue + } + responses.ProvisionPlan = append(responses.ProvisionPlan, &proto.Response{ + Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ + Error: resp.GetApply().GetError(), + Resources: resp.GetApply().GetResources(), + Parameters: resp.GetApply().GetParameters(), + GitAuthProviders: resp.GetApply().GetGitAuthProviders(), + }}, + }) + } } var buffer bytes.Buffer @@ -245,20 +266,20 @@ func Tar(responses *Responses) ([]byte, error) { } } for index, response := range responses.ProvisionApply { - err := writeProto(fmt.Sprintf("%d.provision.apply.protobuf", index), response) + err := writeProto(fmt.Sprintf("%d.apply.protobuf", index), response) if err != nil { return nil, err } } for index, response := range responses.ProvisionPlan { - err := writeProto(fmt.Sprintf("%d.provision.plan.protobuf", index), response) + err := writeProto(fmt.Sprintf("%d.plan.protobuf", index), response) if err != nil { return nil, err } } for trans, m := range responses.ProvisionApplyMap { for i, rs := range m { - err := writeProto(fmt.Sprintf("%d.%s.provision.apply.protobuf", i, strings.ToLower(trans.String())), rs) + err := writeProto(fmt.Sprintf("%d.%s.apply.protobuf", i, strings.ToLower(trans.String())), rs) if err != nil { return nil, err } @@ -266,7 +287,7 @@ func Tar(responses *Responses) ([]byte, error) { } for trans, m := range responses.ProvisionPlanMap { for i, rs := range m { - err := writeProto(fmt.Sprintf("%d.%s.provision.plan.protobuf", i, strings.ToLower(trans.String())), rs) + err := writeProto(fmt.Sprintf("%d.%s.plan.protobuf", i, strings.ToLower(trans.String())), rs) if err != nil { return nil, err } @@ -279,22 +300,14 @@ func Tar(responses *Responses) ([]byte, error) { return buffer.Bytes(), nil } -func filterLogResponses(config *proto.Provision_Config, response *proto.Provision_Response) (*proto.Provision_Response, bool) { - responseLog, ok := response.Type.(*proto.Provision_Response_Log) - if !ok { - // Pass all non-log responses - return response, true - } - - if config.ProvisionerLogLevel == "" { - // Don't change the default behavior of "echo" - return response, true - } - - provisionerLogLevel := proto.LogLevel_value[strings.ToUpper(config.ProvisionerLogLevel)] - if int32(responseLog.Log.Level) < provisionerLogLevel { - // Log level is not enabled - return nil, false +func WithResources(resources []*proto.Resource) *Responses { + return &Responses{ + Parse: ParseComplete, + ProvisionApply: []*proto.Response{{Type: &proto.Response_Apply{Apply: &proto.ApplyComplete{ + Resources: resources, + }}}}, + ProvisionPlan: []*proto.Response{{Type: &proto.Response_Plan{Plan: &proto.PlanComplete{ + Resources: resources, + }}}}, } - return response, true } diff --git a/provisioner/echo/serve_test.go b/provisioner/echo/serve_test.go index 01b283f8a55f5..6590f2ecafc54 100644 --- a/provisioner/echo/serve_test.go +++ b/provisioner/echo/serve_test.go @@ -1,27 +1,23 @@ package echo_test import ( - "archive/tar" - "bytes" "context" - "io" - "os" - "path/filepath" "testing" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/testutil" ) func TestEcho(t *testing.T) { t.Parallel() - fs := afero.NewMemMapFs() + workdir := t.TempDir() + // Create an in-memory provisioner to communicate with. client, server := provisionersdk.MemTransportPipe() ctx, cancelFunc := context.WithCancel(context.Background()) @@ -31,8 +27,9 @@ func TestEcho(t *testing.T) { cancelFunc() }) go func() { - err := echo.Serve(ctx, fs, &provisionersdk.ServeOptions{ - Listener: server, + err := echo.Serve(ctx, &provisionersdk.ServeOptions{ + Listener: server, + WorkDirectory: workdir, }) assert.NoError(t, err) }() @@ -40,25 +37,39 @@ func TestEcho(t *testing.T) { t.Run("Parse", func(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(ctx, testutil.WaitShort) + defer cancel() - responses := []*proto.Parse_Response{{ - Type: &proto.Parse_Response_Log{ - Log: &proto.Log{ - Output: "log-output", + responses := []*proto.Response{ + { + Type: &proto.Response_Log{ + Log: &proto.Log{ + Output: "log-output", + }, }, }, - }, { - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{}, + { + Type: &proto.Response_Parse{ + Parse: &proto.ParseComplete{}, + }, }, - }} + } data, err := echo.Tar(&echo.Responses{ Parse: responses, }) require.NoError(t, err) - client, err := api.Parse(ctx, &proto.Parse_Request{ - Directory: unpackTar(t, fs, data), - }) + client, err := api.Session(ctx) + require.NoError(t, err) + defer func() { + err := client.Close() + require.NoError(t, err) + }() + err = client.Send(&proto.Request{Type: &proto.Request_Config{Config: &proto.Config{ + TemplateSourceArchive: data, + }}}) + require.NoError(t, err) + + err = client.Send(&proto.Request{Type: &proto.Request_Parse{Parse: &proto.ParseRequest{}}}) require.NoError(t, err) log, err := client.Recv() require.NoError(t, err) @@ -70,95 +81,117 @@ func TestEcho(t *testing.T) { t.Run("Provision", func(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(ctx, testutil.WaitShort) + defer cancel() - responses := []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ - Log: &proto.Log{ - Level: proto.LogLevel_INFO, - Output: "log-output", + planResponses := []*proto.Response{ + { + Type: &proto.Response_Log{ + Log: &proto.Log{ + Level: proto.LogLevel_INFO, + Output: "log-output", + }, }, }, - }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "resource", - }}, + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{{ + Name: "resource", + }}, + }, }, }, - }} - data, err := echo.Tar(&echo.Responses{ - ProvisionApply: responses, - }) - require.NoError(t, err) - client, err := api.Provision(ctx) - require.NoError(t, err) - err = client.Send(&proto.Provision_Request{ - Type: &proto.Provision_Request_Plan{ - Plan: &proto.Provision_Plan{ - Config: &proto.Provision_Config{ - Directory: unpackTar(t, fs, data), + } + applyResponses := []*proto.Response{ + { + Type: &proto.Response_Log{ + Log: &proto.Log{ + Level: proto.LogLevel_INFO, + Output: "log-output", + }, + }, + }, + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: "resource", + }}, }, }, }, + } + data, err := echo.Tar(&echo.Responses{ + ProvisionPlan: planResponses, + ProvisionApply: applyResponses, }) require.NoError(t, err) + client, err := api.Session(ctx) + require.NoError(t, err) + defer func() { + err := client.Close() + require.NoError(t, err) + }() + err = client.Send(&proto.Request{Type: &proto.Request_Config{Config: &proto.Config{ + TemplateSourceArchive: data, + }}}) + require.NoError(t, err) + + err = client.Send(&proto.Request{Type: &proto.Request_Plan{Plan: &proto.PlanRequest{}}}) + require.NoError(t, err) log, err := client.Recv() require.NoError(t, err) - require.Equal(t, responses[0].GetLog().Output, log.GetLog().Output) + require.Equal(t, planResponses[0].GetLog().Output, log.GetLog().Output) complete, err := client.Recv() require.NoError(t, err) - require.Equal(t, responses[1].GetComplete().Resources[0].Name, - complete.GetComplete().Resources[0].Name) + require.Equal(t, planResponses[1].GetPlan().Resources[0].Name, + complete.GetPlan().Resources[0].Name) + + err = client.Send(&proto.Request{Type: &proto.Request_Apply{Apply: &proto.ApplyRequest{}}}) + require.NoError(t, err) + log, err = client.Recv() + require.NoError(t, err) + require.Equal(t, applyResponses[0].GetLog().Output, log.GetLog().Output) + complete, err = client.Recv() + require.NoError(t, err) + require.Equal(t, applyResponses[1].GetApply().Resources[0].Name, + complete.GetApply().Resources[0].Name) }) t.Run("ProvisionStop", func(t *testing.T) { t.Parallel() // Stop responses should be returned when the workspace is being stopped. - - defaultResponses := []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "DEFAULT", - }}, - }, - }, - }} - stopResponses := []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "STOP", - }}, - }, - }, - }} data, err := echo.Tar(&echo.Responses{ - ProvisionApply: defaultResponses, - ProvisionPlan: defaultResponses, - ProvisionPlanMap: map[proto.WorkspaceTransition][]*proto.Provision_Response{ - proto.WorkspaceTransition_STOP: stopResponses, + ProvisionApply: applyCompleteResource("DEFAULT"), + ProvisionPlan: planCompleteResource("DEFAULT"), + ProvisionPlanMap: map[proto.WorkspaceTransition][]*proto.Response{ + proto.WorkspaceTransition_STOP: planCompleteResource("STOP"), }, - ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Provision_Response{ - proto.WorkspaceTransition_STOP: stopResponses, + ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Response{ + proto.WorkspaceTransition_STOP: applyCompleteResource("STOP"), }, }) require.NoError(t, err) - client, err := api.Provision(ctx) + client, err := api.Session(ctx) + require.NoError(t, err) + defer func() { + err := client.Close() + require.NoError(t, err) + }() + err = client.Send(&proto.Request{Type: &proto.Request_Config{Config: &proto.Config{ + TemplateSourceArchive: data, + }}}) require.NoError(t, err) // Do stop. - err = client.Send(&proto.Provision_Request{ - Type: &proto.Provision_Request_Plan{ - Plan: &proto.Provision_Plan{ - Config: &proto.Provision_Config{ - Directory: unpackTar(t, fs, data), - Metadata: &proto.Provision_Metadata{ - WorkspaceTransition: proto.WorkspaceTransition_STOP, - }, + err = client.Send(&proto.Request{ + Type: &proto.Request_Plan{ + Plan: &proto.PlanRequest{ + Metadata: &proto.Metadata{ + WorkspaceTransition: proto.WorkspaceTransition_STOP, }, }, }, @@ -168,22 +201,16 @@ func TestEcho(t *testing.T) { complete, err := client.Recv() require.NoError(t, err) require.Equal(t, - stopResponses[0].GetComplete().Resources[0].Name, - complete.GetComplete().Resources[0].Name, + "STOP", + complete.GetPlan().Resources[0].Name, ) // Do start. - client, err = api.Provision(ctx) - require.NoError(t, err) - - err = client.Send(&proto.Provision_Request{ - Type: &proto.Provision_Request_Plan{ - Plan: &proto.Provision_Plan{ - Config: &proto.Provision_Config{ - Directory: unpackTar(t, fs, data), - Metadata: &proto.Provision_Metadata{ - WorkspaceTransition: proto.WorkspaceTransition_START, - }, + err = client.Send(&proto.Request{ + Type: &proto.Request_Plan{ + Plan: &proto.PlanRequest{ + Metadata: &proto.Metadata{ + WorkspaceTransition: proto.WorkspaceTransition_START, }, }, }, @@ -193,31 +220,33 @@ func TestEcho(t *testing.T) { complete, err = client.Recv() require.NoError(t, err) require.Equal(t, - defaultResponses[0].GetComplete().Resources[0].Name, - complete.GetComplete().Resources[0].Name, + "DEFAULT", + complete.GetPlan().Resources[0].Name, ) }) t.Run("ProvisionWithLogLevel", func(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(ctx, testutil.WaitShort) + defer cancel() - responses := []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ + responses := []*proto.Response{{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_TRACE, Output: "log-output-trace", }, }, }, { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "log-output-info", }, }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "resource", }}, @@ -225,49 +254,62 @@ func TestEcho(t *testing.T) { }, }} data, err := echo.Tar(&echo.Responses{ + ProvisionPlan: echo.PlanComplete, ProvisionApply: responses, }) require.NoError(t, err) - client, err := api.Provision(ctx) + client, err := api.Session(ctx) require.NoError(t, err) - err = client.Send(&proto.Provision_Request{ - Type: &proto.Provision_Request_Plan{ - Plan: &proto.Provision_Plan{ - Config: &proto.Provision_Config{ - Directory: unpackTar(t, fs, data), - ProvisionerLogLevel: "debug", - }, - }, - }, - }) + defer func() { + err := client.Close() + require.NoError(t, err) + }() + err = client.Send(&proto.Request{Type: &proto.Request_Config{Config: &proto.Config{ + TemplateSourceArchive: data, + ProvisionerLogLevel: "debug", + }}}) + require.NoError(t, err) + + // Plan is required before apply + err = client.Send(&proto.Request{Type: &proto.Request_Plan{Plan: &proto.PlanRequest{}}}) + require.NoError(t, err) + complete, err := client.Recv() + require.NoError(t, err) + require.NotNil(t, complete.GetPlan()) + + err = client.Send(&proto.Request{Type: &proto.Request_Apply{Apply: &proto.ApplyRequest{}}}) require.NoError(t, err) log, err := client.Recv() require.NoError(t, err) // Skip responses[0] as it's trace level require.Equal(t, responses[1].GetLog().Output, log.GetLog().Output) - complete, err := client.Recv() + complete, err = client.Recv() require.NoError(t, err) - require.Equal(t, responses[2].GetComplete().Resources[0].Name, - complete.GetComplete().Resources[0].Name) + require.Equal(t, responses[2].GetApply().Resources[0].Name, + complete.GetApply().Resources[0].Name) }) } -func unpackTar(t *testing.T, fs afero.Fs, data []byte) string { - directory := t.TempDir() - reader := tar.NewReader(bytes.NewReader(data)) - for { - header, err := reader.Next() - if err != nil { - break - } - // #nosec - path := filepath.Join(directory, header.Name) - file, err := fs.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) - require.NoError(t, err) - _, err = io.CopyN(file, reader, 1<<20) - require.ErrorIs(t, err, io.EOF) - err = file.Close() - require.NoError(t, err) - } - return directory +func planCompleteResource(name string) []*proto.Response { + return []*proto.Response{{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{{ + Name: name, + }}, + }, + }, + }} +} + +func applyCompleteResource(name string) []*proto.Response { + return []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: name, + }}, + }, + }, + }} } diff --git a/provisioner/terraform/executor.go b/provisioner/terraform/executor.go index cd847910e4c0f..d523eeca198e5 100644 --- a/provisioner/terraform/executor.go +++ b/provisioner/terraform/executor.go @@ -25,6 +25,7 @@ import ( ) type executor struct { + logger slog.Logger server *server mut *sync.Mutex binaryPath string @@ -50,8 +51,10 @@ func (e *executor) execWriteOutput(ctx, killCtx context.Context, args, env []str ctx, span := e.server.startTrace(ctx, fmt.Sprintf("exec - terraform %s", args[0])) defer span.End() span.SetAttributes(attribute.StringSlice("args", args)) + e.logger.Debug(ctx, "starting command", slog.F("args", args)) defer func() { + e.logger.Debug(ctx, "closing writers", slog.Error(err)) closeErr := stdOutWriter.Close() if err == nil && closeErr != nil { err = closeErr @@ -62,6 +65,7 @@ func (e *executor) execWriteOutput(ctx, killCtx context.Context, args, env []str } }() if ctx.Err() != nil { + e.logger.Debug(ctx, "context canceled before command started", slog.F("args", args)) return ctx.Err() } @@ -90,11 +94,14 @@ func (e *executor) execWriteOutput(ctx, killCtx context.Context, args, env []str ) err = cmd.Start() if err != nil { + e.logger.Debug(ctx, "failed to start command", slog.F("args", args)) return err } - interruptCommandOnCancel(ctx, killCtx, cmd) + interruptCommandOnCancel(ctx, killCtx, e.logger, cmd) - return cmd.Wait() + err = cmd.Wait() + e.logger.Debug(ctx, "command done", slog.F("args", args), slog.Error(err)) + return err } // execParseJSON must only be called while the lock is held. @@ -120,7 +127,7 @@ func (e *executor) execParseJSON(ctx, killCtx context.Context, args, env []strin if err != nil { return err } - interruptCommandOnCancel(ctx, killCtx, cmd) + interruptCommandOnCancel(ctx, killCtx, e.logger, cmd) err = cmd.Wait() if err != nil { @@ -207,15 +214,23 @@ func (e *executor) init(ctx, killCtx context.Context, logr logSink) error { return e.execWriteOutput(ctx, killCtx, args, e.basicEnv(), outWriter, errWriter) } +func getPlanFilePath(workdir string) string { + return filepath.Join(workdir, "terraform.tfplan") +} + +func getStateFilePath(workdir string) string { + return filepath.Join(workdir, "terraform.tfstate") +} + // revive:disable-next-line:flag-parameter -func (e *executor) plan(ctx, killCtx context.Context, env, vars []string, logr logSink, destroy bool) (*proto.Provision_Response, error) { +func (e *executor) plan(ctx, killCtx context.Context, env, vars []string, logr logSink, destroy bool) (*proto.PlanComplete, error) { ctx, span := e.server.startTrace(ctx, tracing.FuncName()) defer span.End() e.mut.Lock() defer e.mut.Unlock() - planfilePath := filepath.Join(e.workdir, "terraform.tfplan") + planfilePath := getPlanFilePath(e.workdir) args := []string{ "plan", "-no-color", @@ -248,19 +263,10 @@ func (e *executor) plan(ctx, killCtx context.Context, env, vars []string, logr l if err != nil { return nil, err } - planFileByt, err := os.ReadFile(planfilePath) - if err != nil { - return nil, err - } - return &proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: state.Parameters, - Resources: state.Resources, - GitAuthProviders: state.GitAuthProviders, - Plan: planFileByt, - }, - }, + return &proto.PlanComplete{ + Parameters: state.Parameters, + Resources: state.Resources, + GitAuthProviders: state.GitAuthProviders, }, nil } @@ -346,7 +352,7 @@ func (e *executor) graph(ctx, killCtx context.Context) (string, error) { if err != nil { return "", err } - interruptCommandOnCancel(ctx, killCtx, cmd) + interruptCommandOnCancel(ctx, killCtx, e.logger, cmd) err = cmd.Wait() if err != nil { @@ -357,33 +363,22 @@ func (e *executor) graph(ctx, killCtx context.Context) (string, error) { func (e *executor) apply( ctx, killCtx context.Context, - plan []byte, env []string, logr logSink, -) (*proto.Provision_Response, error) { +) (*proto.ApplyComplete, error) { ctx, span := e.server.startTrace(ctx, tracing.FuncName()) defer span.End() e.mut.Lock() defer e.mut.Unlock() - planFile, err := os.CreateTemp("", "coder-terrafrom-plan") - if err != nil { - return nil, xerrors.Errorf("create plan file: %w", err) - } - _, err = planFile.Write(plan) - if err != nil { - return nil, xerrors.Errorf("write plan file: %w", err) - } - defer os.Remove(planFile.Name()) - args := []string{ "apply", "-no-color", "-auto-approve", "-input=false", "-json", - planFile.Name(), + getPlanFilePath(e.workdir), } outWriter, doneOut := provisionLogWriter(logr) @@ -395,7 +390,7 @@ func (e *executor) apply( <-doneErr }() - err = e.execWriteOutput(ctx, killCtx, args, env, outWriter, errWriter) + err := e.execWriteOutput(ctx, killCtx, args, env, outWriter, errWriter) if err != nil { return nil, xerrors.Errorf("terraform apply: %w", err) } @@ -408,15 +403,11 @@ func (e *executor) apply( if err != nil { return nil, xerrors.Errorf("read statefile %q: %w", statefilePath, err) } - return &proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: state.Parameters, - Resources: state.Resources, - GitAuthProviders: state.GitAuthProviders, - State: stateContent, - }, - }, + return &proto.ApplyComplete{ + Parameters: state.Parameters, + Resources: state.Resources, + GitAuthProviders: state.GitAuthProviders, + State: stateContent, }, nil } @@ -461,48 +452,28 @@ func (e *executor) state(ctx, killCtx context.Context) (*tfjson.State, error) { return state, nil } -func interruptCommandOnCancel(ctx, killCtx context.Context, cmd *exec.Cmd) { +func interruptCommandOnCancel(ctx, killCtx context.Context, logger slog.Logger, cmd *exec.Cmd) { go func() { select { case <-ctx.Done(): + var err error switch runtime.GOOS { case "windows": // Interrupts aren't supported by Windows. - _ = cmd.Process.Kill() + err = cmd.Process.Kill() default: - _ = cmd.Process.Signal(os.Interrupt) + err = cmd.Process.Signal(os.Interrupt) } + logger.Debug(ctx, "interrupted command", slog.F("args", cmd.Args), slog.Error(err)) case <-killCtx.Done(): + logger.Debug(ctx, "kill context ended", slog.F("args", cmd.Args)) } }() } type logSink interface { - Log(*proto.Log) -} - -type streamLogSink struct { - // Any errors writing to the stream will be logged to logger. - logger slog.Logger - stream proto.DRPCProvisioner_ProvisionStream -} - -var _ logSink = streamLogSink{} - -func (s streamLogSink) Log(l *proto.Log) { - err := s.stream.Send(&proto.Provision_Response{ - Type: &proto.Provision_Response_Log{ - Log: l, - }, - }) - if err != nil { - s.logger.Warn(context.Background(), "write log to stream", - slog.F("level", l.Level.String()), - slog.F("message", l.Output), - slog.Error(err), - ) - } + ProvisionLog(l proto.LogLevel, o string) } // logWriter creates a WriteCloser that will log each line of text at the given level. The WriteCloser must be closed @@ -526,7 +497,7 @@ func readAndLog(sink logSink, r io.Reader, done chan<- any, level proto.LogLevel continue } - sink.Log(&proto.Log{Level: level, Output: scanner.Text()}) + sink.ProvisionLog(level, scanner.Text()) continue } @@ -543,7 +514,7 @@ func readAndLog(sink logSink, r io.Reader, done chan<- any, level proto.LogLevel if logLevel == proto.LogLevel_INFO { logLevel = proto.LogLevel_DEBUG } - sink.Log(&proto.Log{Level: logLevel, Output: log.Message}) + sink.ProvisionLog(logLevel, log.Message) } } @@ -588,7 +559,7 @@ func provisionReadAndLog(sink logSink, r io.Reader, done chan<- any) { } logLevel := convertTerraformLogLevel(log.Level, sink) - sink.Log(&proto.Log{Level: logLevel, Output: log.Message}) + sink.ProvisionLog(logLevel, log.Message) // If the diagnostic is provided, let's provide a bit more info! if log.Diagnostic == nil { @@ -596,7 +567,7 @@ func provisionReadAndLog(sink logSink, r io.Reader, done chan<- any) { } logLevel = convertTerraformLogLevel(string(log.Diagnostic.Severity), sink) for _, diagLine := range strings.Split(FormatDiagnostic(log.Diagnostic), "\n") { - sink.Log(&proto.Log{Level: logLevel, Output: diagLine}) + sink.ProvisionLog(logLevel, diagLine) } } } @@ -614,10 +585,7 @@ func convertTerraformLogLevel(logLevel string, sink logSink) proto.LogLevel { case "error": return proto.LogLevel_ERROR default: - sink.Log(&proto.Log{ - Level: proto.LogLevel_WARN, - Output: fmt.Sprintf("unable to convert log level %s", logLevel), - }) + sink.ProvisionLog(proto.LogLevel_WARN, fmt.Sprintf("unable to convert log level %s", logLevel)) return proto.LogLevel_INFO } } diff --git a/provisioner/terraform/executor_internal_test.go b/provisioner/terraform/executor_internal_test.go index fd203c9b1ee5f..97cb5285372f2 100644 --- a/provisioner/terraform/executor_internal_test.go +++ b/provisioner/terraform/executor_internal_test.go @@ -16,8 +16,8 @@ type mockLogger struct { var _ logSink = &mockLogger{} -func (m *mockLogger) Log(l *proto.Log) { - m.logs = append(m.logs, l) +func (m *mockLogger) ProvisionLog(l proto.LogLevel, o string) { + m.logs = append(m.logs, &proto.Log{Level: l, Output: o}) } func TestLogWriter_Mainline(t *testing.T) { diff --git a/provisioner/terraform/parse.go b/provisioner/terraform/parse.go index 6c52dc1313e1e..10ab7b801b071 100644 --- a/provisioner/terraform/parse.go +++ b/provisioner/terraform/parse.go @@ -12,18 +12,20 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/tracing" + "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/provisionersdk/proto" ) // Parse extracts Terraform variables from source-code. -func (s *server) Parse(request *proto.Parse_Request, stream proto.DRPCProvisioner_ParseStream) error { - _, span := s.startTrace(stream.Context(), tracing.FuncName()) +func (s *server) Parse(sess *provisionersdk.Session, _ *proto.ParseRequest, _ <-chan struct{}) *proto.ParseComplete { + ctx := sess.Context() + _, span := s.startTrace(ctx, tracing.FuncName()) defer span.End() // Load the module and print any parse errors. - module, diags := tfconfig.LoadModule(request.Directory) + module, diags := tfconfig.LoadModule(sess.WorkDirectory) if diags.HasErrors() { - return xerrors.Errorf("load module: %s", formatDiagnostics(request.Directory, diags)) + return provisionersdk.ParseErrorf("load module: %s", formatDiagnostics(sess.WorkDirectory, diags)) } // Sort variables by (filename, line) to make the ordering consistent @@ -40,17 +42,13 @@ func (s *server) Parse(request *proto.Parse_Request, stream proto.DRPCProvisione for _, v := range variables { mv, err := convertTerraformVariable(v) if err != nil { - return xerrors.Errorf("can't convert the Terraform variable to a managed one: %w", err) + return provisionersdk.ParseErrorf("can't convert the Terraform variable to a managed one: %s", err) } templateVariables = append(templateVariables, mv) } - return stream.Send(&proto.Parse_Response{ - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ - TemplateVariables: templateVariables, - }, - }, - }) + return &proto.ParseComplete{ + TemplateVariables: templateVariables, + } } // Converts a Terraform variable to a template-wide variable, processed by Coder. diff --git a/provisioner/terraform/parse_test.go b/provisioner/terraform/parse_test.go index aa0e19984b224..c28532af25831 100644 --- a/provisioner/terraform/parse_test.go +++ b/provisioner/terraform/parse_test.go @@ -4,8 +4,6 @@ package terraform_test import ( "encoding/json" - "os" - "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -21,9 +19,8 @@ func TestParse(t *testing.T) { testCases := []struct { Name string Files map[string]string - Response *proto.Parse_Response - // If ErrorContains is not empty, then response.Recv() should return an - // error containing this string before a Complete response is returned. + Response *proto.ParseComplete + // If ErrorContains is not empty, then the ParseComplete should have an Error containing the given string ErrorContains string }{ { @@ -33,16 +30,12 @@ func TestParse(t *testing.T) { description = "Testing!" }`, }, - Response: &proto.Parse_Response{ - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ - TemplateVariables: []*proto.TemplateVariable{ - { - Name: "A", - Description: "Testing!", - Required: true, - }, - }, + Response: &proto.ParseComplete{ + TemplateVariables: []*proto.TemplateVariable{ + { + Name: "A", + Description: "Testing!", + Required: true, }, }, }, @@ -54,15 +47,11 @@ func TestParse(t *testing.T) { default = "wow" }`, }, - Response: &proto.Parse_Response{ - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ - TemplateVariables: []*proto.TemplateVariable{ - { - Name: "A", - DefaultValue: "wow", - }, - }, + Response: &proto.ParseComplete{ + TemplateVariables: []*proto.TemplateVariable{ + { + Name: "A", + DefaultValue: "wow", }, }, }, @@ -76,15 +65,11 @@ func TestParse(t *testing.T) { } }`, }, - Response: &proto.Parse_Response{ - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ - TemplateVariables: []*proto.TemplateVariable{ - { - Name: "A", - Required: true, - }, - }, + Response: &proto.ParseComplete{ + TemplateVariables: []*proto.TemplateVariable{ + { + Name: "A", + Required: true, }, }, }, @@ -104,27 +89,23 @@ func TestParse(t *testing.T) { "main2.tf": `variable "baz" { } variable "quux" { }`, }, - Response: &proto.Parse_Response{ - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ - TemplateVariables: []*proto.TemplateVariable{ - { - Name: "foo", - Required: true, - }, - { - Name: "bar", - Required: true, - }, - { - Name: "baz", - Required: true, - }, - { - Name: "quux", - Required: true, - }, - }, + Response: &proto.ParseComplete{ + TemplateVariables: []*proto.TemplateVariable{ + { + Name: "foo", + Required: true, + }, + { + Name: "bar", + Required: true, + }, + { + Name: "baz", + Required: true, + }, + { + Name: "quux", + Required: true, }, }, }, @@ -139,19 +120,15 @@ func TestParse(t *testing.T) { sensitive = true }`, }, - Response: &proto.Parse_Response{ - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ - TemplateVariables: []*proto.TemplateVariable{ - { - Name: "A", - Description: "Testing!", - Type: "bool", - DefaultValue: "true", - Required: false, - Sensitive: true, - }, - }, + Response: &proto.ParseComplete{ + TemplateVariables: []*proto.TemplateVariable{ + { + Name: "A", + Description: "Testing!", + Type: "bool", + DefaultValue: "true", + Required: false, + Sensitive: true, }, }, }, @@ -166,19 +143,15 @@ func TestParse(t *testing.T) { sensitive = true }`, }, - Response: &proto.Parse_Response{ - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ - TemplateVariables: []*proto.TemplateVariable{ - { - Name: "A", - Description: "Testing!", - Type: "string", - DefaultValue: "abc", - Required: false, - Sensitive: true, - }, - }, + Response: &proto.ParseComplete{ + TemplateVariables: []*proto.TemplateVariable{ + { + Name: "A", + Description: "Testing!", + Type: "string", + DefaultValue: "abc", + Required: false, + Sensitive: true, }, }, }, @@ -193,19 +166,15 @@ func TestParse(t *testing.T) { sensitive = true }`, }, - Response: &proto.Parse_Response{ - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ - TemplateVariables: []*proto.TemplateVariable{ - { - Name: "A", - Description: "Testing!", - Type: "string", - DefaultValue: "", - Required: false, - Sensitive: true, - }, - }, + Response: &proto.ParseComplete{ + TemplateVariables: []*proto.TemplateVariable{ + { + Name: "A", + Description: "Testing!", + Type: "string", + DefaultValue: "", + Required: false, + Sensitive: true, }, }, }, @@ -219,19 +188,15 @@ func TestParse(t *testing.T) { sensitive = true }`, }, - Response: &proto.Parse_Response{ - Type: &proto.Parse_Response_Complete{ - Complete: &proto.Parse_Complete{ - TemplateVariables: []*proto.TemplateVariable{ - { - Name: "A", - Description: "Testing!", - Type: "string", - DefaultValue: "", - Required: true, - Sensitive: true, - }, - }, + Response: &proto.ParseComplete{ + TemplateVariables: []*proto.TemplateVariable{ + { + Name: "A", + Description: "Testing!", + Type: "string", + DefaultValue: "", + Required: true, + Sensitive: true, }, }, }, @@ -243,40 +208,31 @@ func TestParse(t *testing.T) { t.Run(testCase.Name, func(t *testing.T) { t.Parallel() - // Write all files to the temporary test directory. - directory := t.TempDir() - for path, content := range testCase.Files { - err := os.WriteFile(filepath.Join(directory, path), []byte(content), 0o600) - require.NoError(t, err) - } - - response, err := api.Parse(ctx, &proto.Parse_Request{ - Directory: directory, + session := configure(ctx, t, api, &proto.Config{ + TemplateSourceArchive: makeTar(t, testCase.Files), }) + + err := session.Send(&proto.Request{Type: &proto.Request_Parse{Parse: &proto.ParseRequest{}}}) require.NoError(t, err) for { - msg, err := response.Recv() - if err != nil { - if testCase.ErrorContains != "" { - require.ErrorContains(t, err, testCase.ErrorContains) - break - } + msg, err := session.Recv() + require.NoError(t, err) - require.NoError(t, err) + if testCase.ErrorContains != "" { + require.Contains(t, msg.GetParse().GetError(), testCase.ErrorContains) + break } - if msg.GetComplete() == nil { + // Ignore logs in this test + if msg.GetLog() != nil { continue } - if testCase.ErrorContains != "" { - t.Fatal("expected error but job completed successfully") - } // Ensure the want and got are equivalent! want, err := json.Marshal(testCase.Response) require.NoError(t, err) - got, err := json.Marshal(msg) + got, err := json.Marshal(msg.GetParse()) require.NoError(t, err) require.Equal(t, string(want), string(got)) diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index 504984520d3f2..ab832e4408683 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -4,11 +4,10 @@ import ( "context" "fmt" "os" - "path/filepath" "strings" "time" - "golang.org/x/xerrors" + "cdr.dev/slog" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/provisionersdk" @@ -16,48 +15,23 @@ import ( "github.com/coder/terraform-provider-coder/provider" ) -// Provision executes `terraform apply` or `terraform plan` for dry runs. -func (s *server) Provision(stream proto.DRPCProvisioner_ProvisionStream) error { - ctx, span := s.startTrace(stream.Context(), tracing.FuncName()) - defer span.End() - - request, err := stream.Recv() - if err != nil { - return err - } - if request.GetCancel() != nil { - return nil - } - - var ( - applyRequest = request.GetApply() - planRequest = request.GetPlan() - ) - - var config *proto.Provision_Config - if applyRequest == nil && planRequest == nil { - return nil - } else if applyRequest != nil { - config = applyRequest.Config - } else if planRequest != nil { - config = planRequest.Config - } - - // Create a context for graceful cancellation bound to the stream +func (s *server) setupContexts(parent context.Context, canceledOrComplete <-chan struct{}) ( + ctx context.Context, cancel func(), killCtx context.Context, kill func(), +) { + // Create a context for graceful cancellation bound to the session // context. This ensures that we will perform graceful cancellation // even on connection loss. - ctx, cancel := context.WithCancel(ctx) - defer cancel() + ctx, cancel = context.WithCancel(parent) // Create a separate context for forceful cancellation not tied to // the stream so that we can control when to terminate the process. - killCtx, kill := context.WithCancel(context.Background()) - defer kill() + killCtx, kill = context.WithCancel(context.Background()) // Ensure processes are eventually cleaned up on graceful // cancellation or disconnect. go func() { <-ctx.Done() + s.logger.Debug(ctx, "graceful context done") // TODO(mafredri): We should track this provision request as // part of graceful server shutdown procedure. Waiting on a @@ -66,134 +40,131 @@ func (s *server) Provision(stream proto.DRPCProvisioner_ProvisionStream) error { defer t.Stop() select { case <-t.C: + s.logger.Debug(ctx, "exit timeout hit") kill() case <-killCtx.Done(): + s.logger.Debug(ctx, "kill context done") } }() + // Process cancel go func() { - for { - request, err := stream.Recv() - if err != nil { - return - } - if request.GetCancel() == nil { - // We only process cancellation requests here. - continue - } - cancel() - return - } + <-canceledOrComplete + s.logger.Debug(ctx, "canceledOrComplete closed") + cancel() }() + return ctx, cancel, killCtx, kill +} - sink := streamLogSink{ - logger: s.logger.Named("execution_logs"), - stream: stream, - } - - e := s.executor(config.Directory) - if err = e.checkMinVersion(ctx); err != nil { - return err - } - logTerraformEnvVars(sink) +func (s *server) Plan( + sess *provisionersdk.Session, request *proto.PlanRequest, canceledOrComplete <-chan struct{}, +) *proto.PlanComplete { + ctx, span := s.startTrace(sess.Context(), tracing.FuncName()) + defer span.End() + ctx, cancel, killCtx, kill := s.setupContexts(ctx, canceledOrComplete) + defer cancel() + defer kill() - statefilePath := filepath.Join(config.Directory, "terraform.tfstate") - if len(config.State) > 0 { - err = os.WriteFile(statefilePath, config.State, 0o600) - if err != nil { - return xerrors.Errorf("write statefile %q: %w", statefilePath, err) - } + e := s.executor(sess.WorkDirectory) + if err := e.checkMinVersion(ctx); err != nil { + return provisionersdk.PlanErrorf(err.Error()) } + logTerraformEnvVars(sess) // If we're destroying, exit early if there's no state. This is necessary to // avoid any cases where a workspace is "locked out" of terraform due to // e.g. bad template param values and cannot be deleted. This is just for // contingency, in the future we will try harder to prevent workspaces being // broken this hard. - if config.Metadata.WorkspaceTransition == proto.WorkspaceTransition_DESTROY && len(config.State) == 0 { - _ = stream.Send(&proto.Provision_Response{ - Type: &proto.Provision_Response_Log{ - Log: &proto.Log{ - Level: proto.LogLevel_INFO, - Output: "The terraform state does not exist, there is nothing to do", - }, - }, - }) + if request.Metadata.GetWorkspaceTransition() == proto.WorkspaceTransition_DESTROY && len(sess.Config.State) == 0 { + sess.ProvisionLog(proto.LogLevel_INFO, "The terraform state does not exist, there is nothing to do") + return &proto.PlanComplete{} + } - return stream.Send(&proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }) + statefilePath := getStateFilePath(sess.WorkDirectory) + if len(sess.Config.State) > 0 { + err := os.WriteFile(statefilePath, sess.Config.State, 0o600) + if err != nil { + return provisionersdk.PlanErrorf("write statefile %q: %s", statefilePath, err) + } } s.logger.Debug(ctx, "running initialization") - err = e.init(ctx, killCtx, sink) + err := e.init(ctx, killCtx, sess) if err != nil { - if ctx.Err() != nil { - return stream.Send(&proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Error: err.Error(), - }, - }, - }) - } - return xerrors.Errorf("initialize terraform: %w", err) + s.logger.Debug(ctx, "init failed", slog.Error(err)) + return provisionersdk.PlanErrorf("initialize terraform: %s", err) } s.logger.Debug(ctx, "ran initialization") - env, err := provisionEnv(config, request.GetPlan().GetRichParameterValues(), request.GetPlan().GetGitAuthProviders()) + + env, err := provisionEnv(sess.Config, request.Metadata, request.RichParameterValues, request.GitAuthProviders) if err != nil { - return err + return provisionersdk.PlanErrorf("setup env: %s", err) } - var resp *proto.Provision_Response - if planRequest != nil { - vars, err := planVars(planRequest) - if err != nil { - return err - } + vars, err := planVars(request) + if err != nil { + return provisionersdk.PlanErrorf("plan vars: %s", err) + } - resp, err = e.plan( - ctx, killCtx, env, vars, sink, - config.Metadata.WorkspaceTransition == proto.WorkspaceTransition_DESTROY, - ) - if err != nil { - if ctx.Err() != nil { - return stream.Send(&proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Error: err.Error(), - }, - }, - }) - } - return xerrors.Errorf("plan terraform: %w", err) - } - return stream.Send(resp) + resp, err := e.plan( + ctx, killCtx, env, vars, sess, + request.Metadata.GetWorkspaceTransition() == proto.WorkspaceTransition_DESTROY, + ) + if err != nil { + return provisionersdk.PlanErrorf(err.Error()) } - // Must be apply - resp, err = e.apply( - ctx, killCtx, applyRequest.Plan, env, sink, + return resp +} + +func (s *server) Apply( + sess *provisionersdk.Session, request *proto.ApplyRequest, canceledOrComplete <-chan struct{}, +) *proto.ApplyComplete { + ctx, span := s.startTrace(sess.Context(), tracing.FuncName()) + defer span.End() + ctx, cancel, killCtx, kill := s.setupContexts(ctx, canceledOrComplete) + defer cancel() + defer kill() + + e := s.executor(sess.WorkDirectory) + if err := e.checkMinVersion(ctx); err != nil { + return provisionersdk.ApplyErrorf(err.Error()) + } + logTerraformEnvVars(sess) + + // Exit early if there is no plan file. This is necessary to + // avoid any cases where a workspace is "locked out" of terraform due to + // e.g. bad template param values and cannot be deleted. This is just for + // contingency, in the future we will try harder to prevent workspaces being + // broken this hard. + if request.Metadata.GetWorkspaceTransition() == proto.WorkspaceTransition_DESTROY && len(sess.Config.State) == 0 { + sess.ProvisionLog(proto.LogLevel_INFO, "The terraform plan does not exist, there is nothing to do") + return &proto.ApplyComplete{} + } + + // Earlier in the session, Plan() will have written the state file and the plan file. + statefilePath := getStateFilePath(sess.WorkDirectory) + env, err := provisionEnv(sess.Config, request.Metadata, nil, nil) + if err != nil { + return provisionersdk.ApplyErrorf("provision env: %s", err) + } + resp, err := e.apply( + ctx, killCtx, env, sess, ) if err != nil { errorMessage := err.Error() // Terraform can fail and apply and still need to store it's state. // In this case, we return Complete with an explicit error message. stateData, _ := os.ReadFile(statefilePath) - return stream.Send(&proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - State: stateData, - Error: errorMessage, - }, - }, - }) + return &proto.ApplyComplete{ + State: stateData, + Error: errorMessage, + } } - return stream.Send(resp) + return resp } -func planVars(plan *proto.Provision_Plan) ([]string, error) { +func planVars(plan *proto.PlanRequest) ([]string, error) { vars := []string{} for _, variable := range plan.VariableValues { vars = append(vars, fmt.Sprintf("%s=%s", variable.Name, variable.Value)) @@ -201,18 +172,21 @@ func planVars(plan *proto.Provision_Plan) ([]string, error) { return vars, nil } -func provisionEnv(config *proto.Provision_Config, richParams []*proto.RichParameterValue, gitAuth []*proto.GitAuthProvider) ([]string, error) { +func provisionEnv( + config *proto.Config, metadata *proto.Metadata, + richParams []*proto.RichParameterValue, gitAuth []*proto.GitAuthProvider, +) ([]string, error) { env := safeEnviron() env = append(env, - "CODER_AGENT_URL="+config.Metadata.CoderUrl, - "CODER_WORKSPACE_TRANSITION="+strings.ToLower(config.Metadata.WorkspaceTransition.String()), - "CODER_WORKSPACE_NAME="+config.Metadata.WorkspaceName, - "CODER_WORKSPACE_OWNER="+config.Metadata.WorkspaceOwner, - "CODER_WORKSPACE_OWNER_EMAIL="+config.Metadata.WorkspaceOwnerEmail, - "CODER_WORKSPACE_OWNER_OIDC_ACCESS_TOKEN="+config.Metadata.WorkspaceOwnerOidcAccessToken, - "CODER_WORKSPACE_ID="+config.Metadata.WorkspaceId, - "CODER_WORKSPACE_OWNER_ID="+config.Metadata.WorkspaceOwnerId, - "CODER_WORKSPACE_OWNER_SESSION_TOKEN="+config.Metadata.WorkspaceOwnerSessionToken, + "CODER_AGENT_URL="+metadata.GetCoderUrl(), + "CODER_WORKSPACE_TRANSITION="+strings.ToLower(metadata.GetWorkspaceTransition().String()), + "CODER_WORKSPACE_NAME="+metadata.GetWorkspaceName(), + "CODER_WORKSPACE_OWNER="+metadata.GetWorkspaceOwner(), + "CODER_WORKSPACE_OWNER_EMAIL="+metadata.GetWorkspaceOwnerEmail(), + "CODER_WORKSPACE_OWNER_OIDC_ACCESS_TOKEN="+metadata.GetWorkspaceOwnerOidcAccessToken(), + "CODER_WORKSPACE_ID="+metadata.GetWorkspaceId(), + "CODER_WORKSPACE_OWNER_ID="+metadata.GetWorkspaceOwnerId(), + "CODER_WORKSPACE_OWNER_SESSION_TOKEN="+metadata.GetWorkspaceOwnerSessionToken(), ) for key, value := range provisionersdk.AgentScriptEnv() { env = append(env, key+"="+value) @@ -258,10 +232,10 @@ func logTerraformEnvVars(sink logSink) { if !tfEnvSafeToPrint[parts[0]] { parts[1] = "" } - sink.Log(&proto.Log{ - Level: proto.LogLevel_WARN, - Output: fmt.Sprintf("terraform environment variable: %s=%s", parts[0], parts[1]), - }) + sink.ProvisionLog( + proto.LogLevel_WARN, + fmt.Sprintf("terraform environment variable: %s=%s", parts[0], parts[1]), + ) } } } diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index 03fc70ed696c5..254ddec45b5e6 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -3,6 +3,8 @@ package terraform_test import ( + "archive/tar" + "bytes" "context" "encoding/json" "errors" @@ -35,6 +37,7 @@ func setupProvisioner(t *testing.T, opts *provisionerServeOptions) (context.Cont opts = &provisionerServeOptions{} } cachePath := t.TempDir() + workDir := t.TempDir() client, server := provisionersdk.MemTransportPipe() ctx, cancelFunc := context.WithCancel(context.Background()) serverErr := make(chan error, 1) @@ -50,40 +53,75 @@ func setupProvisioner(t *testing.T, opts *provisionerServeOptions) (context.Cont go func() { serverErr <- terraform.Serve(ctx, &terraform.ServeOptions{ ServeOptions: &provisionersdk.ServeOptions{ - Listener: server, + Listener: server, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + WorkDirectory: workDir, }, BinaryPath: opts.binaryPath, CachePath: cachePath, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), ExitTimeout: opts.exitTimeout, }) }() api := proto.NewDRPCProvisionerClient(client) + return ctx, api } -func readProvisionLog(t *testing.T, response proto.DRPCProvisioner_ProvisionClient) ( - string, - *proto.Provision_Complete, -) { - var ( - logBuf strings.Builder - c *proto.Provision_Complete - ) +func makeTar(t *testing.T, files map[string]string) []byte { + t.Helper() + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + for name, content := range files { + err := writer.WriteHeader(&tar.Header{ + Name: name, + Size: int64(len(content)), + Mode: 0o644, + }) + require.NoError(t, err) + _, err = writer.Write([]byte(content)) + require.NoError(t, err) + } + err := writer.Flush() + require.NoError(t, err) + return buffer.Bytes() +} + +func configure(ctx context.Context, t *testing.T, client proto.DRPCProvisionerClient, config *proto.Config) proto.DRPCProvisioner_SessionClient { + t.Helper() + sess, err := client.Session(ctx) + require.NoError(t, err) + err = sess.Send(&proto.Request{Type: &proto.Request_Config{Config: config}}) + require.NoError(t, err) + return sess +} + +func readProvisionLog(t *testing.T, response proto.DRPCProvisioner_SessionClient) string { + var logBuf strings.Builder for { msg, err := response.Recv() require.NoError(t, err) if log := msg.GetLog(); log != nil { t.Log(log.Level.String(), log.Output) - _, _ = logBuf.WriteString(log.Output) - } - if c = msg.GetComplete(); c != nil { - require.Empty(t, c.Error) - break + _, err = logBuf.WriteString(log.Output) + require.NoError(t, err) + continue } + break } - return logBuf.String(), c + return logBuf.String() +} + +func sendPlan(sess proto.DRPCProvisioner_SessionClient, transition proto.WorkspaceTransition) error { + return sess.Send(&proto.Request{Type: &proto.Request_Plan{Plan: &proto.PlanRequest{ + Metadata: &proto.Metadata{WorkspaceTransition: transition}, + }}}) +} + +func sendApply(sess proto.DRPCProvisioner_SessionClient, transition proto.WorkspaceTransition) error { + return sess.Send(&proto.Request{Type: &proto.Request_Apply{Apply: &proto.ApplyRequest{ + Metadata: &proto.Metadata{WorkspaceTransition: transition}, + }}}) } func TestProvision_Cancel(t *testing.T) { @@ -109,9 +147,10 @@ func TestProvision_Cancel(t *testing.T) { wantLog: []string{"interrupt", "exit"}, }, { - name: "Cancel apply", - mode: "apply", - startSequence: []string{"init", "apply_start"}, + // Provisioner requires a plan before an apply, so test cancel with plan. + name: "Cancel plan", + mode: "plan", + startSequence: []string{"init", "plan_start"}, wantLog: []string{"interrupt", "exit"}, }, } @@ -131,24 +170,16 @@ func TestProvision_Cancel(t *testing.T) { ctx, api := setupProvisioner(t, &provisionerServeOptions{ binaryPath: binPath, }) - - response, err := api.Provision(ctx) - require.NoError(t, err) - err = response.Send(&proto.Provision_Request{ - Type: &proto.Provision_Request_Apply{ - Apply: &proto.Provision_Apply{ - Config: &proto.Provision_Config{ - Directory: dir, - Metadata: &proto.Provision_Metadata{}, - }, - }, - }, + sess := configure(ctx, t, api, &proto.Config{ + TemplateSourceArchive: makeTar(t, nil), }) + + err = sendPlan(sess, proto.WorkspaceTransition_START) require.NoError(t, err) for _, line := range tt.startSequence { LoopStart: - msg, err := response.Recv() + msg, err := sess.Recv() require.NoError(t, err) t.Log(msg.Type) @@ -160,22 +191,22 @@ func TestProvision_Cancel(t *testing.T) { require.Equal(t, line, log.Output) } - err = response.Send(&proto.Provision_Request{ - Type: &proto.Provision_Request_Cancel{ - Cancel: &proto.Provision_Cancel{}, + err = sess.Send(&proto.Request{ + Type: &proto.Request_Cancel{ + Cancel: &proto.CancelRequest{}, }, }) require.NoError(t, err) var gotLog []string for { - msg, err := response.Recv() + msg, err := sess.Recv() require.NoError(t, err) if log := msg.GetLog(); log != nil { gotLog = append(gotLog, log.Output) } - if c := msg.GetComplete(); c != nil { + if c := msg.GetPlan(); c != nil { require.Contains(t, c.Error, "exit status 1") break } @@ -208,23 +239,17 @@ func TestProvision_CancelTimeout(t *testing.T) { exitTimeout: time.Second, }) - response, err := api.Provision(ctx) - require.NoError(t, err) - err = response.Send(&proto.Provision_Request{ - Type: &proto.Provision_Request_Apply{ - Apply: &proto.Provision_Apply{ - Config: &proto.Provision_Config{ - Directory: dir, - Metadata: &proto.Provision_Metadata{}, - }, - }, - }, + sess := configure(ctx, t, api, &proto.Config{ + TemplateSourceArchive: makeTar(t, nil), }) + + // provisioner requires plan before apply, so test cancel with plan. + err = sendPlan(sess, proto.WorkspaceTransition_START) require.NoError(t, err) - for _, line := range []string{"init", "apply_start"} { + for _, line := range []string{"init", "plan_start"} { LoopStart: - msg, err := response.Recv() + msg, err := sess.Recv() require.NoError(t, err) t.Log(msg.Type) @@ -236,18 +261,14 @@ func TestProvision_CancelTimeout(t *testing.T) { require.Equal(t, line, log.Output) } - err = response.Send(&proto.Provision_Request{ - Type: &proto.Provision_Request_Cancel{ - Cancel: &proto.Provision_Cancel{}, - }, - }) + err = sess.Send(&proto.Request{Type: &proto.Request_Cancel{Cancel: &proto.CancelRequest{}}}) require.NoError(t, err) for { - msg, err := response.Recv() + msg, err := sess.Recv() require.NoError(t, err) - if c := msg.GetComplete(); c != nil { + if c := msg.GetPlan(); c != nil { require.Contains(t, c.Error, "killed") break } @@ -258,17 +279,18 @@ func TestProvision(t *testing.T) { t.Parallel() testCases := []struct { - Name string - Files map[string]string - Request *proto.Provision_Plan + Name string + Files map[string]string + Metadata *proto.Metadata + Request *proto.PlanRequest // Response may be nil to not check the response. - Response *proto.Provision_Response - // If ErrorContains is not empty, then response.Recv() should return an - // error containing this string before a Complete response is returned. + Response *proto.PlanComplete + // If ErrorContains is not empty, PlanComplete should have an Error containing the given string ErrorContains string // If ExpectLogContains is not empty, then the logs should contain it. ExpectLogContains string - Apply bool + // If Apply is true, then send an Apply request and check we get the same Resources as in Response. + Apply bool }{ { Name: "missing-variable", @@ -293,15 +315,11 @@ func TestProvision(t *testing.T) { Files: map[string]string{ "main.tf": `resource "null_resource" "A" {}`, }, - Response: &proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "A", - Type: "null_resource", - }}, - }, - }, + Response: &proto.PlanComplete{ + Resources: []*proto.Resource{{ + Name: "A", + Type: "null_resource", + }}, }, }, { @@ -309,15 +327,11 @@ func TestProvision(t *testing.T) { Files: map[string]string{ "main.tf": `resource "null_resource" "A" {}`, }, - Response: &proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "A", - Type: "null_resource", - }}, - }, - }, + Response: &proto.PlanComplete{ + Resources: []*proto.Resource{{ + Name: "A", + Type: "null_resource", + }}, }, Apply: true, }, @@ -334,15 +348,11 @@ func TestProvision(t *testing.T) { } }`, }, - Response: &proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "A", - Type: "null_resource", - }}, - }, - }, + Response: &proto.PlanComplete{ + Resources: []*proto.Resource{{ + Name: "A", + Type: "null_resource", + }}, }, Apply: true, }, @@ -367,12 +377,8 @@ func TestProvision(t *testing.T) { Files: map[string]string{ "main.tf": `resource "null_resource" "A" {}`, }, - Request: &proto.Provision_Plan{ - Config: &proto.Provision_Config{ - Metadata: &proto.Provision_Metadata{ - WorkspaceTransition: proto.WorkspaceTransition_DESTROY, - }, - }, + Metadata: &proto.Metadata{ + WorkspaceTransition: proto.WorkspaceTransition_DESTROY, }, ExpectLogContains: "nothing to do", }, @@ -406,7 +412,7 @@ func TestProvision(t *testing.T) { } }`, }, - Request: &proto.Provision_Plan{ + Request: &proto.PlanRequest{ RichParameterValues: []*proto.RichParameterValue{ { Name: "Example", @@ -418,27 +424,23 @@ func TestProvision(t *testing.T) { }, }, }, - Response: &proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: []*proto.RichParameter{ - { - Name: "Example", - Type: "string", - DefaultValue: "foobar", - }, - { - Name: "Sample", - Type: "string", - DefaultValue: "foobaz", - }, - }, - Resources: []*proto.Resource{{ - Name: "example", - Type: "null_resource", - }}, + Response: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{ + { + Name: "Example", + Type: "string", + DefaultValue: "foobar", + }, + { + Name: "Sample", + Type: "string", + DefaultValue: "foobaz", }, }, + Resources: []*proto.Resource{{ + Name: "example", + Type: "null_resource", + }}, }, }, { @@ -488,7 +490,7 @@ func TestProvision(t *testing.T) { ] }`, }, - Request: &proto.Provision_Plan{ + Request: &proto.PlanRequest{ RichParameterValues: []*proto.RichParameterValue{ { Name: "Example", @@ -500,27 +502,23 @@ func TestProvision(t *testing.T) { }, }, }, - Response: &proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Parameters: []*proto.RichParameter{ - { - Name: "Example", - Type: "string", - DefaultValue: "foobar", - }, - { - Name: "Sample", - Type: "string", - DefaultValue: "foobaz", - }, - }, - Resources: []*proto.Resource{{ - Name: "example", - Type: "null_resource", - }}, + Response: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{ + { + Name: "Example", + Type: "string", + DefaultValue: "foobar", + }, + { + Name: "Sample", + Type: "string", + DefaultValue: "foobaz", }, }, + Resources: []*proto.Resource{{ + Name: "example", + Type: "null_resource", + }}, }, }, { @@ -550,25 +548,21 @@ func TestProvision(t *testing.T) { } `, }, - Request: &proto.Provision_Plan{ + Request: &proto.PlanRequest{ GitAuthProviders: []*proto.GitAuthProvider{{ Id: "github", AccessToken: "some-value", }}, }, - Response: &proto.Provision_Response{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "example", - Type: "null_resource", - Metadata: []*proto.Resource_Metadata{{ - Key: "token", - Value: "some-value", - }}, - }}, - }, - }, + Response: &proto.PlanComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "null_resource", + Metadata: []*proto.Resource_Metadata{{ + Key: "token", + Value: "some-value", + }}, + }}, }, }, } @@ -579,50 +573,26 @@ func TestProvision(t *testing.T) { t.Parallel() ctx, api := setupProvisioner(t, nil) + sess := configure(ctx, t, api, &proto.Config{ + TemplateSourceArchive: makeTar(t, testCase.Files), + }) - directory := t.TempDir() - for path, content := range testCase.Files { - err := os.WriteFile(filepath.Join(directory, path), []byte(content), 0o600) - require.NoError(t, err) - } - - planRequest := &proto.Provision_Request{ - Type: &proto.Provision_Request_Plan{ - Plan: &proto.Provision_Plan{ - Config: &proto.Provision_Config{ - Directory: directory, - }, - }, - }, - } + planRequest := &proto.Request{Type: &proto.Request_Plan{Plan: &proto.PlanRequest{ + Metadata: testCase.Metadata, + }}} if testCase.Request != nil { - if planRequest.GetPlan().GetConfig() == nil { - planRequest.GetPlan().Config = &proto.Provision_Config{} - } - planRequest.GetPlan().RichParameterValues = testCase.Request.RichParameterValues - planRequest.GetPlan().GitAuthProviders = testCase.Request.GitAuthProviders - if testCase.Request.Config != nil { - planRequest.GetPlan().Config.State = testCase.Request.Config.State - planRequest.GetPlan().Config.Metadata = testCase.Request.Config.Metadata - } - } - if planRequest.GetPlan().Config.Metadata == nil { - planRequest.GetPlan().Config.Metadata = &proto.Provision_Metadata{} + planRequest = &proto.Request{Type: &proto.Request_Plan{Plan: testCase.Request}} } gotExpectedLog := testCase.ExpectLogContains == "" - provision := func(req *proto.Provision_Request) *proto.Provision_Complete { - response, err := api.Provision(ctx) - require.NoError(t, err) - err = response.Send(req) + provision := func(req *proto.Request) *proto.Response { + err := sess.Send(req) require.NoError(t, err) - - var complete *proto.Provision_Complete - for { - msg, err := response.Recv() - if msg != nil && msg.GetLog() != nil { + msg, err := sess.Recv() + require.NoError(t, err) + if msg.GetLog() != nil { if testCase.ExpectLogContains != "" && strings.Contains(msg.GetLog().Output, testCase.ExpectLogContains) { gotExpectedLog = true } @@ -630,67 +600,51 @@ func TestProvision(t *testing.T) { t.Logf("log: [%s] %s", msg.GetLog().Level, msg.GetLog().Output) continue } - if testCase.ErrorContains != "" { - require.ErrorContains(t, err, testCase.ErrorContains) - break - } - require.NoError(t, err) - - if complete = msg.GetComplete(); complete == nil { - continue - } - - require.NoError(t, err) - - // Remove randomly generated data. - for _, resource := range msg.GetComplete().Resources { - sort.Slice(resource.Agents, func(i, j int) bool { - return resource.Agents[i].Name < resource.Agents[j].Name - }) - - for _, agent := range resource.Agents { - agent.Id = "" - if agent.GetToken() == "" { - continue - } - agent.Auth = &proto.Agent_Token{} - } - } + return msg + } + } - if testCase.Response != nil { - require.Equal(t, testCase.Response.GetComplete().Error, msg.GetComplete().Error) + resp := provision(planRequest) + planComplete := resp.GetPlan() + require.NotNil(t, planComplete) - resourcesGot, err := json.Marshal(msg.GetComplete().Resources) - require.NoError(t, err) - resourcesWant, err := json.Marshal(testCase.Response.GetComplete().Resources) - require.NoError(t, err) + if testCase.ErrorContains != "" { + require.Contains(t, planComplete.GetError(), testCase.ErrorContains) + } - require.Equal(t, string(resourcesWant), string(resourcesGot)) + if testCase.Response != nil { + require.Equal(t, testCase.Response.Error, planComplete.Error) - parametersGot, err := json.Marshal(msg.GetComplete().Parameters) - require.NoError(t, err) - parametersWant, err := json.Marshal(testCase.Response.GetComplete().Parameters) - require.NoError(t, err) - require.Equal(t, string(parametersWant), string(parametersGot)) - } - break - } + // Remove randomly generated data. + normalizeResources(planComplete.Resources) + resourcesGot, err := json.Marshal(planComplete.Resources) + require.NoError(t, err) + resourcesWant, err := json.Marshal(testCase.Response.Resources) + require.NoError(t, err) + require.Equal(t, string(resourcesWant), string(resourcesGot)) - return complete + parametersGot, err := json.Marshal(planComplete.Parameters) + require.NoError(t, err) + parametersWant, err := json.Marshal(testCase.Response.Parameters) + require.NoError(t, err) + require.Equal(t, string(parametersWant), string(parametersGot)) } - planComplete := provision(planRequest) - if testCase.Apply { - require.NotNil(t, planComplete.Plan) - provision(&proto.Provision_Request{ - Type: &proto.Provision_Request_Apply{ - Apply: &proto.Provision_Apply{ - Config: planRequest.GetPlan().GetConfig(), - Plan: planComplete.Plan, - }, - }, - }) + resp = provision(&proto.Request{Type: &proto.Request_Apply{Apply: &proto.ApplyRequest{ + Metadata: &proto.Metadata{WorkspaceTransition: proto.WorkspaceTransition_START}, + }}}) + applyComplete := resp.GetApply() + require.NotNil(t, applyComplete) + + if testCase.Response != nil { + normalizeResources(applyComplete.Resources) + resourcesGot, err := json.Marshal(applyComplete.Resources) + require.NoError(t, err) + resourcesWant, err := json.Marshal(testCase.Response.Resources) + require.NoError(t, err) + require.Equal(t, string(resourcesWant), string(resourcesGot)) + } } if !gotExpectedLog { @@ -700,6 +654,22 @@ func TestProvision(t *testing.T) { } } +func normalizeResources(resources []*proto.Resource) { + for _, resource := range resources { + sort.Slice(resource.Agents, func(i, j int) bool { + return resource.Agents[i].Name < resource.Agents[j].Name + }) + + for _, agent := range resource.Agents { + agent.Id = "" + if agent.GetToken() == "" { + continue + } + agent.Auth = &proto.Agent_Token{} + } + } +} + // nolint:paralleltest func TestProvision_ExtraEnv(t *testing.T) { // #nosec @@ -708,31 +678,15 @@ func TestProvision_ExtraEnv(t *testing.T) { t.Setenv("TF_SUPERSECRET", secretValue) ctx, api := setupProvisioner(t, nil) + sess := configure(ctx, t, api, &proto.Config{ + TemplateSourceArchive: makeTar(t, map[string]string{"main.tf": `resource "null_resource" "A" {}`}), + }) - directory := t.TempDir() - path := filepath.Join(directory, "main.tf") - err := os.WriteFile(path, []byte(`resource "null_resource" "A" {}`), 0o600) - require.NoError(t, err) - - request := &proto.Provision_Request{ - Type: &proto.Provision_Request_Plan{ - Plan: &proto.Provision_Plan{ - Config: &proto.Provision_Config{ - Directory: directory, - Metadata: &proto.Provision_Metadata{ - WorkspaceTransition: proto.WorkspaceTransition_START, - }, - }, - }, - }, - } - response, err := api.Provision(ctx) - require.NoError(t, err) - err = response.Send(request) + err := sendPlan(sess, proto.WorkspaceTransition_START) require.NoError(t, err) found := false for { - msg, err := response.Recv() + msg, err := sess.Recv() require.NoError(t, err) if log := msg.GetLog(); log != nil { @@ -742,7 +696,7 @@ func TestProvision_ExtraEnv(t *testing.T) { } require.NotContains(t, log.Output, secretValue) } - if c := msg.GetComplete(); c != nil { + if c := msg.GetPlan(); c != nil { require.Empty(t, c.Error) break } @@ -774,48 +728,19 @@ func TestProvision_SafeEnv(t *testing.T) { ` ctx, api := setupProvisioner(t, nil) - - directory := t.TempDir() - path := filepath.Join(directory, "main.tf") - err := os.WriteFile(path, []byte(echoResource), 0o600) - require.NoError(t, err) - - response, err := api.Provision(ctx) - require.NoError(t, err) - err = response.Send(&proto.Provision_Request{ - Type: &proto.Provision_Request_Plan{ - Plan: &proto.Provision_Plan{ - Config: &proto.Provision_Config{ - Directory: directory, - Metadata: &proto.Provision_Metadata{ - WorkspaceTransition: proto.WorkspaceTransition_START, - }, - }, - }, - }, + sess := configure(ctx, t, api, &proto.Config{ + TemplateSourceArchive: makeTar(t, map[string]string{"main.tf": echoResource}), }) + + err := sendPlan(sess, proto.WorkspaceTransition_START) require.NoError(t, err) - _, complete := readProvisionLog(t, response) + _ = readProvisionLog(t, sess) - response, err = api.Provision(ctx) - require.NoError(t, err) - err = response.Send(&proto.Provision_Request{ - Type: &proto.Provision_Request_Apply{ - Apply: &proto.Provision_Apply{ - Config: &proto.Provision_Config{ - Directory: directory, - Metadata: &proto.Provision_Metadata{ - WorkspaceTransition: proto.WorkspaceTransition_START, - }, - }, - Plan: complete.GetPlan(), - }, - }, - }) + err = sendApply(sess, proto.WorkspaceTransition_START) require.NoError(t, err) - log, _ := readProvisionLog(t, response) + log := readProvisionLog(t, sess) require.Contains(t, log, passedValue) require.NotContains(t, log, secretValue) require.Contains(t, log, "CODER_") diff --git a/provisioner/terraform/serve.go b/provisioner/terraform/serve.go index 7a25d27dca598..0fc12ea870896 100644 --- a/provisioner/terraform/serve.go +++ b/provisioner/terraform/serve.go @@ -24,7 +24,6 @@ type ServeOptions struct { BinaryPath string // CachePath must not be used by multiple processes at once. CachePath string - Logger slog.Logger Tracer trace.Tracer // ExitTimeout defines how long we will wait for a running Terraform @@ -128,5 +127,6 @@ func (s *server) executor(workdir string) *executor { binaryPath: s.binaryPath, cachePath: s.cachePath, workdir: workdir, + logger: s.logger.Named("executor"), } } diff --git a/provisioner/terraform/testdata/fake_cancel.sh b/provisioner/terraform/testdata/fake_cancel.sh index cd2511facf938..2ea713379cce9 100755 --- a/provisioner/terraform/testdata/fake_cancel.sh +++ b/provisioner/terraform/testdata/fake_cancel.sh @@ -22,8 +22,9 @@ version) ;; init) case "$MODE" in - apply) + plan) echo "init" + exit 0 ;; init) sleep 10 & @@ -39,7 +40,7 @@ init) ;; esac ;; -apply) +plan) sleep 10 & sleep_pid=$! @@ -47,14 +48,14 @@ apply) trap 'json_print interrupt; exit 1' INT trap 'json_print terminate; exit 2' TERM - json_print apply_start + json_print plan_start wait - json_print apply_end + json_print plan_end ;; -plan) - echo "plan not supported" +apply) + echo "apply not supported" exit 1 ;; esac -exit 0 +exit 10 diff --git a/provisioner/terraform/testdata/fake_cancel_hang.sh b/provisioner/terraform/testdata/fake_cancel_hang.sh index c6d29c88c733f..e8db67f6837cd 100755 --- a/provisioner/terraform/testdata/fake_cancel_hang.sh +++ b/provisioner/terraform/testdata/fake_cancel_hang.sh @@ -23,19 +23,19 @@ init) echo "init" exit 0 ;; -apply) +plan) trap 'json_print interrupt' INT - json_print apply_start + json_print plan_start sleep 10 2>/dev/null >/dev/null - json_print apply_end + json_print plan_end exit 0 ;; -plan) - echo "plan not supported" +apply) + echo "apply not supported" exit 1 ;; esac -exit 0 +exit 10 diff --git a/provisionerd/proto/provisionerd.pb.go b/provisionerd/proto/provisionerd.pb.go index 29a1e7dc505a9..018e0f25ac8e1 100644 --- a/provisionerd/proto/provisionerd.pb.go +++ b/provisionerd/proto/provisionerd.pb.go @@ -819,7 +819,7 @@ type AcquiredJob_WorkspaceBuild struct { RichParameterValues []*proto.RichParameterValue `protobuf:"bytes,4,rep,name=rich_parameter_values,json=richParameterValues,proto3" json:"rich_parameter_values,omitempty"` VariableValues []*proto.VariableValue `protobuf:"bytes,5,rep,name=variable_values,json=variableValues,proto3" json:"variable_values,omitempty"` GitAuthProviders []*proto.GitAuthProvider `protobuf:"bytes,6,rep,name=git_auth_providers,json=gitAuthProviders,proto3" json:"git_auth_providers,omitempty"` - Metadata *proto.Provision_Metadata `protobuf:"bytes,7,opt,name=metadata,proto3" json:"metadata,omitempty"` + Metadata *proto.Metadata `protobuf:"bytes,7,opt,name=metadata,proto3" json:"metadata,omitempty"` State []byte `protobuf:"bytes,8,opt,name=state,proto3" json:"state,omitempty"` LogLevel string `protobuf:"bytes,9,opt,name=log_level,json=logLevel,proto3" json:"log_level,omitempty"` } @@ -891,7 +891,7 @@ func (x *AcquiredJob_WorkspaceBuild) GetGitAuthProviders() []*proto.GitAuthProvi return nil } -func (x *AcquiredJob_WorkspaceBuild) GetMetadata() *proto.Provision_Metadata { +func (x *AcquiredJob_WorkspaceBuild) GetMetadata() *proto.Metadata { if x != nil { return x.Metadata } @@ -917,8 +917,8 @@ type AcquiredJob_TemplateImport struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Metadata *proto.Provision_Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` - UserVariableValues []*proto.VariableValue `protobuf:"bytes,2,rep,name=user_variable_values,json=userVariableValues,proto3" json:"user_variable_values,omitempty"` + Metadata *proto.Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + UserVariableValues []*proto.VariableValue `protobuf:"bytes,2,rep,name=user_variable_values,json=userVariableValues,proto3" json:"user_variable_values,omitempty"` } func (x *AcquiredJob_TemplateImport) Reset() { @@ -953,7 +953,7 @@ func (*AcquiredJob_TemplateImport) Descriptor() ([]byte, []int) { return file_provisionerd_proto_provisionerd_proto_rawDescGZIP(), []int{1, 1} } -func (x *AcquiredJob_TemplateImport) GetMetadata() *proto.Provision_Metadata { +func (x *AcquiredJob_TemplateImport) GetMetadata() *proto.Metadata { if x != nil { return x.Metadata } @@ -974,7 +974,7 @@ type AcquiredJob_TemplateDryRun struct { RichParameterValues []*proto.RichParameterValue `protobuf:"bytes,2,rep,name=rich_parameter_values,json=richParameterValues,proto3" json:"rich_parameter_values,omitempty"` VariableValues []*proto.VariableValue `protobuf:"bytes,3,rep,name=variable_values,json=variableValues,proto3" json:"variable_values,omitempty"` - Metadata *proto.Provision_Metadata `protobuf:"bytes,4,opt,name=metadata,proto3" json:"metadata,omitempty"` + Metadata *proto.Metadata `protobuf:"bytes,4,opt,name=metadata,proto3" json:"metadata,omitempty"` } func (x *AcquiredJob_TemplateDryRun) Reset() { @@ -1023,7 +1023,7 @@ func (x *AcquiredJob_TemplateDryRun) GetVariableValues() []*proto.VariableValue return nil } -func (x *AcquiredJob_TemplateDryRun) GetMetadata() *proto.Provision_Metadata { +func (x *AcquiredJob_TemplateDryRun) GetMetadata() *proto.Metadata { if x != nil { return x.Metadata } @@ -1335,7 +1335,7 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x1a, 0x26, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x73, 0x64, 0x6b, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x07, 0x0a, - 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xab, 0x0b, 0x0a, 0x0b, 0x41, 0x63, 0x71, 0x75, 0x69, + 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x8d, 0x0b, 0x0a, 0x0b, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, @@ -1368,7 +1368,7 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x2e, 0x54, 0x72, 0x61, 0x63, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0d, 0x74, 0x72, 0x61, 0x63, 0x65, - 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0xc1, 0x03, 0x0a, 0x0e, 0x57, 0x6f, 0x72, + 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0xb7, 0x03, 0x0a, 0x0e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x12, 0x2c, 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, @@ -1389,193 +1389,191 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x47, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x10, 0x67, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, - 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, - 0x61, 0x74, 0x61, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x6c, 0x6f, 0x67, - 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x6f, - 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x1a, 0x9b, 0x01, 0x0a, - 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x12, - 0x3b, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x4c, 0x0a, 0x14, - 0x75, 0x73, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, - 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x12, 0x75, 0x73, 0x65, 0x72, 0x56, 0x61, 0x72, 0x69, - 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x1a, 0xed, 0x01, 0x0a, 0x0e, 0x54, - 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x12, 0x53, 0x0a, - 0x15, 0x72, 0x69, 0x63, 0x68, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, - 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x13, 0x72, - 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, - 0x65, 0x73, 0x12, 0x43, 0x0a, 0x0f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, - 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, - 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, - 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, - 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, - 0x6e, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x1a, 0x40, 0x0a, 0x12, 0x54, 0x72, - 0x61, 0x63, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, - 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x22, 0xa5, 0x03, 0x0a, 0x09, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x4a, - 0x6f, 0x62, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, - 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, - 0x51, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x62, 0x75, 0x69, - 0x6c, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x4a, 0x6f, - 0x62, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, - 0x48, 0x00, 0x52, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, - 0x6c, 0x64, 0x12, 0x51, 0x0a, 0x0f, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x69, - 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x70, 0x72, - 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x65, - 0x64, 0x4a, 0x6f, 0x62, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, - 0x6f, 0x72, 0x74, 0x48, 0x00, 0x52, 0x0e, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, - 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x52, 0x0a, 0x10, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, - 0x65, 0x5f, 0x64, 0x72, 0x79, 0x5f, 0x72, 0x75, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x46, - 0x61, 0x69, 0x6c, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, + 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, + 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, + 0x1b, 0x0a, 0x09, 0x6c, 0x6f, 0x67, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x6c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x4a, 0x04, 0x08, 0x03, + 0x10, 0x04, 0x1a, 0x91, 0x01, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, + 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, + 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, + 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x4c, 0x0a, 0x14, 0x75, 0x73, 0x65, 0x72, + 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, + 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x52, 0x12, 0x75, 0x73, 0x65, 0x72, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x1a, 0xe3, 0x01, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, + 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x12, 0x53, 0x0a, 0x15, 0x72, 0x69, 0x63, + 0x68, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, + 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x13, 0x72, 0x69, 0x63, 0x68, 0x50, + 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x43, + 0x0a, 0x0f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x73, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x1a, 0x40, 0x0a, 0x12, + 0x54, 0x72, 0x61, 0x63, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, + 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x06, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xa5, 0x03, 0x0a, 0x09, 0x46, 0x61, 0x69, 0x6c, 0x65, + 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x12, 0x51, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x62, + 0x75, 0x69, 0x6c, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, + 0x4a, 0x6f, 0x62, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, + 0x6c, 0x64, 0x48, 0x00, 0x52, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, + 0x75, 0x69, 0x6c, 0x64, 0x12, 0x51, 0x0a, 0x0f, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, + 0x5f, 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, + 0x6c, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, + 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x48, 0x00, 0x52, 0x0e, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, + 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x52, 0x0a, 0x10, 0x74, 0x65, 0x6d, 0x70, 0x6c, + 0x61, 0x74, 0x65, 0x5f, 0x64, 0x72, 0x79, 0x5f, 0x72, 0x75, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, + 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, + 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x48, 0x00, 0x52, 0x0e, 0x74, 0x65, 0x6d, + 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x1a, 0x26, 0x0a, 0x0e, 0x57, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, + 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, + 0x74, 0x65, 0x1a, 0x10, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, + 0x70, 0x6f, 0x72, 0x74, 0x1a, 0x10, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, + 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xd8, + 0x05, 0x0a, 0x0c, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x12, + 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x54, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x29, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x2e, 0x57, 0x6f, 0x72, 0x6b, + 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x48, 0x00, 0x52, 0x0e, 0x77, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x12, 0x54, 0x0a, 0x0f, + 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, + 0x62, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, + 0x48, 0x00, 0x52, 0x0e, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, + 0x72, 0x74, 0x12, 0x55, 0x0a, 0x10, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x64, + 0x72, 0x79, 0x5f, 0x72, 0x75, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x70, + 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x48, 0x00, 0x52, 0x0e, 0x74, 0x65, 0x6d, 0x70, 0x6c, - 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x72, 0x72, - 0x6f, 0x72, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x1a, 0x26, 0x0a, 0x0e, 0x57, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, - 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, - 0x1a, 0x10, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, - 0x72, 0x74, 0x1a, 0x10, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, - 0x79, 0x52, 0x75, 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xd8, 0x05, 0x0a, - 0x0c, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x15, 0x0a, - 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, - 0x6f, 0x62, 0x49, 0x64, 0x12, 0x54, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, - 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x48, 0x00, 0x52, 0x0e, 0x77, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x12, 0x54, 0x0a, 0x0f, 0x74, 0x65, - 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x2e, - 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x48, 0x00, - 0x52, 0x0e, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, - 0x12, 0x55, 0x0a, 0x10, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x64, 0x72, 0x79, - 0x5f, 0x72, 0x75, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, - 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, - 0x72, 0x79, 0x52, 0x75, 0x6e, 0x48, 0x00, 0x52, 0x0e, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, - 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x1a, 0x5b, 0x0a, 0x0e, 0x57, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, - 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, - 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x1a, 0x5b, 0x0a, 0x0e, 0x57, 0x6f, 0x72, + 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, + 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, + 0x65, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x1a, 0x81, 0x02, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, + 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x3e, 0x0a, 0x0f, 0x73, 0x74, 0x61, + 0x72, 0x74, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x73, 0x1a, 0x81, 0x02, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, - 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x3e, 0x0a, 0x0f, 0x73, 0x74, 0x61, 0x72, 0x74, - 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x0e, 0x73, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x73, 0x74, 0x6f, 0x70, 0x5f, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x0e, 0x73, 0x74, 0x61, 0x72, 0x74, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x73, 0x74, 0x6f, + 0x70, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x0d, 0x73, 0x74, 0x6f, 0x70, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x43, 0x0a, 0x0f, 0x72, 0x69, 0x63, 0x68, 0x5f, + 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, + 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x52, 0x0e, 0x72, 0x69, + 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12, + 0x67, 0x69, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x67, 0x69, 0x74, 0x41, 0x75, 0x74, + 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x1a, 0x45, 0x0a, 0x0e, 0x54, 0x65, + 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x12, 0x33, 0x0a, 0x09, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x0d, 0x73, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x43, 0x0a, 0x0f, 0x72, 0x69, 0x63, 0x68, 0x5f, 0x70, 0x61, - 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, - 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, - 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x52, 0x0e, 0x72, 0x69, 0x63, 0x68, - 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x67, 0x69, - 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, - 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x67, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x50, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x1a, 0x45, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, - 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x42, - 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xb0, 0x01, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, - 0x2f, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x4c, - 0x6f, 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x12, 0x2b, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, - 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d, 0x0a, - 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x14, 0x0a, 0x05, - 0x73, 0x74, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, - 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22, 0x8a, 0x02, 0x0a, 0x10, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x18, 0x02, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x64, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x12, 0x4c, 0x0a, - 0x12, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, - 0x6c, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, - 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x11, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, - 0x74, 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x12, 0x4c, 0x0a, 0x14, 0x75, - 0x73, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, - 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x12, 0x75, 0x73, 0x65, 0x72, 0x56, 0x61, 0x72, 0x69, 0x61, - 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, - 0x64, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x72, 0x65, 0x61, 0x64, 0x6d, - 0x65, 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x22, 0x7a, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, - 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, - 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x65, 0x64, 0x12, 0x43, 0x0a, 0x0f, 0x76, 0x61, 0x72, 0x69, - 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x76, - 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x4a, 0x04, 0x08, - 0x02, 0x10, 0x03, 0x22, 0x4a, 0x0a, 0x12, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, - 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, - 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, - 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x43, 0x6f, 0x73, 0x74, 0x22, - 0x68, 0x0a, 0x13, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x29, 0x0a, 0x10, 0x63, 0x72, 0x65, 0x64, 0x69, 0x74, - 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, - 0x52, 0x0f, 0x63, 0x72, 0x65, 0x64, 0x69, 0x74, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, - 0x64, 0x12, 0x16, 0x0a, 0x06, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x05, 0x52, 0x06, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74, 0x2a, 0x34, 0x0a, 0x09, 0x4c, 0x6f, 0x67, - 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, - 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x5f, 0x44, 0x41, 0x45, 0x4d, 0x4f, 0x4e, 0x10, 0x00, 0x12, 0x0f, - 0x0a, 0x0b, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x10, 0x01, 0x32, - 0xec, 0x02, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x44, - 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x12, 0x3c, 0x0a, 0x0a, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, - 0x4a, 0x6f, 0x62, 0x12, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, - 0x4a, 0x6f, 0x62, 0x12, 0x52, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, - 0x74, 0x61, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x73, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xb0, 0x01, 0x0a, 0x03, 0x4c, 0x6f, + 0x67, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, + 0x2e, 0x4c, 0x6f, 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, + 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, + 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x14, + 0x0a, 0x05, 0x73, 0x74, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, + 0x74, 0x61, 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22, 0x8a, 0x02, 0x0a, + 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x04, 0x6c, 0x6f, 0x67, 0x73, + 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x12, + 0x4c, 0x0a, 0x12, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, 0x61, 0x72, 0x69, + 0x61, 0x62, 0x6c, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, + 0x74, 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x11, 0x74, 0x65, 0x6d, 0x70, + 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x12, 0x4c, 0x0a, + 0x14, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, + 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x12, 0x75, 0x73, 0x65, 0x72, 0x56, 0x61, 0x72, + 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, + 0x65, 0x61, 0x64, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x72, 0x65, 0x61, + 0x64, 0x6d, 0x65, 0x4a, 0x04, 0x08, 0x03, 0x10, 0x04, 0x22, 0x7a, 0x0a, 0x11, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, + 0x0a, 0x08, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x08, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x65, 0x64, 0x12, 0x43, 0x0a, 0x0f, 0x76, 0x61, + 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, + 0x0e, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x4a, + 0x04, 0x08, 0x02, 0x10, 0x03, 0x22, 0x4a, 0x0a, 0x12, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, + 0x75, 0x6f, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x6a, + 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, + 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x43, 0x6f, 0x73, + 0x74, 0x22, 0x68, 0x0a, 0x13, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x29, 0x0a, 0x10, 0x63, 0x72, 0x65, 0x64, + 0x69, 0x74, 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x05, 0x52, 0x0f, 0x63, 0x72, 0x65, 0x64, 0x69, 0x74, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x75, + 0x6d, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x06, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74, 0x2a, 0x34, 0x0a, 0x09, 0x4c, + 0x6f, 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x56, + 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x5f, 0x44, 0x41, 0x45, 0x4d, 0x4f, 0x4e, 0x10, 0x00, + 0x12, 0x0f, 0x0a, 0x0b, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x10, + 0x01, 0x32, 0xec, 0x02, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x12, 0x3c, 0x0a, 0x0a, 0x41, 0x63, 0x71, 0x75, 0x69, + 0x72, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x19, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, + 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x52, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, + 0x75, 0x6f, 0x74, 0x61, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x61, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x09, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x07, 0x46, 0x61, 0x69, 0x6c, 0x4a, 0x6f, 0x62, - 0x12, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, - 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3e, - 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1a, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, - 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x2e, - 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, - 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x51, 0x75, 0x6f, 0x74, + 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x09, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x07, 0x46, 0x61, 0x69, 0x6c, 0x4a, + 0x6f, 0x62, 0x12, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x64, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x12, 0x3e, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, + 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1618,7 +1616,7 @@ var file_provisionerd_proto_provisionerd_proto_goTypes = []interface{}{ (*proto.VariableValue)(nil), // 22: provisioner.VariableValue (*proto.RichParameterValue)(nil), // 23: provisioner.RichParameterValue (*proto.GitAuthProvider)(nil), // 24: provisioner.GitAuthProvider - (*proto.Provision_Metadata)(nil), // 25: provisioner.Provision.Metadata + (*proto.Metadata)(nil), // 25: provisioner.Metadata (*proto.Resource)(nil), // 26: provisioner.Resource (*proto.RichParameter)(nil), // 27: provisioner.RichParameter } @@ -1642,12 +1640,12 @@ var file_provisionerd_proto_provisionerd_proto_depIdxs = []int32{ 23, // 16: provisionerd.AcquiredJob.WorkspaceBuild.rich_parameter_values:type_name -> provisioner.RichParameterValue 22, // 17: provisionerd.AcquiredJob.WorkspaceBuild.variable_values:type_name -> provisioner.VariableValue 24, // 18: provisionerd.AcquiredJob.WorkspaceBuild.git_auth_providers:type_name -> provisioner.GitAuthProvider - 25, // 19: provisionerd.AcquiredJob.WorkspaceBuild.metadata:type_name -> provisioner.Provision.Metadata - 25, // 20: provisionerd.AcquiredJob.TemplateImport.metadata:type_name -> provisioner.Provision.Metadata + 25, // 19: provisionerd.AcquiredJob.WorkspaceBuild.metadata:type_name -> provisioner.Metadata + 25, // 20: provisionerd.AcquiredJob.TemplateImport.metadata:type_name -> provisioner.Metadata 22, // 21: provisionerd.AcquiredJob.TemplateImport.user_variable_values:type_name -> provisioner.VariableValue 23, // 22: provisionerd.AcquiredJob.TemplateDryRun.rich_parameter_values:type_name -> provisioner.RichParameterValue 22, // 23: provisionerd.AcquiredJob.TemplateDryRun.variable_values:type_name -> provisioner.VariableValue - 25, // 24: provisionerd.AcquiredJob.TemplateDryRun.metadata:type_name -> provisioner.Provision.Metadata + 25, // 24: provisionerd.AcquiredJob.TemplateDryRun.metadata:type_name -> provisioner.Metadata 26, // 25: provisionerd.CompletedJob.WorkspaceBuild.resources:type_name -> provisioner.Resource 26, // 26: provisionerd.CompletedJob.TemplateImport.start_resources:type_name -> provisioner.Resource 26, // 27: provisionerd.CompletedJob.TemplateImport.stop_resources:type_name -> provisioner.Resource diff --git a/provisionerd/proto/provisionerd.proto b/provisionerd/proto/provisionerd.proto index 2a417f48a0cc7..8d4fadffc6373 100644 --- a/provisionerd/proto/provisionerd.proto +++ b/provisionerd/proto/provisionerd.proto @@ -19,12 +19,12 @@ message AcquiredJob { repeated provisioner.RichParameterValue rich_parameter_values = 4; repeated provisioner.VariableValue variable_values = 5; repeated provisioner.GitAuthProvider git_auth_providers = 6; - provisioner.Provision.Metadata metadata = 7; + provisioner.Metadata metadata = 7; bytes state = 8; string log_level = 9; } message TemplateImport { - provisioner.Provision.Metadata metadata = 1; + provisioner.Metadata metadata = 1; repeated provisioner.VariableValue user_variable_values = 2; } message TemplateDryRun { @@ -32,7 +32,7 @@ message AcquiredJob { repeated provisioner.RichParameterValue rich_parameter_values = 2; repeated provisioner.VariableValue variable_values = 3; - provisioner.Provision.Metadata metadata = 4; + provisioner.Metadata metadata = 4; } string job_id = 1; @@ -45,9 +45,9 @@ message AcquiredJob { TemplateImport template_import = 7; TemplateDryRun template_dry_run = 8; } - // trace_metadata is currently used for tracing information only. It allows - // jobs to be tied to the request that created them. - map trace_metadata = 9; + // trace_metadata is currently used for tracing information only. It allows + // jobs to be tied to the request that created them. + map trace_metadata = 9; } message FailedJob { @@ -113,7 +113,7 @@ message UpdateJobRequest { string job_id = 1; repeated Log logs = 2; repeated provisioner.TemplateVariable template_variables = 4; - repeated provisioner.VariableValue user_variable_values = 5; + repeated provisioner.VariableValue user_variable_values = 5; bytes readme = 6; } @@ -121,7 +121,7 @@ message UpdateJobResponse { reserved 2; bool canceled = 1; - repeated provisioner.VariableValue variable_values = 3; + repeated provisioner.VariableValue variable_values = 3; } message CommitQuotaRequest { diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index f127ab7b584bd..a341bd5a3df85 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -12,7 +12,6 @@ import ( "github.com/hashicorp/yamux" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/spf13/afero" "github.com/valyala/fasthttp/fasthttputil" "go.opentelemetry.io/otel/attribute" semconv "go.opentelemetry.io/otel/semconv/v1.14.0" @@ -44,7 +43,6 @@ type Provisioners map[string]sdkproto.DRPCProvisionerClient // Options provides customizations to the behavior of a provisioner daemon. type Options struct { - Filesystem afero.Fs Logger slog.Logger TracerProvider trace.TracerProvider Metrics *Metrics @@ -56,8 +54,6 @@ type Options struct { JobPollJitter time.Duration JobPollDebounce time.Duration Provisioners Provisioners - // WorkDirectory must not be used by multiple processes at once. - WorkDirectory string } // New creates and starts a provisioner daemon. @@ -80,9 +76,6 @@ func New(clientDialer Dialer, opts *Options) *Server { if opts.LogBufferInterval == 0 { opts.LogBufferInterval = 250 * time.Millisecond } - if opts.Filesystem == nil { - opts.Filesystem = afero.NewOsFs() - } if opts.TracerProvider == nil { opts.TracerProvider = trace.NewNoopTracerProvider() } @@ -405,8 +398,6 @@ func (p *Server) acquireJob(ctx context.Context) { Updater: p, QuotaCommitter: p, Logger: p.opts.Logger.Named("runner"), - Filesystem: p.opts.Filesystem, - WorkDirectory: p.opts.WorkDirectory, Provisioner: provisioner, UpdateInterval: p.opts.UpdateInterval, ForceCancelInterval: p.opts.ForceCancelInterval, diff --git a/provisionerd/provisionerd_test.go b/provisionerd/provisionerd_test.go index 90b9923996be9..ee379e0ab9929 100644 --- a/provisionerd/provisionerd_test.go +++ b/provisionerd/provisionerd_test.go @@ -25,7 +25,6 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/provisionerd" "github.com/coder/coder/v2/provisionerd/proto" - "github.com/coder/coder/v2/provisionerd/runner" "github.com/coder/coder/v2/provisionersdk" sdkproto "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" @@ -129,7 +128,7 @@ func TestProvisionerd(t *testing.T) { }), Type: &proto.AcquiredJob_TemplateImport_{ TemplateImport: &proto.AcquiredJob_TemplateImport{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -144,10 +143,15 @@ func TestProvisionerd(t *testing.T) { }), nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - parse: func(request *sdkproto.Parse_Request, stream sdkproto.DRPCProvisioner_ParseStream) error { + parse: func(_ *provisionersdk.Session, _ *sdkproto.ParseRequest, _ <-chan struct{}) *sdkproto.ParseComplete { closerMutex.Lock() defer closerMutex.Unlock() - return closer.Close() + err := closer.Close() + c := &sdkproto.ParseComplete{} + if err != nil { + c.Error = err.Error() + } + return c }, }), }) @@ -180,7 +184,7 @@ func TestProvisionerd(t *testing.T) { }), Type: &proto.AcquiredJob_TemplateImport_{ TemplateImport: &proto.AcquiredJob_TemplateImport{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -220,7 +224,7 @@ func TestProvisionerd(t *testing.T) { }), Type: &proto.AcquiredJob_TemplateImport_{ TemplateImport: &proto.AcquiredJob_TemplateImport{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -235,9 +239,13 @@ func TestProvisionerd(t *testing.T) { }), nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - parse: func(request *sdkproto.Parse_Request, stream sdkproto.DRPCProvisioner_ParseStream) error { - <-stream.Context().Done() - return nil + parse: func( + _ *provisionersdk.Session, + _ *sdkproto.ParseRequest, + cancelOrComplete <-chan struct{}, + ) *sdkproto.ParseComplete { + <-cancelOrComplete + return &sdkproto.ParseComplete{} }, }), }) @@ -255,7 +263,6 @@ func TestProvisionerd(t *testing.T) { didComplete atomic.Bool didLog atomic.Bool didAcquireJob atomic.Bool - didDryRun = atomic.NewBool(true) didReadme atomic.Bool completeChan = make(chan struct{}) completeOnce sync.Once @@ -273,12 +280,12 @@ func TestProvisionerd(t *testing.T) { JobId: "test", Provisioner: "someprovisioner", TemplateSourceArchive: createTar(t, map[string]string{ - "test.txt": "content", - runner.ReadmeFile: "# A cool template 😎\n", + "test.txt": "content", + provisionersdk.ReadmeFile: "# A cool template 😎\n", }), Type: &proto.AcquiredJob_TemplateImport_{ TemplateImport: &proto.AcquiredJob_TemplateImport{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -299,54 +306,34 @@ func TestProvisionerd(t *testing.T) { }), nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - parse: func(request *sdkproto.Parse_Request, stream sdkproto.DRPCProvisioner_ParseStream) error { - data, err := os.ReadFile(filepath.Join(request.Directory, "test.txt")) + parse: func( + s *provisionersdk.Session, + _ *sdkproto.ParseRequest, + cancelOrComplete <-chan struct{}, + ) *sdkproto.ParseComplete { + data, err := os.ReadFile(filepath.Join(s.WorkDirectory, "test.txt")) require.NoError(t, err) require.Equal(t, "content", string(data)) - - err = stream.Send(&sdkproto.Parse_Response{ - Type: &sdkproto.Parse_Response_Log{ - Log: &sdkproto.Log{ - Level: sdkproto.LogLevel_INFO, - Output: "hello", - }, - }, - }) - require.NoError(t, err) - - err = stream.Send(&sdkproto.Parse_Response{ - Type: &sdkproto.Parse_Response_Complete{ - Complete: &sdkproto.Parse_Complete{}, - }, - }) - require.NoError(t, err) - return nil + s.ProvisionLog(sdkproto.LogLevel_INFO, "hello") + return &sdkproto.ParseComplete{} }, - provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { - request, err := stream.Recv() - require.NoError(t, err) - if request.GetApply() != nil { - didDryRun.Store(false) + plan: func( + s *provisionersdk.Session, + _ *sdkproto.PlanRequest, + cancelOrComplete <-chan struct{}, + ) *sdkproto.PlanComplete { + s.ProvisionLog(sdkproto.LogLevel_INFO, "hello") + return &sdkproto.PlanComplete{ + Resources: []*sdkproto.Resource{}, } - err = stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Log{ - Log: &sdkproto.Log{ - Level: sdkproto.LogLevel_INFO, - Output: "hello", - }, - }, - }) - require.NoError(t, err) - - err = stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Complete{ - Complete: &sdkproto.Provision_Complete{ - Resources: []*sdkproto.Resource{}, - }, - }, - }) - require.NoError(t, err) - return nil + }, + apply: func( + _ *provisionersdk.Session, + _ *sdkproto.ApplyRequest, + _ <-chan struct{}, + ) *sdkproto.ApplyComplete { + t.Error("dry run should not apply") + return &sdkproto.ApplyComplete{} }, }), }) @@ -355,7 +342,6 @@ func TestProvisionerd(t *testing.T) { require.NoError(t, closer.Close()) assert.True(t, didLog.Load(), "should log some updates") assert.True(t, didComplete.Load(), "should complete the job") - assert.True(t, didDryRun.Load(), "should be a dry run") }) t.Run("TemplateDryRun", func(t *testing.T) { @@ -371,7 +357,7 @@ func TestProvisionerd(t *testing.T) { completeChan = make(chan struct{}) completeOnce sync.Once - metadata = &sdkproto.Provision_Metadata{} + metadata = &sdkproto.Metadata{} ) closer := createProvisionerd(t, func(ctx context.Context) (proto.DRPCProvisionerDaemonClient, error) { @@ -414,16 +400,22 @@ func TestProvisionerd(t *testing.T) { }), nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { - err := stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Complete{ - Complete: &sdkproto.Provision_Complete{ - Resources: []*sdkproto.Resource{}, - }, - }, - }) - require.NoError(t, err) - return nil + plan: func( + _ *provisionersdk.Session, + _ *sdkproto.PlanRequest, + _ <-chan struct{}, + ) *sdkproto.PlanComplete { + return &sdkproto.PlanComplete{ + Resources: []*sdkproto.Resource{}, + } + }, + apply: func( + _ *provisionersdk.Session, + _ *sdkproto.ApplyRequest, + _ <-chan struct{}, + ) *sdkproto.ApplyComplete { + t.Error("dry run should not apply") + return &sdkproto.ApplyComplete{} }, }), }) @@ -464,7 +456,7 @@ func TestProvisionerd(t *testing.T) { }), Type: &proto.AcquiredJob_WorkspaceBuild_{ WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -482,24 +474,20 @@ func TestProvisionerd(t *testing.T) { }), nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { - err := stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Log{ - Log: &sdkproto.Log{ - Level: sdkproto.LogLevel_DEBUG, - Output: "wow", - }, - }, - }) - require.NoError(t, err) - - err = stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Complete{ - Complete: &sdkproto.Provision_Complete{}, - }, - }) - require.NoError(t, err) - return nil + plan: func( + s *provisionersdk.Session, + _ *sdkproto.PlanRequest, + cancelOrComplete <-chan struct{}, + ) *sdkproto.PlanComplete { + s.ProvisionLog(sdkproto.LogLevel_DEBUG, "wow") + return &sdkproto.PlanComplete{} + }, + apply: func( + _ *provisionersdk.Session, + _ *sdkproto.ApplyRequest, + _ <-chan struct{}, + ) *sdkproto.ApplyComplete { + return &sdkproto.ApplyComplete{} }, }), }) @@ -540,7 +528,7 @@ func TestProvisionerd(t *testing.T) { }), Type: &proto.AcquiredJob_WorkspaceBuild_{ WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -567,40 +555,46 @@ func TestProvisionerd(t *testing.T) { }), nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { - err := stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Log{ - Log: &sdkproto.Log{ - Level: sdkproto.LogLevel_DEBUG, - Output: "wow", + plan: func( + s *provisionersdk.Session, + _ *sdkproto.PlanRequest, + cancelOrComplete <-chan struct{}, + ) *sdkproto.PlanComplete { + s.ProvisionLog(sdkproto.LogLevel_DEBUG, "wow") + return &sdkproto.PlanComplete{ + Resources: []*sdkproto.Resource{ + { + DailyCost: 10, + }, + { + DailyCost: 15, }, }, - }) - require.NoError(t, err) - - err = stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Complete{ - Complete: &sdkproto.Provision_Complete{ - Resources: []*sdkproto.Resource{ - { - DailyCost: 10, - }, - { - DailyCost: 15, - }, - }, + } + }, + apply: func( + _ *provisionersdk.Session, + _ *sdkproto.ApplyRequest, + _ <-chan struct{}, + ) *sdkproto.ApplyComplete { + t.Error("should not apply when resources exceed quota") + return &sdkproto.ApplyComplete{ + Resources: []*sdkproto.Resource{ + { + DailyCost: 10, + }, + { + DailyCost: 15, }, }, - }) - require.NoError(t, err) - return nil + } }, }), }) require.Condition(t, closedWithin(completeChan, testutil.WaitShort)) require.NoError(t, closer.Close()) assert.True(t, didLog.Load(), "should log some updates") - assert.False(t, didComplete.Load(), "should complete the job") + assert.False(t, didComplete.Load(), "should not complete the job") assert.True(t, didFail.Load(), "should fail the job") }) @@ -633,7 +627,7 @@ func TestProvisionerd(t *testing.T) { }), Type: &proto.AcquiredJob_WorkspaceBuild_{ WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -646,14 +640,24 @@ func TestProvisionerd(t *testing.T) { }), nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { - return stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Complete{ - Complete: &sdkproto.Provision_Complete{ - Error: "some error", - }, - }, - }) + plan: func( + s *provisionersdk.Session, + _ *sdkproto.PlanRequest, + cancelOrComplete <-chan struct{}, + ) *sdkproto.PlanComplete { + return &sdkproto.PlanComplete{ + Error: "some error", + } + }, + apply: func( + _ *provisionersdk.Session, + _ *sdkproto.ApplyRequest, + _ <-chan struct{}, + ) *sdkproto.ApplyComplete { + t.Error("should not apply when plan errors") + return &sdkproto.ApplyComplete{ + Error: "some error", + } }, }), }) @@ -683,7 +687,7 @@ func TestProvisionerd(t *testing.T) { }), Type: &proto.AcquiredJob_WorkspaceBuild_{ WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -712,31 +716,24 @@ func TestProvisionerd(t *testing.T) { }), nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { - // Ignore the first provision message! - _, _ = stream.Recv() - - err := stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Log{ - Log: &sdkproto.Log{ - Level: sdkproto.LogLevel_DEBUG, - Output: "in progress", - }, - }, - }) - require.NoError(t, err) - - msg, err := stream.Recv() - require.NoError(t, err) - require.NotNil(t, msg.GetCancel()) - - return stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Complete{ - Complete: &sdkproto.Provision_Complete{ - Error: "some error", - }, - }, - }) + plan: func( + s *provisionersdk.Session, + _ *sdkproto.PlanRequest, + canceledOrComplete <-chan struct{}, + ) *sdkproto.PlanComplete { + s.ProvisionLog(sdkproto.LogLevel_DEBUG, "in progress") + <-canceledOrComplete + return &sdkproto.PlanComplete{ + Error: "some error", + } + }, + apply: func( + _ *provisionersdk.Session, + _ *sdkproto.ApplyRequest, + _ <-chan struct{}, + ) *sdkproto.ApplyComplete { + t.Error("should not apply when shut down during plan") + return &sdkproto.ApplyComplete{} }, }), }) @@ -768,7 +765,7 @@ func TestProvisionerd(t *testing.T) { }), Type: &proto.AcquiredJob_WorkspaceBuild_{ WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -805,31 +802,24 @@ func TestProvisionerd(t *testing.T) { }), nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { - // Ignore the first provision message! - _, _ = stream.Recv() - - err := stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Log{ - Log: &sdkproto.Log{ - Level: sdkproto.LogLevel_DEBUG, - Output: "in progress", - }, - }, - }) - require.NoError(t, err) - - msg, err := stream.Recv() - require.NoError(t, err) - require.NotNil(t, msg.GetCancel()) - - return stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Complete{ - Complete: &sdkproto.Provision_Complete{ - Error: "some error", - }, - }, - }) + plan: func( + s *provisionersdk.Session, + _ *sdkproto.PlanRequest, + canceledOrComplete <-chan struct{}, + ) *sdkproto.PlanComplete { + s.ProvisionLog(sdkproto.LogLevel_DEBUG, "in progress") + <-canceledOrComplete + return &sdkproto.PlanComplete{ + Error: "some error", + } + }, + apply: func( + _ *provisionersdk.Session, + _ *sdkproto.ApplyRequest, + _ <-chan struct{}, + ) *sdkproto.ApplyComplete { + t.Error("should not apply when shut down during plan") + return &sdkproto.ApplyComplete{} }, }), }) @@ -867,7 +857,7 @@ func TestProvisionerd(t *testing.T) { }), Type: &proto.AcquiredJob_WorkspaceBuild_{ WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -898,16 +888,22 @@ func TestProvisionerd(t *testing.T) { return client, nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { - // Ignore the first provision message! - _, _ = stream.Recv() - return stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Complete{ - Complete: &sdkproto.Provision_Complete{ - Error: "some error", - }, - }, - }) + plan: func( + _ *provisionersdk.Session, + _ *sdkproto.PlanRequest, + _ <-chan struct{}, + ) *sdkproto.PlanComplete { + return &sdkproto.PlanComplete{ + Error: "some error", + } + }, + apply: func( + _ *provisionersdk.Session, + _ *sdkproto.ApplyRequest, + _ <-chan struct{}, + ) *sdkproto.ApplyComplete { + t.Error("should not apply when error during plan") + return &sdkproto.ApplyComplete{} }, }), }) @@ -945,7 +941,7 @@ func TestProvisionerd(t *testing.T) { }), Type: &proto.AcquiredJob_WorkspaceBuild_{ WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -977,14 +973,19 @@ func TestProvisionerd(t *testing.T) { return client, nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { - // Ignore the first provision message! - _, _ = stream.Recv() - return stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Complete{ - Complete: &sdkproto.Provision_Complete{}, - }, - }) + plan: func( + _ *provisionersdk.Session, + _ *sdkproto.PlanRequest, + _ <-chan struct{}, + ) *sdkproto.PlanComplete { + return &sdkproto.PlanComplete{} + }, + apply: func( + _ *provisionersdk.Session, + _ *sdkproto.ApplyRequest, + _ <-chan struct{}, + ) *sdkproto.ApplyComplete { + return &sdkproto.ApplyComplete{} }, }), }) @@ -1023,7 +1024,7 @@ func TestProvisionerd(t *testing.T) { }), Type: &proto.AcquiredJob_WorkspaceBuild_{ WorkspaceBuild: &proto.AcquiredJob_WorkspaceBuild{ - Metadata: &sdkproto.Provision_Metadata{}, + Metadata: &sdkproto.Metadata{}, }, }, }, nil @@ -1056,24 +1057,21 @@ func TestProvisionerd(t *testing.T) { }), nil }, provisionerd.Provisioners{ "someprovisioner": createProvisionerClient(t, done, provisionerTestServer{ - provision: func(stream sdkproto.DRPCProvisioner_ProvisionStream) error { - err := stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Log{ - Log: &sdkproto.Log{ - Level: sdkproto.LogLevel_DEBUG, - Output: "wow", - }, - }, - }) - require.NoError(t, err) - - err = stream.Send(&sdkproto.Provision_Response{ - Type: &sdkproto.Provision_Response_Complete{ - Complete: &sdkproto.Provision_Complete{}, - }, - }) - require.NoError(t, err) - return nil + plan: func( + s *provisionersdk.Session, + _ *sdkproto.PlanRequest, + _ <-chan struct{}, + ) *sdkproto.PlanComplete { + s.ProvisionLog(sdkproto.LogLevel_DEBUG, "wow") + return &sdkproto.PlanComplete{} + }, + apply: func( + s *provisionersdk.Session, + _ *sdkproto.ApplyRequest, + _ <-chan struct{}, + ) *sdkproto.ApplyComplete { + s.ProvisionLog(sdkproto.LogLevel_DEBUG, "wow") + return &sdkproto.ApplyComplete{} }, }), }) @@ -1111,7 +1109,6 @@ func createProvisionerd(t *testing.T, dialer provisionerd.Dialer, provisioners p JobPollInterval: 50 * time.Millisecond, UpdateInterval: 50 * time.Millisecond, Provisioners: provisioners, - WorkDirectory: t.TempDir(), }) t.Cleanup(func() { _ = server.Close() @@ -1172,15 +1169,15 @@ func createProvisionerClient(t *testing.T, done <-chan struct{}, server provisio _ = clientPipe.Close() _ = serverPipe.Close() }) - mux := drpcmux.New() - err := sdkproto.DRPCRegisterProvisioner(mux, &server) - require.NoError(t, err) - srv := drpcserver.New(mux) ctx, cancelFunc := context.WithCancel(context.Background()) closed := make(chan struct{}) go func() { defer close(closed) - _ = srv.Serve(ctx, serverPipe) + _ = provisionersdk.Serve(ctx, &server, &provisionersdk.ServeOptions{ + Listener: serverPipe, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug).Named("test-provisioner"), + WorkDirectory: t.TempDir(), + }) }() t.Cleanup(func() { cancelFunc() @@ -1200,16 +1197,21 @@ func createProvisionerClient(t *testing.T, done <-chan struct{}, server provisio } type provisionerTestServer struct { - parse func(request *sdkproto.Parse_Request, stream sdkproto.DRPCProvisioner_ParseStream) error - provision func(stream sdkproto.DRPCProvisioner_ProvisionStream) error + parse func(s *provisionersdk.Session, r *sdkproto.ParseRequest, canceledOrComplete <-chan struct{}) *sdkproto.ParseComplete + plan func(s *provisionersdk.Session, r *sdkproto.PlanRequest, canceledOrComplete <-chan struct{}) *sdkproto.PlanComplete + apply func(s *provisionersdk.Session, r *sdkproto.ApplyRequest, canceledOrComplete <-chan struct{}) *sdkproto.ApplyComplete +} + +func (p *provisionerTestServer) Parse(s *provisionersdk.Session, r *sdkproto.ParseRequest, canceledOrComplete <-chan struct{}) *sdkproto.ParseComplete { + return p.parse(s, r, canceledOrComplete) } -func (p *provisionerTestServer) Parse(request *sdkproto.Parse_Request, stream sdkproto.DRPCProvisioner_ParseStream) error { - return p.parse(request, stream) +func (p *provisionerTestServer) Plan(s *provisionersdk.Session, r *sdkproto.PlanRequest, canceledOrComplete <-chan struct{}) *sdkproto.PlanComplete { + return p.plan(s, r, canceledOrComplete) } -func (p *provisionerTestServer) Provision(stream sdkproto.DRPCProvisioner_ProvisionStream) error { - return p.provision(stream) +func (p *provisionerTestServer) Apply(s *provisionersdk.Session, r *sdkproto.ApplyRequest, canceledOrComplete <-chan struct{}) *sdkproto.ApplyComplete { + return p.apply(s, r, canceledOrComplete) } // Fulfills the protobuf interface for a ProvisionerDaemon with diff --git a/provisionerd/runner/runner.go b/provisionerd/runner/runner.go index 5911004f98e2e..7afa7a0999627 100644 --- a/provisionerd/runner/runner.go +++ b/provisionerd/runner/runner.go @@ -1,15 +1,9 @@ package runner import ( - "archive/tar" - "bytes" "context" "errors" "fmt" - "io" - "os" - "path" - "path/filepath" "reflect" "strings" "sync" @@ -18,7 +12,6 @@ import ( "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" - "github.com/spf13/afero" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" semconv "go.opentelemetry.io/otel/semconv/v1.14.0" @@ -54,14 +47,14 @@ type Runner struct { sender JobUpdater quotaCommitter QuotaCommitter logger slog.Logger - filesystem afero.Fs - workDirectory string provisioner sdkproto.DRPCProvisionerClient lastUpdate atomic.Pointer[time.Time] updateInterval time.Duration forceCancelInterval time.Duration logBufferInterval time.Duration + // session is the provisioning session with the (possibly remote) provisioner + session sdkproto.DRPCProvisioner_SessionClient // closed when the Runner is finished sending any updates/failed/complete. done chan struct{} // active as long as we are not canceled @@ -108,8 +101,6 @@ type Options struct { Updater JobUpdater QuotaCommitter QuotaCommitter Logger slog.Logger - Filesystem afero.Fs - WorkDirectory string Provisioner sdkproto.DRPCProvisionerClient UpdateInterval time.Duration ForceCancelInterval time.Duration @@ -149,8 +140,6 @@ func New( sender: opts.Updater, quotaCommitter: opts.QuotaCommitter, logger: logger, - filesystem: opts.Filesystem, - workDirectory: opts.WorkDirectory, provisioner: opts.Provisioner, updateInterval: opts.UpdateInterval, forceCancelInterval: opts.ForceCancelInterval, @@ -386,6 +375,14 @@ func (r *Runner) doCleanFinish(ctx context.Context) { r.setComplete(completedJob) }() + var err error + r.session, err = r.provisioner.Session(ctx) + if err != nil { + failedJob = r.failedJobf("open session: %s", err) + return + } + defer r.session.Close() + defer func() { ctx, span := r.startTrace(ctx, tracing.FuncName()) defer span.End() @@ -396,23 +393,6 @@ func (r *Runner) doCleanFinish(ctx context.Context) { Stage: "Cleaning Up", CreatedAt: time.Now().UnixMilli(), }) - - // Cleanup the work directory after execution. - for attempt := 0; attempt < 5; attempt++ { - err := r.filesystem.RemoveAll(r.workDirectory) - if err != nil { - // On Windows, open files cannot be removed. - // When the provisioner daemon is shutting down, - // it may take a few milliseconds for processes to exit. - // See: https://github.com/golang/go/issues/50510 - r.logger.Debug(ctx, "failed to clean work directory; trying again", slog.Error(err)) - time.Sleep(250 * time.Millisecond) - continue - } - r.logger.Debug(ctx, "cleaned up work directory") - break - } - r.flushQueuedLogs(ctx) }() @@ -424,85 +404,19 @@ func (r *Runner) do(ctx context.Context) (*proto.CompletedJob, *proto.FailedJob) ctx, span := r.startTrace(ctx, tracing.FuncName()) defer span.End() - err := r.filesystem.MkdirAll(r.workDirectory, 0o700) - if err != nil { - return nil, r.failedJobf("create work directory %q: %s", r.workDirectory, err) - } - r.queueLog(ctx, &proto.Log{ Source: proto.LogSource_PROVISIONER_DAEMON, Level: sdkproto.LogLevel_INFO, Stage: "Setting up", CreatedAt: time.Now().UnixMilli(), }) - if err != nil { - return nil, r.failedJobf("write log: %s", err) - } - - r.logger.Info(ctx, "unpacking template source archive", - slog.F("size_bytes", len(r.job.TemplateSourceArchive)), - ) - reader := tar.NewReader(bytes.NewBuffer(r.job.TemplateSourceArchive)) - for { - header, err := reader.Next() - if err != nil { - if errors.Is(err, io.EOF) { - break - } - return nil, r.failedJobf("read template source archive: %s", err) - } - // #nosec - headerPath := filepath.Join(r.workDirectory, header.Name) - if !strings.HasPrefix(headerPath, filepath.Clean(r.workDirectory)) { - return nil, r.failedJobf("tar attempts to target relative upper directory") - } - mode := header.FileInfo().Mode() - if mode == 0 { - mode = 0o600 - } - switch header.Typeflag { - case tar.TypeDir: - err = r.filesystem.MkdirAll(headerPath, mode) - if err != nil { - return nil, r.failedJobf("mkdir %q: %s", headerPath, err) - } - r.logger.Debug(context.Background(), "extracted directory", slog.F("path", headerPath)) - case tar.TypeReg: - file, err := r.filesystem.OpenFile(headerPath, os.O_CREATE|os.O_RDWR, mode) - if err != nil { - return nil, r.failedJobf("create file %q (mode %s): %s", headerPath, mode, err) - } - // Max file size of 10MiB. - size, err := io.CopyN(file, reader, 10<<20) - if errors.Is(err, io.EOF) { - err = nil - } - if err != nil { - _ = file.Close() - return nil, r.failedJobf("copy file %q: %s", headerPath, err) - } - err = file.Close() - if err != nil { - return nil, r.failedJobf("close file %q: %s", headerPath, err) - } - r.logger.Debug(context.Background(), "extracted file", - slog.F("size_bytes", size), - slog.F("path", headerPath), - slog.F("mode", mode), - ) - } - } switch jobType := r.job.Type.(type) { case *proto.AcquiredJob_TemplateImport_: r.logger.Debug(context.Background(), "acquired job is template import", slog.F("user_variable_values", redactVariableValues(jobType.TemplateImport.UserVariableValues)), ) - failedJob := r.runReadmeParse(ctx) - if failedJob != nil { - return nil, failedJob - } return r.runTemplateImport(ctx) case *proto.AcquiredJob_TemplateDryRun_: r.logger.Debug(context.Background(), "acquired job is template dry-run", @@ -525,6 +439,14 @@ func (r *Runner) do(ctx context.Context) (*proto.CompletedJob, *proto.FailedJob) } } +func (r *Runner) configure(config *sdkproto.Config) *proto.FailedJob { + err := r.session.Send(&sdkproto.Request{Type: &sdkproto.Request_Config{Config: config}}) + if err != nil { + return r.failedJobf("send config: %s", err) + } + return nil +} + // heartbeatRoutine periodically sends updates on the job, which keeps coder server // from assuming the job is stalled, and allows the runner to learn if the job // has been canceled by the user. @@ -577,44 +499,16 @@ func (r *Runner) heartbeatRoutine(ctx context.Context) { } } -// ReadmeFile is the location we look for to extract documentation from template -// versions. -const ReadmeFile = "README.md" - -func (r *Runner) runReadmeParse(ctx context.Context) *proto.FailedJob { +func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *proto.FailedJob) { ctx, span := r.startTrace(ctx, tracing.FuncName()) defer span.End() - fi, err := afero.ReadFile(r.filesystem, path.Join(r.workDirectory, ReadmeFile)) - if err != nil { - r.queueLog(ctx, &proto.Log{ - Source: proto.LogSource_PROVISIONER_DAEMON, - Level: sdkproto.LogLevel_DEBUG, - Stage: "No README.md provided", - CreatedAt: time.Now().UnixMilli(), - }) - return nil - } - - _, err = r.update(ctx, &proto.UpdateJobRequest{ - JobId: r.job.JobId, - Logs: []*proto.Log{{ - Source: proto.LogSource_PROVISIONER_DAEMON, - Level: sdkproto.LogLevel_INFO, - Stage: "Adding README.md...", - CreatedAt: time.Now().UnixMilli(), - }}, - Readme: fi, + failedJob := r.configure(&sdkproto.Config{ + TemplateSourceArchive: r.job.GetTemplateSourceArchive(), }) - if err != nil { - return r.failedJobf("write log: %s", err) + if failedJob != nil { + return nil, failedJob } - return nil -} - -func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *proto.FailedJob) { - ctx, span := r.startTrace(ctx, tracing.FuncName()) - defer span.End() // Parse parameters and update the job with the parameter specs r.queueLog(ctx, &proto.Log{ @@ -623,7 +517,7 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p Stage: "Parsing template parameters", CreatedAt: time.Now().UnixMilli(), }) - templateVariables, err := r.runTemplateImportParse(ctx) + templateVariables, readme, err := r.runTemplateImportParse(ctx) if err != nil { return nil, r.failedJobf("run parse: %s", err) } @@ -634,6 +528,7 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p JobId: r.job.JobId, TemplateVariables: templateVariables, UserVariableValues: r.job.GetTemplateImport().GetUserVariableValues(), + Readme: readme, }) if err != nil { return nil, r.failedJobf("update job: %s", err) @@ -646,7 +541,7 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p Stage: "Detecting persistent resources", CreatedAt: time.Now().UnixMilli(), }) - startProvision, err := r.runTemplateImportProvision(ctx, updateResponse.VariableValues, &sdkproto.Provision_Metadata{ + startProvision, err := r.runTemplateImportProvision(ctx, updateResponse.VariableValues, &sdkproto.Metadata{ CoderUrl: r.job.GetTemplateImport().Metadata.CoderUrl, WorkspaceTransition: sdkproto.WorkspaceTransition_START, }) @@ -661,7 +556,7 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p Stage: "Detecting ephemeral resources", CreatedAt: time.Now().UnixMilli(), }) - stopProvision, err := r.runTemplateImportProvision(ctx, updateResponse.VariableValues, &sdkproto.Provision_Metadata{ + stopProvision, err := r.runTemplateImportProvision(ctx, updateResponse.VariableValues, &sdkproto.Metadata{ CoderUrl: r.job.GetTemplateImport().Metadata.CoderUrl, WorkspaceTransition: sdkproto.WorkspaceTransition_STOP, }) @@ -682,25 +577,24 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p }, nil } -// Parses template variables and parameter schemas from source. -func (r *Runner) runTemplateImportParse(ctx context.Context) ([]*sdkproto.TemplateVariable, error) { +// Parses template variables and README from source. +func (r *Runner) runTemplateImportParse(ctx context.Context) ( + vars []*sdkproto.TemplateVariable, readme []byte, err error, +) { ctx, span := r.startTrace(ctx, tracing.FuncName()) defer span.End() - stream, err := r.provisioner.Parse(ctx, &sdkproto.Parse_Request{ - Directory: r.workDirectory, - }) + err = r.session.Send(&sdkproto.Request{Type: &sdkproto.Request_Parse{Parse: &sdkproto.ParseRequest{}}}) if err != nil { - return nil, xerrors.Errorf("parse source: %w", err) + return nil, nil, xerrors.Errorf("parse source: %w", err) } - defer stream.Close() for { - msg, err := stream.Recv() + msg, err := r.session.Recv() if err != nil { - return nil, xerrors.Errorf("recv parse source: %w", err) + return nil, nil, xerrors.Errorf("recv parse source: %w", err) } switch msgType := msg.Type.(type) { - case *sdkproto.Parse_Response_Log: + case *sdkproto.Response_Log: r.logger.Debug(context.Background(), "parse job logged", slog.F("level", msgType.Log.Level), slog.F("output", msgType.Log.Output), @@ -713,14 +607,20 @@ func (r *Runner) runTemplateImportParse(ctx context.Context) ([]*sdkproto.Templa Output: msgType.Log.Output, Stage: "Parse parameters", }) - case *sdkproto.Parse_Response_Complete: + case *sdkproto.Response_Parse: + pc := msgType.Parse r.logger.Debug(context.Background(), "parse complete", - slog.F("template_variables", msgType.Complete.TemplateVariables), + slog.F("template_variables", pc.TemplateVariables), + slog.F("readme_len", len(pc.Readme)), + slog.F("error", pc.Error), ) + if pc.Error != "" { + return nil, nil, xerrors.Errorf("parse error: %s", pc.Error) + } - return msgType.Complete.TemplateVariables, nil + return msgType.Parse.TemplateVariables, msgType.Parse.Readme, nil default: - return nil, xerrors.Errorf("invalid message type %q received from provisioner", + return nil, nil, xerrors.Errorf("invalid message type %q received from provisioner", reflect.TypeOf(msg.Type).String()) } } @@ -735,13 +635,18 @@ type templateImportProvision struct { // Performs a dry-run provision when importing a template. // This is used to detect resources that would be provisioned for a workspace in various states. // It doesn't define values for rich parameters as they're unknown during template import. -func (r *Runner) runTemplateImportProvision(ctx context.Context, variableValues []*sdkproto.VariableValue, metadata *sdkproto.Provision_Metadata) (*templateImportProvision, error) { +func (r *Runner) runTemplateImportProvision(ctx context.Context, variableValues []*sdkproto.VariableValue, metadata *sdkproto.Metadata) (*templateImportProvision, error) { return r.runTemplateImportProvisionWithRichParameters(ctx, variableValues, nil, metadata) } // Performs a dry-run provision with provided rich parameters. // This is used to detect resources that would be provisioned for a workspace in various states. -func (r *Runner) runTemplateImportProvisionWithRichParameters(ctx context.Context, variableValues []*sdkproto.VariableValue, richParameterValues []*sdkproto.RichParameterValue, metadata *sdkproto.Provision_Metadata) (*templateImportProvision, error) { +func (r *Runner) runTemplateImportProvisionWithRichParameters( + ctx context.Context, + variableValues []*sdkproto.VariableValue, + richParameterValues []*sdkproto.RichParameterValue, + metadata *sdkproto.Metadata, +) (*templateImportProvision, error) { ctx, span := r.startTrace(ctx, tracing.FuncName()) defer span.End() @@ -754,46 +659,38 @@ func (r *Runner) runTemplateImportProvisionWithRichParameters(ctx context.Contex } // use the notStopped so that if we attempt to gracefully cancel, the stream will still be available for us // to send the cancel to the provisioner - stream, err := r.provisioner.Provision(ctx) + err := r.session.Send(&sdkproto.Request{Type: &sdkproto.Request_Plan{Plan: &sdkproto.PlanRequest{ + Metadata: metadata, + RichParameterValues: richParameterValues, + VariableValues: variableValues, + }}}) if err != nil { - return nil, xerrors.Errorf("provision: %w", err) + return nil, xerrors.Errorf("start provision: %w", err) } - defer stream.Close() + nevermind := make(chan struct{}) + defer close(nevermind) go func() { select { + case <-nevermind: + return case <-r.notStopped.Done(): return case <-r.notCanceled.Done(): - _ = stream.Send(&sdkproto.Provision_Request{ - Type: &sdkproto.Provision_Request_Cancel{ - Cancel: &sdkproto.Provision_Cancel{}, + _ = r.session.Send(&sdkproto.Request{ + Type: &sdkproto.Request_Cancel{ + Cancel: &sdkproto.CancelRequest{}, }, }) } }() - err = stream.Send(&sdkproto.Provision_Request{ - Type: &sdkproto.Provision_Request_Plan{ - Plan: &sdkproto.Provision_Plan{ - Config: &sdkproto.Provision_Config{ - Directory: r.workDirectory, - Metadata: metadata, - }, - RichParameterValues: richParameterValues, - VariableValues: variableValues, - }, - }, - }) - if err != nil { - return nil, xerrors.Errorf("start provision: %w", err) - } for { - msg, err := stream.Recv() + msg, err := r.session.Recv() if err != nil { return nil, xerrors.Errorf("recv import provision: %w", err) } switch msgType := msg.Type.(type) { - case *sdkproto.Provision_Response_Log: + case *sdkproto.Response_Log: r.logger.Debug(context.Background(), "template import provision job logged", slog.F("level", msgType.Log.Level), slog.F("output", msgType.Log.Output), @@ -805,25 +702,25 @@ func (r *Runner) runTemplateImportProvisionWithRichParameters(ctx context.Contex Output: msgType.Log.Output, Stage: stage, }) - case *sdkproto.Provision_Response_Complete: - if msgType.Complete.Error != "" { + case *sdkproto.Response_Plan: + c := msgType.Plan + if c.Error != "" { r.logger.Info(context.Background(), "dry-run provision failure", - slog.F("error", msgType.Complete.Error), + slog.F("error", c.Error), ) - return nil, xerrors.New(msgType.Complete.Error) + return nil, xerrors.New(c.Error) } r.logger.Info(context.Background(), "parse dry-run provision successful", - slog.F("resource_count", len(msgType.Complete.Resources)), - slog.F("resources", msgType.Complete.Resources), - slog.F("state_length", len(msgType.Complete.State)), + slog.F("resource_count", len(c.Resources)), + slog.F("resources", c.Resources), ) return &templateImportProvision{ - Resources: msgType.Complete.Resources, - Parameters: msgType.Complete.Parameters, - GitAuthProviders: msgType.Complete.GitAuthProviders, + Resources: c.Resources, + Parameters: c.Parameters, + GitAuthProviders: c.GitAuthProviders, }, nil default: return nil, xerrors.Errorf("invalid message type %q received from provisioner", @@ -864,6 +761,13 @@ func (r *Runner) runTemplateDryRun(ctx context.Context) (*proto.CompletedJob, *p metadata.WorkspaceOwnerId = id.String() } + failedJob := r.configure(&sdkproto.Config{ + TemplateSourceArchive: r.job.GetTemplateSourceArchive(), + }) + if failedJob != nil { + return nil, failedJob + } + // Run the template import provision task since it's already a dry run. provision, err := r.runTemplateImportProvisionWithRichParameters(ctx, r.job.GetTemplateDryRun().GetVariableValues(), @@ -884,41 +788,39 @@ func (r *Runner) runTemplateDryRun(ctx context.Context) (*proto.CompletedJob, *p }, nil } -func (r *Runner) buildWorkspace(ctx context.Context, stage string, req *sdkproto.Provision_Request) ( - *sdkproto.Provision_Complete, *proto.FailedJob, +func (r *Runner) buildWorkspace(ctx context.Context, stage string, req *sdkproto.Request) ( + *sdkproto.Response, *proto.FailedJob, ) { // use the notStopped so that if we attempt to gracefully cancel, the stream // will still be available for us to send the cancel to the provisioner - stream, err := r.provisioner.Provision(ctx) + err := r.session.Send(req) if err != nil { - return nil, r.failedWorkspaceBuildf("provision: %s", err) + return nil, r.failedWorkspaceBuildf("start provision: %s", err) } - defer stream.Close() + nevermind := make(chan struct{}) + defer close(nevermind) go func() { select { + case <-nevermind: + return case <-r.notStopped.Done(): return case <-r.notCanceled.Done(): - _ = stream.Send(&sdkproto.Provision_Request{ - Type: &sdkproto.Provision_Request_Cancel{ - Cancel: &sdkproto.Provision_Cancel{}, + _ = r.session.Send(&sdkproto.Request{ + Type: &sdkproto.Request_Cancel{ + Cancel: &sdkproto.CancelRequest{}, }, }) } }() - err = stream.Send(req) - if err != nil { - return nil, r.failedWorkspaceBuildf("start provision: %s", err) - } - for { - msg, err := stream.Recv() + msg, err := r.session.Recv() if err != nil { return nil, r.failedWorkspaceBuildf("recv workspace provision: %s", err) } switch msgType := msg.Type.(type) { - case *sdkproto.Provision_Response_Log: + case *sdkproto.Response_Log: r.logProvisionerJobLog(context.Background(), msgType.Log.Level, "workspace provisioner job logged", slog.F("level", msgType.Log.Level), slog.F("output", msgType.Log.Output), @@ -932,33 +834,9 @@ func (r *Runner) buildWorkspace(ctx context.Context, stage string, req *sdkproto Output: msgType.Log.Output, Stage: stage, }) - case *sdkproto.Provision_Response_Complete: - if msgType.Complete.Error != "" { - r.logger.Warn(context.Background(), "provision failed; updating state", - slog.F("state_length", len(msgType.Complete.State)), - slog.F("error", msgType.Complete.Error), - ) - - return nil, &proto.FailedJob{ - JobId: r.job.JobId, - Error: msgType.Complete.Error, - Type: &proto.FailedJob_WorkspaceBuild_{ - WorkspaceBuild: &proto.FailedJob_WorkspaceBuild{ - State: msgType.Complete.State, - }, - }, - } - } - - r.logger.Info(context.Background(), "provision successful", - slog.F("resource_count", len(msgType.Complete.Resources)), - slog.F("resources", msgType.Complete.Resources), - slog.F("state_length", len(msgType.Complete.State)), - ) - // Stop looping! - return msgType.Complete, nil default: - return nil, r.failedWorkspaceBuildf("invalid message type %T received from provisioner", msg.Type) + // Stop looping! + return msg, nil } } } @@ -1035,18 +913,19 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p applyStage = "Destroying workspace" } - config := &sdkproto.Provision_Config{ - Directory: r.workDirectory, - Metadata: r.job.GetWorkspaceBuild().Metadata, - State: r.job.GetWorkspaceBuild().State, - - ProvisionerLogLevel: r.job.GetWorkspaceBuild().LogLevel, + failedJob := r.configure(&sdkproto.Config{ + TemplateSourceArchive: r.job.GetTemplateSourceArchive(), + State: r.job.GetWorkspaceBuild().State, + ProvisionerLogLevel: r.job.GetWorkspaceBuild().LogLevel, + }) + if failedJob != nil { + return nil, failedJob } - completedPlan, failed := r.buildWorkspace(ctx, "Planning infrastructure", &sdkproto.Provision_Request{ - Type: &sdkproto.Provision_Request_Plan{ - Plan: &sdkproto.Provision_Plan{ - Config: config, + resp, failed := r.buildWorkspace(ctx, "Planning infrastructure", &sdkproto.Request{ + Type: &sdkproto.Request_Plan{ + Plan: &sdkproto.PlanRequest{ + Metadata: r.job.GetWorkspaceBuild().Metadata, RichParameterValues: r.job.GetWorkspaceBuild().RichParameterValues, VariableValues: r.job.GetWorkspaceBuild().VariableValues, GitAuthProviders: r.job.GetWorkspaceBuild().GitAuthProviders, @@ -1056,9 +935,31 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p if failed != nil { return nil, failed } + planComplete := resp.GetPlan() + if planComplete == nil { + return nil, r.failedWorkspaceBuildf("invalid message type %T received from provisioner", resp.Type) + } + if planComplete.Error != "" { + r.logger.Warn(context.Background(), "plan request failed", + slog.F("error", planComplete.Error), + ) + + return nil, &proto.FailedJob{ + JobId: r.job.JobId, + Error: planComplete.Error, + Type: &proto.FailedJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.FailedJob_WorkspaceBuild{}, + }, + } + } + + r.logger.Info(context.Background(), "plan request successful", + slog.F("resource_count", len(planComplete.Resources)), + slog.F("resources", planComplete.Resources), + ) r.flushQueuedLogs(ctx) if commitQuota { - failed = r.commitQuota(ctx, completedPlan.GetResources()) + failed = r.commitQuota(ctx, planComplete.Resources) r.flushQueuedLogs(ctx) if failed != nil { return nil, failed @@ -1072,25 +973,50 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p CreatedAt: time.Now().UnixMilli(), }) - completedApply, failed := r.buildWorkspace(ctx, applyStage, &sdkproto.Provision_Request{ - Type: &sdkproto.Provision_Request_Apply{ - Apply: &sdkproto.Provision_Apply{ - Config: config, - Plan: completedPlan.GetPlan(), + resp, failed = r.buildWorkspace(ctx, applyStage, &sdkproto.Request{ + Type: &sdkproto.Request_Apply{ + Apply: &sdkproto.ApplyRequest{ + Metadata: r.job.GetWorkspaceBuild().Metadata, }, }, }) if failed != nil { return nil, failed } + applyComplete := resp.GetApply() + if applyComplete == nil { + return nil, r.failedWorkspaceBuildf("invalid message type %T received from provisioner", resp.Type) + } + if applyComplete.Error != "" { + r.logger.Warn(context.Background(), "apply failed; updating state", + slog.F("error", applyComplete.Error), + slog.F("state_len", len(applyComplete.State)), + ) + + return nil, &proto.FailedJob{ + JobId: r.job.JobId, + Error: applyComplete.Error, + Type: &proto.FailedJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.FailedJob_WorkspaceBuild{ + State: applyComplete.State, + }, + }, + } + } + + r.logger.Info(context.Background(), "apply successful", + slog.F("resource_count", len(applyComplete.Resources)), + slog.F("resources", applyComplete.Resources), + slog.F("state_len", len(applyComplete.State)), + ) r.flushQueuedLogs(ctx) return &proto.CompletedJob{ JobId: r.job.JobId, Type: &proto.CompletedJob_WorkspaceBuild_{ WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{ - State: completedApply.GetState(), - Resources: completedApply.GetResources(), + State: applyComplete.State, + Resources: applyComplete.Resources, }, }, }, nil diff --git a/provisionersdk/errors.go b/provisionersdk/errors.go new file mode 100644 index 0000000000000..0dc66e6e6b301 --- /dev/null +++ b/provisionersdk/errors.go @@ -0,0 +1,19 @@ +package provisionersdk + +import ( + "fmt" + + "github.com/coder/coder/v2/provisionersdk/proto" +) + +func ParseErrorf(format string, args ...any) *proto.ParseComplete { + return &proto.ParseComplete{Error: fmt.Sprintf(format, args...)} +} + +func PlanErrorf(format string, args ...any) *proto.PlanComplete { + return &proto.PlanComplete{Error: fmt.Sprintf(format, args...)} +} + +func ApplyErrorf(format string, args ...any) *proto.ApplyComplete { + return &proto.ApplyComplete{Error: fmt.Sprintf(format, args...)} +} diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index f39e9731e6101..c0ea0be327953 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -125,6 +125,7 @@ func (AppSharingLevel) EnumDescriptor() ([]byte, []int) { return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{1} } +// WorkspaceTransition is the desired outcome of a build type WorkspaceTransition int32 const ( @@ -1313,15 +1314,27 @@ func (x *Resource) GetDailyCost() int32 { return 0 } -// Parse consumes source-code from a directory to produce inputs. -type Parse struct { +// Metadata is information about a workspace used in the execution of a build +type Metadata struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields + + CoderUrl string `protobuf:"bytes,1,opt,name=coder_url,json=coderUrl,proto3" json:"coder_url,omitempty"` + WorkspaceTransition WorkspaceTransition `protobuf:"varint,2,opt,name=workspace_transition,json=workspaceTransition,proto3,enum=provisioner.WorkspaceTransition" json:"workspace_transition,omitempty"` + WorkspaceName string `protobuf:"bytes,3,opt,name=workspace_name,json=workspaceName,proto3" json:"workspace_name,omitempty"` + WorkspaceOwner string `protobuf:"bytes,4,opt,name=workspace_owner,json=workspaceOwner,proto3" json:"workspace_owner,omitempty"` + WorkspaceId string `protobuf:"bytes,5,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + WorkspaceOwnerId string `protobuf:"bytes,6,opt,name=workspace_owner_id,json=workspaceOwnerId,proto3" json:"workspace_owner_id,omitempty"` + WorkspaceOwnerEmail string `protobuf:"bytes,7,opt,name=workspace_owner_email,json=workspaceOwnerEmail,proto3" json:"workspace_owner_email,omitempty"` + TemplateName string `protobuf:"bytes,8,opt,name=template_name,json=templateName,proto3" json:"template_name,omitempty"` + TemplateVersion string `protobuf:"bytes,9,opt,name=template_version,json=templateVersion,proto3" json:"template_version,omitempty"` + WorkspaceOwnerOidcAccessToken string `protobuf:"bytes,10,opt,name=workspace_owner_oidc_access_token,json=workspaceOwnerOidcAccessToken,proto3" json:"workspace_owner_oidc_access_token,omitempty"` + WorkspaceOwnerSessionToken string `protobuf:"bytes,11,opt,name=workspace_owner_session_token,json=workspaceOwnerSessionToken,proto3" json:"workspace_owner_session_token,omitempty"` } -func (x *Parse) Reset() { - *x = Parse{} +func (x *Metadata) Reset() { + *x = Metadata{} if protoimpl.UnsafeEnabled { mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -1329,13 +1342,13 @@ func (x *Parse) Reset() { } } -func (x *Parse) String() string { +func (x *Metadata) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Parse) ProtoMessage() {} +func (*Metadata) ProtoMessage() {} -func (x *Parse) ProtoReflect() protoreflect.Message { +func (x *Metadata) ProtoReflect() protoreflect.Message { mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[13] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -1347,158 +1360,118 @@ func (x *Parse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Parse.ProtoReflect.Descriptor instead. -func (*Parse) Descriptor() ([]byte, []int) { +// Deprecated: Use Metadata.ProtoReflect.Descriptor instead. +func (*Metadata) Descriptor() ([]byte, []int) { return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{13} } -// Provision consumes source-code from a directory to produce resources. -// Exactly one of Plan or Apply must be provided in a single session. -type Provision struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields -} - -func (x *Provision) Reset() { - *x = Provision{} - if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) +func (x *Metadata) GetCoderUrl() string { + if x != nil { + return x.CoderUrl } + return "" } -func (x *Provision) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Provision) ProtoMessage() {} - -func (x *Provision) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[14] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms +func (x *Metadata) GetWorkspaceTransition() WorkspaceTransition { + if x != nil { + return x.WorkspaceTransition } - return mi.MessageOf(x) -} - -// Deprecated: Use Provision.ProtoReflect.Descriptor instead. -func (*Provision) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{14} -} - -type Agent_Metadata struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` - DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` - Script string `protobuf:"bytes,3,opt,name=script,proto3" json:"script,omitempty"` - Interval int64 `protobuf:"varint,4,opt,name=interval,proto3" json:"interval,omitempty"` - Timeout int64 `protobuf:"varint,5,opt,name=timeout,proto3" json:"timeout,omitempty"` + return WorkspaceTransition_START } -func (x *Agent_Metadata) Reset() { - *x = Agent_Metadata{} - if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) +func (x *Metadata) GetWorkspaceName() string { + if x != nil { + return x.WorkspaceName } + return "" } -func (x *Agent_Metadata) String() string { - return protoimpl.X.MessageStringOf(x) +func (x *Metadata) GetWorkspaceOwner() string { + if x != nil { + return x.WorkspaceOwner + } + return "" } -func (*Agent_Metadata) ProtoMessage() {} - -func (x *Agent_Metadata) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[15] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms +func (x *Metadata) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId } - return mi.MessageOf(x) + return "" } -// Deprecated: Use Agent_Metadata.ProtoReflect.Descriptor instead. -func (*Agent_Metadata) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{9, 0} +func (x *Metadata) GetWorkspaceOwnerId() string { + if x != nil { + return x.WorkspaceOwnerId + } + return "" } -func (x *Agent_Metadata) GetKey() string { +func (x *Metadata) GetWorkspaceOwnerEmail() string { if x != nil { - return x.Key + return x.WorkspaceOwnerEmail } return "" } -func (x *Agent_Metadata) GetDisplayName() string { +func (x *Metadata) GetTemplateName() string { if x != nil { - return x.DisplayName + return x.TemplateName } return "" } -func (x *Agent_Metadata) GetScript() string { +func (x *Metadata) GetTemplateVersion() string { if x != nil { - return x.Script + return x.TemplateVersion } return "" } -func (x *Agent_Metadata) GetInterval() int64 { +func (x *Metadata) GetWorkspaceOwnerOidcAccessToken() string { if x != nil { - return x.Interval + return x.WorkspaceOwnerOidcAccessToken } - return 0 + return "" } -func (x *Agent_Metadata) GetTimeout() int64 { +func (x *Metadata) GetWorkspaceOwnerSessionToken() string { if x != nil { - return x.Timeout + return x.WorkspaceOwnerSessionToken } - return 0 + return "" } -type Resource_Metadata struct { +// Config represents execution configuration shared by all subsequent requests in the Session +type Config struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` - Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` - Sensitive bool `protobuf:"varint,3,opt,name=sensitive,proto3" json:"sensitive,omitempty"` - IsNull bool `protobuf:"varint,4,opt,name=is_null,json=isNull,proto3" json:"is_null,omitempty"` + // template_source_archive is a tar of the template source files + TemplateSourceArchive []byte `protobuf:"bytes,1,opt,name=template_source_archive,json=templateSourceArchive,proto3" json:"template_source_archive,omitempty"` + // state is the provisioner state (if any) + State []byte `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + ProvisionerLogLevel string `protobuf:"bytes,3,opt,name=provisioner_log_level,json=provisionerLogLevel,proto3" json:"provisioner_log_level,omitempty"` } -func (x *Resource_Metadata) Reset() { - *x = Resource_Metadata{} +func (x *Config) Reset() { + *x = Config{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[17] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *Resource_Metadata) String() string { +func (x *Config) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Resource_Metadata) ProtoMessage() {} +func (*Config) ProtoMessage() {} -func (x *Resource_Metadata) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[17] +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[14] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1509,64 +1482,56 @@ func (x *Resource_Metadata) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Resource_Metadata.ProtoReflect.Descriptor instead. -func (*Resource_Metadata) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{12, 0} -} - -func (x *Resource_Metadata) GetKey() string { - if x != nil { - return x.Key - } - return "" +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{14} } -func (x *Resource_Metadata) GetValue() string { +func (x *Config) GetTemplateSourceArchive() []byte { if x != nil { - return x.Value + return x.TemplateSourceArchive } - return "" + return nil } -func (x *Resource_Metadata) GetSensitive() bool { +func (x *Config) GetState() []byte { if x != nil { - return x.Sensitive + return x.State } - return false + return nil } -func (x *Resource_Metadata) GetIsNull() bool { +func (x *Config) GetProvisionerLogLevel() string { if x != nil { - return x.IsNull + return x.ProvisionerLogLevel } - return false + return "" } -type Parse_Request struct { +// ParseRequest consumes source-code to produce inputs. +type ParseRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - - Directory string `protobuf:"bytes,1,opt,name=directory,proto3" json:"directory,omitempty"` } -func (x *Parse_Request) Reset() { - *x = Parse_Request{} +func (x *ParseRequest) Reset() { + *x = ParseRequest{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[18] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *Parse_Request) String() string { +func (x *ParseRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Parse_Request) ProtoMessage() {} +func (*ParseRequest) ProtoMessage() {} -func (x *Parse_Request) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[18] +func (x *ParseRequest) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[15] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1577,43 +1542,39 @@ func (x *Parse_Request) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Parse_Request.ProtoReflect.Descriptor instead. -func (*Parse_Request) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{13, 0} +// Deprecated: Use ParseRequest.ProtoReflect.Descriptor instead. +func (*ParseRequest) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{15} } -func (x *Parse_Request) GetDirectory() string { - if x != nil { - return x.Directory - } - return "" -} - -type Parse_Complete struct { +// ParseComplete indicates a request to parse completed. +type ParseComplete struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - TemplateVariables []*TemplateVariable `protobuf:"bytes,1,rep,name=template_variables,json=templateVariables,proto3" json:"template_variables,omitempty"` + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + TemplateVariables []*TemplateVariable `protobuf:"bytes,2,rep,name=template_variables,json=templateVariables,proto3" json:"template_variables,omitempty"` + Readme []byte `protobuf:"bytes,3,opt,name=readme,proto3" json:"readme,omitempty"` } -func (x *Parse_Complete) Reset() { - *x = Parse_Complete{} +func (x *ParseComplete) Reset() { + *x = ParseComplete{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[19] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *Parse_Complete) String() string { +func (x *ParseComplete) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Parse_Complete) ProtoMessage() {} +func (*ParseComplete) ProtoMessage() {} -func (x *Parse_Complete) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[19] +func (x *ParseComplete) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[16] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1624,47 +1585,61 @@ func (x *Parse_Complete) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Parse_Complete.ProtoReflect.Descriptor instead. -func (*Parse_Complete) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{13, 1} +// Deprecated: Use ParseComplete.ProtoReflect.Descriptor instead. +func (*ParseComplete) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{16} } -func (x *Parse_Complete) GetTemplateVariables() []*TemplateVariable { +func (x *ParseComplete) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *ParseComplete) GetTemplateVariables() []*TemplateVariable { if x != nil { return x.TemplateVariables } return nil } -type Parse_Response struct { +func (x *ParseComplete) GetReadme() []byte { + if x != nil { + return x.Readme + } + return nil +} + +// PlanRequest asks the provisioner to plan what resources & parameters it will create +type PlanRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Types that are assignable to Type: - // - // *Parse_Response_Log - // *Parse_Response_Complete - Type isParse_Response_Type `protobuf_oneof:"type"` + Metadata *Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + RichParameterValues []*RichParameterValue `protobuf:"bytes,2,rep,name=rich_parameter_values,json=richParameterValues,proto3" json:"rich_parameter_values,omitempty"` + VariableValues []*VariableValue `protobuf:"bytes,3,rep,name=variable_values,json=variableValues,proto3" json:"variable_values,omitempty"` + GitAuthProviders []*GitAuthProvider `protobuf:"bytes,4,rep,name=git_auth_providers,json=gitAuthProviders,proto3" json:"git_auth_providers,omitempty"` } -func (x *Parse_Response) Reset() { - *x = Parse_Response{} +func (x *PlanRequest) Reset() { + *x = PlanRequest{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[20] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *Parse_Response) String() string { +func (x *PlanRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Parse_Response) ProtoMessage() {} +func (*PlanRequest) ProtoMessage() {} -func (x *Parse_Response) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[20] +func (x *PlanRequest) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[17] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1675,83 +1650,68 @@ func (x *Parse_Response) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Parse_Response.ProtoReflect.Descriptor instead. -func (*Parse_Response) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{13, 2} +// Deprecated: Use PlanRequest.ProtoReflect.Descriptor instead. +func (*PlanRequest) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{17} } -func (m *Parse_Response) GetType() isParse_Response_Type { - if m != nil { - return m.Type +func (x *PlanRequest) GetMetadata() *Metadata { + if x != nil { + return x.Metadata } return nil } -func (x *Parse_Response) GetLog() *Log { - if x, ok := x.GetType().(*Parse_Response_Log); ok { - return x.Log +func (x *PlanRequest) GetRichParameterValues() []*RichParameterValue { + if x != nil { + return x.RichParameterValues } return nil } -func (x *Parse_Response) GetComplete() *Parse_Complete { - if x, ok := x.GetType().(*Parse_Response_Complete); ok { - return x.Complete +func (x *PlanRequest) GetVariableValues() []*VariableValue { + if x != nil { + return x.VariableValues } return nil } -type isParse_Response_Type interface { - isParse_Response_Type() -} - -type Parse_Response_Log struct { - Log *Log `protobuf:"bytes,1,opt,name=log,proto3,oneof"` -} - -type Parse_Response_Complete struct { - Complete *Parse_Complete `protobuf:"bytes,2,opt,name=complete,proto3,oneof"` +func (x *PlanRequest) GetGitAuthProviders() []*GitAuthProvider { + if x != nil { + return x.GitAuthProviders + } + return nil } -func (*Parse_Response_Log) isParse_Response_Type() {} - -func (*Parse_Response_Complete) isParse_Response_Type() {} - -type Provision_Metadata struct { +// PlanComplete indicates a request to plan completed. +type PlanComplete struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - CoderUrl string `protobuf:"bytes,1,opt,name=coder_url,json=coderUrl,proto3" json:"coder_url,omitempty"` - WorkspaceTransition WorkspaceTransition `protobuf:"varint,2,opt,name=workspace_transition,json=workspaceTransition,proto3,enum=provisioner.WorkspaceTransition" json:"workspace_transition,omitempty"` - WorkspaceName string `protobuf:"bytes,3,opt,name=workspace_name,json=workspaceName,proto3" json:"workspace_name,omitempty"` - WorkspaceOwner string `protobuf:"bytes,4,opt,name=workspace_owner,json=workspaceOwner,proto3" json:"workspace_owner,omitempty"` - WorkspaceId string `protobuf:"bytes,5,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` - WorkspaceOwnerId string `protobuf:"bytes,6,opt,name=workspace_owner_id,json=workspaceOwnerId,proto3" json:"workspace_owner_id,omitempty"` - WorkspaceOwnerEmail string `protobuf:"bytes,7,opt,name=workspace_owner_email,json=workspaceOwnerEmail,proto3" json:"workspace_owner_email,omitempty"` - TemplateName string `protobuf:"bytes,8,opt,name=template_name,json=templateName,proto3" json:"template_name,omitempty"` - TemplateVersion string `protobuf:"bytes,9,opt,name=template_version,json=templateVersion,proto3" json:"template_version,omitempty"` - WorkspaceOwnerOidcAccessToken string `protobuf:"bytes,10,opt,name=workspace_owner_oidc_access_token,json=workspaceOwnerOidcAccessToken,proto3" json:"workspace_owner_oidc_access_token,omitempty"` - WorkspaceOwnerSessionToken string `protobuf:"bytes,11,opt,name=workspace_owner_session_token,json=workspaceOwnerSessionToken,proto3" json:"workspace_owner_session_token,omitempty"` + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + Resources []*Resource `protobuf:"bytes,2,rep,name=resources,proto3" json:"resources,omitempty"` + Parameters []*RichParameter `protobuf:"bytes,3,rep,name=parameters,proto3" json:"parameters,omitempty"` + GitAuthProviders []string `protobuf:"bytes,4,rep,name=git_auth_providers,json=gitAuthProviders,proto3" json:"git_auth_providers,omitempty"` } -func (x *Provision_Metadata) Reset() { - *x = Provision_Metadata{} +func (x *PlanComplete) Reset() { + *x = PlanComplete{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[21] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *Provision_Metadata) String() string { +func (x *PlanComplete) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Provision_Metadata) ProtoMessage() {} +func (*PlanComplete) ProtoMessage() {} -func (x *Provision_Metadata) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[21] +func (x *PlanComplete) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[18] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1762,118 +1722,118 @@ func (x *Provision_Metadata) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Provision_Metadata.ProtoReflect.Descriptor instead. -func (*Provision_Metadata) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{14, 0} +// Deprecated: Use PlanComplete.ProtoReflect.Descriptor instead. +func (*PlanComplete) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{18} } -func (x *Provision_Metadata) GetCoderUrl() string { +func (x *PlanComplete) GetError() string { if x != nil { - return x.CoderUrl + return x.Error } return "" } -func (x *Provision_Metadata) GetWorkspaceTransition() WorkspaceTransition { +func (x *PlanComplete) GetResources() []*Resource { if x != nil { - return x.WorkspaceTransition + return x.Resources } - return WorkspaceTransition_START + return nil } -func (x *Provision_Metadata) GetWorkspaceName() string { +func (x *PlanComplete) GetParameters() []*RichParameter { if x != nil { - return x.WorkspaceName + return x.Parameters } - return "" + return nil } -func (x *Provision_Metadata) GetWorkspaceOwner() string { +func (x *PlanComplete) GetGitAuthProviders() []string { if x != nil { - return x.WorkspaceOwner + return x.GitAuthProviders } - return "" + return nil } -func (x *Provision_Metadata) GetWorkspaceId() string { - if x != nil { - return x.WorkspaceId - } - return "" -} +// ApplyRequest asks the provisioner to apply the changes. Apply MUST be preceded by a successful plan request/response +// in the same Session. The plan data is not transmitted over the wire and is cached by the provisioner in the Session. +type ApplyRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields -func (x *Provision_Metadata) GetWorkspaceOwnerId() string { - if x != nil { - return x.WorkspaceOwnerId - } - return "" + Metadata *Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` } -func (x *Provision_Metadata) GetWorkspaceOwnerEmail() string { - if x != nil { - return x.WorkspaceOwnerEmail +func (x *ApplyRequest) Reset() { + *x = ApplyRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } - return "" } -func (x *Provision_Metadata) GetTemplateName() string { - if x != nil { - return x.TemplateName - } - return "" +func (x *ApplyRequest) String() string { + return protoimpl.X.MessageStringOf(x) } -func (x *Provision_Metadata) GetTemplateVersion() string { - if x != nil { - return x.TemplateVersion +func (*ApplyRequest) ProtoMessage() {} + +func (x *ApplyRequest) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[19] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return "" + return mi.MessageOf(x) } -func (x *Provision_Metadata) GetWorkspaceOwnerOidcAccessToken() string { - if x != nil { - return x.WorkspaceOwnerOidcAccessToken - } - return "" +// Deprecated: Use ApplyRequest.ProtoReflect.Descriptor instead. +func (*ApplyRequest) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{19} } -func (x *Provision_Metadata) GetWorkspaceOwnerSessionToken() string { +func (x *ApplyRequest) GetMetadata() *Metadata { if x != nil { - return x.WorkspaceOwnerSessionToken + return x.Metadata } - return "" + return nil } -// Config represents execution configuration shared by both Plan and -// Apply commands. -type Provision_Config struct { +// ApplyComplete indicates a request to apply completed. +type ApplyComplete struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Directory string `protobuf:"bytes,1,opt,name=directory,proto3" json:"directory,omitempty"` - State []byte `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` - Metadata *Provision_Metadata `protobuf:"bytes,3,opt,name=metadata,proto3" json:"metadata,omitempty"` - ProvisionerLogLevel string `protobuf:"bytes,4,opt,name=provisioner_log_level,json=provisionerLogLevel,proto3" json:"provisioner_log_level,omitempty"` + State []byte `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + Resources []*Resource `protobuf:"bytes,3,rep,name=resources,proto3" json:"resources,omitempty"` + Parameters []*RichParameter `protobuf:"bytes,4,rep,name=parameters,proto3" json:"parameters,omitempty"` + GitAuthProviders []string `protobuf:"bytes,5,rep,name=git_auth_providers,json=gitAuthProviders,proto3" json:"git_auth_providers,omitempty"` } -func (x *Provision_Config) Reset() { - *x = Provision_Config{} +func (x *ApplyComplete) Reset() { + *x = ApplyComplete{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[22] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *Provision_Config) String() string { +func (x *ApplyComplete) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Provision_Config) ProtoMessage() {} +func (*ApplyComplete) ProtoMessage() {} -func (x *Provision_Config) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[22] +func (x *ApplyComplete) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[20] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1884,67 +1844,70 @@ func (x *Provision_Config) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Provision_Config.ProtoReflect.Descriptor instead. -func (*Provision_Config) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{14, 1} +// Deprecated: Use ApplyComplete.ProtoReflect.Descriptor instead. +func (*ApplyComplete) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{20} } -func (x *Provision_Config) GetDirectory() string { +func (x *ApplyComplete) GetState() []byte { if x != nil { - return x.Directory + return x.State + } + return nil +} + +func (x *ApplyComplete) GetError() string { + if x != nil { + return x.Error } return "" } -func (x *Provision_Config) GetState() []byte { +func (x *ApplyComplete) GetResources() []*Resource { if x != nil { - return x.State + return x.Resources } return nil } -func (x *Provision_Config) GetMetadata() *Provision_Metadata { +func (x *ApplyComplete) GetParameters() []*RichParameter { if x != nil { - return x.Metadata + return x.Parameters } return nil } -func (x *Provision_Config) GetProvisionerLogLevel() string { +func (x *ApplyComplete) GetGitAuthProviders() []string { if x != nil { - return x.ProvisionerLogLevel + return x.GitAuthProviders } - return "" + return nil } -type Provision_Plan struct { +// CancelRequest requests that the previous request be canceled gracefully. +type CancelRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - - Config *Provision_Config `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` - RichParameterValues []*RichParameterValue `protobuf:"bytes,3,rep,name=rich_parameter_values,json=richParameterValues,proto3" json:"rich_parameter_values,omitempty"` - VariableValues []*VariableValue `protobuf:"bytes,4,rep,name=variable_values,json=variableValues,proto3" json:"variable_values,omitempty"` - GitAuthProviders []*GitAuthProvider `protobuf:"bytes,5,rep,name=git_auth_providers,json=gitAuthProviders,proto3" json:"git_auth_providers,omitempty"` } -func (x *Provision_Plan) Reset() { - *x = Provision_Plan{} +func (x *CancelRequest) Reset() { + *x = CancelRequest{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[23] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *Provision_Plan) String() string { +func (x *CancelRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Provision_Plan) ProtoMessage() {} +func (*CancelRequest) ProtoMessage() {} -func (x *Provision_Plan) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[23] +func (x *CancelRequest) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[21] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1955,65 +1918,43 @@ func (x *Provision_Plan) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Provision_Plan.ProtoReflect.Descriptor instead. -func (*Provision_Plan) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{14, 2} -} - -func (x *Provision_Plan) GetConfig() *Provision_Config { - if x != nil { - return x.Config - } - return nil -} - -func (x *Provision_Plan) GetRichParameterValues() []*RichParameterValue { - if x != nil { - return x.RichParameterValues - } - return nil -} - -func (x *Provision_Plan) GetVariableValues() []*VariableValue { - if x != nil { - return x.VariableValues - } - return nil -} - -func (x *Provision_Plan) GetGitAuthProviders() []*GitAuthProvider { - if x != nil { - return x.GitAuthProviders - } - return nil +// Deprecated: Use CancelRequest.ProtoReflect.Descriptor instead. +func (*CancelRequest) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{21} } -type Provision_Apply struct { +type Request struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Config *Provision_Config `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` - Plan []byte `protobuf:"bytes,2,opt,name=plan,proto3" json:"plan,omitempty"` + // Types that are assignable to Type: + // + // *Request_Config + // *Request_Parse + // *Request_Plan + // *Request_Apply + // *Request_Cancel + Type isRequest_Type `protobuf_oneof:"type"` } -func (x *Provision_Apply) Reset() { - *x = Provision_Apply{} +func (x *Request) Reset() { + *x = Request{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[24] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *Provision_Apply) String() string { +func (x *Request) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Provision_Apply) ProtoMessage() {} +func (*Request) ProtoMessage() {} -func (x *Provision_Apply) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[24] +func (x *Request) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[22] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2024,93 +1965,118 @@ func (x *Provision_Apply) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Provision_Apply.ProtoReflect.Descriptor instead. -func (*Provision_Apply) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{14, 3} +// Deprecated: Use Request.ProtoReflect.Descriptor instead. +func (*Request) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{22} } -func (x *Provision_Apply) GetConfig() *Provision_Config { - if x != nil { +func (m *Request) GetType() isRequest_Type { + if m != nil { + return m.Type + } + return nil +} + +func (x *Request) GetConfig() *Config { + if x, ok := x.GetType().(*Request_Config); ok { return x.Config } return nil } -func (x *Provision_Apply) GetPlan() []byte { - if x != nil { +func (x *Request) GetParse() *ParseRequest { + if x, ok := x.GetType().(*Request_Parse); ok { + return x.Parse + } + return nil +} + +func (x *Request) GetPlan() *PlanRequest { + if x, ok := x.GetType().(*Request_Plan); ok { return x.Plan } return nil } -type Provision_Cancel struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields +func (x *Request) GetApply() *ApplyRequest { + if x, ok := x.GetType().(*Request_Apply); ok { + return x.Apply + } + return nil } -func (x *Provision_Cancel) Reset() { - *x = Provision_Cancel{} - if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[25] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) +func (x *Request) GetCancel() *CancelRequest { + if x, ok := x.GetType().(*Request_Cancel); ok { + return x.Cancel } + return nil } -func (x *Provision_Cancel) String() string { - return protoimpl.X.MessageStringOf(x) +type isRequest_Type interface { + isRequest_Type() +} + +type Request_Config struct { + Config *Config `protobuf:"bytes,1,opt,name=config,proto3,oneof"` } -func (*Provision_Cancel) ProtoMessage() {} +type Request_Parse struct { + Parse *ParseRequest `protobuf:"bytes,2,opt,name=parse,proto3,oneof"` +} -func (x *Provision_Cancel) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[25] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) +type Request_Plan struct { + Plan *PlanRequest `protobuf:"bytes,3,opt,name=plan,proto3,oneof"` +} + +type Request_Apply struct { + Apply *ApplyRequest `protobuf:"bytes,4,opt,name=apply,proto3,oneof"` } -// Deprecated: Use Provision_Cancel.ProtoReflect.Descriptor instead. -func (*Provision_Cancel) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{14, 4} +type Request_Cancel struct { + Cancel *CancelRequest `protobuf:"bytes,5,opt,name=cancel,proto3,oneof"` } -type Provision_Request struct { +func (*Request_Config) isRequest_Type() {} + +func (*Request_Parse) isRequest_Type() {} + +func (*Request_Plan) isRequest_Type() {} + +func (*Request_Apply) isRequest_Type() {} + +func (*Request_Cancel) isRequest_Type() {} + +type Response struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields // Types that are assignable to Type: // - // *Provision_Request_Plan - // *Provision_Request_Apply - // *Provision_Request_Cancel - Type isProvision_Request_Type `protobuf_oneof:"type"` + // *Response_Log + // *Response_Parse + // *Response_Plan + // *Response_Apply + Type isResponse_Type `protobuf_oneof:"type"` } -func (x *Provision_Request) Reset() { - *x = Provision_Request{} +func (x *Response) Reset() { + *x = Response{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[26] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *Provision_Request) String() string { +func (x *Response) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Provision_Request) ProtoMessage() {} +func (*Response) ProtoMessage() {} -func (x *Provision_Request) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[26] +func (x *Response) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[23] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2121,91 +2087,103 @@ func (x *Provision_Request) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Provision_Request.ProtoReflect.Descriptor instead. -func (*Provision_Request) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{14, 5} +// Deprecated: Use Response.ProtoReflect.Descriptor instead. +func (*Response) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{23} } -func (m *Provision_Request) GetType() isProvision_Request_Type { +func (m *Response) GetType() isResponse_Type { if m != nil { return m.Type } return nil } -func (x *Provision_Request) GetPlan() *Provision_Plan { - if x, ok := x.GetType().(*Provision_Request_Plan); ok { - return x.Plan +func (x *Response) GetLog() *Log { + if x, ok := x.GetType().(*Response_Log); ok { + return x.Log } return nil } -func (x *Provision_Request) GetApply() *Provision_Apply { - if x, ok := x.GetType().(*Provision_Request_Apply); ok { - return x.Apply +func (x *Response) GetParse() *ParseComplete { + if x, ok := x.GetType().(*Response_Parse); ok { + return x.Parse } return nil } -func (x *Provision_Request) GetCancel() *Provision_Cancel { - if x, ok := x.GetType().(*Provision_Request_Cancel); ok { - return x.Cancel +func (x *Response) GetPlan() *PlanComplete { + if x, ok := x.GetType().(*Response_Plan); ok { + return x.Plan + } + return nil +} + +func (x *Response) GetApply() *ApplyComplete { + if x, ok := x.GetType().(*Response_Apply); ok { + return x.Apply } return nil } -type isProvision_Request_Type interface { - isProvision_Request_Type() +type isResponse_Type interface { + isResponse_Type() +} + +type Response_Log struct { + Log *Log `protobuf:"bytes,1,opt,name=log,proto3,oneof"` } -type Provision_Request_Plan struct { - Plan *Provision_Plan `protobuf:"bytes,1,opt,name=plan,proto3,oneof"` +type Response_Parse struct { + Parse *ParseComplete `protobuf:"bytes,2,opt,name=parse,proto3,oneof"` } -type Provision_Request_Apply struct { - Apply *Provision_Apply `protobuf:"bytes,2,opt,name=apply,proto3,oneof"` +type Response_Plan struct { + Plan *PlanComplete `protobuf:"bytes,3,opt,name=plan,proto3,oneof"` } -type Provision_Request_Cancel struct { - Cancel *Provision_Cancel `protobuf:"bytes,3,opt,name=cancel,proto3,oneof"` +type Response_Apply struct { + Apply *ApplyComplete `protobuf:"bytes,4,opt,name=apply,proto3,oneof"` } -func (*Provision_Request_Plan) isProvision_Request_Type() {} +func (*Response_Log) isResponse_Type() {} -func (*Provision_Request_Apply) isProvision_Request_Type() {} +func (*Response_Parse) isResponse_Type() {} -func (*Provision_Request_Cancel) isProvision_Request_Type() {} +func (*Response_Plan) isResponse_Type() {} -type Provision_Complete struct { +func (*Response_Apply) isResponse_Type() {} + +type Agent_Metadata struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - State []byte `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"` - Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` - Resources []*Resource `protobuf:"bytes,3,rep,name=resources,proto3" json:"resources,omitempty"` - Parameters []*RichParameter `protobuf:"bytes,4,rep,name=parameters,proto3" json:"parameters,omitempty"` - GitAuthProviders []string `protobuf:"bytes,5,rep,name=git_auth_providers,json=gitAuthProviders,proto3" json:"git_auth_providers,omitempty"` - Plan []byte `protobuf:"bytes,6,opt,name=plan,proto3" json:"plan,omitempty"` + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + Script string `protobuf:"bytes,3,opt,name=script,proto3" json:"script,omitempty"` + Interval int64 `protobuf:"varint,4,opt,name=interval,proto3" json:"interval,omitempty"` + Timeout int64 `protobuf:"varint,5,opt,name=timeout,proto3" json:"timeout,omitempty"` } -func (x *Provision_Complete) Reset() { - *x = Provision_Complete{} +func (x *Agent_Metadata) Reset() { + *x = Agent_Metadata{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[27] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *Provision_Complete) String() string { +func (x *Agent_Metadata) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Provision_Complete) ProtoMessage() {} +func (*Agent_Metadata) ProtoMessage() {} -func (x *Provision_Complete) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[27] +func (x *Agent_Metadata) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[24] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2216,82 +2194,74 @@ func (x *Provision_Complete) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Provision_Complete.ProtoReflect.Descriptor instead. -func (*Provision_Complete) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{14, 6} -} - -func (x *Provision_Complete) GetState() []byte { - if x != nil { - return x.State - } - return nil +// Deprecated: Use Agent_Metadata.ProtoReflect.Descriptor instead. +func (*Agent_Metadata) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{9, 0} } -func (x *Provision_Complete) GetError() string { +func (x *Agent_Metadata) GetKey() string { if x != nil { - return x.Error + return x.Key } return "" } -func (x *Provision_Complete) GetResources() []*Resource { +func (x *Agent_Metadata) GetDisplayName() string { if x != nil { - return x.Resources + return x.DisplayName } - return nil + return "" } -func (x *Provision_Complete) GetParameters() []*RichParameter { +func (x *Agent_Metadata) GetScript() string { if x != nil { - return x.Parameters + return x.Script } - return nil + return "" } -func (x *Provision_Complete) GetGitAuthProviders() []string { +func (x *Agent_Metadata) GetInterval() int64 { if x != nil { - return x.GitAuthProviders + return x.Interval } - return nil + return 0 } -func (x *Provision_Complete) GetPlan() []byte { +func (x *Agent_Metadata) GetTimeout() int64 { if x != nil { - return x.Plan + return x.Timeout } - return nil + return 0 } -type Provision_Response struct { +type Resource_Metadata struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Types that are assignable to Type: - // - // *Provision_Response_Log - // *Provision_Response_Complete - Type isProvision_Response_Type `protobuf_oneof:"type"` + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + Sensitive bool `protobuf:"varint,3,opt,name=sensitive,proto3" json:"sensitive,omitempty"` + IsNull bool `protobuf:"varint,4,opt,name=is_null,json=isNull,proto3" json:"is_null,omitempty"` } -func (x *Provision_Response) Reset() { - *x = Provision_Response{} +func (x *Resource_Metadata) Reset() { + *x = Resource_Metadata{} if protoimpl.UnsafeEnabled { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[28] + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } -func (x *Provision_Response) String() string { +func (x *Resource_Metadata) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Provision_Response) ProtoMessage() {} +func (*Resource_Metadata) ProtoMessage() {} -func (x *Provision_Response) ProtoReflect() protoreflect.Message { - mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[28] +func (x *Resource_Metadata) ProtoReflect() protoreflect.Message { + mi := &file_provisionersdk_proto_provisioner_proto_msgTypes[26] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2302,48 +2272,39 @@ func (x *Provision_Response) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Provision_Response.ProtoReflect.Descriptor instead. -func (*Provision_Response) Descriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{14, 7} +// Deprecated: Use Resource_Metadata.ProtoReflect.Descriptor instead. +func (*Resource_Metadata) Descriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{12, 0} } -func (m *Provision_Response) GetType() isProvision_Response_Type { - if m != nil { - return m.Type +func (x *Resource_Metadata) GetKey() string { + if x != nil { + return x.Key } - return nil + return "" } -func (x *Provision_Response) GetLog() *Log { - if x, ok := x.GetType().(*Provision_Response_Log); ok { - return x.Log +func (x *Resource_Metadata) GetValue() string { + if x != nil { + return x.Value } - return nil + return "" } -func (x *Provision_Response) GetComplete() *Provision_Complete { - if x, ok := x.GetType().(*Provision_Response_Complete); ok { - return x.Complete +func (x *Resource_Metadata) GetSensitive() bool { + if x != nil { + return x.Sensitive } - return nil -} - -type isProvision_Response_Type interface { - isProvision_Response_Type() -} - -type Provision_Response_Log struct { - Log *Log `protobuf:"bytes,1,opt,name=log,proto3,oneof"` + return false } -type Provision_Response_Complete struct { - Complete *Provision_Complete `protobuf:"bytes,2,opt,name=complete,proto3,oneof"` +func (x *Resource_Metadata) GetIsNull() bool { + if x != nil { + return x.IsNull + } + return false } -func (*Provision_Response_Log) isProvision_Response_Type() {} - -func (*Provision_Response_Complete) isProvision_Response_Type() {} - var File_provisionersdk_proto_provisioner_proto protoreflect.FileDescriptor var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ @@ -2543,154 +2504,161 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x4e, 0x75, 0x6c, 0x6c, 0x22, 0x85, 0x02, - 0x0a, 0x05, 0x50, 0x61, 0x72, 0x73, 0x65, 0x1a, 0x27, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, - 0x1a, 0x5e, 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x4c, 0x0a, 0x12, - 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, - 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x11, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, - 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, - 0x1a, 0x73, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, - 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, - 0x6f, 0x67, 0x12, 0x39, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, - 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, - 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0x91, 0x0d, 0x0a, 0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x1a, 0xae, 0x04, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x53, 0x0a, - 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, - 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x70, 0x72, - 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x13, 0x77, - 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x77, 0x6f, 0x72, - 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, - 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, - 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, - 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, - 0x72, 0x49, 0x64, 0x12, 0x32, 0x0a, 0x15, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, - 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, - 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x65, 0x6d, 0x70, 0x6c, - 0x61, 0x74, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, - 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x29, 0x0a, 0x10, - 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, - 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x48, 0x0a, 0x21, 0x77, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x6f, 0x69, 0x64, 0x63, 0x5f, - 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x0a, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x1d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, - 0x65, 0x72, 0x4f, 0x69, 0x64, 0x63, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x12, 0x41, 0x0a, 0x1d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, - 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1a, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x1a, 0xad, 0x01, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, - 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x14, 0x0a, - 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, - 0x61, 0x74, 0x65, 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, - 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x4d, 0x65, - 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x12, 0x32, 0x0a, 0x15, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x5f, - 0x6c, 0x6f, 0x67, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x13, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x4c, - 0x65, 0x76, 0x65, 0x6c, 0x1a, 0xa9, 0x02, 0x0a, 0x04, 0x50, 0x6c, 0x61, 0x6e, 0x12, 0x35, 0x0a, - 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x12, 0x53, 0x0a, 0x15, 0x72, 0x69, 0x63, 0x68, 0x5f, 0x70, 0x61, 0x72, - 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, - 0x61, 0x6c, 0x75, 0x65, 0x52, 0x13, 0x72, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, - 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x43, 0x0a, 0x0f, 0x76, 0x61, 0x72, - 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, + 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x4e, 0x75, 0x6c, 0x6c, 0x22, 0xae, 0x04, + 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x53, 0x0a, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, + 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4e, + 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, + 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x77, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, + 0x2c, 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, + 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x32, 0x0a, + 0x15, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, + 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, + 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, + 0x74, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, + 0x74, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0f, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x48, 0x0a, 0x21, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, + 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x6f, 0x69, 0x64, 0x63, 0x5f, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1d, 0x77, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x4f, 0x69, 0x64, 0x63, + 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x41, 0x0a, 0x1d, 0x77, + 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x0b, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x1a, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, + 0x65, 0x72, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x8a, + 0x01, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x17, 0x74, 0x65, 0x6d, + 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x61, 0x72, 0x63, + 0x68, 0x69, 0x76, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x15, 0x74, 0x65, 0x6d, 0x70, + 0x6c, 0x61, 0x74, 0x65, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x41, 0x72, 0x63, 0x68, 0x69, 0x76, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x32, 0x0a, 0x15, 0x70, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x22, 0x0e, 0x0a, 0x0c, 0x50, + 0x61, 0x72, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x8b, 0x01, 0x0a, 0x0d, + 0x50, 0x61, 0x72, 0x73, 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x12, 0x4c, 0x0a, 0x12, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, + 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x54, 0x65, + 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x11, + 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, + 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x64, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x06, 0x72, 0x65, 0x61, 0x64, 0x6d, 0x65, 0x22, 0xa6, 0x02, 0x0a, 0x0b, 0x50, 0x6c, + 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x53, 0x0a, 0x15, + 0x72, 0x69, 0x63, 0x68, 0x5f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, + 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x13, 0x72, 0x69, + 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, + 0x73, 0x12, 0x43, 0x0a, 0x0f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, + 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x4a, 0x0a, 0x12, 0x67, 0x69, 0x74, 0x5f, 0x61, 0x75, + 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x47, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x52, 0x10, 0x67, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x73, 0x22, 0xc3, 0x01, 0x0a, 0x0c, 0x50, 0x6c, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, + 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x3a, + 0x0a, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x52, 0x0a, + 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x67, 0x69, + 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, + 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x67, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x50, + 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x22, 0x41, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x6c, + 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, + 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0xda, 0x01, 0x0a, 0x0d, + 0x41, 0x70, 0x70, 0x6c, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, + 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, + 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x3a, + 0x0a, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, - 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x4a, - 0x0a, 0x12, 0x67, 0x69, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x64, 0x65, 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x47, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x10, 0x67, 0x69, 0x74, 0x41, 0x75, 0x74, - 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, - 0x1a, 0x52, 0x0a, 0x05, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x35, 0x0a, 0x06, 0x63, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, - 0x6e, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, - 0x70, 0x6c, 0x61, 0x6e, 0x1a, 0x08, 0x0a, 0x06, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0xb3, - 0x01, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x04, 0x70, 0x6c, - 0x61, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x34, 0x0a, - 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x48, 0x00, 0x52, 0x05, 0x61, 0x70, - 0x70, 0x6c, 0x79, 0x12, 0x37, 0x0a, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x61, 0x6e, 0x63, - 0x65, 0x6c, 0x48, 0x00, 0x52, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x42, 0x06, 0x0a, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x1a, 0xe9, 0x01, 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, - 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, - 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x73, 0x12, 0x3a, 0x0a, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, - 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, - 0x65, 0x72, 0x52, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2c, - 0x0a, 0x12, 0x67, 0x69, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x64, 0x65, 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x67, 0x69, 0x74, 0x41, - 0x75, 0x74, 0x68, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, 0x12, 0x0a, 0x04, - 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, - 0x1a, 0x77, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, - 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, - 0x6f, 0x67, 0x12, 0x3d, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6d, - 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, - 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x2a, 0x3f, 0x0a, 0x08, 0x4c, 0x6f, 0x67, - 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, 0x43, 0x45, 0x10, 0x00, - 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x49, - 0x4e, 0x46, 0x4f, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x57, 0x41, 0x52, 0x4e, 0x10, 0x03, 0x12, - 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x2a, 0x3b, 0x0a, 0x0f, 0x41, 0x70, - 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, - 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x41, 0x55, 0x54, 0x48, - 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x50, - 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, 0x2a, 0x37, 0x0a, 0x13, 0x57, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x09, - 0x0a, 0x05, 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x54, 0x4f, - 0x50, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x53, 0x54, 0x52, 0x4f, 0x59, 0x10, 0x02, - 0x32, 0xa3, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x12, 0x42, 0x0a, 0x05, 0x50, 0x61, 0x72, 0x73, 0x65, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, - 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x30, 0x01, 0x12, 0x50, 0x0a, 0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x30, 0x5a, 0x2e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2f, 0x76, 0x32, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x73, - 0x64, 0x6b, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x2e, 0x52, 0x69, 0x63, 0x68, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x52, 0x0a, + 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x67, 0x69, + 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, + 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x67, 0x69, 0x74, 0x41, 0x75, 0x74, 0x68, 0x50, + 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x22, 0x0f, 0x0a, 0x0d, 0x43, 0x61, 0x6e, 0x63, + 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x8c, 0x02, 0x0a, 0x07, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2d, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x48, 0x00, 0x52, 0x06, 0x63, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x12, 0x31, 0x0a, 0x05, 0x70, 0x61, 0x72, 0x73, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, + 0x52, 0x05, 0x70, 0x61, 0x72, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, + 0x00, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x12, 0x31, 0x0a, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, + 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x48, 0x00, 0x52, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x34, 0x0a, 0x06, 0x63, 0x61, + 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, + 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xd1, 0x01, 0x0a, 0x08, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x32, 0x0a, 0x05, 0x70, + 0x61, 0x72, 0x73, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, 0x65, 0x43, 0x6f, + 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x05, 0x70, 0x61, 0x72, 0x73, 0x65, 0x12, + 0x2f, 0x0a, 0x04, 0x70, 0x6c, 0x61, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x6c, 0x61, 0x6e, + 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6c, 0x61, 0x6e, + 0x12, 0x32, 0x0a, 0x05, 0x61, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, + 0x70, 0x6c, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x05, 0x61, + 0x70, 0x70, 0x6c, 0x79, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x2a, 0x3f, 0x0a, 0x08, + 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, 0x43, + 0x45, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x01, 0x12, 0x08, + 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x57, 0x41, 0x52, 0x4e, + 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x2a, 0x3b, 0x0a, + 0x0f, 0x41, 0x70, 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, + 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x41, + 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, + 0x0a, 0x06, 0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, 0x2a, 0x37, 0x0a, 0x13, 0x57, 0x6f, + 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, + 0x53, 0x54, 0x4f, 0x50, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x53, 0x54, 0x52, 0x4f, + 0x59, 0x10, 0x02, 0x32, 0x49, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x12, 0x3a, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x30, + 0x5a, 0x2e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x73, 0x64, 0x6b, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2706,7 +2674,7 @@ func file_provisionersdk_proto_provisioner_proto_rawDescGZIP() []byte { } var file_provisionersdk_proto_provisioner_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_provisionersdk_proto_provisioner_proto_msgTypes = make([]protoimpl.MessageInfo, 29) +var file_provisionersdk_proto_provisioner_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_provisionersdk_proto_provisioner_proto_goTypes = []interface{}{ (LogLevel)(0), // 0: provisioner.LogLevel (AppSharingLevel)(0), // 1: provisioner.AppSharingLevel @@ -2724,59 +2692,58 @@ var file_provisionersdk_proto_provisioner_proto_goTypes = []interface{}{ (*App)(nil), // 13: provisioner.App (*Healthcheck)(nil), // 14: provisioner.Healthcheck (*Resource)(nil), // 15: provisioner.Resource - (*Parse)(nil), // 16: provisioner.Parse - (*Provision)(nil), // 17: provisioner.Provision - (*Agent_Metadata)(nil), // 18: provisioner.Agent.Metadata - nil, // 19: provisioner.Agent.EnvEntry - (*Resource_Metadata)(nil), // 20: provisioner.Resource.Metadata - (*Parse_Request)(nil), // 21: provisioner.Parse.Request - (*Parse_Complete)(nil), // 22: provisioner.Parse.Complete - (*Parse_Response)(nil), // 23: provisioner.Parse.Response - (*Provision_Metadata)(nil), // 24: provisioner.Provision.Metadata - (*Provision_Config)(nil), // 25: provisioner.Provision.Config - (*Provision_Plan)(nil), // 26: provisioner.Provision.Plan - (*Provision_Apply)(nil), // 27: provisioner.Provision.Apply - (*Provision_Cancel)(nil), // 28: provisioner.Provision.Cancel - (*Provision_Request)(nil), // 29: provisioner.Provision.Request - (*Provision_Complete)(nil), // 30: provisioner.Provision.Complete - (*Provision_Response)(nil), // 31: provisioner.Provision.Response + (*Metadata)(nil), // 16: provisioner.Metadata + (*Config)(nil), // 17: provisioner.Config + (*ParseRequest)(nil), // 18: provisioner.ParseRequest + (*ParseComplete)(nil), // 19: provisioner.ParseComplete + (*PlanRequest)(nil), // 20: provisioner.PlanRequest + (*PlanComplete)(nil), // 21: provisioner.PlanComplete + (*ApplyRequest)(nil), // 22: provisioner.ApplyRequest + (*ApplyComplete)(nil), // 23: provisioner.ApplyComplete + (*CancelRequest)(nil), // 24: provisioner.CancelRequest + (*Request)(nil), // 25: provisioner.Request + (*Response)(nil), // 26: provisioner.Response + (*Agent_Metadata)(nil), // 27: provisioner.Agent.Metadata + nil, // 28: provisioner.Agent.EnvEntry + (*Resource_Metadata)(nil), // 29: provisioner.Resource.Metadata } var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ 5, // 0: provisioner.RichParameter.options:type_name -> provisioner.RichParameterOption 0, // 1: provisioner.Log.level:type_name -> provisioner.LogLevel - 19, // 2: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry + 28, // 2: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry 13, // 3: provisioner.Agent.apps:type_name -> provisioner.App - 18, // 4: provisioner.Agent.metadata:type_name -> provisioner.Agent.Metadata + 27, // 4: provisioner.Agent.metadata:type_name -> provisioner.Agent.Metadata 14, // 5: provisioner.App.healthcheck:type_name -> provisioner.Healthcheck 1, // 6: provisioner.App.sharing_level:type_name -> provisioner.AppSharingLevel 12, // 7: provisioner.Resource.agents:type_name -> provisioner.Agent - 20, // 8: provisioner.Resource.metadata:type_name -> provisioner.Resource.Metadata - 4, // 9: provisioner.Parse.Complete.template_variables:type_name -> provisioner.TemplateVariable - 9, // 10: provisioner.Parse.Response.log:type_name -> provisioner.Log - 22, // 11: provisioner.Parse.Response.complete:type_name -> provisioner.Parse.Complete - 2, // 12: provisioner.Provision.Metadata.workspace_transition:type_name -> provisioner.WorkspaceTransition - 24, // 13: provisioner.Provision.Config.metadata:type_name -> provisioner.Provision.Metadata - 25, // 14: provisioner.Provision.Plan.config:type_name -> provisioner.Provision.Config - 7, // 15: provisioner.Provision.Plan.rich_parameter_values:type_name -> provisioner.RichParameterValue - 8, // 16: provisioner.Provision.Plan.variable_values:type_name -> provisioner.VariableValue - 11, // 17: provisioner.Provision.Plan.git_auth_providers:type_name -> provisioner.GitAuthProvider - 25, // 18: provisioner.Provision.Apply.config:type_name -> provisioner.Provision.Config - 26, // 19: provisioner.Provision.Request.plan:type_name -> provisioner.Provision.Plan - 27, // 20: provisioner.Provision.Request.apply:type_name -> provisioner.Provision.Apply - 28, // 21: provisioner.Provision.Request.cancel:type_name -> provisioner.Provision.Cancel - 15, // 22: provisioner.Provision.Complete.resources:type_name -> provisioner.Resource - 6, // 23: provisioner.Provision.Complete.parameters:type_name -> provisioner.RichParameter - 9, // 24: provisioner.Provision.Response.log:type_name -> provisioner.Log - 30, // 25: provisioner.Provision.Response.complete:type_name -> provisioner.Provision.Complete - 21, // 26: provisioner.Provisioner.Parse:input_type -> provisioner.Parse.Request - 29, // 27: provisioner.Provisioner.Provision:input_type -> provisioner.Provision.Request - 23, // 28: provisioner.Provisioner.Parse:output_type -> provisioner.Parse.Response - 31, // 29: provisioner.Provisioner.Provision:output_type -> provisioner.Provision.Response - 28, // [28:30] is the sub-list for method output_type - 26, // [26:28] is the sub-list for method input_type - 26, // [26:26] is the sub-list for extension type_name - 26, // [26:26] is the sub-list for extension extendee - 0, // [0:26] is the sub-list for field type_name + 29, // 8: provisioner.Resource.metadata:type_name -> provisioner.Resource.Metadata + 2, // 9: provisioner.Metadata.workspace_transition:type_name -> provisioner.WorkspaceTransition + 4, // 10: provisioner.ParseComplete.template_variables:type_name -> provisioner.TemplateVariable + 16, // 11: provisioner.PlanRequest.metadata:type_name -> provisioner.Metadata + 7, // 12: provisioner.PlanRequest.rich_parameter_values:type_name -> provisioner.RichParameterValue + 8, // 13: provisioner.PlanRequest.variable_values:type_name -> provisioner.VariableValue + 11, // 14: provisioner.PlanRequest.git_auth_providers:type_name -> provisioner.GitAuthProvider + 15, // 15: provisioner.PlanComplete.resources:type_name -> provisioner.Resource + 6, // 16: provisioner.PlanComplete.parameters:type_name -> provisioner.RichParameter + 16, // 17: provisioner.ApplyRequest.metadata:type_name -> provisioner.Metadata + 15, // 18: provisioner.ApplyComplete.resources:type_name -> provisioner.Resource + 6, // 19: provisioner.ApplyComplete.parameters:type_name -> provisioner.RichParameter + 17, // 20: provisioner.Request.config:type_name -> provisioner.Config + 18, // 21: provisioner.Request.parse:type_name -> provisioner.ParseRequest + 20, // 22: provisioner.Request.plan:type_name -> provisioner.PlanRequest + 22, // 23: provisioner.Request.apply:type_name -> provisioner.ApplyRequest + 24, // 24: provisioner.Request.cancel:type_name -> provisioner.CancelRequest + 9, // 25: provisioner.Response.log:type_name -> provisioner.Log + 19, // 26: provisioner.Response.parse:type_name -> provisioner.ParseComplete + 21, // 27: provisioner.Response.plan:type_name -> provisioner.PlanComplete + 23, // 28: provisioner.Response.apply:type_name -> provisioner.ApplyComplete + 25, // 29: provisioner.Provisioner.Session:input_type -> provisioner.Request + 26, // 30: provisioner.Provisioner.Session:output_type -> provisioner.Response + 30, // [30:31] is the sub-list for method output_type + 29, // [29:30] is the sub-list for method input_type + 29, // [29:29] is the sub-list for extension type_name + 29, // [29:29] is the sub-list for extension extendee + 0, // [0:29] is the sub-list for field type_name } func init() { file_provisionersdk_proto_provisioner_proto_init() } @@ -2942,7 +2909,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Parse); i { + switch v := v.(*Metadata); i { case 0: return &v.state case 1: @@ -2954,7 +2921,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision); i { + switch v := v.(*Config); i { case 0: return &v.state case 1: @@ -2966,7 +2933,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Agent_Metadata); i { + switch v := v.(*ParseRequest); i { case 0: return &v.state case 1: @@ -2977,8 +2944,8 @@ func file_provisionersdk_proto_provisioner_proto_init() { return nil } } - file_provisionersdk_proto_provisioner_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Resource_Metadata); i { + file_provisionersdk_proto_provisioner_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ParseComplete); i { case 0: return &v.state case 1: @@ -2989,8 +2956,8 @@ func file_provisionersdk_proto_provisioner_proto_init() { return nil } } - file_provisionersdk_proto_provisioner_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Parse_Request); i { + file_provisionersdk_proto_provisioner_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PlanRequest); i { case 0: return &v.state case 1: @@ -3001,8 +2968,8 @@ func file_provisionersdk_proto_provisioner_proto_init() { return nil } } - file_provisionersdk_proto_provisioner_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Parse_Complete); i { + file_provisionersdk_proto_provisioner_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PlanComplete); i { case 0: return &v.state case 1: @@ -3013,8 +2980,8 @@ func file_provisionersdk_proto_provisioner_proto_init() { return nil } } - file_provisionersdk_proto_provisioner_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Parse_Response); i { + file_provisionersdk_proto_provisioner_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ApplyRequest); i { case 0: return &v.state case 1: @@ -3025,8 +2992,8 @@ func file_provisionersdk_proto_provisioner_proto_init() { return nil } } - file_provisionersdk_proto_provisioner_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Metadata); i { + file_provisionersdk_proto_provisioner_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ApplyComplete); i { case 0: return &v.state case 1: @@ -3037,8 +3004,8 @@ func file_provisionersdk_proto_provisioner_proto_init() { return nil } } - file_provisionersdk_proto_provisioner_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Config); i { + file_provisionersdk_proto_provisioner_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CancelRequest); i { case 0: return &v.state case 1: @@ -3049,8 +3016,8 @@ func file_provisionersdk_proto_provisioner_proto_init() { return nil } } - file_provisionersdk_proto_provisioner_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Plan); i { + file_provisionersdk_proto_provisioner_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Request); i { case 0: return &v.state case 1: @@ -3061,8 +3028,8 @@ func file_provisionersdk_proto_provisioner_proto_init() { return nil } } - file_provisionersdk_proto_provisioner_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Apply); i { + file_provisionersdk_proto_provisioner_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Response); i { case 0: return &v.state case 1: @@ -3073,8 +3040,8 @@ func file_provisionersdk_proto_provisioner_proto_init() { return nil } } - file_provisionersdk_proto_provisioner_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Cancel); i { + file_provisionersdk_proto_provisioner_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Agent_Metadata); i { case 0: return &v.state case 1: @@ -3086,31 +3053,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { } } file_provisionersdk_proto_provisioner_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Request); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_provisionersdk_proto_provisioner_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Complete); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_provisionersdk_proto_provisioner_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Provision_Response); i { + switch v := v.(*Resource_Metadata); i { case 0: return &v.state case 1: @@ -3127,18 +3070,18 @@ func file_provisionersdk_proto_provisioner_proto_init() { (*Agent_Token)(nil), (*Agent_InstanceId)(nil), } - file_provisionersdk_proto_provisioner_proto_msgTypes[20].OneofWrappers = []interface{}{ - (*Parse_Response_Log)(nil), - (*Parse_Response_Complete)(nil), - } - file_provisionersdk_proto_provisioner_proto_msgTypes[26].OneofWrappers = []interface{}{ - (*Provision_Request_Plan)(nil), - (*Provision_Request_Apply)(nil), - (*Provision_Request_Cancel)(nil), + file_provisionersdk_proto_provisioner_proto_msgTypes[22].OneofWrappers = []interface{}{ + (*Request_Config)(nil), + (*Request_Parse)(nil), + (*Request_Plan)(nil), + (*Request_Apply)(nil), + (*Request_Cancel)(nil), } - file_provisionersdk_proto_provisioner_proto_msgTypes[28].OneofWrappers = []interface{}{ - (*Provision_Response_Log)(nil), - (*Provision_Response_Complete)(nil), + file_provisionersdk_proto_provisioner_proto_msgTypes[23].OneofWrappers = []interface{}{ + (*Response_Log)(nil), + (*Response_Parse)(nil), + (*Response_Plan)(nil), + (*Response_Apply)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -3146,7 +3089,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_provisionersdk_proto_provisioner_proto_rawDesc, NumEnums: 3, - NumMessages: 29, + NumMessages: 27, NumExtensions: 0, NumServices: 1, }, diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index 789735782f37a..5670fbde2675d 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -82,8 +82,8 @@ message InstanceIdentityAuth { } message GitAuthProvider { - string id = 1; - string access_token = 2; + string id = 1; + string access_token = 2; } // Agent represents a running agent on the workspace. @@ -110,15 +110,15 @@ message Agent { string token = 9; string instance_id = 10; } - int32 connection_timeout_seconds = 11; - string troubleshooting_url = 12; - string motd_file = 13; - // Field 14 was bool login_before_ready = 14, now removed. - int32 startup_script_timeout_seconds = 15; - string shutdown_script = 16; - int32 shutdown_script_timeout_seconds = 17; + int32 connection_timeout_seconds = 11; + string troubleshooting_url = 12; + string motd_file = 13; + // Field 14 was bool login_before_ready = 14, now removed. + int32 startup_script_timeout_seconds = 15; + string shutdown_script = 16; + int32 shutdown_script_timeout_seconds = 17; repeated Metadata metadata = 18; - string startup_script_behavior = 19; + string startup_script_behavior = 19; } enum AppSharingLevel { @@ -168,96 +168,111 @@ message Resource { int32 daily_cost = 8; } -// Parse consumes source-code from a directory to produce inputs. -message Parse { - message Request { - string directory = 1; - } - message Complete { - reserved 2; - - repeated TemplateVariable template_variables = 1; - } - message Response { - oneof type { - Log log = 1; - Complete complete = 2; - } - } -} - +// WorkspaceTransition is the desired outcome of a build enum WorkspaceTransition { START = 0; STOP = 1; DESTROY = 2; } -// Provision consumes source-code from a directory to produce resources. -// Exactly one of Plan or Apply must be provided in a single session. -message Provision { - message Metadata { - string coder_url = 1; - WorkspaceTransition workspace_transition = 2; - string workspace_name = 3; - string workspace_owner = 4; - string workspace_id = 5; - string workspace_owner_id = 6; - string workspace_owner_email = 7; - string template_name = 8; - string template_version = 9; - string workspace_owner_oidc_access_token = 10; - string workspace_owner_session_token = 11; - } +// Metadata is information about a workspace used in the execution of a build +message Metadata { + string coder_url = 1; + WorkspaceTransition workspace_transition = 2; + string workspace_name = 3; + string workspace_owner = 4; + string workspace_id = 5; + string workspace_owner_id = 6; + string workspace_owner_email = 7; + string template_name = 8; + string template_version = 9; + string workspace_owner_oidc_access_token = 10; + string workspace_owner_session_token = 11; +} - // Config represents execution configuration shared by both Plan and - // Apply commands. - message Config { - string directory = 1; - bytes state = 2; - Metadata metadata = 3; +// Config represents execution configuration shared by all subsequent requests in the Session +message Config { + // template_source_archive is a tar of the template source files + bytes template_source_archive = 1; + // state is the provisioner state (if any) + bytes state = 2; + string provisioner_log_level = 3; +} - string provisioner_log_level = 4; - } +// ParseRequest consumes source-code to produce inputs. +message ParseRequest { +} - message Plan { - reserved 2; +// ParseComplete indicates a request to parse completed. +message ParseComplete { + string error = 1; + repeated TemplateVariable template_variables = 2; + bytes readme = 3; +} - Config config = 1; - repeated RichParameterValue rich_parameter_values = 3; - repeated VariableValue variable_values = 4; - repeated GitAuthProvider git_auth_providers = 5; - } +// PlanRequest asks the provisioner to plan what resources & parameters it will create +message PlanRequest { + Metadata metadata = 1; + repeated RichParameterValue rich_parameter_values = 2; + repeated VariableValue variable_values = 3; + repeated GitAuthProvider git_auth_providers = 4; +} + +// PlanComplete indicates a request to plan completed. +message PlanComplete { + string error = 1; + repeated Resource resources = 2; + repeated RichParameter parameters = 3; + repeated string git_auth_providers = 4; +} + +// ApplyRequest asks the provisioner to apply the changes. Apply MUST be preceded by a successful plan request/response +// in the same Session. The plan data is not transmitted over the wire and is cached by the provisioner in the Session. +message ApplyRequest { + Metadata metadata = 1; +} - message Apply { +// ApplyComplete indicates a request to apply completed. +message ApplyComplete { + bytes state = 1; + string error = 2; + repeated Resource resources = 3; + repeated RichParameter parameters = 4; + repeated string git_auth_providers = 5; +} + +// CancelRequest requests that the previous request be canceled gracefully. +message CancelRequest {} + +message Request { + oneof type { Config config = 1; - bytes plan = 2; + ParseRequest parse = 2; + PlanRequest plan = 3; + ApplyRequest apply = 4; + CancelRequest cancel = 5; } +} - message Cancel {} - message Request { - oneof type { - Plan plan = 1; - Apply apply = 2; - Cancel cancel = 3; - } - } - message Complete { - bytes state = 1; - string error = 2; - repeated Resource resources = 3; - repeated RichParameter parameters = 4; - repeated string git_auth_providers = 5; - bytes plan = 6; - } - message Response { - oneof type { - Log log = 1; - Complete complete = 2; - } +message Response { + oneof type { + Log log = 1; + ParseComplete parse = 2; + PlanComplete plan = 3; + ApplyComplete apply = 4; } } service Provisioner { - rpc Parse(Parse.Request) returns (stream Parse.Response); - rpc Provision(stream Provision.Request) returns (stream Provision.Response); + // Session represents provisioning a single template import or workspace. The daemon always sends Config followed + // by one of the requests (ParseRequest, PlanRequest, ApplyRequest). The provisioner should respond with a stream + // of zero or more Logs, followed by the corresponding complete message (ParseComplete, PlanComplete, + // ApplyComplete). The daemon may then send a new request. A request to apply MUST be preceded by a request plan, + // and the provisioner should store the plan data on the Session after a successful plan, so that the daemon may + // request an apply. If the daemon closes the Session without an apply, the plan data may be safely discarded. + // + // The daemon may send a CancelRequest, asynchronously to ask the provisioner to cancel the previous ParseRequest, + // PlanRequest, or ApplyRequest. The provisioner MUST reply with a complete message corresponding to the request + // that was canceled. If the provisioner has already completed the request, it may ignore the CancelRequest. + rpc Session(stream Request) returns (stream Response); } diff --git a/provisionersdk/proto/provisioner_drpc.pb.go b/provisionersdk/proto/provisioner_drpc.pb.go index d8b40060cd376..de310e779dcaa 100644 --- a/provisionersdk/proto/provisioner_drpc.pb.go +++ b/provisionersdk/proto/provisioner_drpc.pb.go @@ -38,8 +38,7 @@ func (drpcEncoding_File_provisionersdk_proto_provisioner_proto) JSONUnmarshal(bu type DRPCProvisionerClient interface { DRPCConn() drpc.Conn - Parse(ctx context.Context, in *Parse_Request) (DRPCProvisioner_ParseClient, error) - Provision(ctx context.Context) (DRPCProvisioner_ProvisionClient, error) + Session(ctx context.Context) (DRPCProvisioner_SessionClient, error) } type drpcProvisionerClient struct { @@ -52,123 +51,69 @@ func NewDRPCProvisionerClient(cc drpc.Conn) DRPCProvisionerClient { func (c *drpcProvisionerClient) DRPCConn() drpc.Conn { return c.cc } -func (c *drpcProvisionerClient) Parse(ctx context.Context, in *Parse_Request) (DRPCProvisioner_ParseClient, error) { - stream, err := c.cc.NewStream(ctx, "/provisioner.Provisioner/Parse", drpcEncoding_File_provisionersdk_proto_provisioner_proto{}) +func (c *drpcProvisionerClient) Session(ctx context.Context) (DRPCProvisioner_SessionClient, error) { + stream, err := c.cc.NewStream(ctx, "/provisioner.Provisioner/Session", drpcEncoding_File_provisionersdk_proto_provisioner_proto{}) if err != nil { return nil, err } - x := &drpcProvisioner_ParseClient{stream} - if err := x.MsgSend(in, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}); err != nil { - return nil, err - } - if err := x.CloseSend(); err != nil { - return nil, err - } + x := &drpcProvisioner_SessionClient{stream} return x, nil } -type DRPCProvisioner_ParseClient interface { +type DRPCProvisioner_SessionClient interface { drpc.Stream - Recv() (*Parse_Response, error) + Send(*Request) error + Recv() (*Response, error) } -type drpcProvisioner_ParseClient struct { +type drpcProvisioner_SessionClient struct { drpc.Stream } -func (x *drpcProvisioner_ParseClient) GetStream() drpc.Stream { +func (x *drpcProvisioner_SessionClient) GetStream() drpc.Stream { return x.Stream } -func (x *drpcProvisioner_ParseClient) Recv() (*Parse_Response, error) { - m := new(Parse_Response) - if err := x.MsgRecv(m, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}); err != nil { - return nil, err - } - return m, nil -} - -func (x *drpcProvisioner_ParseClient) RecvMsg(m *Parse_Response) error { - return x.MsgRecv(m, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}) -} - -func (c *drpcProvisionerClient) Provision(ctx context.Context) (DRPCProvisioner_ProvisionClient, error) { - stream, err := c.cc.NewStream(ctx, "/provisioner.Provisioner/Provision", drpcEncoding_File_provisionersdk_proto_provisioner_proto{}) - if err != nil { - return nil, err - } - x := &drpcProvisioner_ProvisionClient{stream} - return x, nil -} - -type DRPCProvisioner_ProvisionClient interface { - drpc.Stream - Send(*Provision_Request) error - Recv() (*Provision_Response, error) -} - -type drpcProvisioner_ProvisionClient struct { - drpc.Stream -} - -func (x *drpcProvisioner_ProvisionClient) GetStream() drpc.Stream { - return x.Stream -} - -func (x *drpcProvisioner_ProvisionClient) Send(m *Provision_Request) error { +func (x *drpcProvisioner_SessionClient) Send(m *Request) error { return x.MsgSend(m, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}) } -func (x *drpcProvisioner_ProvisionClient) Recv() (*Provision_Response, error) { - m := new(Provision_Response) +func (x *drpcProvisioner_SessionClient) Recv() (*Response, error) { + m := new(Response) if err := x.MsgRecv(m, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}); err != nil { return nil, err } return m, nil } -func (x *drpcProvisioner_ProvisionClient) RecvMsg(m *Provision_Response) error { +func (x *drpcProvisioner_SessionClient) RecvMsg(m *Response) error { return x.MsgRecv(m, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}) } type DRPCProvisionerServer interface { - Parse(*Parse_Request, DRPCProvisioner_ParseStream) error - Provision(DRPCProvisioner_ProvisionStream) error + Session(DRPCProvisioner_SessionStream) error } type DRPCProvisionerUnimplementedServer struct{} -func (s *DRPCProvisionerUnimplementedServer) Parse(*Parse_Request, DRPCProvisioner_ParseStream) error { - return drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) -} - -func (s *DRPCProvisionerUnimplementedServer) Provision(DRPCProvisioner_ProvisionStream) error { +func (s *DRPCProvisionerUnimplementedServer) Session(DRPCProvisioner_SessionStream) error { return drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) } type DRPCProvisionerDescription struct{} -func (DRPCProvisionerDescription) NumMethods() int { return 2 } +func (DRPCProvisionerDescription) NumMethods() int { return 1 } func (DRPCProvisionerDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) { switch n { case 0: - return "/provisioner.Provisioner/Parse", drpcEncoding_File_provisionersdk_proto_provisioner_proto{}, + return "/provisioner.Provisioner/Session", drpcEncoding_File_provisionersdk_proto_provisioner_proto{}, func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { return nil, srv.(DRPCProvisionerServer). - Parse( - in1.(*Parse_Request), - &drpcProvisioner_ParseStream{in2.(drpc.Stream)}, + Session( + &drpcProvisioner_SessionStream{in1.(drpc.Stream)}, ) - }, DRPCProvisionerServer.Parse, true - case 1: - return "/provisioner.Provisioner/Provision", drpcEncoding_File_provisionersdk_proto_provisioner_proto{}, - func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { - return nil, srv.(DRPCProvisionerServer). - Provision( - &drpcProvisioner_ProvisionStream{in1.(drpc.Stream)}, - ) - }, DRPCProvisionerServer.Provision, true + }, DRPCProvisionerServer.Session, true default: return "", nil, nil, nil, false } @@ -178,41 +123,28 @@ func DRPCRegisterProvisioner(mux drpc.Mux, impl DRPCProvisionerServer) error { return mux.Register(impl, DRPCProvisionerDescription{}) } -type DRPCProvisioner_ParseStream interface { - drpc.Stream - Send(*Parse_Response) error -} - -type drpcProvisioner_ParseStream struct { - drpc.Stream -} - -func (x *drpcProvisioner_ParseStream) Send(m *Parse_Response) error { - return x.MsgSend(m, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}) -} - -type DRPCProvisioner_ProvisionStream interface { +type DRPCProvisioner_SessionStream interface { drpc.Stream - Send(*Provision_Response) error - Recv() (*Provision_Request, error) + Send(*Response) error + Recv() (*Request, error) } -type drpcProvisioner_ProvisionStream struct { +type drpcProvisioner_SessionStream struct { drpc.Stream } -func (x *drpcProvisioner_ProvisionStream) Send(m *Provision_Response) error { +func (x *drpcProvisioner_SessionStream) Send(m *Response) error { return x.MsgSend(m, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}) } -func (x *drpcProvisioner_ProvisionStream) Recv() (*Provision_Request, error) { - m := new(Provision_Request) +func (x *drpcProvisioner_SessionStream) Recv() (*Request, error) { + m := new(Request) if err := x.MsgRecv(m, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}); err != nil { return nil, err } return m, nil } -func (x *drpcProvisioner_ProvisionStream) RecvMsg(m *Provision_Request) error { +func (x *drpcProvisioner_SessionStream) RecvMsg(m *Request) error { return x.MsgRecv(m, drpcEncoding_File_provisionersdk_proto_provisioner_proto{}) } diff --git a/provisionersdk/serve.go b/provisionersdk/serve.go index ea3364b6054fe..924c7ad013982 100644 --- a/provisionersdk/serve.go +++ b/provisionersdk/serve.go @@ -13,6 +13,8 @@ import ( "storj.io/drpc/drpcmux" "storj.io/drpc/drpcserver" + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/provisionersdk/proto" ) @@ -20,11 +22,19 @@ import ( // ServeOptions are configurations to serve a provisioner. type ServeOptions struct { // Conn specifies a custom transport to serve the dRPC connection. - Listener net.Listener + Listener net.Listener + Logger slog.Logger + WorkDirectory string +} + +type Server interface { + Parse(s *Session, r *proto.ParseRequest, canceledOrComplete <-chan struct{}) *proto.ParseComplete + Plan(s *Session, r *proto.PlanRequest, canceledOrComplete <-chan struct{}) *proto.PlanComplete + Apply(s *Session, r *proto.ApplyRequest, canceledOrComplete <-chan struct{}) *proto.ApplyComplete } // Serve starts a dRPC connection for the provisioner and transport provided. -func Serve(ctx context.Context, server proto.DRPCProvisionerServer, options *ServeOptions) error { +func Serve(ctx context.Context, server Server, options *ServeOptions) error { if options == nil { options = &ServeOptions{} } @@ -45,11 +55,22 @@ func Serve(ctx context.Context, server proto.DRPCProvisionerServer, options *Ser }() options.Listener = stdio } + if options.WorkDirectory == "" { + var err error + options.WorkDirectory, err = os.MkdirTemp("", "coderprovisioner") + if err != nil { + return xerrors.Errorf("failed to init temp work dir: %w", err) + } + } // dRPC is a drop-in replacement for gRPC with less generated code, and faster transports. // See: https://www.storj.io/blog/introducing-drpc-our-replacement-for-grpc mux := drpcmux.New() - err := proto.DRPCRegisterProvisioner(mux, server) + ps := &protoServer{ + server: server, + opts: *options, + } + err := proto.DRPCRegisterProvisioner(mux, ps) if err != nil { return xerrors.Errorf("register provisioner: %w", err) } diff --git a/provisionersdk/serve_test.go b/provisionersdk/serve_test.go index dedf891889d8a..baa5d2ba62b28 100644 --- a/provisionersdk/serve_test.go +++ b/provisionersdk/serve_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" - "storj.io/drpc/drpcerr" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -28,17 +27,37 @@ func TestProvisionerSDK(t *testing.T) { ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() go func() { - err := provisionersdk.Serve(ctx, &proto.DRPCProvisionerUnimplementedServer{}, &provisionersdk.ServeOptions{ - Listener: server, + err := provisionersdk.Serve(ctx, unimplementedServer{}, &provisionersdk.ServeOptions{ + Listener: server, + WorkDirectory: t.TempDir(), }) assert.NoError(t, err) }() api := proto.NewDRPCProvisionerClient(client) - stream, err := api.Parse(context.Background(), &proto.Parse_Request{}) + s, err := api.Session(ctx) require.NoError(t, err) - _, err = stream.Recv() - require.Equal(t, drpcerr.Unimplemented, int(drpcerr.Code(err))) + err = s.Send(&proto.Request{Type: &proto.Request_Config{Config: &proto.Config{}}}) + require.NoError(t, err) + + err = s.Send(&proto.Request{Type: &proto.Request_Parse{Parse: &proto.ParseRequest{}}}) + require.NoError(t, err) + msg, err := s.Recv() + require.NoError(t, err) + require.Equal(t, "unimplemented", msg.GetParse().GetError()) + + err = s.Send(&proto.Request{Type: &proto.Request_Plan{Plan: &proto.PlanRequest{}}}) + require.NoError(t, err) + msg, err = s.Recv() + require.NoError(t, err) + // Plan has no error so that we're allowed to run Apply + require.Equal(t, "", msg.GetPlan().GetError()) + + err = s.Send(&proto.Request{Type: &proto.Request_Apply{Apply: &proto.ApplyRequest{}}}) + require.NoError(t, err) + msg, err = s.Recv() + require.NoError(t, err) + require.Equal(t, "unimplemented", msg.GetApply().GetError()) }) t.Run("ServeClosedPipe", func(t *testing.T) { @@ -47,9 +66,24 @@ func TestProvisionerSDK(t *testing.T) { _ = client.Close() _ = server.Close() - err := provisionersdk.Serve(context.Background(), &proto.DRPCProvisionerUnimplementedServer{}, &provisionersdk.ServeOptions{ - Listener: server, + err := provisionersdk.Serve(context.Background(), unimplementedServer{}, &provisionersdk.ServeOptions{ + Listener: server, + WorkDirectory: t.TempDir(), }) require.NoError(t, err) }) } + +type unimplementedServer struct{} + +func (unimplementedServer) Parse(_ *provisionersdk.Session, _ *proto.ParseRequest, _ <-chan struct{}) *proto.ParseComplete { + return &proto.ParseComplete{Error: "unimplemented"} +} + +func (unimplementedServer) Plan(_ *provisionersdk.Session, _ *proto.PlanRequest, _ <-chan struct{}) *proto.PlanComplete { + return &proto.PlanComplete{} +} + +func (unimplementedServer) Apply(_ *provisionersdk.Session, _ *proto.ApplyRequest, _ <-chan struct{}) *proto.ApplyComplete { + return &proto.ApplyComplete{Error: "unimplemented"} +} diff --git a/provisionersdk/session.go b/provisionersdk/session.go new file mode 100644 index 0000000000000..dfcd981ce77f5 --- /dev/null +++ b/provisionersdk/session.go @@ -0,0 +1,318 @@ +package provisionersdk + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/v2/provisionersdk/proto" +) + +// ReadmeFile is the location we look for to extract documentation from template +// versions. +const ReadmeFile = "README.md" + +// protoServer is a wrapper that translates the dRPC protocol into a Session with method calls into the Server. +type protoServer struct { + server Server + opts ServeOptions +} + +func (p *protoServer) Session(stream proto.DRPCProvisioner_SessionStream) error { + sessID := uuid.New().String() + s := &Session{ + Logger: p.opts.Logger.With(slog.F("session_id", sessID)), + stream: stream, + server: p.server, + } + sessDir := fmt.Sprintf("Session%s", sessID) + s.WorkDirectory = filepath.Join(p.opts.WorkDirectory, sessDir) + err := os.MkdirAll(s.WorkDirectory, 0o700) + if err != nil { + return xerrors.Errorf("create work directory %q: %w", s.WorkDirectory, err) + } + defer func() { + var err error + // Cleanup the work directory after execution. + for attempt := 0; attempt < 5; attempt++ { + err = os.RemoveAll(s.WorkDirectory) + if err != nil { + // On Windows, open files cannot be removed. + // When the provisioner daemon is shutting down, + // it may take a few milliseconds for processes to exit. + // See: https://github.com/golang/go/issues/50510 + s.Logger.Debug(s.Context(), "failed to clean work directory; trying again", slog.Error(err)) + time.Sleep(250 * time.Millisecond) + continue + } + s.Logger.Debug(s.Context(), "cleaned up work directory") + return + } + s.Logger.Error(s.Context(), "failed to clean up work directory after multiple attempts", + slog.F("path", s.WorkDirectory), slog.Error(err)) + }() + req, err := stream.Recv() + if err != nil { + return xerrors.Errorf("receive config: %w", err) + } + config := req.GetConfig() + if config == nil { + return xerrors.New("first request must be Config") + } + s.Config = config + if s.Config.ProvisionerLogLevel != "" { + s.logLevel = proto.LogLevel_value[strings.ToUpper(s.Config.ProvisionerLogLevel)] + } + + err = s.extractArchive() + if err != nil { + return xerrors.Errorf("extract archive: %w", err) + } + return s.handleRequests() +} + +func (s *Session) requestReader(done <-chan struct{}) <-chan *proto.Request { + ch := make(chan *proto.Request) + go func() { + defer close(ch) + for { + req, err := s.stream.Recv() + if err != nil { + s.Logger.Info(s.Context(), "recv done on Session", slog.Error(err)) + return + } + select { + case ch <- req: + continue + case <-done: + return + } + } + }() + return ch +} + +func (s *Session) handleRequests() error { + done := make(chan struct{}) + defer close(done) + requests := s.requestReader(done) + planned := false + for req := range requests { + if req.GetCancel() != nil { + s.Logger.Warn(s.Context(), "ignoring cancel before request or after complete") + continue + } + resp := &proto.Response{} + if parse := req.GetParse(); parse != nil { + r := &request[*proto.ParseRequest, *proto.ParseComplete]{ + req: parse, + session: s, + serverFn: s.server.Parse, + cancels: requests, + } + complete, err := r.do() + if err != nil { + return err + } + // Handle README centrally, so that individual provisioners don't need to mess with it. + readme, err := os.ReadFile(filepath.Join(s.WorkDirectory, ReadmeFile)) + if err == nil { + complete.Readme = readme + } else { + s.Logger.Debug(s.Context(), "failed to parse readme (missing ok)", slog.Error(err)) + } + resp.Type = &proto.Response_Parse{Parse: complete} + } + if plan := req.GetPlan(); plan != nil { + r := &request[*proto.PlanRequest, *proto.PlanComplete]{ + req: plan, + session: s, + serverFn: s.server.Plan, + cancels: requests, + } + complete, err := r.do() + if err != nil { + return err + } + resp.Type = &proto.Response_Plan{Plan: complete} + if complete.Error == "" { + planned = true + } + } + if apply := req.GetApply(); apply != nil { + if !planned { + return xerrors.New("cannot apply before successful plan") + } + r := &request[*proto.ApplyRequest, *proto.ApplyComplete]{ + req: apply, + session: s, + serverFn: s.server.Apply, + cancels: requests, + } + complete, err := r.do() + if err != nil { + return err + } + resp.Type = &proto.Response_Apply{Apply: complete} + } + err := s.stream.Send(resp) + if err != nil { + return xerrors.Errorf("send response: %w", err) + } + } + return nil +} + +type Session struct { + Logger slog.Logger + WorkDirectory string + Config *proto.Config + + server Server + stream proto.DRPCProvisioner_SessionStream + logLevel int32 +} + +func (s *Session) Context() context.Context { + return s.stream.Context() +} + +func (s *Session) extractArchive() error { + ctx := s.Context() + + s.Logger.Info(ctx, "unpacking template source archive", + slog.F("size_bytes", len(s.Config.TemplateSourceArchive)), + ) + + reader := tar.NewReader(bytes.NewBuffer(s.Config.TemplateSourceArchive)) + // for safety, nil out the reference on Config, since the reader now owns it. + s.Config.TemplateSourceArchive = nil + for { + header, err := reader.Next() + if err != nil { + if xerrors.Is(err, io.EOF) { + break + } + return xerrors.Errorf("read template source archive: %w", err) + } + // Security: don't untar absolute or relative paths, as this can allow a malicious tar to overwrite + // files outside the workdir. + if !filepath.IsLocal(header.Name) { + return xerrors.Errorf("refusing to extract to non-local path") + } + // nolint: gosec + headerPath := filepath.Join(s.WorkDirectory, header.Name) + if !strings.HasPrefix(headerPath, filepath.Clean(s.WorkDirectory)) { + return xerrors.New("tar attempts to target relative upper directory") + } + mode := header.FileInfo().Mode() + if mode == 0 { + mode = 0o600 + } + switch header.Typeflag { + case tar.TypeDir: + err = os.MkdirAll(headerPath, mode) + if err != nil { + return xerrors.Errorf("mkdir %q: %w", headerPath, err) + } + s.Logger.Debug(context.Background(), "extracted directory", + slog.F("path", headerPath), + slog.F("mode", fmt.Sprintf("%O", mode))) + case tar.TypeReg: + file, err := os.OpenFile(headerPath, os.O_CREATE|os.O_RDWR, mode) + if err != nil { + return xerrors.Errorf("create file %q (mode %s): %w", headerPath, mode, err) + } + // Max file size of 10MiB. + size, err := io.CopyN(file, reader, 10<<20) + if xerrors.Is(err, io.EOF) { + err = nil + } + if err != nil { + _ = file.Close() + return xerrors.Errorf("copy file %q: %w", headerPath, err) + } + err = file.Close() + if err != nil { + return xerrors.Errorf("close file %q: %s", headerPath, err) + } + s.Logger.Debug(context.Background(), "extracted file", + slog.F("size_bytes", size), + slog.F("path", headerPath), + slog.F("mode", mode), + ) + } + } + return nil +} + +func (s *Session) ProvisionLog(level proto.LogLevel, output string) { + if int32(level) < s.logLevel { + return + } + + err := s.stream.Send(&proto.Response{Type: &proto.Response_Log{Log: &proto.Log{ + Level: level, + Output: output, + }}}) + if err != nil { + s.Logger.Error(s.Context(), "failed to transmit log", + slog.F("level", level), slog.F("output", output)) + } +} + +type pRequest interface { + *proto.ParseRequest | *proto.PlanRequest | *proto.ApplyRequest +} + +type pComplete interface { + *proto.ParseComplete | *proto.PlanComplete | *proto.ApplyComplete +} + +// request processes a single request call to the Server and returns its complete result, while also processing cancel +// requests from the daemon. Provisioner implementations read from canceledOrComplete to be asynchronously informed +// of cancel. +type request[R pRequest, C pComplete] struct { + req R + session *Session + cancels <-chan *proto.Request + serverFn func(*Session, R, <-chan struct{}) C +} + +func (r *request[R, C]) do() (C, error) { + canceledOrComplete := make(chan struct{}) + result := make(chan C) + go func() { + c := r.serverFn(r.session, r.req, canceledOrComplete) + result <- c + }() + select { + case req := <-r.cancels: + close(canceledOrComplete) + // wait for server to complete the request, even though we have canceled, + // so that we can't start a new request, and so that if the job was close + // to completion and the cancel was ignored, we return to complete. + c := <-result + // verify we got a cancel instead of another request or closed channel --- which is an error! + if req.GetCancel() != nil { + return c, nil + } + if req == nil { + return c, xerrors.New("got nil while old request still processing") + } + return c, xerrors.Errorf("got new request %T while old request still processing", req.Type) + case c := <-result: + close(canceledOrComplete) + return c, nil + } +} diff --git a/provisionersdk/transport.go b/provisionersdk/transport.go index 19730d3bf0530..f5df895d64eaa 100644 --- a/provisionersdk/transport.go +++ b/provisionersdk/transport.go @@ -19,7 +19,7 @@ const ( MaxMessageSize = 4 << 20 ) -// MultiplexedConn returns a multiplexed dRPC connection from a yamux session. +// MultiplexedConn returns a multiplexed dRPC connection from a yamux Session. func MultiplexedConn(session *yamux.Session) drpc.Conn { return &multiplexedDRPC{session} } diff --git a/scaletest/agentconn/run_test.go b/scaletest/agentconn/run_test.go index e9697ddedeb7a..4d3ffb8d0da2d 100644 --- a/scaletest/agentconn/run_test.go +++ b/scaletest/agentconn/run_test.go @@ -231,10 +231,10 @@ func setupRunnerTest(t *testing.T) (client *codersdk.Client, agentID uuid.UUID) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", diff --git a/scaletest/createworkspaces/run_test.go b/scaletest/createworkspaces/run_test.go index b1d871cefd743..b69297f622676 100644 --- a/scaletest/createworkspaces/run_test.go +++ b/scaletest/createworkspaces/run_test.go @@ -48,10 +48,10 @@ func Test_Runner(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "hello from logs", @@ -59,8 +59,8 @@ func Test_Runner(t *testing.T) { }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{ { Name: "example", @@ -170,10 +170,10 @@ func Test_Runner(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Log{Log: &proto.Log{}}, + Type: &proto.Response_Log{Log: &proto.Log{}}, }, }, }) @@ -282,10 +282,10 @@ func Test_Runner(t *testing.T) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "hello from logs", @@ -293,8 +293,8 @@ func Test_Runner(t *testing.T) { }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{ { Name: "example", @@ -407,11 +407,11 @@ func Test_Runner(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Error: "test error", }, }, diff --git a/scaletest/reconnectingpty/run_test.go b/scaletest/reconnectingpty/run_test.go index 8b45f9e01587b..81de3dcfb9da8 100644 --- a/scaletest/reconnectingpty/run_test.go +++ b/scaletest/reconnectingpty/run_test.go @@ -252,10 +252,10 @@ func setupRunnerTest(t *testing.T) (client *codersdk.Client, agentID uuid.UUID) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", diff --git a/scaletest/workspacebuild/run_test.go b/scaletest/workspacebuild/run_test.go index 2b50c95bdad5f..c07b10f8095b9 100644 --- a/scaletest/workspacebuild/run_test.go +++ b/scaletest/workspacebuild/run_test.go @@ -45,10 +45,10 @@ func Test_Runner(t *testing.T) { authToken3 := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Log{ + Type: &proto.Response_Log{ Log: &proto.Log{ Level: proto.LogLevel_INFO, Output: "hello from logs", @@ -56,8 +56,8 @@ func Test_Runner(t *testing.T) { }, }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{ { Name: "example1", @@ -199,11 +199,11 @@ func Test_Runner(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{ { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Error: "test error", }, }, diff --git a/scaletest/workspacetraffic/run_test.go b/scaletest/workspacetraffic/run_test.go index 4077a266aa124..308630910427d 100644 --- a/scaletest/workspacetraffic/run_test.go +++ b/scaletest/workspacetraffic/run_test.go @@ -41,10 +41,10 @@ func TestRun(t *testing.T) { agentName = "agent" version = coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", @@ -154,10 +154,10 @@ func TestRun(t *testing.T) { agentName = "agent" version = coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionPlan: echo.ProvisionComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ Resources: []*proto.Resource{{ Name: "example", Type: "aws_instance", diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 6525fa3b01f9f..aa0e2e32eb9ab 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -8,10 +8,10 @@ import { Agent, App, AppSharingLevel, - Parse_Complete, - Parse_Response, - Provision_Complete, - Provision_Response, + Response, + ParseComplete, + PlanComplete, + ApplyComplete, Resource, RichParameter, } from "./provisionerGenerated" @@ -337,11 +337,11 @@ type RecursivePartial = { interface EchoProvisionerResponses { // parse is for observing any Terraform variables - parse?: RecursivePartial[] + parse?: RecursivePartial[] // plan occurs when the template is imported - plan?: RecursivePartial[] + plan?: RecursivePartial[] // apply occurs when the workspace is built - apply?: RecursivePartial[] + apply?: RecursivePartial[] } // createTemplateVersionTar consumes a series of echo provisioner protobufs and @@ -353,109 +353,133 @@ const createTemplateVersionTar = async ( responses = {} } if (!responses.parse) { - responses.parse = [{}] + responses.parse = [ + { + parse: {}, + }, + ] } if (!responses.apply) { - responses.apply = [{}] + responses.apply = [ + { + apply: {}, + }, + ] } if (!responses.plan) { - responses.plan = responses.apply + responses.plan = responses.apply.map((response) => { + if (response.log) { + return response + } + return { + plan: { + error: response.apply?.error ?? "", + resources: response.apply?.resources ?? [], + parameters: response.apply?.parameters ?? [], + gitAuthProviders: response.apply?.gitAuthProviders ?? [], + }, + } + }) } const tar = new TarWriter() responses.parse.forEach((response, index) => { - response.complete = { + response.parse = { templateVariables: [], - ...response.complete, - } as Parse_Complete + error: "", + readme: new Uint8Array(), + ...response.parse, + } as ParseComplete tar.addFile( `${index}.parse.protobuf`, - Parse_Response.encode(response as Parse_Response).finish(), + Response.encode(response as Response).finish(), ) }) - const fillProvisionResponse = ( - response: RecursivePartial, - ) => { - response.complete = { + const fillResource = (resource: RecursivePartial) => { + if (resource.agents) { + resource.agents = resource.agents?.map( + (agent: RecursivePartial) => { + if (agent.apps) { + agent.apps = agent.apps?.map((app: RecursivePartial) => { + return { + command: "", + displayName: "example", + external: false, + icon: "", + sharingLevel: AppSharingLevel.PUBLIC, + slug: "example", + subdomain: false, + url: "", + ...app, + } as App + }) + } + return { + apps: [], + architecture: "amd64", + connectionTimeoutSeconds: 300, + directory: "", + env: {}, + id: randomUUID(), + metadata: [], + motdFile: "", + name: "dev", + operatingSystem: "linux", + shutdownScript: "", + shutdownScriptTimeoutSeconds: 0, + startupScript: "", + startupScriptBehavior: "", + startupScriptTimeoutSeconds: 300, + troubleshootingUrl: "", + token: randomUUID(), + ...agent, + } as Agent + }, + ) + } + return { + agents: [], + dailyCost: 0, + hide: false, + icon: "", + instanceType: "", + metadata: [], + name: "dev", + type: "echo", + ...resource, + } as Resource + } + + responses.apply.forEach((response, index) => { + response.apply = { error: "", state: new Uint8Array(), resources: [], parameters: [], gitAuthProviders: [], - plan: new Uint8Array(), - ...response.complete, - } as Provision_Complete - response.complete.resources = response.complete.resources?.map( - (resource) => { - if (resource.agents) { - resource.agents = resource.agents?.map((agent) => { - if (agent.apps) { - agent.apps = agent.apps?.map((app) => { - return { - command: "", - displayName: "example", - external: false, - icon: "", - sharingLevel: AppSharingLevel.PUBLIC, - slug: "example", - subdomain: false, - url: "", - ...app, - } as App - }) - } - return { - apps: [], - architecture: "amd64", - connectionTimeoutSeconds: 300, - directory: "", - env: {}, - id: randomUUID(), - metadata: [], - motdFile: "", - name: "dev", - operatingSystem: "linux", - shutdownScript: "", - shutdownScriptTimeoutSeconds: 0, - startupScript: "", - startupScriptBehavior: "", - startupScriptTimeoutSeconds: 300, - troubleshootingUrl: "", - token: randomUUID(), - ...agent, - } as Agent - }) - } - return { - agents: [], - dailyCost: 0, - hide: false, - icon: "", - instanceType: "", - metadata: [], - name: "dev", - type: "echo", - ...resource, - } as Resource - }, - ) - } - - responses.apply.forEach((response, index) => { - fillProvisionResponse(response) + ...response.apply, + } as ApplyComplete + response.apply.resources = response.apply.resources?.map(fillResource) tar.addFile( - `${index}.provision.apply.protobuf`, - Provision_Response.encode(response as Provision_Response).finish(), + `${index}.apply.protobuf`, + Response.encode(response as Response).finish(), ) }) responses.plan.forEach((response, index) => { - fillProvisionResponse(response) + response.plan = { + error: "", + resources: [], + parameters: [], + gitAuthProviders: [], + ...response.plan, + } as PlanComplete + response.plan.resources = response.plan.resources?.map(fillResource) tar.addFile( - `${index}.provision.plan.protobuf`, - Provision_Response.encode(response as Provision_Response).finish(), + `${index}.plan.protobuf`, + Response.encode(response as Response).finish(), ) }) const tarFile = await tar.write() @@ -512,16 +536,21 @@ export const echoResponsesWithParameters = ( richParameters: RichParameter[], ): EchoProvisionerResponses => { return { + parse: [ + { + parse: {}, + }, + ], plan: [ { - complete: { + plan: { parameters: richParameters, }, }, ], apply: [ { - complete: { + apply: { resources: [ { name: "example", diff --git a/site/e2e/provisionerGenerated.ts b/site/e2e/provisionerGenerated.ts index 66b6c222c9ac7..d79d5b422282c 100644 --- a/site/e2e/provisionerGenerated.ts +++ b/site/e2e/provisionerGenerated.ts @@ -21,6 +21,7 @@ export enum AppSharingLevel { UNRECOGNIZED = -1, } +/** WorkspaceTransition is the desired outcome of a build */ export enum WorkspaceTransition { START = 0, STOP = 1, @@ -177,29 +178,8 @@ export interface Resource_Metadata { isNull: boolean } -/** Parse consumes source-code from a directory to produce inputs. */ -export interface Parse {} - -export interface Parse_Request { - directory: string -} - -export interface Parse_Complete { - templateVariables: TemplateVariable[] -} - -export interface Parse_Response { - log?: Log | undefined - complete?: Parse_Complete | undefined -} - -/** - * Provision consumes source-code from a directory to produce resources. - * Exactly one of Plan or Apply must be provided in a single session. - */ -export interface Provision {} - -export interface Provision_Metadata { +/** Metadata is information about a workspace used in the execution of a build */ +export interface Metadata { coderUrl: string workspaceTransition: WorkspaceTransition workspaceName: string @@ -213,49 +193,74 @@ export interface Provision_Metadata { workspaceOwnerSessionToken: string } -/** - * Config represents execution configuration shared by both Plan and - * Apply commands. - */ -export interface Provision_Config { - directory: string +/** Config represents execution configuration shared by all subsequent requests in the Session */ +export interface Config { + /** template_source_archive is a tar of the template source files */ + templateSourceArchive: Uint8Array + /** state is the provisioner state (if any) */ state: Uint8Array - metadata: Provision_Metadata | undefined provisionerLogLevel: string } -export interface Provision_Plan { - config: Provision_Config | undefined +/** ParseRequest consumes source-code to produce inputs. */ +export interface ParseRequest {} + +/** ParseComplete indicates a request to parse completed. */ +export interface ParseComplete { + error: string + templateVariables: TemplateVariable[] + readme: Uint8Array +} + +/** PlanRequest asks the provisioner to plan what resources & parameters it will create */ +export interface PlanRequest { + metadata: Metadata | undefined richParameterValues: RichParameterValue[] variableValues: VariableValue[] gitAuthProviders: GitAuthProvider[] } -export interface Provision_Apply { - config: Provision_Config | undefined - plan: Uint8Array +/** PlanComplete indicates a request to plan completed. */ +export interface PlanComplete { + error: string + resources: Resource[] + parameters: RichParameter[] + gitAuthProviders: string[] } -export interface Provision_Cancel {} - -export interface Provision_Request { - plan?: Provision_Plan | undefined - apply?: Provision_Apply | undefined - cancel?: Provision_Cancel | undefined +/** + * ApplyRequest asks the provisioner to apply the changes. Apply MUST be preceded by a successful plan request/response + * in the same Session. The plan data is not transmitted over the wire and is cached by the provisioner in the Session. + */ +export interface ApplyRequest { + metadata: Metadata | undefined } -export interface Provision_Complete { +/** ApplyComplete indicates a request to apply completed. */ +export interface ApplyComplete { state: Uint8Array error: string resources: Resource[] parameters: RichParameter[] gitAuthProviders: string[] - plan: Uint8Array } -export interface Provision_Response { +/** CancelRequest requests that the previous request be canceled gracefully. */ +export interface CancelRequest {} + +export interface Request { + config?: Config | undefined + parse?: ParseRequest | undefined + plan?: PlanRequest | undefined + apply?: ApplyRequest | undefined + cancel?: CancelRequest | undefined +} + +export interface Response { log?: Log | undefined - complete?: Provision_Complete | undefined + parse?: ParseComplete | undefined + plan?: PlanComplete | undefined + apply?: ApplyComplete | undefined } export const Empty = { @@ -648,60 +653,9 @@ export const Resource_Metadata = { }, } -export const Parse = { - encode(_: Parse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { - return writer - }, -} - -export const Parse_Request = { +export const Metadata = { encode( - message: Parse_Request, - writer: _m0.Writer = _m0.Writer.create(), - ): _m0.Writer { - if (message.directory !== "") { - writer.uint32(10).string(message.directory) - } - return writer - }, -} - -export const Parse_Complete = { - encode( - message: Parse_Complete, - writer: _m0.Writer = _m0.Writer.create(), - ): _m0.Writer { - for (const v of message.templateVariables) { - TemplateVariable.encode(v!, writer.uint32(10).fork()).ldelim() - } - return writer - }, -} - -export const Parse_Response = { - encode( - message: Parse_Response, - writer: _m0.Writer = _m0.Writer.create(), - ): _m0.Writer { - if (message.log !== undefined) { - Log.encode(message.log, writer.uint32(10).fork()).ldelim() - } - if (message.complete !== undefined) { - Parse_Complete.encode(message.complete, writer.uint32(18).fork()).ldelim() - } - return writer - }, -} - -export const Provision = { - encode(_: Provision, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { - return writer - }, -} - -export const Provision_Metadata = { - encode( - message: Provision_Metadata, + message: Metadata, writer: _m0.Writer = _m0.Writer.create(), ): _m0.Writer { if (message.coderUrl !== "") { @@ -741,96 +695,108 @@ export const Provision_Metadata = { }, } -export const Provision_Config = { +export const Config = { encode( - message: Provision_Config, + message: Config, writer: _m0.Writer = _m0.Writer.create(), ): _m0.Writer { - if (message.directory !== "") { - writer.uint32(10).string(message.directory) + if (message.templateSourceArchive.length !== 0) { + writer.uint32(10).bytes(message.templateSourceArchive) } if (message.state.length !== 0) { writer.uint32(18).bytes(message.state) } - if (message.metadata !== undefined) { - Provision_Metadata.encode( - message.metadata, - writer.uint32(26).fork(), - ).ldelim() - } if (message.provisionerLogLevel !== "") { - writer.uint32(34).string(message.provisionerLogLevel) + writer.uint32(26).string(message.provisionerLogLevel) } return writer }, } -export const Provision_Plan = { +export const ParseRequest = { encode( - message: Provision_Plan, + _: ParseRequest, writer: _m0.Writer = _m0.Writer.create(), ): _m0.Writer { - if (message.config !== undefined) { - Provision_Config.encode(message.config, writer.uint32(10).fork()).ldelim() - } - for (const v of message.richParameterValues) { - RichParameterValue.encode(v!, writer.uint32(26).fork()).ldelim() + return writer + }, +} + +export const ParseComplete = { + encode( + message: ParseComplete, + writer: _m0.Writer = _m0.Writer.create(), + ): _m0.Writer { + if (message.error !== "") { + writer.uint32(10).string(message.error) } - for (const v of message.variableValues) { - VariableValue.encode(v!, writer.uint32(34).fork()).ldelim() + for (const v of message.templateVariables) { + TemplateVariable.encode(v!, writer.uint32(18).fork()).ldelim() } - for (const v of message.gitAuthProviders) { - GitAuthProvider.encode(v!, writer.uint32(42).fork()).ldelim() + if (message.readme.length !== 0) { + writer.uint32(26).bytes(message.readme) } return writer }, } -export const Provision_Apply = { +export const PlanRequest = { encode( - message: Provision_Apply, + message: PlanRequest, writer: _m0.Writer = _m0.Writer.create(), ): _m0.Writer { - if (message.config !== undefined) { - Provision_Config.encode(message.config, writer.uint32(10).fork()).ldelim() + if (message.metadata !== undefined) { + Metadata.encode(message.metadata, writer.uint32(10).fork()).ldelim() } - if (message.plan.length !== 0) { - writer.uint32(18).bytes(message.plan) + for (const v of message.richParameterValues) { + RichParameterValue.encode(v!, writer.uint32(18).fork()).ldelim() + } + for (const v of message.variableValues) { + VariableValue.encode(v!, writer.uint32(26).fork()).ldelim() + } + for (const v of message.gitAuthProviders) { + GitAuthProvider.encode(v!, writer.uint32(34).fork()).ldelim() } return writer }, } -export const Provision_Cancel = { +export const PlanComplete = { encode( - _: Provision_Cancel, + message: PlanComplete, writer: _m0.Writer = _m0.Writer.create(), ): _m0.Writer { + if (message.error !== "") { + writer.uint32(10).string(message.error) + } + for (const v of message.resources) { + Resource.encode(v!, writer.uint32(18).fork()).ldelim() + } + for (const v of message.parameters) { + RichParameter.encode(v!, writer.uint32(26).fork()).ldelim() + } + for (const v of message.gitAuthProviders) { + writer.uint32(34).string(v!) + } return writer }, } -export const Provision_Request = { +export const ApplyRequest = { encode( - message: Provision_Request, + message: ApplyRequest, writer: _m0.Writer = _m0.Writer.create(), ): _m0.Writer { - if (message.plan !== undefined) { - Provision_Plan.encode(message.plan, writer.uint32(10).fork()).ldelim() - } - if (message.apply !== undefined) { - Provision_Apply.encode(message.apply, writer.uint32(18).fork()).ldelim() - } - if (message.cancel !== undefined) { - Provision_Cancel.encode(message.cancel, writer.uint32(26).fork()).ldelim() + if (message.metadata !== undefined) { + Metadata.encode(message.metadata, writer.uint32(10).fork()).ldelim() } return writer }, } -export const Provision_Complete = { +export const ApplyComplete = { encode( - message: Provision_Complete, + message: ApplyComplete, writer: _m0.Writer = _m0.Writer.create(), ): _m0.Writer { if (message.state.length !== 0) { @@ -848,34 +814,76 @@ export const Provision_Complete = { for (const v of message.gitAuthProviders) { writer.uint32(42).string(v!) } - if (message.plan.length !== 0) { - writer.uint32(50).bytes(message.plan) + return writer + }, +} + +export const CancelRequest = { + encode( + _: CancelRequest, + writer: _m0.Writer = _m0.Writer.create(), + ): _m0.Writer { + return writer + }, +} + +export const Request = { + encode( + message: Request, + writer: _m0.Writer = _m0.Writer.create(), + ): _m0.Writer { + if (message.config !== undefined) { + Config.encode(message.config, writer.uint32(10).fork()).ldelim() + } + if (message.parse !== undefined) { + ParseRequest.encode(message.parse, writer.uint32(18).fork()).ldelim() + } + if (message.plan !== undefined) { + PlanRequest.encode(message.plan, writer.uint32(26).fork()).ldelim() + } + if (message.apply !== undefined) { + ApplyRequest.encode(message.apply, writer.uint32(34).fork()).ldelim() + } + if (message.cancel !== undefined) { + CancelRequest.encode(message.cancel, writer.uint32(42).fork()).ldelim() } return writer }, } -export const Provision_Response = { +export const Response = { encode( - message: Provision_Response, + message: Response, writer: _m0.Writer = _m0.Writer.create(), ): _m0.Writer { if (message.log !== undefined) { Log.encode(message.log, writer.uint32(10).fork()).ldelim() } - if (message.complete !== undefined) { - Provision_Complete.encode( - message.complete, - writer.uint32(18).fork(), - ).ldelim() + if (message.parse !== undefined) { + ParseComplete.encode(message.parse, writer.uint32(18).fork()).ldelim() + } + if (message.plan !== undefined) { + PlanComplete.encode(message.plan, writer.uint32(26).fork()).ldelim() + } + if (message.apply !== undefined) { + ApplyComplete.encode(message.apply, writer.uint32(34).fork()).ldelim() } return writer }, } export interface Provisioner { - Parse(request: Parse_Request): Observable - Provision( - request: Observable, - ): Observable + /** + * Session represents provisioning a single template import or workspace. The daemon always sends Config followed + * by one of the requests (ParseRequest, PlanRequest, ApplyRequest). The provisioner should respond with a stream + * of zero or more Logs, followed by the corresponding complete message (ParseComplete, PlanComplete, + * ApplyComplete). The daemon may then send a new request. A request to apply MUST be preceded by a request plan, + * and the provisioner should store the plan data on the Session after a successful plan, so that the daemon may + * request an apply. If the daemon closes the Session without an apply, the plan data may be safely discarded. + * + * The daemon may send a CancelRequest, asynchronously to ask the provisioner to cancel the previous ParseRequest, + * PlanRequest, or ApplyRequest. The provisioner MUST reply with a complete message corresponding to the request + * that was canceled. If the provisioner has already completed the request, it may ignore the CancelRequest. + */ + Session(request: Observable): Observable } diff --git a/site/e2e/tests/app.spec.ts b/site/e2e/tests/app.spec.ts index b3646fbac1caa..aa69475dc8184 100644 --- a/site/e2e/tests/app.spec.ts +++ b/site/e2e/tests/app.spec.ts @@ -20,7 +20,7 @@ test("app", async ({ context, page }) => { const template = await createTemplate(page, { apply: [ { - complete: { + apply: { resources: [ { agents: [ diff --git a/site/e2e/tests/createWorkspace.spec.ts b/site/e2e/tests/createWorkspace.spec.ts index 10630e2f46ab3..1effc01976651 100644 --- a/site/e2e/tests/createWorkspace.spec.ts +++ b/site/e2e/tests/createWorkspace.spec.ts @@ -21,7 +21,7 @@ test("create workspace", async ({ page }) => { const template = await createTemplate(page, { apply: [ { - complete: { + apply: { resources: [ { name: "example", diff --git a/site/e2e/tests/outdatedAgent.spec.ts b/site/e2e/tests/outdatedAgent.spec.ts index 2b88ea71110df..e10c3f6edb290 100644 --- a/site/e2e/tests/outdatedAgent.spec.ts +++ b/site/e2e/tests/outdatedAgent.spec.ts @@ -15,7 +15,7 @@ test("ssh with agent " + agentVersion, async ({ page }) => { const template = await createTemplate(page, { apply: [ { - complete: { + apply: { resources: [ { agents: [ diff --git a/site/e2e/tests/outdatedCLI.spec.ts b/site/e2e/tests/outdatedCLI.spec.ts index ab143bad27c34..1b09fccf5e13f 100644 --- a/site/e2e/tests/outdatedCLI.spec.ts +++ b/site/e2e/tests/outdatedCLI.spec.ts @@ -15,7 +15,7 @@ test("ssh with client " + clientVersion, async ({ page }) => { const template = await createTemplate(page, { apply: [ { - complete: { + apply: { resources: [ { agents: [ diff --git a/site/e2e/tests/webTerminal.spec.ts b/site/e2e/tests/webTerminal.spec.ts index 492634b293a3e..8869d352d3fc8 100644 --- a/site/e2e/tests/webTerminal.spec.ts +++ b/site/e2e/tests/webTerminal.spec.ts @@ -7,7 +7,7 @@ test("web terminal", async ({ context, page }) => { const template = await createTemplate(page, { apply: [ { - complete: { + apply: { resources: [ { agents: [ From 058fb2ecf07604ed4ddfa6f7f78e1829abe48210 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 25 Aug 2023 07:28:18 -0300 Subject: [PATCH 243/277] fix(site): fix default ephemeral parameter value on parameters page (#9314) --- .../WorkspaceParametersPage/WorkspaceParametersForm.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx index 15c38499b8e1c..8ebf0ee609d05 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx @@ -132,10 +132,7 @@ export const WorkspaceParametersForm: FC<{ }) }} parameter={parameter} - initialValue={workspaceBuildParameterValue( - buildParameters, - parameter, - )} + initialValue={form.values.rich_parameter_values[index]?.value} /> ) : null, )} From e7a231e44fc3179094b5761f70199e38c6ae0d79 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 25 Aug 2023 14:48:42 +0400 Subject: [PATCH 244/277] fix: fix test flake introduced by #9264 (#9330) * Fix test flake introduced by #9264 Signed-off-by: Spike Curtis * change check to match suffix Signed-off-by: Spike Curtis --------- Signed-off-by: Spike Curtis --- coderd/templateversions_test.go | 10 ++++++---- coderd/workspacebuilds_test.go | 7 ++++++- scaletest/createworkspaces/run_test.go | 13 ++++++++++--- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index d06e68fabb368..2ca934e2d4085 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -308,11 +308,13 @@ func TestPatchCancelTemplateVersion(t *testing.T) { require.Eventually(t, func() bool { var err error version, err = client.TemplateVersion(ctx, version.ID) + // job gets marked Failed when there is an Error; in practice we never get to Status = Canceled + // because provisioners report an Error when canceled. We check the Error string to ensure we don't mask + // other errors in this test. + t.Logf("got version %s | %s", version.Job.Error, version.Job.Status) return assert.NoError(t, err) && - // The job will never actually cancel successfully because it will never send a - // provision complete response. - assert.Empty(t, version.Job.Error) && - version.Job.Status == codersdk.ProvisionerJobCanceling + strings.HasSuffix(version.Job.Error, "canceled") && + version.Job.Status == codersdk.ProvisionerJobFailed }, testutil.WaitShort, testutil.IntervalFast) }) } diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 0ee810e3e3eda..2c32f9ac39b7a 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -14,6 +14,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" @@ -412,7 +414,10 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) { t.Run("User is not allowed to cancel", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + // need to include our own logger because the provisioner (rightly) drops error logs when we shut down the + // test with a build in progress. + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Logger: &logger}) owner := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, diff --git a/scaletest/createworkspaces/run_test.go b/scaletest/createworkspaces/run_test.go index b69297f622676..d5e96e22fcc83 100644 --- a/scaletest/createworkspaces/run_test.go +++ b/scaletest/createworkspaces/run_test.go @@ -163,8 +163,12 @@ func Test_Runner(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() + // need to include our own logger because the provisioner (rightly) drops error logs when we shut down the + // test with a build in progress. + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client := coderdtest.New(t, &coderdtest.Options{ IncludeProvisionerDaemon: true, + Logger: &logger, }) user := coderdtest.CreateFirstUser(t, client) @@ -251,14 +255,17 @@ func Test_Runner(t *testing.T) { if err != nil { return false } - for _, build := range builds { + for i, build := range builds { + t.Logf("checking build #%d: %s | %s", i, build.Transition, build.Job.Status) // One of the builds should be for creating the workspace, if build.Transition != codersdk.WorkspaceTransitionStart { continue } - // And it should be either canceled or canceling - if build.Job.Status == codersdk.ProvisionerJobCanceled || build.Job.Status == codersdk.ProvisionerJobCanceling { + // And it should be either failed (Echo returns an error when job is canceled), canceling, or canceled. + if build.Job.Status == codersdk.ProvisionerJobFailed || + build.Job.Status == codersdk.ProvisionerJobCanceling || + build.Job.Status == codersdk.ProvisionerJobCanceled { return true } } From aed891b4ff823e2ffcaae3fc69a87707beee75f6 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 25 Aug 2023 14:58:13 +0400 Subject: [PATCH 245/277] fix: fix coder template pull on Windows (#9327) * fix: fix coder template pull on Windows Signed-off-by: Spike Curtis * appease linter Signed-off-by: Spike Curtis * improvements from code review Signed-off-by: Spike Curtis --------- Signed-off-by: Spike Curtis --- cli/templatepull.go | 2 +- cli/templatepull_test.go | 275 +++++++++++++++++++++++---------------- 2 files changed, 165 insertions(+), 112 deletions(-) diff --git a/cli/templatepull.go b/cli/templatepull.go index 8f234447a6dba..eb772379b9611 100644 --- a/cli/templatepull.go +++ b/cli/templatepull.go @@ -83,7 +83,7 @@ func (r *RootCmd) templatePull() *clibase.Cmd { } if dest == "" { - dest = templateName + "/" + dest = templateName } err = os.MkdirAll(dest, 0o750) diff --git a/cli/templatepull_test.go b/cli/templatepull_test.go index 95b0a6cf9aa30..5a5e7bc6c9e06 100644 --- a/cli/templatepull_test.go +++ b/cli/templatepull_test.go @@ -40,165 +40,218 @@ func dirSum(t *testing.T, dir string) string { return hex.EncodeToString(sum.Sum(nil)) } -func TestTemplatePull(t *testing.T) { +func TestTemplatePull_NoName(t *testing.T) { t.Parallel() - t.Run("NoName", func(t *testing.T) { - t.Parallel() + inv, _ := clitest.New(t, "templates", "pull") + err := inv.Run() + require.Error(t, err) +} - inv, _ := clitest.New(t, "templates", "pull") - err := inv.Run() - require.Error(t, err) - }) +// Stdout tests that 'templates pull' pulls down the latest template +// and writes it to stdout. +func TestTemplatePull_Stdout(t *testing.T) { + t.Parallel() - // Stdout tests that 'templates pull' pulls down the latest template - // and writes it to stdout. - t.Run("Stdout", func(t *testing.T) { - t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + // Create an initial template bundle. + source1 := genTemplateVersionSource() + // Create an updated template bundle. This will be used to ensure + // that templates are correctly returned in order from latest to oldest. + source2 := genTemplateVersionSource() - // Create an initial template bundle. - source1 := genTemplateVersionSource() - // Create an updated template bundle. This will be used to ensure - // that templates are correctly returned in order from latest to oldest. - source2 := genTemplateVersionSource() + expected, err := echo.Tar(source2) + require.NoError(t, err) - expected, err := echo.Tar(source2) - require.NoError(t, err) + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) - version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + // Update the template version so that we can assert that templates + // are being sorted correctly. + _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) - // Update the template version so that we can assert that templates - // are being sorted correctly. - _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name) + clitest.SetupConfig(t, client, root) - inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name) - clitest.SetupConfig(t, client, root) + var buf bytes.Buffer + inv.Stdout = &buf - var buf bytes.Buffer - inv.Stdout = &buf + err = inv.Run() + require.NoError(t, err) - err = inv.Run() - require.NoError(t, err) + require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ") +} - require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ") - }) +// ToDir tests that 'templates pull' pulls down the latest template +// and writes it to the correct directory. +func TestTemplatePull_ToDir(t *testing.T) { + t.Parallel() - // ToDir tests that 'templates pull' pulls down the latest template - // and writes it to the correct directory. - t.Run("ToDir", func(t *testing.T) { - t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + // Create an initial template bundle. + source1 := genTemplateVersionSource() + // Create an updated template bundle. This will be used to ensure + // that templates are correctly returned in order from latest to oldest. + source2 := genTemplateVersionSource() - // Create an initial template bundle. - source1 := genTemplateVersionSource() - // Create an updated template bundle. This will be used to ensure - // that templates are correctly returned in order from latest to oldest. - source2 := genTemplateVersionSource() + expected, err := echo.Tar(source2) + require.NoError(t, err) - expected, err := echo.Tar(source2) - require.NoError(t, err) + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) - version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + // Update the template version so that we can assert that templates + // are being sorted correctly. + _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) - // Update the template version so that we can assert that templates - // are being sorted correctly. - _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + dir := t.TempDir() - dir := t.TempDir() + expectedDest := filepath.Join(dir, "expected") + actualDest := filepath.Join(dir, "actual") + ctx := context.Background() - expectedDest := filepath.Join(dir, "expected") - actualDest := filepath.Join(dir, "actual") - ctx := context.Background() + err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) + require.NoError(t, err) - err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) - require.NoError(t, err) + inv, root := clitest.New(t, "templates", "pull", template.Name, actualDest) + clitest.SetupConfig(t, client, root) - inv, root := clitest.New(t, "templates", "pull", template.Name, actualDest) - clitest.SetupConfig(t, client, root) + ptytest.New(t).Attach(inv) - ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) - require.NoError(t, inv.Run()) + require.Equal(t, + dirSum(t, expectedDest), + dirSum(t, actualDest), + ) +} - require.Equal(t, - dirSum(t, expectedDest), - dirSum(t, actualDest), - ) - }) +// ToDir tests that 'templates pull' pulls down the latest template +// and writes it to a directory with the name of the template if the path is not implicitly supplied. +// nolint: paralleltest +func TestTemplatePull_ToImplicit(t *testing.T) { + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) - // FolderConflict tests that 'templates pull' fails when a folder with has - // existing - t.Run("FolderConflict", func(t *testing.T) { - t.Parallel() + // Create an initial template bundle. + source1 := genTemplateVersionSource() + // Create an updated template bundle. This will be used to ensure + // that templates are correctly returned in order from latest to oldest. + source2 := genTemplateVersionSource() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) + expected, err := echo.Tar(source2) + require.NoError(t, err) - // Create an initial template bundle. - source1 := genTemplateVersionSource() - // Create an updated template bundle. This will be used to ensure - // that templates are correctly returned in order from latest to oldest. - source2 := genTemplateVersionSource() + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) - expected, err := echo.Tar(source2) - require.NoError(t, err) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) - version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) - _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) + // Update the template version so that we can assert that templates + // are being sorted correctly. + _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + // create a tempdir and change the working directory to it for the duration of the test (cannot run in parallel) + dir := t.TempDir() + wd, err := os.Getwd() + require.NoError(t, err) + err = os.Chdir(dir) + require.NoError(t, err) + defer func() { + err := os.Chdir(wd) + require.NoError(t, err, "if this fails, it can break other subsequent tests due to wrong working directory") + }() - // Update the template version so that we can assert that templates - // are being sorted correctly. - _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + expectedDest := filepath.Join(dir, "expected") + actualDest := filepath.Join(dir, template.Name) - dir := t.TempDir() + ctx := context.Background() - expectedDest := filepath.Join(dir, "expected") - conflictDest := filepath.Join(dir, "conflict") + err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) + require.NoError(t, err) - err = os.MkdirAll(conflictDest, 0o700) - require.NoError(t, err) + inv, root := clitest.New(t, "templates", "pull", template.Name) + clitest.SetupConfig(t, client, root) - err = os.WriteFile( - filepath.Join(conflictDest, "conflict-file"), - []byte("conflict"), 0o600, - ) - require.NoError(t, err) + ptytest.New(t).Attach(inv) - ctx := context.Background() + require.NoError(t, inv.Run()) - err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) - require.NoError(t, err) + require.Equal(t, + dirSum(t, expectedDest), + dirSum(t, actualDest), + ) +} + +// FolderConflict tests that 'templates pull' fails when a folder with has +// existing +func TestTemplatePull_FolderConflict(t *testing.T) { + t.Parallel() - inv, root := clitest.New(t, "templates", "pull", template.Name, conflictDest) - clitest.SetupConfig(t, client, root) + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) - pty := ptytest.New(t).Attach(inv) + // Create an initial template bundle. + source1 := genTemplateVersionSource() + // Create an updated template bundle. This will be used to ensure + // that templates are correctly returned in order from latest to oldest. + source2 := genTemplateVersionSource() - waiter := clitest.StartWithWaiter(t, inv) + expected, err := echo.Tar(source2) + require.NoError(t, err) - pty.ExpectMatch("not empty") - pty.WriteLine("no") + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, source1) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version1.ID) - waiter.RequireError() + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) - ents, err := os.ReadDir(conflictDest) - require.NoError(t, err) + // Update the template version so that we can assert that templates + // are being sorted correctly. + _ = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, source2, template.ID) + + dir := t.TempDir() + + expectedDest := filepath.Join(dir, "expected") + conflictDest := filepath.Join(dir, "conflict") + + err = os.MkdirAll(conflictDest, 0o700) + require.NoError(t, err) + + err = os.WriteFile( + filepath.Join(conflictDest, "conflict-file"), + []byte("conflict"), 0o600, + ) + require.NoError(t, err) + + ctx := context.Background() + + err = extract.Tar(ctx, bytes.NewReader(expected), expectedDest, nil) + require.NoError(t, err) + + inv, root := clitest.New(t, "templates", "pull", template.Name, conflictDest) + clitest.SetupConfig(t, client, root) + + pty := ptytest.New(t).Attach(inv) + + waiter := clitest.StartWithWaiter(t, inv) + + pty.ExpectMatch("not empty") + pty.WriteLine("no") + + waiter.RequireError() + + ents, err := os.ReadDir(conflictDest) + require.NoError(t, err) - require.Len(t, ents, 1, "conflict folder should have single conflict file") - }) + require.Len(t, ents, 1, "conflict folder should have single conflict file") } // genTemplateVersionSource returns a unique bundle that can be used to create From d7a788d89d520968875cb49f907f6483f89b1a0f Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 25 Aug 2023 14:50:38 +0200 Subject: [PATCH 246/277] test(site): e2e: restart workspace with ephemeral parameters (#9304) --- site/e2e/helpers.ts | 140 ++++++++++++------ site/e2e/parameters.ts | 36 +++-- site/e2e/tests/restartWorkspace.spec.ts | 45 ++++++ site/e2e/tests/startWorkspace.spec.ts | 49 ++++++ .../components/WorkspaceActions/Buttons.tsx | 1 + 5 files changed, 219 insertions(+), 52 deletions(-) create mode 100644 site/e2e/tests/restartWorkspace.spec.ts create mode 100644 site/e2e/tests/startWorkspace.spec.ts diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index aa0e2e32eb9ab..8743b13730b08 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -34,52 +34,16 @@ export const createWorkspace = async ( const name = randomName() await page.getByLabel("name").fill(name) - for (const buildParameter of buildParameters) { - const richParameter = richParameters.find( - (richParam) => richParam.name === buildParameter.name, - ) - if (!richParameter) { - throw new Error( - "build parameter is expected to be present in rich parameter schema", - ) - } - - const parameterLabel = await page.waitForSelector( - "[data-testid='parameter-field-" + richParameter.name + "']", - { state: "visible" }, - ) - - if (richParameter.type === "bool") { - const parameterField = await parameterLabel.waitForSelector( - "[data-testid='parameter-field-bool'] .MuiRadio-root input[value='" + - buildParameter.value + - "']", - ) - await parameterField.check() - } else if (richParameter.options.length > 0) { - const parameterField = await parameterLabel.waitForSelector( - "[data-testid='parameter-field-options'] .MuiRadio-root input[value='" + - buildParameter.value + - "']", - ) - await parameterField.check() - } else if (richParameter.type === "list(string)") { - throw new Error("not implemented yet") // FIXME - } else { - // text or number - const parameterField = await parameterLabel.waitForSelector( - "[data-testid='parameter-field-text'] input", - ) - await parameterField.fill(buildParameter.value) - } - } - + await fillParameters(page, richParameters, buildParameters) await page.getByTestId("form-submit").click() await expect(page).toHaveURL("/@admin/" + name) - await page.waitForSelector("[data-testid='build-status']", { - state: "visible", - }) + await page.waitForSelector( + "span[data-testid='build-status'] >> text=Running", + { + state: "visible", + }, + ) return name } @@ -213,6 +177,50 @@ export const sshIntoWorkspace = async ( }) } +export const stopWorkspace = async (page: Page, workspaceName: string) => { + await page.goto("/@admin/" + workspaceName, { + waitUntil: "domcontentloaded", + }) + await expect(page).toHaveURL("/@admin/" + workspaceName) + + await page.getByTestId("workspace-stop-button").click() + + await page.waitForSelector( + "span[data-testid='build-status'] >> text=Stopped", + { + state: "visible", + }, + ) +} + +export const buildWorkspaceWithParameters = async ( + page: Page, + workspaceName: string, + richParameters: RichParameter[] = [], + buildParameters: WorkspaceBuildParameter[] = [], + confirm: boolean = false, +) => { + await page.goto("/@admin/" + workspaceName, { + waitUntil: "domcontentloaded", + }) + await expect(page).toHaveURL("/@admin/" + workspaceName) + + await page.getByTestId("build-parameters-button").click() + + await fillParameters(page, richParameters, buildParameters) + await page.getByTestId("build-parameters-submit").click() + if (confirm) { + await page.getByTestId("confirm-button").click() + } + + await page.waitForSelector( + "span[data-testid='build-status'] >> text=Running", + { + state: "visible", + }, + ) +} + // startAgent runs the coder agent with the provided token. // It awaits the agent to be ready before returning. export const startAgent = async (page: Page, token: string): Promise => { @@ -561,3 +569,49 @@ export const echoResponsesWithParameters = ( ], } } + +export const fillParameters = async ( + page: Page, + richParameters: RichParameter[] = [], + buildParameters: WorkspaceBuildParameter[] = [], +) => { + for (const buildParameter of buildParameters) { + const richParameter = richParameters.find( + (richParam) => richParam.name === buildParameter.name, + ) + if (!richParameter) { + throw new Error( + "build parameter is expected to be present in rich parameter schema", + ) + } + + const parameterLabel = await page.waitForSelector( + "[data-testid='parameter-field-" + richParameter.name + "']", + { state: "visible" }, + ) + + if (richParameter.type === "bool") { + const parameterField = await parameterLabel.waitForSelector( + "[data-testid='parameter-field-bool'] .MuiRadio-root input[value='" + + buildParameter.value + + "']", + ) + await parameterField.check() + } else if (richParameter.options.length > 0) { + const parameterField = await parameterLabel.waitForSelector( + "[data-testid='parameter-field-options'] .MuiRadio-root input[value='" + + buildParameter.value + + "']", + ) + await parameterField.check() + } else if (richParameter.type === "list(string)") { + throw new Error("not implemented yet") // FIXME + } else { + // text or number + const parameterField = await parameterLabel.waitForSelector( + "[data-testid='parameter-field-text'] input", + ) + await parameterField.fill(buildParameter.value) + } + } +} diff --git a/site/e2e/parameters.ts b/site/e2e/parameters.ts index c575fdcf83162..240eeb0f8566c 100644 --- a/site/e2e/parameters.ts +++ b/site/e2e/parameters.ts @@ -28,7 +28,6 @@ export const firstParameter: RichParameter = { name: "first_parameter", displayName: "First parameter", type: "number", - options: [], description: "This is first parameter.", icon: "/emojis/1f310.png", defaultValue: "123", @@ -43,10 +42,8 @@ export const secondParameter: RichParameter = { name: "second_parameter", displayName: "Second parameter", type: "string", - options: [], description: "This is second parameter.", defaultValue: "abc", - icon: "", order: 2, } @@ -56,7 +53,6 @@ export const thirdParameter: RichParameter = { name: "third_parameter", type: "string", - options: [], description: "This is third parameter.", defaultValue: "", mutable: true, @@ -69,10 +65,8 @@ export const fourthParameter: RichParameter = { name: "fourth_parameter", type: "bool", - options: [], description: "This is fourth parameter.", defaultValue: "true", - icon: "", order: 3, } @@ -105,7 +99,6 @@ export const fifthParameter: RichParameter = { ], description: "This is fifth parameter.", defaultValue: "def", - icon: "", order: 3, } @@ -116,7 +109,6 @@ export const sixthParameter: RichParameter = { name: "sixth_parameter", displayName: "Sixth parameter", type: "number", - options: [], description: "This is sixth parameter.", icon: "/emojis/1f310.png", required: true, @@ -131,8 +123,34 @@ export const seventhParameter: RichParameter = { name: "seventh_parameter", displayName: "Seventh parameter", type: "string", - options: [], description: "This is seventh parameter.", required: true, order: 1, } + +// Build options + +export const firstBuildOption: RichParameter = { + ...emptyParameter, + + name: "first_build_option", + displayName: "First build option", + type: "string", + description: "This is first build option.", + icon: "/emojis/1f310.png", + defaultValue: "ABCDEF", + mutable: true, + ephemeral: true, +} + +export const secondBuildOption: RichParameter = { + ...emptyParameter, + + name: "second_build_option", + displayName: "Second build option", + type: "bool", + description: "This is second build option.", + defaultValue: "false", + mutable: true, + ephemeral: true, +} diff --git a/site/e2e/tests/restartWorkspace.spec.ts b/site/e2e/tests/restartWorkspace.spec.ts new file mode 100644 index 0000000000000..eb5d0c99c0217 --- /dev/null +++ b/site/e2e/tests/restartWorkspace.spec.ts @@ -0,0 +1,45 @@ +import { test } from "@playwright/test" +import { + buildWorkspaceWithParameters, + createTemplate, + createWorkspace, + echoResponsesWithParameters, + verifyParameters, +} from "../helpers" + +import { firstBuildOption, secondBuildOption } from "../parameters" +import { RichParameter } from "../provisionerGenerated" + +test("restart workspace with ephemeral parameters", async ({ page }) => { + const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption] + const template = await createTemplate( + page, + echoResponsesWithParameters(richParameters), + ) + const workspaceName = await createWorkspace(page, template) + + // Verify that build options are default (not selected). + await verifyParameters(page, workspaceName, richParameters, [ + { name: firstBuildOption.name, value: firstBuildOption.defaultValue }, + { name: secondBuildOption.name, value: secondBuildOption.defaultValue }, + ]) + + // Now, restart the workspace with ephemeral parameters selected. + const buildParameters = [ + { name: firstBuildOption.name, value: "AAAAA" }, + { name: secondBuildOption.name, value: "true" }, + ] + await buildWorkspaceWithParameters( + page, + workspaceName, + richParameters, + buildParameters, + true, + ) + + // Verify that build options are default (not selected). + await verifyParameters(page, workspaceName, richParameters, [ + { name: firstBuildOption.name, value: firstBuildOption.defaultValue }, + { name: secondBuildOption.name, value: secondBuildOption.defaultValue }, + ]) +}) diff --git a/site/e2e/tests/startWorkspace.spec.ts b/site/e2e/tests/startWorkspace.spec.ts new file mode 100644 index 0000000000000..232ac27299849 --- /dev/null +++ b/site/e2e/tests/startWorkspace.spec.ts @@ -0,0 +1,49 @@ +import { test } from "@playwright/test" +import { + buildWorkspaceWithParameters, + createTemplate, + createWorkspace, + echoResponsesWithParameters, + stopWorkspace, + verifyParameters, +} from "../helpers" + +import { firstBuildOption, secondBuildOption } from "../parameters" +import { RichParameter } from "../provisionerGenerated" + +test("start workspace with ephemeral parameters", async ({ page }) => { + const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption] + const template = await createTemplate( + page, + echoResponsesWithParameters(richParameters), + ) + const workspaceName = await createWorkspace(page, template) + + // Verify that build options are default (not selected). + await verifyParameters(page, workspaceName, richParameters, [ + { name: firstBuildOption.name, value: firstBuildOption.defaultValue }, + { name: secondBuildOption.name, value: secondBuildOption.defaultValue }, + ]) + + // Stop the workspace + await stopWorkspace(page, workspaceName) + + // Now, start the workspace with ephemeral parameters selected. + const buildParameters = [ + { name: firstBuildOption.name, value: "AAAAA" }, + { name: secondBuildOption.name, value: "true" }, + ] + + await buildWorkspaceWithParameters( + page, + workspaceName, + richParameters, + buildParameters, + ) + + // Verify that build options are default (not selected). + await verifyParameters(page, workspaceName, richParameters, [ + { name: firstBuildOption.name, value: firstBuildOption.defaultValue }, + { name: secondBuildOption.name, value: secondBuildOption.defaultValue }, + ]) +}) diff --git a/site/src/components/WorkspaceActions/Buttons.tsx b/site/src/components/WorkspaceActions/Buttons.tsx index 4d62e0bb9cc32..e2a005929a5a7 100644 --- a/site/src/components/WorkspaceActions/Buttons.tsx +++ b/site/src/components/WorkspaceActions/Buttons.tsx @@ -94,6 +94,7 @@ export const StopButton: FC = ({ handleAction, loading }) => { loadingPosition="start" startIcon={} onClick={handleAction} + data-testid="workspace-stop-button" > Stop From 3b1ecd3c2fbbca6edfd1db9f9e0b0e44e8048bfd Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 25 Aug 2023 16:50:03 +0300 Subject: [PATCH 247/277] chore: update aws_linux template (#9325) --- examples/templates/aws-linux/main.tf | 73 +++++++--------------------- 1 file changed, 18 insertions(+), 55 deletions(-) diff --git a/examples/templates/aws-linux/main.tf b/examples/templates/aws-linux/main.tf index 96d9136dfe758..f1f41024d938a 100644 --- a/examples/templates/aws-linux/main.tf +++ b/examples/templates/aws-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "~> 0.7.0" + version = "~> 0.11.0" } aws = { source = "hashicorp/aws" @@ -30,24 +30,24 @@ data "coder_parameter" "region" { icon = "/emojis/1f1f0-1f1f7.png" } option { - name = "Asia Pacific (Osaka-Local)" + name = "Asia Pacific (Osaka)" value = "ap-northeast-3" - icon = "/emojis/1f1f0-1f1f7.png" + icon = "/emojis/1f1ef-1f1f5.png" } option { name = "Asia Pacific (Mumbai)" value = "ap-south-1" - icon = "/emojis/1f1f0-1f1f7.png" + icon = "/emojis/1f1ee-1f1f3.png" } option { name = "Asia Pacific (Singapore)" value = "ap-southeast-1" - icon = "/emojis/1f1f0-1f1f7.png" + icon = "/emojis/1f1f8-1f1ec.png" } option { name = "Asia Pacific (Sydney)" value = "ap-southeast-2" - icon = "/emojis/1f1f0-1f1f7.png" + icon = "/emojis/1f1e6-1f1fa.png" } option { name = "Canada (Central)" @@ -176,33 +176,21 @@ resource "coder_agent" "main" { display_name = "CPU Usage" interval = 5 timeout = 5 - script = <<-EOT - #!/bin/bash - set -e - top -bn1 | grep "Cpu(s)" | awk '{print $2 + $4 "%"}' - EOT + script = "coder stat cpu" } metadata { key = "memory" display_name = "Memory Usage" interval = 5 timeout = 5 - script = <<-EOT - #!/bin/bash - set -e - free -m | awk 'NR==2{printf "%.2f%%\t", $3*100/$2 }' - EOT + script = "coder stat mem" } metadata { key = "disk" display_name = "Disk Usage" interval = 600 # every 10 minutes timeout = 30 # df can take a while on large filesystems - script = <<-EOT - #!/bin/bash - set -e - df /home/coder | awk '$NF=="/"{printf "%s", $5}' - EOT + script = "coder stat disk --path $HOME" } } @@ -223,11 +211,9 @@ resource "coder_app" "code-server" { } locals { - - # User data is used to stop/start AWS instances. See: - # https://github.com/hashicorp/terraform-provider-aws/issues/22 - - user_data_start = < Date: Fri, 25 Aug 2023 11:00:38 -0500 Subject: [PATCH 248/277] fix(cli): add --max-ttl to template create (#9319) It was just in template edit by mistake. --- cli/templatecreate.go | 16 ++++++++++++---- .../coder_templates_create_--help.golden | 5 +++++ docs/cli/templates_create.md | 8 ++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 38f5d7d0d7fd0..cc877adeec972 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -29,9 +29,11 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { variablesFile string variables []string disableEveryone bool - defaultTTL time.Duration - failureTTL time.Duration - inactivityTTL time.Duration + + defaultTTL time.Duration + failureTTL time.Duration + inactivityTTL time.Duration + maxTTL time.Duration uploadFlags templateUploadFlags ) @@ -44,7 +46,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { - if failureTTL != 0 || inactivityTTL != 0 { + if failureTTL != 0 || inactivityTTL != 0 || maxTTL != 0 { // This call can be removed when workspace_actions is no longer experimental experiments, exErr := client.Experiments(inv.Context()) if exErr != nil { @@ -134,6 +136,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { VersionID: job.ID, DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), FailureTTLMillis: ptr.Ref(failureTTL.Milliseconds()), + MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), TimeTilDormantMillis: ptr.Ref(inactivityTTL.Milliseconds()), DisableEveryoneGroupAccess: disableEveryone, } @@ -198,6 +201,11 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { Default: "0h", Value: clibase.DurationOf(&inactivityTTL), }, + { + Flag: "max-ttl", + Description: "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature.", + Value: clibase.DurationOf(&maxTTL), + }, { Flag: "test.provisioner", Description: "Customize the provisioner backend.", diff --git a/cli/testdata/coder_templates_create_--help.golden b/cli/testdata/coder_templates_create_--help.golden index a88fe64bdeba3..030fd5f385ee8 100644 --- a/cli/testdata/coder_templates_create_--help.golden +++ b/cli/testdata/coder_templates_create_--help.golden @@ -21,6 +21,11 @@ Create a template from the current directory or as specified by flag Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off). + --max-ttl duration + Edit the template maximum time before shutdown - workspaces created + from this template must shutdown within the given duration after + starting. This is an enterprise-only feature. + -m, --message string Specify a message describing the changes in this version of the template. Messages longer than 72 characters will be displayed as diff --git a/docs/cli/templates_create.md b/docs/cli/templates_create.md index 44081e8986120..8d7e0fa931cb5 100644 --- a/docs/cli/templates_create.md +++ b/docs/cli/templates_create.md @@ -57,6 +57,14 @@ Ignore warnings about not having a .terraform.lock.hcl file present in the templ Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off). +### --max-ttl + +| | | +| ---- | --------------------- | +| Type | duration | + +Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature. + ### -m, --message | | | From 91f900ec649433ea0d10b21eecb9737e76040dec Mon Sep 17 00:00:00 2001 From: Eric Paulsen Date: Fri, 25 Aug 2023 13:39:12 -0400 Subject: [PATCH 249/277] docs: expand on TTL flags (#9286) * docs: expand on TTL flags * make: gen * Discard changes to site/src/api/api.ts * Discard changes to site/src/xServices/templateVersion/templateVersionXService.ts --------- Co-authored-by: Muhammad Atif Ali Co-authored-by: Muhammad Atif Ali --- cli/templatecreate.go | 6 +++--- cli/templateedit.go | 8 ++++---- .../coder_templates_create_--help.golden | 15 +++++++++++---- cli/testdata/coder_templates_edit_--help.golden | 17 ++++++++++++----- docs/cli/templates_create.md | 6 +++--- docs/cli/templates_edit.md | 8 ++++---- 6 files changed, 37 insertions(+), 23 deletions(-) diff --git a/cli/templatecreate.go b/cli/templatecreate.go index cc877adeec972..638f790dd811d 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -185,19 +185,19 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { }, { Flag: "default-ttl", - Description: "Specify a default TTL for workspaces created from this template.", + Description: "Specify a default TTL for workspaces created from this template. It is the default time before shutdown - workspaces created from this template default to this value. Maps to \"Default autostop\" in the UI.", Default: "24h", Value: clibase.DurationOf(&defaultTTL), }, { Flag: "failure-ttl", - Description: "Specify a failure TTL for workspaces created from this template. This licensed feature's default is 0h (off).", + Description: "Specify a failure TTL for workspaces created from this template. It is the amount of time after a failed \"start\" build before coder automatically schedules a \"stop\" build to cleanup.This licensed feature's default is 0h (off). Maps to \"Failure cleanup\"in the UI.", Default: "0h", Value: clibase.DurationOf(&failureTTL), }, { Flag: "inactivity-ttl", - Description: "Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off).", + Description: "Specify an inactivity TTL for workspaces created from this template. It is the amount of time the workspace is not used before it is be stopped and auto-locked. This includes across multiple builds (e.g. auto-starts and stops). This licensed feature's default is 0h (off). Maps to \"Dormancy threshold\" in the UI.", Default: "0h", Value: clibase.DurationOf(&inactivityTTL), }, diff --git a/cli/templateedit.go b/cli/templateedit.go index 5fcd73c432f58..329187ef7ae7c 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -142,12 +142,12 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { }, { Flag: "default-ttl", - Description: "Edit the template default time before shutdown - workspaces created from this template default to this value.", + Description: "Edit the template default time before shutdown - workspaces created from this template default to this value. Maps to \"Default autostop\" in the UI.", Value: clibase.DurationOf(&defaultTTL), }, { Flag: "max-ttl", - Description: "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature.", + Description: "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting, regardless of user activity. This is an enterprise-only feature. Maps to \"Max lifetime\" in the UI.", Value: clibase.DurationOf(&maxTTL), }, { @@ -176,13 +176,13 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { }, { Flag: "failure-ttl", - Description: "Specify a failure TTL for workspaces created from this template. This licensed feature's default is 0h (off).", + Description: "Specify a failure TTL for workspaces created from this template. It is the amount of time after a failed \"start\" build before coder automatically schedules a \"stop\" build to cleanup.This licensed feature's default is 0h (off). Maps to \"Failure cleanup\" in the UI.", Default: "0h", Value: clibase.DurationOf(&failureTTL), }, { Flag: "inactivity-ttl", - Description: "Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off).", + Description: "Specify an inactivity TTL for workspaces created from this template. It is the amount of time the workspace is not used before it is be stopped and auto-locked. This includes across multiple builds (e.g. auto-starts and stops). This licensed feature's default is 0h (off). Maps to \"Dormancy threshold\" in the UI.", Default: "0h", Value: clibase.DurationOf(&inactivityTTL), }, diff --git a/cli/testdata/coder_templates_create_--help.golden b/cli/testdata/coder_templates_create_--help.golden index 030fd5f385ee8..ce71793cebc27 100644 --- a/cli/testdata/coder_templates_create_--help.golden +++ b/cli/testdata/coder_templates_create_--help.golden @@ -4,14 +4,18 @@ Create a template from the current directory or as specified by flag Options --default-ttl duration (default: 24h) - Specify a default TTL for workspaces created from this template. + Specify a default TTL for workspaces created from this template. It is + the default time before shutdown - workspaces created from this + template default to this value. Maps to "Default autostop" in the UI. -d, --directory string (default: .) Specify the directory to create from, use '-' to read tar from stdin. --failure-ttl duration (default: 0h) - Specify a failure TTL for workspaces created from this template. This - licensed feature's default is 0h (off). + Specify a failure TTL for workspaces created from this template. It is + the amount of time after a failed "start" build before coder + automatically schedules a "stop" build to cleanup.This licensed + feature's default is 0h (off). Maps to "Failure cleanup"in the UI. --ignore-lockfile bool (default: false) Ignore warnings about not having a .terraform.lock.hcl file present in @@ -19,7 +23,10 @@ Create a template from the current directory or as specified by flag --inactivity-ttl duration (default: 0h) Specify an inactivity TTL for workspaces created from this template. - This licensed feature's default is 0h (off). + It is the amount of time the workspace is not used before it is be + stopped and auto-locked. This includes across multiple builds (e.g. + auto-starts and stops). This licensed feature's default is 0h (off). + Maps to "Dormancy threshold" in the UI. --max-ttl duration Edit the template maximum time before shutdown - workspaces created diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index 09c0b7209e78a..19dfcd2953c33 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -16,7 +16,8 @@ Edit the metadata of a template by name. --default-ttl duration Edit the template default time before shutdown - workspaces created - from this template default to this value. + from this template default to this value. Maps to "Default autostop" + in the UI. --description string Edit the template description. @@ -25,20 +26,26 @@ Edit the metadata of a template by name. Edit the template display name. --failure-ttl duration (default: 0h) - Specify a failure TTL for workspaces created from this template. This - licensed feature's default is 0h (off). + Specify a failure TTL for workspaces created from this template. It is + the amount of time after a failed "start" build before coder + automatically schedules a "stop" build to cleanup.This licensed + feature's default is 0h (off). Maps to "Failure cleanup" in the UI. --icon string Edit the template icon path. --inactivity-ttl duration (default: 0h) Specify an inactivity TTL for workspaces created from this template. - This licensed feature's default is 0h (off). + It is the amount of time the workspace is not used before it is be + stopped and auto-locked. This includes across multiple builds (e.g. + auto-starts and stops). This licensed feature's default is 0h (off). + Maps to "Dormancy threshold" in the UI. --max-ttl duration Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after - starting. This is an enterprise-only feature. + starting, regardless of user activity. This is an enterprise-only + feature. Maps to "Max lifetime" in the UI. --name string Edit the template name. diff --git a/docs/cli/templates_create.md b/docs/cli/templates_create.md index 8d7e0fa931cb5..2811e4a1ce021 100644 --- a/docs/cli/templates_create.md +++ b/docs/cli/templates_create.md @@ -19,7 +19,7 @@ coder templates create [flags] [name] | Type | duration | | Default | 24h | -Specify a default TTL for workspaces created from this template. +Specify a default TTL for workspaces created from this template. It is the default time before shutdown - workspaces created from this template default to this value. Maps to "Default autostop" in the UI. ### -d, --directory @@ -37,7 +37,7 @@ Specify the directory to create from, use '-' to read tar from stdin. | Type | duration | | Default | 0h | -Specify a failure TTL for workspaces created from this template. This licensed feature's default is 0h (off). +Specify a failure TTL for workspaces created from this template. It is the amount of time after a failed "start" build before coder automatically schedules a "stop" build to cleanup.This licensed feature's default is 0h (off). Maps to "Failure cleanup"in the UI. ### --ignore-lockfile @@ -55,7 +55,7 @@ Ignore warnings about not having a .terraform.lock.hcl file present in the templ | Type | duration | | Default | 0h | -Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off). +Specify an inactivity TTL for workspaces created from this template. It is the amount of time the workspace is not used before it is be stopped and auto-locked. This includes across multiple builds (e.g. auto-starts and stops). This licensed feature's default is 0h (off). Maps to "Dormancy threshold" in the UI. ### --max-ttl diff --git a/docs/cli/templates_edit.md b/docs/cli/templates_edit.md index 2d25da15b7cc1..79f4ec0ba29f6 100644 --- a/docs/cli/templates_edit.md +++ b/docs/cli/templates_edit.md @@ -45,7 +45,7 @@ Allow users to cancel in-progress workspace jobs. | ---- | --------------------- | | Type | duration | -Edit the template default time before shutdown - workspaces created from this template default to this value. +Edit the template default time before shutdown - workspaces created from this template default to this value. Maps to "Default autostop" in the UI. ### --description @@ -70,7 +70,7 @@ Edit the template display name. | Type | duration | | Default | 0h | -Specify a failure TTL for workspaces created from this template. This licensed feature's default is 0h (off). +Specify a failure TTL for workspaces created from this template. It is the amount of time after a failed "start" build before coder automatically schedules a "stop" build to cleanup.This licensed feature's default is 0h (off). Maps to "Failure cleanup" in the UI. ### --icon @@ -87,7 +87,7 @@ Edit the template icon path. | Type | duration | | Default | 0h | -Specify an inactivity TTL for workspaces created from this template. This licensed feature's default is 0h (off). +Specify an inactivity TTL for workspaces created from this template. It is the amount of time the workspace is not used before it is be stopped and auto-locked. This includes across multiple builds (e.g. auto-starts and stops). This licensed feature's default is 0h (off). Maps to "Dormancy threshold" in the UI. ### --max-ttl @@ -95,7 +95,7 @@ Specify an inactivity TTL for workspaces created from this template. This licens | ---- | --------------------- | | Type | duration | -Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature. +Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting, regardless of user activity. This is an enterprise-only feature. Maps to "Max lifetime" in the UI. ### --name From e5c64a8ea9294f37ebdab3473a35706f4a16a36e Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 25 Aug 2023 12:45:36 -0500 Subject: [PATCH 250/277] fix(site): render variable width unicode characters in terminal (#9259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, characters such as 🟢 were given insufficient space, leading to mangled output. --- site/package.json | 1 + site/pnpm-lock.yaml | 11 +++++++++++ site/src/pages/TerminalPage/TerminalPage.tsx | 4 ++++ 3 files changed, 16 insertions(+) diff --git a/site/package.json b/site/package.json index 1b04cfb532ce6..6f49efce97652 100644 --- a/site/package.json +++ b/site/package.json @@ -103,6 +103,7 @@ "xterm": "5.2.1", "xterm-addon-canvas": "0.4.0", "xterm-addon-fit": "0.7.0", + "xterm-addon-unicode11": "0.5.0", "xterm-addon-web-links": "0.8.0", "yup": "1.2.0" }, diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index b3d7c20631048..6892f56aa2ff8 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -225,6 +225,9 @@ dependencies: xterm-addon-fit: specifier: 0.7.0 version: 0.7.0(xterm@5.2.1) + xterm-addon-unicode11: + specifier: 0.5.0 + version: 0.5.0(xterm@5.2.1) xterm-addon-web-links: specifier: 0.8.0 version: 0.8.0(xterm@5.2.1) @@ -14202,6 +14205,14 @@ packages: xterm: 5.2.1 dev: false + /xterm-addon-unicode11@0.5.0(xterm@5.2.1): + resolution: {integrity: sha512-Jm4/g4QiTxiKiTbYICQgC791ubhIZyoIwxAIgOW8z8HWFNY+lwk+dwaKEaEeGBfM48Vk8fklsUW9u/PlenYEBg==} + peerDependencies: + xterm: ^5.0.0 + dependencies: + xterm: 5.2.1 + dev: false + /xterm-addon-web-links@0.8.0(xterm@5.2.1): resolution: {integrity: sha512-J4tKngmIu20ytX9SEJjAP3UGksah7iALqBtfTwT9ZnmFHVplCumYQsUJfKuS+JwMhjsjH61YXfndenLNvjRrEw==} peerDependencies: diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index a09f289da9805..8522daa2d30cb 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -10,6 +10,7 @@ import * as XTerm from "xterm" import { CanvasAddon } from "xterm-addon-canvas" import { FitAddon } from "xterm-addon-fit" import { WebLinksAddon } from "xterm-addon-web-links" +import { Unicode11Addon } from "xterm-addon-unicode11" import "xterm/css/xterm.css" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { pageTitle } from "../../utils/page" @@ -176,6 +177,7 @@ const TerminalPage: FC = ({ renderer }) => { return } const terminal = new XTerm.Terminal({ + allowProposedApi: true, allowTransparency: true, disableStdin: false, fontFamily: MONOSPACE_FONT_FAMILY, @@ -191,6 +193,8 @@ const TerminalPage: FC = ({ renderer }) => { const fitAddon = new FitAddon() setFitAddon(fitAddon) terminal.loadAddon(fitAddon) + terminal.loadAddon(new Unicode11Addon()) + terminal.unicode.activeVersion = "11" terminal.loadAddon( new WebLinksAddon((_, uri) => { handleWebLink(uri) From 14f769d229a81c6b4d07f65e0c273f8bc977862c Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 25 Aug 2023 12:46:14 -0500 Subject: [PATCH 251/277] fix(site): use WebGL renderer for terminal (#9320) --- site/package.json | 1 + site/pnpm-lock.yaml | 11 +++++++++++ site/src/AppRouter.tsx | 2 +- site/src/pages/TerminalPage/TerminalPage.tsx | 8 ++++---- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/site/package.json b/site/package.json index 6f49efce97652..a2230d57694d2 100644 --- a/site/package.json +++ b/site/package.json @@ -105,6 +105,7 @@ "xterm-addon-fit": "0.7.0", "xterm-addon-unicode11": "0.5.0", "xterm-addon-web-links": "0.8.0", + "xterm-addon-webgl": "0.15.0", "yup": "1.2.0" }, "devDependencies": { diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 6892f56aa2ff8..f137e12289fb1 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -231,6 +231,9 @@ dependencies: xterm-addon-web-links: specifier: 0.8.0 version: 0.8.0(xterm@5.2.1) + xterm-addon-webgl: + specifier: 0.15.0 + version: 0.15.0(xterm@5.2.1) yup: specifier: 1.2.0 version: 1.2.0 @@ -14221,6 +14224,14 @@ packages: xterm: 5.2.1 dev: false + /xterm-addon-webgl@0.15.0(xterm@5.2.1): + resolution: {integrity: sha512-ZLcqogMFHr4g/YRhcCh3xE8tTklnyut/M+O/XhVsFBRB/YCvYhPdLQ5/AQk54V0wjWAQpa8CF3W8DVR9OqyMCg==} + peerDependencies: + xterm: ^5.0.0 + dependencies: + xterm: 5.2.1 + dev: false + /xterm@5.2.1: resolution: {integrity: sha512-cs5Y1fFevgcdoh2hJROMVIWwoBHD80P1fIP79gopLHJIE4kTzzblanoivxTiQ4+92YM9IxS36H1q0MxIJXQBcA==} dev: false diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index c1f222d90c334..785048fe7298d 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -322,7 +322,7 @@ export const AppRouter: FC = () => { {/* Terminal and CLI auth pages don't have the dashboard layout */} } + element={} /> } /> diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 8522daa2d30cb..61c3501976329 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -7,7 +7,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom" import { colors } from "theme/colors" import { v4 as uuidv4 } from "uuid" import * as XTerm from "xterm" -import { CanvasAddon } from "xterm-addon-canvas" +import { WebglAddon } from "xterm-addon-webgl" import { FitAddon } from "xterm-addon-fit" import { WebLinksAddon } from "xterm-addon-web-links" import { Unicode11Addon } from "xterm-addon-unicode11" @@ -58,7 +58,7 @@ const useTerminalWarning = ({ agent }: { agent?: WorkspaceAgent }) => { } type TerminalPageProps = React.PropsWithChildren<{ - renderer: "canvas" | "dom" + renderer: "webgl" | "dom" }> const TerminalPage: FC = ({ renderer }) => { @@ -187,8 +187,8 @@ const TerminalPage: FC = ({ renderer }) => { }, }) // DOM is the default renderer. - if (renderer === "canvas") { - terminal.loadAddon(new CanvasAddon()) + if (renderer === "webgl") { + terminal.loadAddon(new WebglAddon()) } const fitAddon = new FitAddon() setFitAddon(fitAddon) From 0a213a6ac32af45b3f80102bdc90a3705d21eef1 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 25 Aug 2023 14:59:41 -0300 Subject: [PATCH 252/277] refactor(site): improve the overall user table design (#9342) --- .../EditRolesButton/EditRolesButton.tsx | 11 +- .../UsersTable/UsersTable.stories.tsx | 113 ++++++++++-------- site/src/components/UsersTable/UsersTable.tsx | 9 +- .../components/UsersTable/UsersTableBody.tsx | 113 ++++++++++++++++-- site/src/pages/UsersPage/UsersPage.tsx | 25 ++-- .../pages/UsersPage/UsersPageView.stories.tsx | 2 + site/src/pages/UsersPage/UsersPageView.tsx | 3 + .../WorkspacesPage}/LastUsed.tsx | 0 .../pages/WorkspacesPage/WorkspacesTable.tsx | 2 +- 9 files changed, 198 insertions(+), 80 deletions(-) rename site/src/{components/LastUsed => pages/WorkspacesPage}/LastUsed.tsx (100%) diff --git a/site/src/components/EditRolesButton/EditRolesButton.tsx b/site/src/components/EditRolesButton/EditRolesButton.tsx index e49902936bee3..771ba95e7068e 100644 --- a/site/src/components/EditRolesButton/EditRolesButton.tsx +++ b/site/src/components/EditRolesButton/EditRolesButton.tsx @@ -37,7 +37,7 @@ const Option: React.FC<{ onChange(e.currentTarget.value) }} /> - + {name} {description} @@ -142,7 +142,7 @@ export const EditRolesButton: FC = ({
- + {t("member")} {t("roleDescription.member")} @@ -182,7 +182,7 @@ const useStyles = makeStyles((theme) => ({ padding: 0, "&:disabled": { - opacity: 0.5, + opacity: 0, }, }, options: { @@ -190,6 +190,7 @@ const useStyles = makeStyles((theme) => ({ }, option: { cursor: "pointer", + fontSize: 14, }, checkbox: { padding: 0, @@ -202,13 +203,15 @@ const useStyles = makeStyles((theme) => ({ }, }, optionDescription: { - fontSize: 12, + fontSize: 13, color: theme.palette.text.secondary, + lineHeight: "160%", }, footer: { padding: theme.spacing(3), backgroundColor: theme.palette.background.paper, borderTop: `1px solid ${theme.palette.divider}`, + fontSize: 14, }, userIcon: { width: theme.spacing(2.5), // Same as the checkbox diff --git a/site/src/components/UsersTable/UsersTable.stories.tsx b/site/src/components/UsersTable/UsersTable.stories.tsx index 536c39253fa44..a40d44e8611c3 100644 --- a/site/src/components/UsersTable/UsersTable.stories.tsx +++ b/site/src/components/UsersTable/UsersTable.stories.tsx @@ -1,73 +1,80 @@ -import { ComponentMeta, Story } from "@storybook/react" import { MockUser, MockUser2, MockAssignableSiteRoles, + MockAuthMethods, } from "testHelpers/entities" -import { UsersTable, UsersTableProps } from "./UsersTable" +import { UsersTable } from "./UsersTable" +import type { Meta, StoryObj } from "@storybook/react" -export default { +const meta: Meta = { title: "components/UsersTable", component: UsersTable, args: { isNonInitialPage: false, + authMethods: MockAuthMethods, }, -} as ComponentMeta +} -const Template: Story = (args) => +export default meta +type Story = StoryObj -export const Example = Template.bind({}) -Example.args = { - users: [MockUser, MockUser2], - roles: MockAssignableSiteRoles, - canEditUsers: false, +export const Example: Story = { + args: { + users: [MockUser, MockUser2], + roles: MockAssignableSiteRoles, + canEditUsers: false, + }, } -export const Editable = Template.bind({}) -Editable.args = { - users: [ - MockUser, - MockUser2, - { - ...MockUser, - username: "John Doe", - email: "john.doe@coder.com", - roles: [], - status: "dormant", - }, - { - ...MockUser, - username: "Roger Moore", - email: "roger.moore@coder.com", - roles: [], - status: "suspended", - }, - { - ...MockUser, - username: "OIDC User", - email: "oidc.user@coder.com", - roles: [], - status: "active", - login_type: "oidc", - }, - ], - roles: MockAssignableSiteRoles, - canEditUsers: true, - canViewActivity: true, +export const Editable: Story = { + args: { + users: [ + MockUser, + MockUser2, + { + ...MockUser, + username: "John Doe", + email: "john.doe@coder.com", + roles: [], + status: "dormant", + }, + { + ...MockUser, + username: "Roger Moore", + email: "roger.moore@coder.com", + roles: [], + status: "suspended", + }, + { + ...MockUser, + username: "OIDC User", + email: "oidc.user@coder.com", + roles: [], + status: "active", + login_type: "oidc", + }, + ], + roles: MockAssignableSiteRoles, + canEditUsers: true, + canViewActivity: true, + }, } -export const Empty = Template.bind({}) -Empty.args = { - users: [], - roles: MockAssignableSiteRoles, +export const Empty: Story = { + args: { + users: [], + roles: MockAssignableSiteRoles, + }, } -export const Loading = Template.bind({}) -Loading.args = { - users: [], - roles: MockAssignableSiteRoles, - isLoading: true, -} -Loading.parameters = { - chromatic: { pauseAnimationAtEnd: true }, +export const Loading: Story = { + args: { + users: [], + roles: MockAssignableSiteRoles, + isLoading: true, + }, + parameters: { + chromatic: { pauseAnimationAtEnd: true }, + }, } diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index d84386a2e968e..51edb7e23c2a4 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -38,6 +38,7 @@ export interface UsersTableProps { isNonInitialPage: boolean actorID: string oidcRoleSyncEnabled: boolean + authMethods?: TypesGen.AuthMethods } export const UsersTable: FC> = ({ @@ -57,6 +58,7 @@ export const UsersTable: FC> = ({ isNonInitialPage, actorID, oidcRoleSyncEnabled, + authMethods, }) => { return ( @@ -70,10 +72,8 @@ export const UsersTable: FC> = ({ - {Language.loginTypeLabel} - {Language.statusLabel} - {Language.lastSeenLabel} - + {Language.loginTypeLabel} + {Language.statusLabel} {/* 1% is a trick to make the table cell width fit the content */} {canEditUsers && } @@ -96,6 +96,7 @@ export const UsersTable: FC> = ({ isNonInitialPage={isNonInitialPage} actorID={actorID} oidcRoleSyncEnabled={oidcRoleSyncEnabled} + authMethods={authMethods} /> diff --git a/site/src/components/UsersTable/UsersTableBody.tsx b/site/src/components/UsersTable/UsersTableBody.tsx index 82e154a3d0d0b..73abfd639a273 100644 --- a/site/src/components/UsersTable/UsersTableBody.tsx +++ b/site/src/components/UsersTable/UsersTableBody.tsx @@ -1,9 +1,8 @@ -import Box from "@mui/material/Box" -import { makeStyles } from "@mui/styles" +import Box, { BoxProps } from "@mui/material/Box" +import { makeStyles, useTheme } from "@mui/styles" import TableCell from "@mui/material/TableCell" import TableRow from "@mui/material/TableRow" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" -import { LastUsed } from "components/LastUsed/LastUsed" import { Pill } from "components/Pill/Pill" import { FC } from "react" import { useTranslation } from "react-i18next" @@ -16,6 +15,16 @@ import { TableRowMenu } from "../TableRowMenu/TableRowMenu" import { EditRolesButton } from "components/EditRolesButton/EditRolesButton" import { Stack } from "components/Stack/Stack" import { EnterpriseBadge } from "components/DeploySettingsLayout/Badges" +import dayjs from "dayjs" +import { SxProps, Theme } from "@mui/material/styles" +import HideSourceOutlined from "@mui/icons-material/HideSourceOutlined" +import KeyOutlined from "@mui/icons-material/KeyOutlined" +import GitHub from "@mui/icons-material/GitHub" +import PasswordOutlined from "@mui/icons-material/PasswordOutlined" +import relativeTime from "dayjs/plugin/relativeTime" +import ShieldOutlined from "@mui/icons-material/ShieldOutlined" + +dayjs.extend(relativeTime) const isOwnerRole = (role: TypesGen.Role): boolean => { return role.name === "owner" @@ -31,6 +40,7 @@ const sortRoles = (roles: TypesGen.Role[]) => { interface UsersTableBodyProps { users?: TypesGen.User[] + authMethods?: TypesGen.AuthMethods roles?: TypesGen.AssignableRoles[] isUpdatingUserRoles?: boolean canEditUsers?: boolean @@ -58,6 +68,7 @@ export const UsersTableBody: FC< React.PropsWithChildren > = ({ users, + authMethods, roles, onSuspendUser, onDeleteUser, @@ -80,7 +91,7 @@ export const UsersTableBody: FC< return ( - + @@ -156,7 +167,10 @@ export const UsersTableBody: FC< -
{user.login_type}
+
- {user.status} - - - + {user.status} + + {canEditUsers && ( { + let displayName = value as string + let icon = <> + const iconStyles: SxProps = { width: 14, height: 14 } + + if (value === "password") { + displayName = "Password" + icon = + } else if (value === "none") { + displayName = "None" + icon = + } else if (value === "github") { + displayName = "GitHub" + icon = + } else if (value === "token") { + displayName = "Token" + icon = + } else if (value === "oidc") { + displayName = + authMethods.oidc.signInText === "" ? "OIDC" : authMethods.oidc.signInText + icon = + authMethods.oidc.iconUrl === "" ? ( + + ) : ( + + ) + } + + return ( + + {icon} + {displayName} + + ) +} + +const LastSeen = ({ value, ...boxProps }: { value: string } & BoxProps) => { + const theme: Theme = useTheme() + const t = dayjs(value) + const now = dayjs() + + let message = t.fromNow() + let color = theme.palette.text.secondary + + if (t.isAfter(now.subtract(1, "hour"))) { + color = theme.palette.success.light + // Since the agent reports on a 10m interval, + // the last_used_at can be inaccurate when recent. + message = "Now" + } else if (t.isAfter(now.subtract(3, "day"))) { + color = theme.palette.text.secondary + } else if (t.isAfter(now.subtract(1, "month"))) { + color = theme.palette.warning.light + } else if (t.isAfter(now.subtract(100, "year"))) { + color = theme.palette.error.light + } else { + message = "Never" + } + + return ( + + {message} + + ) +} + const useStyles = makeStyles((theme) => ({ status: { textTransform: "capitalize", diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index ad3ccb25badb2..241f150650cef 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -20,6 +20,8 @@ import { useStatusFilterMenu } from "./UsersFilter" import { useFilter } from "components/Filter/filter" import { useDashboard } from "components/Dashboard/DashboardProvider" import { deploymentConfigMachine } from "xServices/deploymentConfig/deploymentConfigMachine" +import { useQuery } from "@tanstack/react-query" +import { getAuthMethods } from "api/api" export const Language = { suspendDialogTitle: "Suspend user", @@ -78,16 +80,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { const oidcRoleSyncEnabled = viewDeploymentValues && deploymentValues?.config.oidc?.user_role_field !== "" - - // Is loading if - // - users are loading or - // - the user can edit the users but the roles are loading - const isLoading = - usersState.matches("gettingUsers") || - (canEditUsers && rolesState.matches("gettingRoles")) - const me = useMe() - const useFilterResult = useFilter({ searchParamsResult, onUpdate: () => { @@ -105,6 +98,19 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { status: option?.value, }), }) + const authMethods = useQuery({ + queryKey: ["authMethods"], + queryFn: () => { + return getAuthMethods() + }, + }) + // Is loading if + // - users are loading or + // - the user can edit the users but the roles are loading + const isLoading = + usersState.matches("gettingUsers") || + (canEditUsers && rolesState.matches("gettingRoles")) || + authMethods.isLoading return ( <> @@ -115,6 +121,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { oidcRoleSyncEnabled={oidcRoleSyncEnabled} roles={roles} users={users} + authMethods={authMethods.data} count={count} onListWorkspaces={(user) => { navigate( diff --git a/site/src/pages/UsersPage/UsersPageView.stories.tsx b/site/src/pages/UsersPage/UsersPageView.stories.tsx index 1b860d2d4e29f..a08a758ad3dcd 100644 --- a/site/src/pages/UsersPage/UsersPageView.stories.tsx +++ b/site/src/pages/UsersPage/UsersPageView.stories.tsx @@ -5,6 +5,7 @@ import { MockUser2, MockAssignableSiteRoles, mockApiError, + MockAuthMethods, } from "testHelpers/entities" import { UsersPageView } from "./UsersPageView" import { ComponentProps } from "react" @@ -33,6 +34,7 @@ const meta: Meta = { count: 2, canEditUsers: true, filterProps: defaultFilterProps, + authMethods: MockAuthMethods, }, } diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index f11224eb609f8..13ba8ef9c4047 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -22,6 +22,7 @@ export interface UsersPageViewProps { oidcRoleSyncEnabled: boolean canViewActivity?: boolean isLoading?: boolean + authMethods?: TypesGen.AuthMethods onSuspendUser: (user: TypesGen.User) => void onDeleteUser: (user: TypesGen.User) => void onListWorkspaces: (user: TypesGen.User) => void @@ -58,6 +59,7 @@ export const UsersPageView: FC> = ({ paginationRef, isNonInitialPage, actorID, + authMethods, }) => { return ( <> @@ -89,6 +91,7 @@ export const UsersPageView: FC> = ({ isLoading={isLoading} isNonInitialPage={isNonInitialPage} actorID={actorID} + authMethods={authMethods} /> diff --git a/site/src/components/LastUsed/LastUsed.tsx b/site/src/pages/WorkspacesPage/LastUsed.tsx similarity index 100% rename from site/src/components/LastUsed/LastUsed.tsx rename to site/src/pages/WorkspacesPage/LastUsed.tsx diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 1e15f4c362d4f..9ebffb5cfc6da 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -27,7 +27,7 @@ import Box from "@mui/material/Box" import { AvatarData } from "components/AvatarData/AvatarData" import { Avatar } from "components/Avatar/Avatar" import { Stack } from "components/Stack/Stack" -import { LastUsed } from "components/LastUsed/LastUsed" +import { LastUsed } from "pages/WorkspacesPage/LastUsed" import { WorkspaceOutdatedTooltip } from "components/Tooltips" import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge" import { getDisplayWorkspaceTemplateName } from "utils/workspace" From d9d4d74f99fd20854dc88f55476c53cfc6fde05a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 25 Aug 2023 14:34:07 -0500 Subject: [PATCH 253/277] test: add full OIDC fake IDP (#9317) * test: implement fake OIDC provider with full functionality * Refactor existing tests --- coderd/coderdtest/coderdtest.go | 166 +----- coderd/coderdtest/oidctest/helper.go | 103 ++++ coderd/coderdtest/oidctest/idp.go | 793 +++++++++++++++++++++++++ coderd/coderdtest/oidctest/idp_test.go | 72 +++ coderd/oauthpki/oidcpki.go | 5 +- coderd/oauthpki/okidcpki_test.go | 59 +- coderd/userauth_test.go | 316 +++++----- coderd/users_test.go | 28 +- coderd/util/syncmap/map.go | 77 +++ enterprise/coderd/userauth_test.go | 603 +++++++++++-------- 10 files changed, 1596 insertions(+), 626 deletions(-) create mode 100644 coderd/coderdtest/oidctest/helper.go create mode 100644 coderd/coderdtest/oidctest/idp.go create mode 100644 coderd/coderdtest/oidctest/idp_test.go create mode 100644 coderd/util/syncmap/map.go diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 18062a549a8bd..08979a67a8c45 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -31,15 +31,13 @@ import ( "time" "cloud.google.com/go/compute/metadata" - "github.com/coreos/go-oidc/v3/oidc" "github.com/fullsailor/pkcs7" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" "github.com/moby/moby/pkg/namesgenerator" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/oauth2" "golang.org/x/xerrors" "google.golang.org/api/idtoken" "google.golang.org/api/option" @@ -1020,152 +1018,6 @@ func NewAWSInstanceIdentity(t *testing.T, instanceID string) (awsidentity.Certif } } -type OIDCConfig struct { - key *rsa.PrivateKey - issuer string - // These are optional - refreshToken string - oidcTokenExpires func() time.Time - tokenSource func() (*oauth2.Token, error) -} - -func WithRefreshToken(token string) func(cfg *OIDCConfig) { - return func(cfg *OIDCConfig) { - cfg.refreshToken = token - } -} - -func WithTokenExpires(expFunc func() time.Time) func(cfg *OIDCConfig) { - return func(cfg *OIDCConfig) { - cfg.oidcTokenExpires = expFunc - } -} - -func WithTokenSource(src func() (*oauth2.Token, error)) func(cfg *OIDCConfig) { - return func(cfg *OIDCConfig) { - cfg.tokenSource = src - } -} - -func NewOIDCConfig(t *testing.T, issuer string, opts ...func(cfg *OIDCConfig)) *OIDCConfig { - t.Helper() - - block, _ := pem.Decode([]byte(testRSAPrivateKey)) - pkey, err := x509.ParsePKCS1PrivateKey(block.Bytes) - require.NoError(t, err) - - if issuer == "" { - issuer = "https://coder.com" - } - - cfg := &OIDCConfig{ - key: pkey, - issuer: issuer, - } - for _, opt := range opts { - opt(cfg) - } - return cfg -} - -func (*OIDCConfig) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string { - return "/?state=" + url.QueryEscape(state) -} - -type tokenSource struct { - src func() (*oauth2.Token, error) -} - -func (s tokenSource) Token() (*oauth2.Token, error) { - return s.src() -} - -func (cfg *OIDCConfig) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { - if cfg.tokenSource == nil { - return nil - } - return tokenSource{ - src: cfg.tokenSource, - } -} - -func (cfg *OIDCConfig) Exchange(_ context.Context, code string, _ ...oauth2.AuthCodeOption) (*oauth2.Token, error) { - token, err := base64.StdEncoding.DecodeString(code) - if err != nil { - return nil, xerrors.Errorf("decode code: %w", err) - } - - var exp time.Time - if cfg.oidcTokenExpires != nil { - exp = cfg.oidcTokenExpires() - } - - return (&oauth2.Token{ - AccessToken: "token", - RefreshToken: cfg.refreshToken, - Expiry: exp, - }).WithExtra(map[string]interface{}{ - "id_token": string(token), - }), nil -} - -func (cfg *OIDCConfig) EncodeClaims(t *testing.T, claims jwt.MapClaims) string { - t.Helper() - - if _, ok := claims["exp"]; !ok { - claims["exp"] = time.Now().Add(time.Hour).UnixMilli() - } - - if _, ok := claims["iss"]; !ok { - claims["iss"] = cfg.issuer - } - - if _, ok := claims["sub"]; !ok { - claims["sub"] = "testme" - } - - signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(cfg.key) - require.NoError(t, err) - - return base64.StdEncoding.EncodeToString([]byte(signed)) -} - -func (cfg *OIDCConfig) OIDCConfig(t *testing.T, userInfoClaims jwt.MapClaims, opts ...func(cfg *coderd.OIDCConfig)) *coderd.OIDCConfig { - // By default, the provider can be empty. - // This means it won't support any endpoints! - provider := &oidc.Provider{} - if userInfoClaims != nil { - resp, err := json.Marshal(userInfoClaims) - require.NoError(t, err) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(resp) - })) - t.Cleanup(srv.Close) - cfg := &oidc.ProviderConfig{ - UserInfoURL: srv.URL, - } - provider = cfg.NewProvider(context.Background()) - } - newCFG := &coderd.OIDCConfig{ - OAuth2Config: cfg, - Verifier: oidc.NewVerifier(cfg.issuer, &oidc.StaticKeySet{ - PublicKeys: []crypto.PublicKey{cfg.key.Public()}, - }, &oidc.Config{ - SkipClientIDCheck: true, - }), - Provider: provider, - UsernameField: "preferred_username", - EmailField: "email", - AuthURLParams: map[string]string{"access_type": "offline"}, - GroupField: "groups", - } - for _, opt := range opts { - opt(newCFG) - } - return newCFG -} - // NewAzureInstanceIdentity returns a metadata client and ID token validator for faking // instance authentication for Azure. func NewAzureInstanceIdentity(t *testing.T, instanceID string) (x509.VerifyOptions, *http.Client) { @@ -1254,22 +1106,6 @@ func SDKError(t *testing.T, err error) *codersdk.Error { return cerr } -const testRSAPrivateKey = `-----BEGIN RSA PRIVATE KEY----- -MIICXQIBAAKBgQDLets8+7M+iAQAqN/5BVyCIjhTQ4cmXulL+gm3v0oGMWzLupUS -v8KPA+Tp7dgC/DZPfMLaNH1obBBhJ9DhS6RdS3AS3kzeFrdu8zFHLWF53DUBhS92 -5dCAEuJpDnNizdEhxTfoHrhuCmz8l2nt1pe5eUK2XWgd08Uc93h5ij098wIDAQAB -AoGAHLaZeWGLSaen6O/rqxg2laZ+jEFbMO7zvOTruiIkL/uJfrY1kw+8RLIn+1q0 -wLcWcuEIHgKKL9IP/aXAtAoYh1FBvRPLkovF1NZB0Je/+CSGka6wvc3TGdvppZJe -rKNcUvuOYLxkmLy4g9zuY5qrxFyhtIn2qZzXEtLaVOHzPQECQQDvN0mSajpU7dTB -w4jwx7IRXGSSx65c+AsHSc1Rj++9qtPC6WsFgAfFN2CEmqhMbEUVGPv/aPjdyWk9 -pyLE9xR/AkEA2cGwyIunijE5v2rlZAD7C4vRgdcMyCf3uuPcgzFtsR6ZhyQSgLZ8 -YRPuvwm4cdPJMmO3YwBfxT6XGuSc2k8MjQJBAI0+b8prvpV2+DCQa8L/pjxp+VhR -Xrq2GozrHrgR7NRokTB88hwFRJFF6U9iogy9wOx8HA7qxEbwLZuhm/4AhbECQC2a -d8h4Ht09E+f3nhTEc87mODkl7WJZpHL6V2sORfeq/eIkds+H6CJ4hy5w/bSw8tjf -sz9Di8sGIaUbLZI2rd0CQQCzlVwEtRtoNCyMJTTrkgUuNufLP19RZ5FpyXxBO5/u -QastnN77KfUwdj3SJt44U/uh1jAIv4oSLBr8HYUkbnI8 ------END RSA PRIVATE KEY-----` - func DeploymentValues(t testing.TB) *codersdk.DeploymentValues { var cfg codersdk.DeploymentValues opts := cfg.Options() diff --git a/coderd/coderdtest/oidctest/helper.go b/coderd/coderdtest/oidctest/helper.go new file mode 100644 index 0000000000000..11d9114be2ce8 --- /dev/null +++ b/coderd/coderdtest/oidctest/helper.go @@ -0,0 +1,103 @@ +package oidctest + +import ( + "net/http" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +// LoginHelper helps with logging in a user and refreshing their oauth tokens. +// It is mainly because refreshing oauth tokens is a bit tricky and requires +// some database manipulation. +type LoginHelper struct { + fake *FakeIDP + client *codersdk.Client +} + +func NewLoginHelper(client *codersdk.Client, fake *FakeIDP) *LoginHelper { + if client == nil { + panic("client must not be nil") + } + if fake == nil { + panic("fake must not be nil") + } + return &LoginHelper{ + fake: fake, + client: client, + } +} + +// Login just helps by making an unauthenticated client and logging in with +// the given claims. All Logins should be unauthenticated, so this is a +// convenience method. +func (h *LoginHelper) Login(t *testing.T, idTokenClaims jwt.MapClaims) (*codersdk.Client, *http.Response) { + t.Helper() + unauthenticatedClient := codersdk.New(h.client.URL) + + return h.fake.Login(t, unauthenticatedClient, idTokenClaims) +} + +// ExpireOauthToken expires the oauth token for the given user. +func (*LoginHelper) ExpireOauthToken(t *testing.T, db database.Store, user *codersdk.Client) database.UserLink { + t.Helper() + + //nolint:gocritic // Testing + ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitMedium)) + + id, _, err := httpmw.SplitAPIToken(user.SessionToken()) + require.NoError(t, err) + + // We need to get the OIDC link and update it in the database to force + // it to be expired. + key, err := db.GetAPIKeyByID(ctx, id) + require.NoError(t, err, "get api key") + + link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: key.UserID, + LoginType: database.LoginTypeOIDC, + }) + require.NoError(t, err, "get user link") + + // Expire the oauth link for the given user. + updated, err := db.UpdateUserLink(ctx, database.UpdateUserLinkParams{ + OAuthAccessToken: link.OAuthAccessToken, + OAuthRefreshToken: link.OAuthRefreshToken, + OAuthExpiry: time.Now().Add(time.Hour * -1), + UserID: link.UserID, + LoginType: link.LoginType, + }) + require.NoError(t, err, "expire user link") + + return updated +} + +// ForceRefresh forces the client to refresh its oauth token. It does this by +// expiring the oauth token, then doing an authenticated call. This will force +// the API Key middleware to refresh the oauth token. +// +// A unit test assertion makes sure the refresh token is used. +func (h *LoginHelper) ForceRefresh(t *testing.T, db database.Store, user *codersdk.Client, idToken jwt.MapClaims) { + t.Helper() + + link := h.ExpireOauthToken(t, db, user) + // Updates the claims that the IDP will return. By default, it always + // uses the original claims for the original oauth token. + h.fake.UpdateRefreshClaims(link.OAuthRefreshToken, idToken) + + t.Cleanup(func() { + require.True(t, h.fake.RefreshUsed(link.OAuthRefreshToken), "refresh token must be used, but has not. Did you forget to call the returned function from this call?") + }) + + // Do any authenticated call to force the refresh + _, err := user.User(testutil.Context(t, testutil.WaitShort), "me") + require.NoError(t, err, "user must be able to be fetched") +} diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go new file mode 100644 index 0000000000000..912d9acd7c221 --- /dev/null +++ b/coderd/coderdtest/oidctest/idp.go @@ -0,0 +1,793 @@ +package oidctest + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/coder/coder/v2/coderd/util/syncmap" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-chi/chi/v5" + "github.com/go-jose/go-jose/v3" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/codersdk" +) + +// FakeIDP is a functional OIDC provider. +// It only supports 1 OIDC client. +type FakeIDP struct { + issuer string + key *rsa.PrivateKey + provider providerJSON + handler http.Handler + cfg *oauth2.Config + + // clientID to be used by coderd + clientID string + clientSecret string + logger slog.Logger + + // These maps are used to control the state of the IDP. + // That is the various access tokens, refresh tokens, states, etc. + codeToStateMap *syncmap.Map[string, string] + // Token -> Email + accessTokens *syncmap.Map[string, string] + // Refresh Token -> Email + refreshTokensUsed *syncmap.Map[string, bool] + refreshTokens *syncmap.Map[string, string] + stateToIDTokenClaims *syncmap.Map[string, jwt.MapClaims] + refreshIDTokenClaims *syncmap.Map[string, jwt.MapClaims] + + // hooks + // hookValidRedirectURL can be used to reject a redirect url from the + // IDP -> Application. Almost all IDPs have the concept of + // "Authorized Redirect URLs". This can be used to emulate that. + hookValidRedirectURL func(redirectURL string) error + hookUserInfo func(email string) jwt.MapClaims + fakeCoderd func(req *http.Request) (*http.Response, error) + hookOnRefresh func(email string) error + // Custom authentication for the client. This is useful if you want + // to test something like PKI auth vs a client_secret. + hookAuthenticateClient func(t testing.TB, req *http.Request) (url.Values, error) + serve bool +} + +type FakeIDPOpt func(idp *FakeIDP) + +func WithAuthorizedRedirectURL(hook func(redirectURL string) error) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookValidRedirectURL = hook + } +} + +// WithRefreshHook is called when a refresh token is used. The email is +// the email of the user that is being refreshed assuming the claims are correct. +func WithRefreshHook(hook func(email string) error) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookOnRefresh = hook + } +} + +func WithCustomClientAuth(hook func(t testing.TB, req *http.Request) (url.Values, error)) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookAuthenticateClient = hook + } +} + +// WithLogging is optional, but will log some HTTP calls made to the IDP. +func WithLogging(t testing.TB, options *slogtest.Options) func(*FakeIDP) { + return func(f *FakeIDP) { + f.logger = slogtest.Make(t, options) + } +} + +// WithStaticUserInfo is optional, but will return the same user info for +// every user on the /userinfo endpoint. +func WithStaticUserInfo(info jwt.MapClaims) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookUserInfo = func(_ string) jwt.MapClaims { + return info + } + } +} + +func WithDynamicUserInfo(userInfoFunc func(email string) jwt.MapClaims) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookUserInfo = userInfoFunc + } +} + +// WithServing makes the IDP run an actual http server. +func WithServing() func(*FakeIDP) { + return func(f *FakeIDP) { + f.serve = true + } +} + +func WithIssuer(issuer string) func(*FakeIDP) { + return func(f *FakeIDP) { + f.issuer = issuer + } +} + +const ( + // nolint:gosec // It thinks this is a secret lol + tokenPath = "/oauth2/token" + authorizePath = "/oauth2/authorize" + keysPath = "/oauth2/keys" + userInfoPath = "/oauth2/userinfo" +) + +func NewFakeIDP(t testing.TB, opts ...FakeIDPOpt) *FakeIDP { + t.Helper() + + block, _ := pem.Decode([]byte(testRSAPrivateKey)) + pkey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + require.NoError(t, err) + + idp := &FakeIDP{ + key: pkey, + clientID: uuid.NewString(), + clientSecret: uuid.NewString(), + logger: slog.Make(), + codeToStateMap: syncmap.New[string, string](), + accessTokens: syncmap.New[string, string](), + refreshTokens: syncmap.New[string, string](), + refreshTokensUsed: syncmap.New[string, bool](), + stateToIDTokenClaims: syncmap.New[string, jwt.MapClaims](), + refreshIDTokenClaims: syncmap.New[string, jwt.MapClaims](), + hookOnRefresh: func(_ string) error { return nil }, + hookUserInfo: func(email string) jwt.MapClaims { return jwt.MapClaims{} }, + hookValidRedirectURL: func(redirectURL string) error { return nil }, + } + + for _, opt := range opts { + opt(idp) + } + + if idp.issuer == "" { + idp.issuer = "https://coder.com" + } + + idp.handler = idp.httpHandler(t) + idp.updateIssuerURL(t, idp.issuer) + if idp.serve { + idp.realServer(t) + } + + return idp +} + +func (f *FakeIDP) updateIssuerURL(t testing.TB, issuer string) { + t.Helper() + + u, err := url.Parse(issuer) + require.NoError(t, err, "invalid issuer URL") + + f.issuer = issuer + // providerJSON is the JSON representation of the OpenID Connect provider + // These are all the urls that the IDP will respond to. + f.provider = providerJSON{ + Issuer: issuer, + AuthURL: u.ResolveReference(&url.URL{Path: authorizePath}).String(), + TokenURL: u.ResolveReference(&url.URL{Path: tokenPath}).String(), + JWKSURL: u.ResolveReference(&url.URL{Path: keysPath}).String(), + UserInfoURL: u.ResolveReference(&url.URL{Path: userInfoPath}).String(), + Algorithms: []string{ + "RS256", + }, + } +} + +// realServer turns the FakeIDP into a real http server. +func (f *FakeIDP) realServer(t testing.TB) *httptest.Server { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + srv := httptest.NewUnstartedServer(f.handler) + srv.Config.BaseContext = func(_ net.Listener) context.Context { + return ctx + } + srv.Start() + t.Cleanup(srv.CloseClientConnections) + t.Cleanup(srv.Close) + t.Cleanup(cancel) + + f.updateIssuerURL(t, srv.URL) + return srv +} + +// Login does the full OIDC flow starting at the "LoginButton". +// The client argument is just to get the URL of the Coder instance. +// +// The client passed in is just to get the url of the Coder instance. +// The actual client that is used is 100% unauthenticated and fresh. +func (f *FakeIDP) Login(t testing.TB, client *codersdk.Client, idTokenClaims jwt.MapClaims, opts ...func(r *http.Request)) (*codersdk.Client, *http.Response) { + t.Helper() + + client, resp := f.AttemptLogin(t, client, idTokenClaims, opts...) + require.Equal(t, http.StatusOK, resp.StatusCode, "client failed to login") + return client, resp +} + +func (f *FakeIDP) AttemptLogin(t testing.TB, client *codersdk.Client, idTokenClaims jwt.MapClaims, opts ...func(r *http.Request)) (*codersdk.Client, *http.Response) { + t.Helper() + var err error + + cli := f.HTTPClient(client.HTTPClient) + shallowCpyCli := *cli + + if shallowCpyCli.Jar == nil { + shallowCpyCli.Jar, err = cookiejar.New(nil) + require.NoError(t, err, "failed to create cookie jar") + } + + unauthenticated := codersdk.New(client.URL) + unauthenticated.HTTPClient = &shallowCpyCli + + return f.LoginWithClient(t, unauthenticated, idTokenClaims, opts...) +} + +// LoginWithClient reuses the context of the passed in client. This means the same +// cookies will be used. This should be an unauthenticated client in most cases. +// +// This is a niche case, but it is needed for testing ConvertLoginType. +func (f *FakeIDP) LoginWithClient(t testing.TB, client *codersdk.Client, idTokenClaims jwt.MapClaims, opts ...func(r *http.Request)) (*codersdk.Client, *http.Response) { + t.Helper() + + coderOauthURL, err := client.URL.Parse("/api/v2/users/oidc/callback") + require.NoError(t, err) + f.SetRedirect(t, coderOauthURL.String()) + + cli := f.HTTPClient(client.HTTPClient) + cli.CheckRedirect = func(req *http.Request, via []*http.Request) error { + // Store the idTokenClaims to the specific state request. This ties + // the claims 1:1 with a given authentication flow. + state := req.URL.Query().Get("state") + f.stateToIDTokenClaims.Store(state, idTokenClaims) + return nil + } + + req, err := http.NewRequestWithContext(context.Background(), "GET", coderOauthURL.String(), nil) + require.NoError(t, err) + if cli.Jar == nil { + cli.Jar, err = cookiejar.New(nil) + require.NoError(t, err, "failed to create cookie jar") + } + + for _, opt := range opts { + opt(req) + } + + res, err := cli.Do(req) + require.NoError(t, err) + + // If the coder session token exists, return the new authed client! + var user *codersdk.Client + cookies := cli.Jar.Cookies(client.URL) + for _, cookie := range cookies { + if cookie.Name == codersdk.SessionTokenCookie { + user = codersdk.New(client.URL) + user.SetSessionToken(cookie.Value) + } + } + + t.Cleanup(func() { + if res.Body != nil { + _ = res.Body.Close() + } + }) + + return user, res +} + +// OIDCCallback will emulate the IDP redirecting back to the Coder callback. +// This is helpful if no Coderd exists because the IDP needs to redirect to +// something. +// Essentially this is used to fake the Coderd side of the exchange. +// The flow starts at the user hitting the OIDC login page. +func (f *FakeIDP) OIDCCallback(t testing.TB, state string, idTokenClaims jwt.MapClaims) (*http.Response, error) { + t.Helper() + if f.serve { + panic("cannot use OIDCCallback with WithServing. This is only for the in memory usage") + } + + f.stateToIDTokenClaims.Store(state, idTokenClaims) + + cli := f.HTTPClient(nil) + u := f.cfg.AuthCodeURL(state) + req, err := http.NewRequest("GET", u, nil) + require.NoError(t, err) + + resp, err := cli.Do(req.WithContext(context.Background())) + require.NoError(t, err) + + t.Cleanup(func() { + if resp.Body != nil { + _ = resp.Body.Close() + } + }) + return resp, nil +} + +type providerJSON struct { + Issuer string `json:"issuer"` + AuthURL string `json:"authorization_endpoint"` + TokenURL string `json:"token_endpoint"` + JWKSURL string `json:"jwks_uri"` + UserInfoURL string `json:"userinfo_endpoint"` + Algorithms []string `json:"id_token_signing_alg_values_supported"` +} + +// newCode enforces the code exchanged is actually a valid code +// created by the IDP. +func (f *FakeIDP) newCode(state string) string { + code := uuid.NewString() + f.codeToStateMap.Store(code, state) + return code +} + +// newToken enforces the access token exchanged is actually a valid access token +// created by the IDP. +func (f *FakeIDP) newToken(email string) string { + accessToken := uuid.NewString() + f.accessTokens.Store(accessToken, email) + return accessToken +} + +func (f *FakeIDP) newRefreshTokens(email string) string { + refreshToken := uuid.NewString() + f.refreshTokens.Store(refreshToken, email) + return refreshToken +} + +// authenticateBearerTokenRequest enforces the access token is valid. +func (f *FakeIDP) authenticateBearerTokenRequest(t testing.TB, req *http.Request) (string, error) { + t.Helper() + + auth := req.Header.Get("Authorization") + token := strings.TrimPrefix(auth, "Bearer ") + _, ok := f.accessTokens.Load(token) + if !ok { + return "", xerrors.New("invalid access token") + } + return token, nil +} + +// authenticateOIDCClientRequest enforces the client_id and client_secret are valid. +func (f *FakeIDP) authenticateOIDCClientRequest(t testing.TB, req *http.Request) (url.Values, error) { + t.Helper() + + if f.hookAuthenticateClient != nil { + return f.hookAuthenticateClient(t, req) + } + + data, err := io.ReadAll(req.Body) + if !assert.NoError(t, err, "read token request body") { + return nil, xerrors.Errorf("authenticate request, read body: %w", err) + } + values, err := url.ParseQuery(string(data)) + if !assert.NoError(t, err, "parse token request values") { + return nil, xerrors.New("invalid token request") + } + + if !assert.Equal(t, f.clientID, values.Get("client_id"), "client_id mismatch") { + return nil, xerrors.New("client_id mismatch") + } + + if !assert.Equal(t, f.clientSecret, values.Get("client_secret"), "client_secret mismatch") { + return nil, xerrors.New("client_secret mismatch") + } + + return values, nil +} + +// encodeClaims is a helper func to convert claims to a valid JWT. +func (f *FakeIDP) encodeClaims(t testing.TB, claims jwt.MapClaims) string { + t.Helper() + + if _, ok := claims["exp"]; !ok { + claims["exp"] = time.Now().Add(time.Hour).UnixMilli() + } + + if _, ok := claims["aud"]; !ok { + claims["aud"] = f.clientID + } + + if _, ok := claims["iss"]; !ok { + claims["iss"] = f.issuer + } + + signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(f.key) + require.NoError(t, err) + + return signed +} + +// httpHandler is the IDP http server. +func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { + t.Helper() + + mux := chi.NewMux() + // This endpoint is required to initialize the OIDC provider. + // It is used to get the OIDC configuration. + mux.Get("/.well-known/openid-configuration", func(rw http.ResponseWriter, r *http.Request) { + f.logger.Info(r.Context(), "http OIDC config", slog.F("url", r.URL.String())) + + _ = json.NewEncoder(rw).Encode(f.provider) + }) + + // Authorize is called when the user is redirected to the IDP to login. + // This is the browser hitting the IDP and the user logging into Google or + // w/e and clicking "Allow". They will be redirected back to the redirect + // when this is done. + mux.Handle(authorizePath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + f.logger.Info(r.Context(), "http call authorize", slog.F("url", r.URL.String())) + + clientID := r.URL.Query().Get("client_id") + if !assert.Equal(t, f.clientID, clientID, "unexpected client_id") { + http.Error(rw, "invalid client_id", http.StatusBadRequest) + return + } + + redirectURI := r.URL.Query().Get("redirect_uri") + state := r.URL.Query().Get("state") + + scope := r.URL.Query().Get("scope") + assert.NotEmpty(t, scope, "scope is empty") + + responseType := r.URL.Query().Get("response_type") + switch responseType { + case "code": + case "token": + t.Errorf("response_type %q not supported", responseType) + http.Error(rw, "invalid response_type", http.StatusBadRequest) + return + default: + t.Errorf("unexpected response_type %q", responseType) + http.Error(rw, "invalid response_type", http.StatusBadRequest) + return + } + + err := f.hookValidRedirectURL(redirectURI) + if err != nil { + t.Errorf("not authorized redirect_uri by custom hook %q: %s", redirectURI, err.Error()) + http.Error(rw, fmt.Sprintf("invalid redirect_uri: %s", err.Error()), http.StatusBadRequest) + return + } + + ru, err := url.Parse(redirectURI) + if err != nil { + t.Errorf("invalid redirect_uri %q: %s", redirectURI, err.Error()) + http.Error(rw, fmt.Sprintf("invalid redirect_uri: %s", err.Error()), http.StatusBadRequest) + return + } + + q := ru.Query() + q.Set("state", state) + q.Set("code", f.newCode(state)) + ru.RawQuery = q.Encode() + + http.Redirect(rw, r, ru.String(), http.StatusTemporaryRedirect) + })) + + mux.Handle(tokenPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + values, err := f.authenticateOIDCClientRequest(t, r) + f.logger.Info(r.Context(), "http idp call token", + slog.Error(err), + slog.F("values", values.Encode()), + ) + if err != nil { + http.Error(rw, fmt.Sprintf("invalid token request: %s", err.Error()), http.StatusBadRequest) + return + } + getEmail := func(claims jwt.MapClaims) string { + email, ok := claims["email"] + if !ok { + return "unknown" + } + emailStr, ok := email.(string) + if !ok { + return "wrong-type" + } + return emailStr + } + + var claims jwt.MapClaims + switch values.Get("grant_type") { + case "authorization_code": + code := values.Get("code") + if !assert.NotEmpty(t, code, "code is empty") { + http.Error(rw, "invalid code", http.StatusBadRequest) + return + } + stateStr, ok := f.codeToStateMap.Load(code) + if !assert.True(t, ok, "invalid code") { + http.Error(rw, "invalid code", http.StatusBadRequest) + return + } + // Always invalidate the code after it is used. + f.codeToStateMap.Delete(code) + + idTokenClaims, ok := f.stateToIDTokenClaims.Load(stateStr) + if !ok { + t.Errorf("missing id token claims") + http.Error(rw, "missing id token claims", http.StatusBadRequest) + return + } + claims = idTokenClaims + case "refresh_token": + refreshToken := values.Get("refresh_token") + if !assert.NotEmpty(t, refreshToken, "refresh_token is empty") { + http.Error(rw, "invalid refresh_token", http.StatusBadRequest) + return + } + + _, ok := f.refreshTokens.Load(refreshToken) + if !assert.True(t, ok, "invalid refresh_token") { + http.Error(rw, "invalid refresh_token", http.StatusBadRequest) + return + } + + idTokenClaims, ok := f.refreshIDTokenClaims.Load(refreshToken) + if !ok { + t.Errorf("missing id token claims in refresh") + http.Error(rw, "missing id token claims in refresh", http.StatusBadRequest) + return + } + + claims = idTokenClaims + err := f.hookOnRefresh(getEmail(claims)) + if err != nil { + http.Error(rw, fmt.Sprintf("refresh hook blocked refresh: %s", err.Error()), http.StatusBadRequest) + return + } + + f.refreshTokensUsed.Store(refreshToken, true) + // Always invalidate the refresh token after it is used. + f.refreshTokens.Delete(refreshToken) + default: + t.Errorf("unexpected grant_type %q", values.Get("grant_type")) + http.Error(rw, "invalid grant_type", http.StatusBadRequest) + return + } + + exp := time.Now().Add(time.Minute * 5) + claims["exp"] = exp.UnixMilli() + email := getEmail(claims) + refreshToken := f.newRefreshTokens(email) + token := map[string]interface{}{ + "access_token": f.newToken(email), + "refresh_token": refreshToken, + "token_type": "Bearer", + "expires_in": int64((time.Minute * 5).Seconds()), + "id_token": f.encodeClaims(t, claims), + } + // Store the claims for the next refresh + f.refreshIDTokenClaims.Store(refreshToken, claims) + + rw.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(rw).Encode(token) + })) + + mux.Handle(userInfoPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + token, err := f.authenticateBearerTokenRequest(t, r) + f.logger.Info(r.Context(), "http call idp user info", + slog.Error(err), + slog.F("url", r.URL.String()), + ) + if err != nil { + http.Error(rw, fmt.Sprintf("invalid user info request: %s", err.Error()), http.StatusBadRequest) + return + } + + email, ok := f.accessTokens.Load(token) + if !ok { + t.Errorf("access token user for user_info has no email to indicate which user") + http.Error(rw, "invalid access token, missing user info", http.StatusBadRequest) + return + } + _ = json.NewEncoder(rw).Encode(f.hookUserInfo(email)) + })) + + mux.Handle(keysPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + f.logger.Info(r.Context(), "http call idp /keys") + set := jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{ + { + Key: f.key.Public(), + KeyID: "test-key", + Algorithm: "RSA", + }, + }, + } + _ = json.NewEncoder(rw).Encode(set) + })) + + mux.NotFound(func(rw http.ResponseWriter, r *http.Request) { + f.logger.Error(r.Context(), "http call not found", slog.F("path", r.URL.Path)) + t.Errorf("unexpected request to IDP at path %q. Not supported", r.URL.Path) + }) + + return mux +} + +// HTTPClient does nothing if IsServing is used. +// +// If IsServing is not used, then it will return a client that will make requests +// to the IDP all in memory. If a request is not to the IDP, then the passed in +// client will be used. If no client is passed in, then any regular network +// requests will fail. +func (f *FakeIDP) HTTPClient(rest *http.Client) *http.Client { + if f.serve { + if rest == nil || rest.Transport == nil { + return &http.Client{} + } + return rest + } + + var jar http.CookieJar + if rest != nil { + jar = rest.Jar + } + return &http.Client{ + Jar: jar, + Transport: fakeRoundTripper{ + roundTrip: func(req *http.Request) (*http.Response, error) { + u, _ := url.Parse(f.issuer) + if req.URL.Host != u.Host { + if f.fakeCoderd != nil { + return f.fakeCoderd(req) + } + if rest == nil || rest.Transport == nil { + return nil, fmt.Errorf("unexpected network request to %q", req.URL.Host) + } + return rest.Transport.RoundTrip(req) + } + resp := httptest.NewRecorder() + f.handler.ServeHTTP(resp, req) + return resp.Result(), nil + }, + }, + } +} + +// RefreshUsed returns if the refresh token has been used. All refresh tokens +// can only be used once, then they are deleted. +func (f *FakeIDP) RefreshUsed(refreshToken string) bool { + used, _ := f.refreshTokensUsed.Load(refreshToken) + return used +} + +// UpdateRefreshClaims allows the caller to change what claims are returned +// for a given refresh token. By default, all refreshes use the same claims as +// the original IDToken issuance. +func (f *FakeIDP) UpdateRefreshClaims(refreshToken string, claims jwt.MapClaims) { + f.refreshIDTokenClaims.Store(refreshToken, claims) +} + +// SetRedirect is required for the IDP to know where to redirect and call +// Coderd. +func (f *FakeIDP) SetRedirect(t testing.TB, u string) { + t.Helper() + + f.cfg.RedirectURL = u +} + +// SetCoderdCallback is optional and only works if not using the IsServing. +// It will setup a fake "Coderd" for the IDP to call when the IDP redirects +// back after authenticating. +func (f *FakeIDP) SetCoderdCallback(callback func(req *http.Request) (*http.Response, error)) { + if f.serve { + panic("cannot set callback handler when using 'WithServing'. Must implement an actual 'Coderd'") + } + f.fakeCoderd = callback +} + +func (f *FakeIDP) SetCoderdCallbackHandler(handler http.HandlerFunc) { + f.SetCoderdCallback(func(req *http.Request) (*http.Response, error) { + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + return resp.Result(), nil + }) +} + +// OIDCConfig returns the OIDC config to use for Coderd. +func (f *FakeIDP) OIDCConfig(t testing.TB, scopes []string, opts ...func(cfg *coderd.OIDCConfig)) *coderd.OIDCConfig { + t.Helper() + if len(scopes) == 0 { + scopes = []string{"openid", "email", "profile"} + } + + oauthCfg := &oauth2.Config{ + ClientID: f.clientID, + ClientSecret: f.clientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: f.provider.AuthURL, + TokenURL: f.provider.TokenURL, + AuthStyle: oauth2.AuthStyleInParams, + }, + // If the user is using a real network request, they will need to do + // 'fake.SetRedirect()' + RedirectURL: "https://redirect.com", + Scopes: scopes, + } + + ctx := oidc.ClientContext(context.Background(), f.HTTPClient(nil)) + p, err := oidc.NewProvider(ctx, f.provider.Issuer) + require.NoError(t, err, "failed to create OIDC provider") + cfg := &coderd.OIDCConfig{ + OAuth2Config: oauthCfg, + Provider: p, + Verifier: oidc.NewVerifier(f.provider.Issuer, &oidc.StaticKeySet{ + PublicKeys: []crypto.PublicKey{f.key.Public()}, + }, &oidc.Config{ + ClientID: oauthCfg.ClientID, + SupportedSigningAlgs: []string{ + "RS256", + }, + // Todo: add support for Now() + }), + UsernameField: "preferred_username", + EmailField: "email", + AuthURLParams: map[string]string{"access_type": "offline"}, + } + + for _, opt := range opts { + if opt == nil { + continue + } + opt(cfg) + } + + f.cfg = oauthCfg + + return cfg +} + +type fakeRoundTripper struct { + roundTrip func(req *http.Request) (*http.Response, error) +} + +func (f fakeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return f.roundTrip(req) +} + +const testRSAPrivateKey = `-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDLets8+7M+iAQAqN/5BVyCIjhTQ4cmXulL+gm3v0oGMWzLupUS +v8KPA+Tp7dgC/DZPfMLaNH1obBBhJ9DhS6RdS3AS3kzeFrdu8zFHLWF53DUBhS92 +5dCAEuJpDnNizdEhxTfoHrhuCmz8l2nt1pe5eUK2XWgd08Uc93h5ij098wIDAQAB +AoGAHLaZeWGLSaen6O/rqxg2laZ+jEFbMO7zvOTruiIkL/uJfrY1kw+8RLIn+1q0 +wLcWcuEIHgKKL9IP/aXAtAoYh1FBvRPLkovF1NZB0Je/+CSGka6wvc3TGdvppZJe +rKNcUvuOYLxkmLy4g9zuY5qrxFyhtIn2qZzXEtLaVOHzPQECQQDvN0mSajpU7dTB +w4jwx7IRXGSSx65c+AsHSc1Rj++9qtPC6WsFgAfFN2CEmqhMbEUVGPv/aPjdyWk9 +pyLE9xR/AkEA2cGwyIunijE5v2rlZAD7C4vRgdcMyCf3uuPcgzFtsR6ZhyQSgLZ8 +YRPuvwm4cdPJMmO3YwBfxT6XGuSc2k8MjQJBAI0+b8prvpV2+DCQa8L/pjxp+VhR +Xrq2GozrHrgR7NRokTB88hwFRJFF6U9iogy9wOx8HA7qxEbwLZuhm/4AhbECQC2a +d8h4Ht09E+f3nhTEc87mODkl7WJZpHL6V2sORfeq/eIkds+H6CJ4hy5w/bSw8tjf +sz9Di8sGIaUbLZI2rd0CQQCzlVwEtRtoNCyMJTTrkgUuNufLP19RZ5FpyXxBO5/u +QastnN77KfUwdj3SJt44U/uh1jAIv4oSLBr8HYUkbnI8 +-----END RSA PRIVATE KEY-----` diff --git a/coderd/coderdtest/oidctest/idp_test.go b/coderd/coderdtest/oidctest/idp_test.go new file mode 100644 index 0000000000000..0dc1149d93fa9 --- /dev/null +++ b/coderd/coderdtest/oidctest/idp_test.go @@ -0,0 +1,72 @@ +package oidctest_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +// TestFakeIDPBasicFlow tests the basic flow of the fake IDP. +// It is done all in memory with no actual network requests. +// nolint:bodyclose +func TestFakeIDPBasicFlow(t *testing.T) { + t.Parallel() + + fake := oidctest.NewFakeIDP(t, + oidctest.WithLogging(t, nil), + ) + + var handler http.Handler + srv := httptest.NewServer(http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler.ServeHTTP(w, r) + }))) + defer srv.Close() + + cfg := fake.OIDCConfig(t, nil) + cli := fake.HTTPClient(nil) + ctx := oidc.ClientContext(context.Background(), cli) + + const expectedState = "random-state" + var token *oauth2.Token + // This is the Coder callback using an actual network request. + fake.SetCoderdCallbackHandler(func(w http.ResponseWriter, r *http.Request) { + // Emulate OIDC flow + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + assert.Equal(t, expectedState, state, "state mismatch") + + oauthToken, err := cfg.Exchange(ctx, code) + if assert.NoError(t, err, "failed to exchange code") { + assert.NotEmpty(t, oauthToken.AccessToken, "access token is empty") + assert.NotEmpty(t, oauthToken.RefreshToken, "refresh token is empty") + } + token = oauthToken + }) + + resp, err := fake.OIDCCallback(t, expectedState, jwt.MapClaims{}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Test the user info + _, err = cfg.Provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) + require.NoError(t, err) + + // Now test it can refresh + refreshed, err := cfg.TokenSource(ctx, &oauth2.Token{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + Expiry: time.Now().Add(time.Minute * -1), + }).Token() + require.NoError(t, err, "failed to refresh token") + require.NotEmpty(t, refreshed.AccessToken, "access token is empty on refresh") +} diff --git a/coderd/oauthpki/oidcpki.go b/coderd/oauthpki/oidcpki.go index d5bc625336ab7..c44d130e5be9f 100644 --- a/coderd/oauthpki/oidcpki.go +++ b/coderd/oauthpki/oidcpki.go @@ -215,7 +215,10 @@ func (src *jwtTokenSource) Token() (*oauth2.Token, error) { } var tokenRes struct { - oauth2.Token + AccessToken string `json:"access_token"` + TokenType string `json:"token_type,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + // Extra fields returned by the refresh that are needed IDToken string `json:"id_token"` ExpiresIn int64 `json:"expires_in"` // relative seconds from now diff --git a/coderd/oauthpki/okidcpki_test.go b/coderd/oauthpki/okidcpki_test.go index 27593607f2a16..ab6e3e3a08179 100644 --- a/coderd/oauthpki/okidcpki_test.go +++ b/coderd/oauthpki/okidcpki_test.go @@ -12,12 +12,15 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/oauthpki" "github.com/coder/coder/v2/testutil" ) @@ -123,6 +126,58 @@ func TestAzureADPKIOIDC(t *testing.T) { require.Error(t, err, "error expected") } +// TestAzureAKPKIWithCoderd uses a fake IDP and a real Coderd to test PKI auth. +// nolint:bodyclose +func TestAzureAKPKIWithCoderd(t *testing.T) { + t.Parallel() + + scopes := []string{"openid", "email", "profile", "offline_access"} + fake := oidctest.NewFakeIDP(t, + oidctest.WithIssuer("https://login.microsoftonline.com/fake_app"), + oidctest.WithCustomClientAuth(func(t testing.TB, req *http.Request) (url.Values, error) { + values := assertJWTAuth(t, req) + if values == nil { + return nil, xerrors.New("authorizatin failed in request") + } + return values, nil + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, scopes, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) + + oauthCfg := cfg.OAuth2Config.(*oauth2.Config) + // Create the oauthpki config + pki, err := oauthpki.NewOauth2PKIConfig(oauthpki.ConfigParams{ + ClientID: oauthCfg.ClientID, + TokenURL: oauthCfg.Endpoint.TokenURL, + Scopes: scopes, + PemEncodedKey: []byte(testClientKey), + PemEncodedCert: []byte(testClientCert), + Config: oauthCfg, + }) + require.NoError(t, err) + cfg.OAuth2Config = pki + + owner, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + OIDCConfig: cfg, + }) + + // Create a user and login + const email = "alice@coder.com" + claims := jwt.MapClaims{ + "email": email, + } + helper := oidctest.NewLoginHelper(owner, fake) + user, _ := helper.Login(t, claims) + + // Try refreshing the token more than once. + for i := 0; i < 2; i++ { + helper.ForceRefresh(t, api.Database, user, claims) + } +} + // TestSavedAzureADPKIOIDC was created by capturing actual responses from an Azure // AD instance and saving them to replay, removing some details. // The reason this is done is that this is the only way to assert values @@ -269,7 +324,7 @@ func (f fakeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { // assertJWTAuth will assert the basic JWT auth assertions. It will return the // url.Values from the request body for any additional assertions to be made. -func assertJWTAuth(t *testing.T, r *http.Request) url.Values { +func assertJWTAuth(t testing.TB, r *http.Request) url.Values { body, err := io.ReadAll(r.Body) if !assert.NoError(t, err) { return nil diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 10bf7ecf67234..1f37a0721a1e7 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -4,28 +4,25 @@ import ( "context" "crypto" "fmt" - "io" "net/http" "net/http/cookiejar" + "net/url" "strings" "testing" - "time" "github.com/coreos/go-oidc/v3/oidc" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v4" "github.com/google/go-github/v43/github" "github.com/google/uuid" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/oauth2" "golang.org/x/xerrors" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/codersdk" @@ -35,85 +32,42 @@ import ( // This test specifically tests logging in with OIDC when an expired // OIDC session token exists. // The token refreshing should not happen since we are reauthenticating. +// nolint:bodyclose func TestOIDCOauthLoginWithExisting(t *testing.T) { t.Parallel() - conf := coderdtest.NewOIDCConfig(t, "", - // Provide a refresh token so we use the refresh token flow - coderdtest.WithRefreshToken("refresh_token"), - // We need to set the expire in the future for the first api calls. - coderdtest.WithTokenExpires(func() time.Time { - return time.Now().Add(time.Hour).UTC() - }), - // No refresh should actually happen in this test. - coderdtest.WithTokenSource(func() (*oauth2.Token, error) { - return nil, xerrors.New("token should not require refresh") + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") }), + oidctest.WithServing(), ) - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - auditor := audit.NewMock() + + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.IgnoreUserInfo = true + }) + + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + OIDCConfig: cfg, + }) + const username = "alice" claims := jwt.MapClaims{ "email": "alice@coder.com", "email_verified": true, "preferred_username": username, } - config := conf.OIDCConfig(t, claims) - - config.AllowSignups = true - config.IgnoreUserInfo = true - client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Auditor: auditor, - OIDCConfig: config, - Logger: &logger, - }) + helper := oidctest.NewLoginHelper(client, fake) // Signup alice - resp := oidcCallback(t, client, conf.EncodeClaims(t, claims)) - // Set the client to use this OIDC context - authCookie := authCookieValue(resp.Cookies()) - client.SetSessionToken(authCookie) - _ = resp.Body.Close() + userClient, _ := helper.Login(t, claims) - ctx := testutil.Context(t, testutil.WaitLong) - // Verify the user and oauth link - user, err := client.User(ctx, "me") - require.NoError(t, err) - require.Equal(t, username, user.Username) - - // nolint:gocritic - link, err := api.Database.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{ - UserID: user.ID, - LoginType: database.LoginType(user.LoginType), - }) - require.NoError(t, err, "failed to get user link") - - // Expire the link - // nolint:gocritic - _, err = api.Database.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{ - OAuthAccessToken: link.OAuthAccessToken, - OAuthRefreshToken: link.OAuthRefreshToken, - OAuthExpiry: time.Now().Add(time.Hour * -1).UTC(), - UserID: link.UserID, - LoginType: link.LoginType, - }) - require.NoError(t, err, "failed to update user link") - - // Log in again with OIDC - loginAgain := oidcCallbackWithState(t, client, conf.EncodeClaims(t, claims), "seconds_login", func(req *http.Request) { - req.AddCookie(&http.Cookie{ - Name: codersdk.SessionTokenCookie, - Value: authCookie, - Path: "/", - }) - }) - require.Equal(t, http.StatusTemporaryRedirect, loginAgain.StatusCode) - _ = loginAgain.Body.Close() + // Expire the link. This will force the client to refresh the token. + helper.ExpireOauthToken(t, api.Database, userClient) - // Try to use new login - client.SetSessionToken(authCookieValue(resp.Cookies())) - _, err = client.User(ctx, "me") - require.NoError(t, err, "use new session") + // Instead of refreshing, just log in again. + helper.Login(t, claims) } func TestUserLogin(t *testing.T) { @@ -660,7 +614,7 @@ func TestUserOIDC(t *testing.T) { "email": "kyle@kwc.io", }, AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, Username: "kyle", }, { Name: "EmailNotVerified", @@ -685,7 +639,7 @@ func TestUserOIDC(t *testing.T) { "email_verified": false, }, AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, Username: "kyle", IgnoreEmailVerified: true, }, { @@ -709,7 +663,7 @@ func TestUserOIDC(t *testing.T) { EmailDomain: []string{ "kwc.io", }, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "EmptyClaims", IDTokenClaims: jwt.MapClaims{}, @@ -730,7 +684,7 @@ func TestUserOIDC(t *testing.T) { }, Username: "kyle", AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "UsernameFromClaims", IDTokenClaims: jwt.MapClaims{ @@ -740,7 +694,7 @@ func TestUserOIDC(t *testing.T) { }, Username: "hotdog", AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { // Services like Okta return the email as the username: // https://developer.okta.com/docs/reference/api/oidc/#base-claims-always-present @@ -752,7 +706,7 @@ func TestUserOIDC(t *testing.T) { }, Username: "kyle", AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { // See: https://github.com/coder/coder/issues/4472 Name: "UsernameIsEmail", @@ -761,7 +715,7 @@ func TestUserOIDC(t *testing.T) { }, Username: "kyle", AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "WithPicture", IDTokenClaims: jwt.MapClaims{ @@ -773,7 +727,7 @@ func TestUserOIDC(t *testing.T) { Username: "kyle", AllowSignups: true, AvatarURL: "/example.png", - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "WithUserInfoClaims", IDTokenClaims: jwt.MapClaims{ @@ -787,7 +741,7 @@ func TestUserOIDC(t *testing.T) { Username: "potato", AllowSignups: true, AvatarURL: "/example.png", - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "GroupsDoesNothing", IDTokenClaims: jwt.MapClaims{ @@ -795,7 +749,7 @@ func TestUserOIDC(t *testing.T) { "groups": []string{"pingpong"}, }, AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "UserInfoOverridesIDTokenClaims", IDTokenClaims: jwt.MapClaims{ @@ -810,7 +764,7 @@ func TestUserOIDC(t *testing.T) { Username: "user", AllowSignups: true, IgnoreEmailVerified: false, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }, { Name: "InvalidUserInfo", IDTokenClaims: jwt.MapClaims{ @@ -837,36 +791,41 @@ func TestUserOIDC(t *testing.T) { Username: "user", IgnoreUserInfo: true, AllowSignups: true, - StatusCode: http.StatusTemporaryRedirect, + StatusCode: http.StatusOK, }} { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() - auditor := audit.NewMock() - conf := coderdtest.NewOIDCConfig(t, "") - - config := conf.OIDCConfig(t, tc.UserInfoClaims) - config.AllowSignups = tc.AllowSignups - config.EmailDomain = tc.EmailDomain - config.IgnoreEmailVerified = tc.IgnoreEmailVerified - config.IgnoreUserInfo = tc.IgnoreUserInfo + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + oidctest.WithStaticUserInfo(tc.UserInfoClaims), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = tc.AllowSignups + cfg.EmailDomain = tc.EmailDomain + cfg.IgnoreEmailVerified = tc.IgnoreEmailVerified + cfg.IgnoreUserInfo = tc.IgnoreUserInfo + }) + auditor := audit.NewMock() logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - client := coderdtest.New(t, &coderdtest.Options{ + owner := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, - OIDCConfig: config, + OIDCConfig: cfg, Logger: &logger, }) numLogs := len(auditor.AuditLogs()) - resp := oidcCallback(t, client, conf.EncodeClaims(t, tc.IDTokenClaims)) + client, resp := fake.AttemptLogin(t, owner, tc.IDTokenClaims) numLogs++ // add an audit log for login - assert.Equal(t, tc.StatusCode, resp.StatusCode) + require.Equal(t, tc.StatusCode, resp.StatusCode) ctx := testutil.Context(t, testutil.WaitLong) if tc.Username != "" { - client.SetSessionToken(authCookieValue(resp.Cookies())) user, err := client.User(ctx, "me") require.NoError(t, err) require.Equal(t, tc.Username, user.Username) @@ -877,7 +836,6 @@ func TestUserOIDC(t *testing.T) { } if tc.AvatarURL != "" { - client.SetSessionToken(authCookieValue(resp.Cookies())) user, err := client.User(ctx, "me") require.NoError(t, err) require.Equal(t, tc.AvatarURL, user.AvatarURL) @@ -890,26 +848,29 @@ func TestUserOIDC(t *testing.T) { t.Run("OIDCConvert", func(t *testing.T) { t.Parallel() - auditor := audit.NewMock() - conf := coderdtest.NewOIDCConfig(t, "") - config := conf.OIDCConfig(t, nil) - config.AllowSignups = true + auditor := audit.NewMock() + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) - cfg := coderdtest.DeploymentValues(t) client := coderdtest.New(t, &coderdtest.Options{ - Auditor: auditor, - OIDCConfig: config, - DeploymentValues: cfg, + Auditor: auditor, + OIDCConfig: cfg, }) - owner := coderdtest.CreateFirstUser(t, client) + owner := coderdtest.CreateFirstUser(t, client) user, userData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - code := conf.EncodeClaims(t, jwt.MapClaims{ + claims := jwt.MapClaims{ "email": userData.Email, - }) - + } var err error user.HTTPClient.Jar, err = cookiejar.New(nil) require.NoError(t, err) @@ -921,52 +882,58 @@ func TestUserOIDC(t *testing.T) { }) require.NoError(t, err) - resp := oidcCallbackWithState(t, user, code, convertResponse.StateString, nil) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + fake.LoginWithClient(t, user, claims, func(r *http.Request) { + r.URL.RawQuery = url.Values{ + "oidc_merge_state": {convertResponse.StateString}, + }.Encode() + r.Header.Set(codersdk.SessionTokenHeader, user.SessionToken()) + cookies := user.HTTPClient.Jar.Cookies(r.URL) + for _, cookie := range cookies { + r.AddCookie(cookie) + } + }) }) t.Run("AlternateUsername", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() - conf := coderdtest.NewOIDCConfig(t, "") - - config := conf.OIDCConfig(t, nil) - config.AllowSignups = true + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) client := coderdtest.New(t, &coderdtest.Options{ Auditor: auditor, - OIDCConfig: config, + OIDCConfig: cfg, }) - numLogs := len(auditor.AuditLogs()) - code := conf.EncodeClaims(t, jwt.MapClaims{ + numLogs := len(auditor.AuditLogs()) + claims := jwt.MapClaims{ "email": "jon@coder.com", - }) - resp := oidcCallback(t, client, code) - numLogs++ // add an audit log for login + } - assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + userClient, _ := fake.Login(t, client, claims) + numLogs++ // add an audit log for login ctx := testutil.Context(t, testutil.WaitLong) - - client.SetSessionToken(authCookieValue(resp.Cookies())) - user, err := client.User(ctx, "me") + user, err := userClient.User(ctx, "me") require.NoError(t, err) require.Equal(t, "jon", user.Username) // Pass a different subject field so that we prompt creating a - // new user. - code = conf.EncodeClaims(t, jwt.MapClaims{ + // new user + userClient, _ = fake.Login(t, client, jwt.MapClaims{ "email": "jon@example2.com", "sub": "diff", }) - resp = oidcCallback(t, client, code) numLogs++ // add an audit log for login - assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - - client.SetSessionToken(authCookieValue(resp.Cookies())) - user, err = client.User(ctx, "me") + user, err = userClient.User(ctx, "me") require.NoError(t, err) require.True(t, strings.HasPrefix(user.Username, "jon-"), "username %q should have prefix %q", user.Username, "jon-") @@ -977,45 +944,62 @@ func TestUserOIDC(t *testing.T) { t.Run("Disabled", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) - resp := oidcCallback(t, client, "asdf") + oauthURL, err := client.URL.Parse("/api/v2/users/oidc/callback") + require.NoError(t, err) + + req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) + require.NoError(t, err) + resp, err := client.HTTPClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("NoIDToken", func(t *testing.T) { t.Parallel() + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) + client := coderdtest.New(t, &coderdtest.Options{ - OIDCConfig: &coderd.OIDCConfig{ - OAuth2Config: &testutil.OAuth2Config{}, - }, + OIDCConfig: cfg, }) - resp := oidcCallback(t, client, "asdf") + _, resp := fake.AttemptLogin(t, client, jwt.MapClaims{}) require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("BadVerify", func(t *testing.T) { t.Parallel() - verifier := oidc.NewVerifier("", &oidc.StaticKeySet{ + badVerifier := oidc.NewVerifier("", &oidc.StaticKeySet{ PublicKeys: []crypto.PublicKey{}, }, &oidc.Config{}) - provider := &oidc.Provider{} + badProvider := &oidc.Provider{} + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefreshHook(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.Provider = badProvider + cfg.Verifier = badVerifier + }) client := coderdtest.New(t, &coderdtest.Options{ - OIDCConfig: &coderd.OIDCConfig{ - OAuth2Config: &testutil.OAuth2Config{ - Token: (&oauth2.Token{ - AccessToken: "token", - }).WithExtra(map[string]interface{}{ - "id_token": "invalid", - }), - }, - Provider: provider, - Verifier: verifier, - }, + OIDCConfig: cfg, }) - resp := oidcCallback(t, client, "asdf") - + _, resp := fake.AttemptLogin(t, client, jwt.MapClaims{}) require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) } @@ -1146,36 +1130,6 @@ func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { return res } -func oidcCallback(t *testing.T, client *codersdk.Client, code string) *http.Response { - return oidcCallbackWithState(t, client, code, "somestate", nil) -} - -func oidcCallbackWithState(t *testing.T, client *codersdk.Client, code, state string, modify func(r *http.Request)) *http.Response { - t.Helper() - - client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - oauthURL, err := client.URL.Parse(fmt.Sprintf("/api/v2/users/oidc/callback?code=%s&state=%s", code, state)) - require.NoError(t, err) - req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) - require.NoError(t, err) - req.AddCookie(&http.Cookie{ - Name: codersdk.OAuth2StateCookie, - Value: state, - }) - if modify != nil { - modify(req) - } - res, err := client.HTTPClient.Do(req) - require.NoError(t, err) - defer res.Body.Close() - data, err := io.ReadAll(res.Body) - require.NoError(t, err) - t.Log(string(data)) - return res -} - func i64ptr(i int64) *int64 { return &i } diff --git a/coderd/users_test.go b/coderd/users_test.go index c36b4fad98afd..60e6ddb82aecf 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -8,7 +8,10 @@ import ( "testing" "time" - "github.com/golang-jwt/jwt" + "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" + + "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -403,6 +406,7 @@ func TestPostLogout(t *testing.T) { }) } +// nolint:bodyclose func TestPostUsers(t *testing.T) { t.Parallel() t.Run("NoAuth", func(t *testing.T) { @@ -593,15 +597,15 @@ func TestPostUsers(t *testing.T) { t.Run("CreateOIDCLoginType", func(t *testing.T) { t.Parallel() email := "another@user.org" - conf := coderdtest.NewOIDCConfig(t, "") - config := conf.OIDCConfig(t, jwt.MapClaims{ - "email": email, + fake := oidctest.NewFakeIDP(t, + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true }) - config.AllowSignups = false - config.IgnoreUserInfo = true client := coderdtest.New(t, &coderdtest.Options{ - OIDCConfig: config, + OIDCConfig: cfg, }) first := coderdtest.CreateFirstUser(t, client) @@ -618,15 +622,9 @@ func TestPostUsers(t *testing.T) { require.NoError(t, err) // Try to log in with OIDC. - userClient := codersdk.New(client.URL) - resp := oidcCallback(t, userClient, conf.EncodeClaims(t, jwt.MapClaims{ + userClient, _ := fake.Login(t, client, jwt.MapClaims{ "email": email, - })) - require.Equal(t, resp.StatusCode, http.StatusTemporaryRedirect) - // Set the client to use this OIDC context - authCookie := authCookieValue(resp.Cookies()) - userClient.SetSessionToken(authCookie) - _ = resp.Body.Close() + }) found, err := userClient.User(ctx, "me") require.NoError(t, err) diff --git a/coderd/util/syncmap/map.go b/coderd/util/syncmap/map.go new file mode 100644 index 0000000000000..d245973efa844 --- /dev/null +++ b/coderd/util/syncmap/map.go @@ -0,0 +1,77 @@ +package syncmap + +import "sync" + +// Map is a type safe sync.Map +type Map[K, V any] struct { + m sync.Map +} + +func New[K, V any]() *Map[K, V] { + return &Map[K, V]{ + m: sync.Map{}, + } +} + +func (m *Map[K, V]) Store(k K, v V) { + m.m.Store(k, v) +} + +//nolint:forcetypeassert +func (m *Map[K, V]) Load(key K) (value V, ok bool) { + v, ok := m.m.Load(key) + if !ok { + var empty V + return empty, false + } + return v.(V), ok +} + +func (m *Map[K, V]) Delete(key K) { + m.m.Delete(key) +} + +//nolint:forcetypeassert +func (m *Map[K, V]) LoadAndDelete(key K) (actual V, loaded bool) { + act, loaded := m.m.LoadAndDelete(key) + if !loaded { + var empty V + return empty, loaded + } + return act.(V), loaded +} + +//nolint:forcetypeassert +func (m *Map[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) { + act, loaded := m.m.LoadOrStore(key, value) + if !loaded { + var empty V + return empty, loaded + } + return act.(V), loaded +} + +func (m *Map[K, V]) CompareAndSwap(key K, old V, new V) bool { + return m.m.CompareAndSwap(key, old, new) +} + +func (m *Map[K, V]) CompareAndDelete(key K, old V) (deleted bool) { + return m.m.CompareAndDelete(key, old) +} + +//nolint:forcetypeassert +func (m *Map[K, V]) Swap(key K, value V) (previous any, loaded bool) { + previous, loaded = m.m.Swap(key, value) + if !loaded { + var empty V + return empty, loaded + } + return previous.(V), loaded +} + +//nolint:forcetypeassert +func (m *Map[K, V]) Range(f func(key K, value V) bool) { + m.m.Range(func(key, value interface{}) bool { + return f(key.(K), value.(V)) + }) +} diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index d6f6db3cbedbd..8e76a36b1df14 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -1,25 +1,22 @@ package coderd_test import ( - "context" - "fmt" - "io" "net/http" "regexp" "testing" - "github.com/golang-jwt/jwt" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" + "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" + coderden "github.com/coder/coder/v2/enterprise/coderd" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/testutil" @@ -31,128 +28,123 @@ func TestUserOIDC(t *testing.T) { t.Run("RoleSync", func(t *testing.T) { t.Parallel() + // NoRoles is the "control group". It has claims with 0 roles + // assigned, and asserts that the user has no roles. t.Run("NoRoles", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitMedium) - conf := coderdtest.NewOIDCConfig(t, "") - - oidcRoleName := "TemplateAuthor" - - config := conf.OIDCConfig(t, jwt.MapClaims{}, func(cfg *coderd.OIDCConfig) { - cfg.UserRoleMapping = map[string][]string{oidcRoleName: {rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()}} - }) - config.AllowSignups = true - config.UserRoleField = "roles" - - client, _ := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - OIDCConfig: config, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureUserRoleManagement: 1}, + runner := setupOIDCTest(t, oidcTestConfig{ + Config: func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.UserRoleField = "roles" }, }) - admin, err := client.User(ctx, "me") - require.NoError(t, err) - require.Len(t, admin.OrganizationIDs, 1) - - resp := oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{ + claims := jwt.MapClaims{ "email": "alice@coder.com", - })) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - user, err := client.User(ctx, "alice") - require.NoError(t, err) - - require.Len(t, user.Roles, 0) - roleNames := []string{} - require.ElementsMatch(t, roleNames, []string{}) + } + // Login a new client that signs up + client, resp := runner.Login(t, claims) + require.Equal(t, http.StatusOK, resp.StatusCode) + // User should be in 0 groups. + runner.AssertRoles(t, "alice", []string{}) + // Force a refresh, and assert nothing has changes + runner.ForceRefresh(t, client, claims) + runner.AssertRoles(t, "alice", []string{}) }) - t.Run("NewUserAndRemoveRoles", func(t *testing.T) { + // A user has some roles, then on an oauth refresh will lose said + // roles from an updated claim. + t.Run("NewUserAndRemoveRolesOnRefresh", func(t *testing.T) { + // TODO: Implement new feature to update roles/groups on OIDC + // refresh tokens. https://github.com/coder/coder/issues/9312 + t.Skip("Refreshing tokens does not update roles :(") t.Parallel() - ctx := testutil.Context(t, testutil.WaitMedium) - conf := coderdtest.NewOIDCConfig(t, "") + const oidcRoleName = "TemplateAuthor" + runner := setupOIDCTest(t, oidcTestConfig{ + Userinfo: jwt.MapClaims{oidcRoleName: []string{rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()}}, + Config: func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.UserRoleField = "roles" + cfg.UserRoleMapping = map[string][]string{ + oidcRoleName: {rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()}, + } + }, + }) - oidcRoleName := "TemplateAuthor" + // User starts with the owner role + client, resp := runner.Login(t, jwt.MapClaims{ + "email": "alice@coder.com", + "roles": []string{"random", oidcRoleName, rbac.RoleOwner()}, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(), rbac.RoleOwner()}) - config := conf.OIDCConfig(t, jwt.MapClaims{}, func(cfg *coderd.OIDCConfig) { - cfg.UserRoleMapping = map[string][]string{oidcRoleName: {rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()}} + // Now refresh the oauth, and check the roles are removed. + // Force a refresh, and assert nothing has changes + runner.ForceRefresh(t, client, jwt.MapClaims{ + "email": "alice@coder.com", + "roles": []string{"random"}, }) - config.AllowSignups = true - config.UserRoleField = "roles" + runner.AssertRoles(t, "alice", []string{}) + }) - client, _ := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - OIDCConfig: config, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureUserRoleManagement: 1}, + // A user has some roles, then on another oauth login will lose said + // roles from an updated claim. + t.Run("NewUserAndRemoveRolesOnReAuth", func(t *testing.T) { + t.Parallel() + + const oidcRoleName = "TemplateAuthor" + runner := setupOIDCTest(t, oidcTestConfig{ + Userinfo: jwt.MapClaims{oidcRoleName: []string{rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()}}, + Config: func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.UserRoleField = "roles" + cfg.UserRoleMapping = map[string][]string{ + oidcRoleName: {rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin()}, + } }, }) - admin, err := client.User(ctx, "me") - require.NoError(t, err) - require.Len(t, admin.OrganizationIDs, 1) - - resp := oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{ + // User starts with the owner role + _, resp := runner.Login(t, jwt.MapClaims{ "email": "alice@coder.com", "roles": []string{"random", oidcRoleName, rbac.RoleOwner()}, - })) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - _ = resp.Body.Close() - user, err := client.User(ctx, "alice") - require.NoError(t, err) - - require.Len(t, user.Roles, 3) - roleNames := []string{user.Roles[0].Name, user.Roles[1].Name, user.Roles[2].Name} - require.ElementsMatch(t, roleNames, []string{rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(), rbac.RoleOwner()}) + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + runner.AssertRoles(t, "alice", []string{rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(), rbac.RoleOwner()}) - // Now remove the roles with a new oidc login - resp = oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{ + // Now login with oauth again, and check the roles are removed. + _, resp = runner.Login(t, jwt.MapClaims{ "email": "alice@coder.com", "roles": []string{"random"}, - })) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - _ = resp.Body.Close() - user, err = client.User(ctx, "alice") - require.NoError(t, err) + }) + require.Equal(t, http.StatusOK, resp.StatusCode) - require.Len(t, user.Roles, 0) + runner.AssertRoles(t, "alice", []string{}) }) + + // All manual role updates should fail when role sync is enabled. t.Run("BlockAssignRoles", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitMedium) - conf := coderdtest.NewOIDCConfig(t, "") - - config := conf.OIDCConfig(t, jwt.MapClaims{}) - config.AllowSignups = true - config.UserRoleField = "roles" - - client, _ := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - OIDCConfig: config, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureUserRoleManagement: 1}, + runner := setupOIDCTest(t, oidcTestConfig{ + Config: func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.UserRoleField = "roles" }, }) - admin, err := client.User(ctx, "me") - require.NoError(t, err) - require.Len(t, admin.OrganizationIDs, 1) - - resp := oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{ + _, resp := runner.Login(t, jwt.MapClaims{ "email": "alice@coder.com", "roles": []string{}, - })) - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + }) + require.Equal(t, http.StatusOK, resp.StatusCode) // Try to manually update user roles, even though controlled by oidc // role sync. - _, err = client.UpdateUserRoles(ctx, "alice", codersdk.UpdateRoles{ + ctx := testutil.Context(t, testutil.WaitShort) + _, err := runner.AdminClient.UpdateUserRoles(ctx, "alice", codersdk.UpdateRoles{ Roles: []string{ rbac.RoleTemplateAdmin(), }, @@ -164,199 +156,211 @@ func TestUserOIDC(t *testing.T) { t.Run("Groups", func(t *testing.T) { t.Parallel() + + // Assigns does a simple test of assigning a user to a group based + // on the oidc claims. t.Run("Assigns", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) - conf := coderdtest.NewOIDCConfig(t, "") - const groupClaim = "custom-groups" - config := conf.OIDCConfig(t, jwt.MapClaims{}, func(cfg *coderd.OIDCConfig) { - cfg.GroupField = groupClaim - }) - config.AllowSignups = true - - client, _ := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - OIDCConfig: config, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureTemplateRBAC: 1}, + const groupName = "bingbong" + runner := setupOIDCTest(t, oidcTestConfig{ + Config: func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.GroupField = groupClaim }, }) - admin, err := client.User(ctx, "me") - require.NoError(t, err) - require.Len(t, admin.OrganizationIDs, 1) - - groupName := "bingbong" - group, err := client.CreateGroup(ctx, admin.OrganizationIDs[0], codersdk.CreateGroupRequest{ + ctx := testutil.Context(t, testutil.WaitShort) + group, err := runner.AdminClient.CreateGroup(ctx, runner.AdminUser.OrganizationIDs[0], codersdk.CreateGroupRequest{ Name: groupName, }) require.NoError(t, err) require.Len(t, group.Members, 0) - resp := oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{ - "email": "colin@coder.com", + _, resp := runner.Login(t, jwt.MapClaims{ + "email": "alice@coder.com", groupClaim: []string{groupName}, - })) - assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - - group, err = client.Group(ctx, group.ID) - require.NoError(t, err) - require.Len(t, group.Members, 1) + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + runner.AssertGroups(t, "alice", []string{groupName}) }) + + // Tests the group mapping feature. t.Run("AssignsMapped", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitMedium) - conf := coderdtest.NewOIDCConfig(t, "") - - oidcGroupName := "pingpong" - coderGroupName := "bingbong" - - config := conf.OIDCConfig(t, jwt.MapClaims{}, func(cfg *coderd.OIDCConfig) { - cfg.GroupMapping = map[string]string{oidcGroupName: coderGroupName} - }) - config.AllowSignups = true + const groupClaim = "custom-groups" - client, _ := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - OIDCConfig: config, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureTemplateRBAC: 1}, + const oidcGroupName = "pingpong" + const coderGroupName = "bingbong" + runner := setupOIDCTest(t, oidcTestConfig{ + Config: func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.GroupField = groupClaim + cfg.GroupMapping = map[string]string{oidcGroupName: coderGroupName} }, }) - admin, err := client.User(ctx, "me") - require.NoError(t, err) - require.Len(t, admin.OrganizationIDs, 1) - - group, err := client.CreateGroup(ctx, admin.OrganizationIDs[0], codersdk.CreateGroupRequest{ + ctx := testutil.Context(t, testutil.WaitShort) + group, err := runner.AdminClient.CreateGroup(ctx, runner.AdminUser.OrganizationIDs[0], codersdk.CreateGroupRequest{ Name: coderGroupName, }) require.NoError(t, err) require.Len(t, group.Members, 0) - resp := oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{ - "email": "colin@coder.com", - "groups": []string{oidcGroupName}, - })) - assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - - group, err = client.Group(ctx, group.ID) - require.NoError(t, err) - require.Len(t, group.Members, 1) + _, resp := runner.Login(t, jwt.MapClaims{ + "email": "alice@coder.com", + groupClaim: []string{oidcGroupName}, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + runner.AssertGroups(t, "alice", []string{coderGroupName}) }) - t.Run("AddThenRemove", func(t *testing.T) { + // User is in a group, then on an oauth refresh will lose said + // group. + t.Run("AddThenRemoveOnRefresh", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) - conf := coderdtest.NewOIDCConfig(t, "") - - config := conf.OIDCConfig(t, jwt.MapClaims{}) - config.AllowSignups = true + // TODO: Implement new feature to update roles/groups on OIDC + // refresh tokens. https://github.com/coder/coder/issues/9312 + t.Skip("Refreshing tokens does not update groups :(") - client, firstUser := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - OIDCConfig: config, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureTemplateRBAC: 1}, + const groupClaim = "custom-groups" + const groupName = "bingbong" + runner := setupOIDCTest(t, oidcTestConfig{ + Config: func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.GroupField = groupClaim }, }) - // Add some extra users/groups that should be asserted after. - // Adding this user as there was a bug that removing 1 user removed - // all users from the group. - _, extra := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) - groupName := "bingbong" - group, err := client.CreateGroup(ctx, firstUser.OrganizationID, codersdk.CreateGroupRequest{ + ctx := testutil.Context(t, testutil.WaitShort) + group, err := runner.AdminClient.CreateGroup(ctx, runner.AdminUser.OrganizationIDs[0], codersdk.CreateGroupRequest{ Name: groupName, }) - require.NoError(t, err, "create group") + require.NoError(t, err) + require.Len(t, group.Members, 0) - group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ - AddUsers: []string{ - firstUser.UserID.String(), - extra.ID.String(), - }, + client, resp := runner.Login(t, jwt.MapClaims{ + "email": "alice@coder.com", + groupClaim: []string{groupName}, }) - require.NoError(t, err, "patch group") - require.Len(t, group.Members, 2, "expect both members") + require.Equal(t, http.StatusOK, resp.StatusCode) + runner.AssertGroups(t, "alice", []string{groupName}) - // Now add OIDC user into the group - resp := oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{ - "email": "colin@coder.com", - "groups": []string{groupName}, - })) - assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + // Refresh without the group claim + runner.ForceRefresh(t, client, jwt.MapClaims{ + "email": "alice@coder.com", + }) + runner.AssertGroups(t, "alice", []string{}) + }) - group, err = client.Group(ctx, group.ID) - require.NoError(t, err) - require.Len(t, group.Members, 3) + t.Run("AddThenRemoveOnReAuth", func(t *testing.T) { + t.Parallel() - // Login to remove the OIDC user from the group - resp = oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{ - "email": "colin@coder.com", - "groups": []string{}, - })) - assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + const groupClaim = "custom-groups" + const groupName = "bingbong" + runner := setupOIDCTest(t, oidcTestConfig{ + Config: func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.GroupField = groupClaim + }, + }) - group, err = client.Group(ctx, group.ID) + ctx := testutil.Context(t, testutil.WaitShort) + group, err := runner.AdminClient.CreateGroup(ctx, runner.AdminUser.OrganizationIDs[0], codersdk.CreateGroupRequest{ + Name: groupName, + }) require.NoError(t, err) - require.Len(t, group.Members, 2) - var expected []uuid.UUID - for _, mem := range group.Members { - expected = append(expected, mem.ID) - } - require.ElementsMatchf(t, expected, []uuid.UUID{firstUser.UserID, extra.ID}, "expected members") + require.Len(t, group.Members, 0) + + _, resp := runner.Login(t, jwt.MapClaims{ + "email": "alice@coder.com", + groupClaim: []string{groupName}, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + runner.AssertGroups(t, "alice", []string{groupName}) + + // Refresh without the group claim + _, resp = runner.Login(t, jwt.MapClaims{ + "email": "alice@coder.com", + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + runner.AssertGroups(t, "alice", []string{}) }) + // Updating groups where the claimed group does not exist. t.Run("NoneMatch", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) - conf := coderdtest.NewOIDCConfig(t, "") + const groupClaim = "custom-groups" + runner := setupOIDCTest(t, oidcTestConfig{ + Config: func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.GroupField = groupClaim + }, + }) - config := conf.OIDCConfig(t, jwt.MapClaims{}) - config.AllowSignups = true + _, resp := runner.Login(t, jwt.MapClaims{ + "email": "alice@coder.com", + groupClaim: []string{"not-exists"}, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + runner.AssertGroups(t, "alice", []string{}) + }) - client, _ := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - OIDCConfig: config, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureTemplateRBAC: 1}, + // Updating groups where the claimed group does not exist creates + // the group. + t.Run("AutoCreate", func(t *testing.T) { + t.Parallel() + + const groupClaim = "custom-groups" + const groupName = "make-me" + runner := setupOIDCTest(t, oidcTestConfig{ + Config: func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.GroupField = groupClaim + cfg.CreateMissingGroups = true }, }) - admin, err := client.User(ctx, "me") - require.NoError(t, err) - require.Len(t, admin.OrganizationIDs, 1) + _, resp := runner.Login(t, jwt.MapClaims{ + "email": "alice@coder.com", + groupClaim: []string{groupName}, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + runner.AssertGroups(t, "alice", []string{groupName}) + }) + }) - groupName := "bingbong" - group, err := client.CreateGroup(ctx, admin.OrganizationIDs[0], codersdk.CreateGroupRequest{ - Name: groupName, + t.Run("Refresh", func(t *testing.T) { + t.Run("RefreshTokensMultiple", func(t *testing.T) { + t.Parallel() + + runner := setupOIDCTest(t, oidcTestConfig{ + Config: func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + cfg.UserRoleField = "roles" + }, }) - require.NoError(t, err) - require.Len(t, group.Members, 0) - resp := oidcCallback(t, client, conf.EncodeClaims(t, jwt.MapClaims{ - "email": "colin@coder.com", - "groups": []string{"coolin"}, - })) - assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + claims := jwt.MapClaims{ + "email": "alice@coder.com", + } + // Login a new client that signs up + client, resp := runner.Login(t, claims) + require.Equal(t, http.StatusOK, resp.StatusCode) - group, err = client.Group(ctx, group.ID) - require.NoError(t, err) - require.Len(t, group.Members, 0) + // Refresh multiple times. + for i := 0; i < 3; i++ { + runner.ForceRefresh(t, client, claims) + } }) }) } +// nolint:bodyclose func TestGroupSync(t *testing.T) { t.Parallel() @@ -470,28 +474,20 @@ func TestGroupSync(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) - conf := coderdtest.NewOIDCConfig(t, "") - - config := conf.OIDCConfig(t, jwt.MapClaims{}, tc.modCfg) - - client, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - OIDCConfig: config, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureTemplateRBAC: 1}, + runner := setupOIDCTest(t, oidcTestConfig{ + Config: func(cfg *coderd.OIDCConfig) { + cfg.GroupField = "groups" + tc.modCfg(cfg) }, }) - admin, err := client.User(ctx, "me") - require.NoError(t, err) - require.Len(t, admin.OrganizationIDs, 1) - // Setup + ctx := testutil.Context(t, testutil.WaitLong) + org := runner.AdminUser.OrganizationIDs[0] + initialGroups := make(map[string]codersdk.Group) for _, group := range tc.initialOrgGroups { - newGroup, err := client.CreateGroup(ctx, admin.OrganizationIDs[0], codersdk.CreateGroupRequest{ + newGroup, err := runner.AdminClient.CreateGroup(ctx, org, codersdk.CreateGroupRequest{ Name: group, }) require.NoError(t, err) @@ -500,16 +496,16 @@ func TestGroupSync(t *testing.T) { } // Create the user and add them to their initial groups - _, user := coderdtest.CreateAnotherUser(t, client, admin.OrganizationIDs[0]) + _, user := coderdtest.CreateAnotherUser(t, runner.AdminClient, org) for _, group := range tc.initialUserGroups { - _, err := client.PatchGroup(ctx, initialGroups[group].ID, codersdk.PatchGroupRequest{ + _, err := runner.AdminClient.PatchGroup(ctx, initialGroups[group].ID, codersdk.PatchGroupRequest{ AddUsers: []string{user.ID.String()}, }) require.NoError(t, err) } // nolint:gocritic - _, err = api.Database.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{ + _, err := runner.API.Database.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{ NewLoginType: database.LoginTypeOIDC, UserID: user.ID, }) @@ -517,11 +513,11 @@ func TestGroupSync(t *testing.T) { // Log in the new user tc.claims["email"] = user.Email - resp := oidcCallback(t, client, conf.EncodeClaims(t, tc.claims)) - assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - _ = resp.Body.Close() + _, resp := runner.Login(t, tc.claims) + require.Equal(t, http.StatusOK, resp.StatusCode) - orgGroups, err := client.GroupsByOrganization(ctx, admin.OrganizationIDs[0]) + // Check group sources + orgGroups, err := runner.AdminClient.GroupsByOrganization(ctx, org) require.NoError(t, err) for _, group := range orgGroups { @@ -567,24 +563,107 @@ func TestGroupSync(t *testing.T) { } } -func oidcCallback(t *testing.T, client *codersdk.Client, code string) *http.Response { +// oidcTestRunner is just a helper to setup and run oidc tests. +// An actual Coderd instance is used to run the tests. +type oidcTestRunner struct { + AdminClient *codersdk.Client + AdminUser codersdk.User + API *coderden.API + + // Login will call the OIDC flow with an unauthenticated client. + // The IDP will return the idToken claims. + Login func(t *testing.T, idToken jwt.MapClaims) (*codersdk.Client, *http.Response) + // ForceRefresh will use an authenticated codersdk.Client, and force their + // OIDC token to be expired and require a refresh. The refresh will use the claims provided. + // It just calls the /users/me endpoint to trigger the refresh. + ForceRefresh func(t *testing.T, client *codersdk.Client, idToken jwt.MapClaims) +} + +type oidcTestConfig struct { + Userinfo jwt.MapClaims + + // Config allows modifying the Coderd OIDC configuration. + Config func(cfg *coderd.OIDCConfig) +} + +func (r *oidcTestRunner) AssertRoles(t *testing.T, userIdent string, roles []string) { t.Helper() - client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse + + ctx := testutil.Context(t, testutil.WaitMedium) + user, err := r.AdminClient.User(ctx, userIdent) + require.NoError(t, err) + + roleNames := []string{} + for _, role := range user.Roles { + roleNames = append(roleNames, role.Name) } - oauthURL, err := client.URL.Parse(fmt.Sprintf("/api/v2/users/oidc/callback?code=%s&state=somestate", code)) + require.ElementsMatch(t, roles, roleNames, "expected roles") +} + +func (r *oidcTestRunner) AssertGroups(t *testing.T, userIdent string, groups []string) { + t.Helper() + + if !slice.Contains(groups, database.EveryoneGroup) { + var cpy []string + cpy = append(cpy, groups...) + // always include everyone group + cpy = append(cpy, database.EveryoneGroup) + groups = cpy + } + ctx := testutil.Context(t, testutil.WaitMedium) + user, err := r.AdminClient.User(ctx, userIdent) require.NoError(t, err) - req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) + + allGroups, err := r.AdminClient.GroupsByOrganization(ctx, user.OrganizationIDs[0]) require.NoError(t, err) - req.AddCookie(&http.Cookie{ - Name: codersdk.OAuth2StateCookie, - Value: "somestate", + + userInGroups := []string{} + for _, g := range allGroups { + for _, mem := range g.Members { + if mem.ID == user.ID { + userInGroups = append(userInGroups, g.Name) + } + } + } + + require.ElementsMatch(t, groups, userInGroups, "expected groups") +} + +func setupOIDCTest(t *testing.T, settings oidcTestConfig) *oidcTestRunner { + t.Helper() + + fake := oidctest.NewFakeIDP(t, + oidctest.WithStaticUserInfo(settings.Userinfo), + oidctest.WithLogging(t, nil), + // Run fake IDP on a real webserver + oidctest.WithServing(), + ) + + ctx := testutil.Context(t, testutil.WaitMedium) + cfg := fake.OIDCConfig(t, nil, settings.Config) + owner, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + OIDCConfig: cfg, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureUserRoleManagement: 1, + codersdk.FeatureTemplateRBAC: 1, + }, + }, }) - res, err := client.HTTPClient.Do(req) - require.NoError(t, err) - defer res.Body.Close() - data, err := io.ReadAll(res.Body) + admin, err := owner.User(ctx, "me") require.NoError(t, err) - t.Log(string(data)) - return res + + helper := oidctest.NewLoginHelper(owner, fake) + + return &oidcTestRunner{ + AdminClient: owner, + AdminUser: admin, + API: api, + Login: helper.Login, + ForceRefresh: func(t *testing.T, client *codersdk.Client, idToken jwt.MapClaims) { + helper.ForceRefresh(t, api.Database, client, idToken) + }, + } } From 1de1e3b98ad18f652ee76c4444b2bc423285ed19 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 25 Aug 2023 16:52:10 -0300 Subject: [PATCH 254/277] fix(site): make right panel scrollable on template editor (#9344) --- .../src/components/Resources/ResourceCard.tsx | 2 +- .../TemplateVersionEditor.stories.tsx | 378 ++++++++++++++++-- .../TemplateVersionEditor.tsx | 34 +- 3 files changed, 372 insertions(+), 42 deletions(-) diff --git a/site/src/components/Resources/ResourceCard.tsx b/site/src/components/Resources/ResourceCard.tsx index f951cb0a4d2be..e3667e95bf587 100644 --- a/site/src/components/Resources/ResourceCard.tsx +++ b/site/src/components/Resources/ResourceCard.tsx @@ -28,7 +28,7 @@ export const ResourceCard: FC = ({ resource, agentRow }) => { : metadataToDisplay.slice(0, 4) return ( -
+
= { title: "components/TemplateVersionEditor", component: TemplateVersionEditor, + args: { + template: MockTemplate, + templateVersion: MockTemplateVersion, + defaultFileTree: MockTemplateVersionFileTree, + }, parameters: { layout: "fullscreen", }, } -const Template: Story = ( - args: TemplateVersionEditorProps, -) => - -export const Example = Template.bind({}) -Example.args = { - template: MockTemplate, - templateVersion: MockTemplateVersion, - defaultFileTree: MockTemplateVersionFileTree, -} +export default meta +type Story = StoryObj -export const Logs = Template.bind({}) +export const Example: Story = {} -Logs.args = { - template: MockTemplate, - templateVersion: MockTemplateVersion, - defaultFileTree: MockTemplateVersionFileTree, - buildLogs: MockWorkspaceBuildLogs, +export const Logs = { + args: { + buildLogs: MockWorkspaceBuildLogs, + }, } -export const Resources = Template.bind({}) +export const Resources: Story = { + args: { + buildLogs: MockWorkspaceBuildLogs, + resources: [ + MockWorkspaceResource, + MockWorkspaceResource2, + MockWorkspaceResource3, + ], + }, +} -Resources.args = { - template: MockTemplate, - templateVersion: MockTemplateVersion, - defaultFileTree: MockTemplateVersionFileTree, - buildLogs: MockWorkspaceBuildLogs, - resources: [ - MockWorkspaceResource, - MockWorkspaceResource2, - MockWorkspaceResource3, - ], +export const ManyLogs = { + args: { + templateVersion: { + ...MockTemplateVersion, + job: { + ...MockTemplateVersion.job, + error: + "template import provision for start: terraform plan: exit status 1", + }, + }, + buildLogs: [ + { + id: 938494, + created_at: "2023-08-25T19:07:43.331Z", + log_source: "provisioner_daemon", + log_level: "info", + stage: "Setting up", + output: "", + }, + { + id: 938495, + created_at: "2023-08-25T19:07:43.331Z", + log_source: "provisioner_daemon", + log_level: "info", + stage: "Parsing template parameters", + output: "", + }, + { + id: 938496, + created_at: "2023-08-25T19:07:43.339Z", + log_source: "provisioner_daemon", + log_level: "info", + stage: "Detecting persistent resources", + output: "", + }, + { + id: 938497, + created_at: "2023-08-25T19:07:44.15Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "Initializing the backend...", + }, + { + id: 938498, + created_at: "2023-08-25T19:07:44.215Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "Initializing provider plugins...", + }, + { + id: 938499, + created_at: "2023-08-25T19:07:44.216Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: '- Finding coder/coder versions matching "~> 0.11.0"...', + }, + { + id: 938500, + created_at: "2023-08-25T19:07:44.668Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: '- Finding kreuzwerker/docker versions matching "~> 3.0.1"...', + }, + { + id: 938501, + created_at: "2023-08-25T19:07:44.722Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "- Using coder/coder v0.11.1 from the shared cache directory", + }, + { + id: 938502, + created_at: "2023-08-25T19:07:44.857Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "- Using kreuzwerker/docker v3.0.2 from the shared cache directory", + }, + { + id: 938503, + created_at: "2023-08-25T19:07:45.081Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "Terraform has created a lock file .terraform.lock.hcl to record the provider", + }, + { + id: 938504, + created_at: "2023-08-25T19:07:45.081Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "selections it made above. Include this file in your version control repository", + }, + { + id: 938505, + created_at: "2023-08-25T19:07:45.081Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "so that Terraform can guarantee to make the same selections by default when", + }, + { + id: 938506, + created_at: "2023-08-25T19:07:45.082Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: 'you run "terraform init" in the future.', + }, + { + id: 938507, + created_at: "2023-08-25T19:07:45.083Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "Terraform has been successfully initialized!", + }, + { + id: 938508, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + 'You may now begin working with Terraform. Try running "terraform plan" to see', + }, + { + id: 938509, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "any changes that are required for your infrastructure. All Terraform commands", + }, + { + id: 938510, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "should now work.", + }, + { + id: 938511, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "If you ever set or change modules or backend configuration for Terraform,", + }, + { + id: 938512, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "rerun this command to reinitialize your working directory. If you forget, other", + }, + { + id: 938513, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "commands will detect it and remind you to do so if necessary.", + }, + { + id: 938514, + created_at: "2023-08-25T19:07:45.143Z", + log_source: "provisioner", + log_level: "info", + stage: "Detecting persistent resources", + output: "Terraform 1.1.9", + }, + { + id: 938515, + created_at: "2023-08-25T19:07:46.297Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: "Warning: Argument is deprecated", + }, + { + id: 938516, + created_at: "2023-08-25T19:07:46.297Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: 'on devcontainer-on-docker.tf line 15, in provider "coder":', + }, + { + id: 938517, + created_at: "2023-08-25T19:07:46.297Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: " 15: feature_use_managed_variables = true", + }, + { + id: 938518, + created_at: "2023-08-25T19:07:46.297Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: "", + }, + { + id: 938519, + created_at: "2023-08-25T19:07:46.297Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: + "Terraform variables are now exclusively utilized for template-wide variables after the removal of support for legacy parameters.", + }, + { + id: 938520, + created_at: "2023-08-25T19:07:46.3Z", + log_source: "provisioner", + log_level: "error", + stage: "Detecting persistent resources", + output: "Error: ephemeral parameter requires the default property", + }, + { + id: 938521, + created_at: "2023-08-25T19:07:46.3Z", + log_source: "provisioner", + log_level: "error", + stage: "Detecting persistent resources", + output: + 'on devcontainer-on-docker.tf line 27, in data "coder_parameter" "another_one":', + }, + { + id: 938522, + created_at: "2023-08-25T19:07:46.3Z", + log_source: "provisioner", + log_level: "error", + stage: "Detecting persistent resources", + output: ' 27: data "coder_parameter" "another_one" {', + }, + { + id: 938523, + created_at: "2023-08-25T19:07:46.301Z", + log_source: "provisioner", + log_level: "error", + stage: "Detecting persistent resources", + output: "", + }, + { + id: 938524, + created_at: "2023-08-25T19:07:46.301Z", + log_source: "provisioner", + log_level: "error", + stage: "Detecting persistent resources", + output: "", + }, + { + id: 938525, + created_at: "2023-08-25T19:07:46.303Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: "Warning: Argument is deprecated", + }, + { + id: 938526, + created_at: "2023-08-25T19:07:46.303Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: 'on devcontainer-on-docker.tf line 15, in provider "coder":', + }, + { + id: 938527, + created_at: "2023-08-25T19:07:46.303Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: " 15: feature_use_managed_variables = true", + }, + { + id: 938528, + created_at: "2023-08-25T19:07:46.303Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: "", + }, + { + id: 938529, + created_at: "2023-08-25T19:07:46.303Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: + "Terraform variables are now exclusively utilized for template-wide variables after the removal of support for legacy parameters.", + }, + { + id: 938530, + created_at: "2023-08-25T19:07:46.311Z", + log_source: "provisioner_daemon", + log_level: "info", + stage: "Cleaning Up", + output: "", + }, + ], + }, } diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx index 9ffe7e0e41b4c..33577ea4488c4 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -376,7 +376,17 @@ export const TemplateVersionEditor: FC = ({ > {templateVersion.job.error && (
- + + `1px solid ${theme.palette.divider}`, + borderLeft: (theme) => + `2px solid ${theme.palette.error.main}`, + }} + > Error during the build {templateVersion.job.error} @@ -385,7 +395,7 @@ export const TemplateVersionEditor: FC = ({ {buildLogs && buildLogs.length > 0 && ( @@ -393,7 +403,7 @@ export const TemplateVersionEditor: FC = ({
@@ -470,6 +480,7 @@ const useStyles = makeStyles< display: "flex", flex: 1, flexBasis: 0, + overflow: "hidden", }, sidebar: { minWidth: 256, @@ -505,17 +516,23 @@ const useStyles = makeStyles< }, panelWrapper: { flex: 1, + borderLeft: `1px solid ${theme.palette.divider}`, + overflow: "hidden", display: "flex", flexDirection: "column", - borderLeft: `1px solid ${theme.palette.divider}`, - overflowY: "auto", }, panel: { - padding: theme.spacing(1), + overflowY: "auto", + height: "100%", "&.hidden": { display: "none", }, + + // Hack to access customize resource-card from here + "& .resource-card": { + border: 0, + }, }, tabs: { borderBottom: `1px solid ${theme.palette.divider}`, @@ -586,7 +603,8 @@ const useStyles = makeStyles< buildLogs: { display: "flex", flexDirection: "column", - overflowY: "auto", - gap: theme.spacing(1), + }, + resources: { + paddingBottom: theme.spacing(2), }, })) From 7904d0b92ffe8164e06b624dfe3c2d7ab0392084 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 25 Aug 2023 23:48:35 +0300 Subject: [PATCH 255/277] docs: list firewall exceptions for restricted internet installations (#8936) * docs: add firewall exceptions for restricted internet installtions closes #7542 * fix link * fmt --- docs/install/offline.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/install/offline.md b/docs/install/offline.md index 93039ec3e6670..e7798670c18d5 100644 --- a/docs/install/offline.md +++ b/docs/install/offline.md @@ -225,3 +225,14 @@ server, as demonstrated in the example below: With these steps, you'll have the Coder documentation hosted on your server and accessible for your team to use. + +## Firewall exceptions + +In restricted internet networks, Coder may require connection to internet. +Ensure that the following web addresses are accessible from the machine where +Coder is installed. + +- code-server.dev (install via AUR) +- open-vsx.org (optional if someone would use code-server) +- registry.terraform.io (to create and push template) +- v2-licensor.coder.com (developing Coder in Coder) From 451ca042ceb7228d3784bd5d2ffd4848182265c7 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Fri, 25 Aug 2023 17:16:30 -0500 Subject: [PATCH 256/277] feat(site): show entity name in DeleteDialog (#9347) --- site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx | 2 +- site/src/i18n/en/common.json | 2 +- site/src/pages/UsersPage/UsersPage.test.tsx | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx index 0a56fe65b25c6..f46405cbb4c31 100644 --- a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx +++ b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx @@ -39,7 +39,7 @@ export const DeleteDialog: FC> = ({

{info}

-

{t("deleteDialog.confirm", { entity })}

+

{t("deleteDialog.confirm", { entity, name })}

{ diff --git a/site/src/i18n/en/common.json b/site/src/i18n/en/common.json index a22e318e6e8c5..875976e4d8e41 100644 --- a/site/src/i18n/en/common.json +++ b/site/src/i18n/en/common.json @@ -16,7 +16,7 @@ "deleteDialog": { "title": "Delete {{entity}}", "intro": "Deleting this {{entity}} is irreversible!", - "confirm": "Are you sure you want to proceed? Type the name of this {{entity}} below to confirm.", + "confirm": "Are you sure you want to proceed? Type {{name}} below to confirm.", "confirmLabel": "Name of {{entity}} to delete", "incorrectName": "Incorrect {{entity}} name." }, diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index ef2ec64d44107..16ca542a5d916 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -72,7 +72,11 @@ const deleteUser = async (setupActionSpies: () => void) => { // Check if the confirm message is displayed const confirmDialog = await screen.findByRole("dialog") expect(confirmDialog).toHaveTextContent( - t("deleteDialog.confirm", { ns: "common", entity: "user" }).toString(), + t("deleteDialog.confirm", { + ns: "common", + entity: "user", + name: MockUser2.username, + }).toString(), ) // Confirm with text input From f97b49796628619efc1e39fc4b2f2b6a6c426610 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Sun, 27 Aug 2023 01:22:28 +0300 Subject: [PATCH 257/277] chore(dogfood): update docker tf provider and metadata (#9356) --- dogfood/main.tf | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/dogfood/main.tf b/dogfood/main.tf index 72774cba12f92..6f225f7f32dec 100644 --- a/dogfood/main.tf +++ b/dogfood/main.tf @@ -6,7 +6,7 @@ terraform { } docker = { source = "kreuzwerker/docker" - version = "~> 2.22.0" + version = "~> 3.0.0" } } } @@ -40,9 +40,10 @@ data "coder_parameter" "dotfiles_url" { } data "coder_parameter" "region" { - type = "string" - name = "Region" - icon = "/emojis/1f30e.png" + type = "string" + name = "Region" + icon = "/emojis/1f30e.png" + default = "us-pittsburgh" option { icon = "/emojis/1f1fa-1f1f8.png" name = "Pittsburgh" @@ -326,4 +327,8 @@ resource "coder_metadata" "container_info" { key = "runtime" value = docker_container.workspace[0].runtime } + item { + key = "region" + value = data.coder_parameter.region.option[index(data.coder_parameter.region.option.*.value, data.coder_parameter.region.value)].name + } } From 54032ccfe8b6d1347d12ba0a23ffac1060466b4e Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Sun, 27 Aug 2023 02:02:22 +0300 Subject: [PATCH 258/277] ci: update pr-cleanup.yaml to remove `set -x` (#9358) --- .github/workflows/pr-cleanup.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-cleanup.yaml b/.github/workflows/pr-cleanup.yaml index 510c8f4299361..d32ea2f5d49b7 100644 --- a/.github/workflows/pr-cleanup.yaml +++ b/.github/workflows/pr-cleanup.yaml @@ -1,4 +1,4 @@ -name: Cleanup PR deployment and image +name: pr-cleanup on: pull_request: types: closed @@ -35,14 +35,14 @@ jobs: - name: Set up kubeconfig run: | - set -euxo pipefail + set -euo pipefail mkdir -p ~/.kube echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config export KUBECONFIG=~/.kube/config - name: Delete helm release run: | - set -euxo pipefail + set -euo pipefail helm delete --namespace "pr${{ steps.pr_number.outputs.PR_NUMBER }}" "pr${{ steps.pr_number.outputs.PR_NUMBER }}" || echo "helm release not found" - name: "Remove PR namespace" @@ -51,7 +51,7 @@ jobs: - name: "Remove DNS records" run: | - set -euxo pipefail + set -euo pipefail # Get identifier for the record record_id=$(curl -X GET "https://api.cloudflare.com/client/v4/zones/${{ secrets.PR_DEPLOYMENTS_ZONE_ID }}/dns_records?name=%2A.pr${{ steps.pr_number.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}" \ -H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \ From c3ac55ff4244b32168065c5a81c247fde5a8c8dd Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 27 Aug 2023 11:26:20 -0500 Subject: [PATCH 259/277] feat: add `template_active_version_id` to workspaces (#9226) * feat: add `template_active_version_id` to workspaces This reduces a fetch in the VS Code extension when getting the active version update message! * Fix entities.ts * Fix golden gen --- cli/testdata/coder_list_--output_json.golden | 1 + coderd/apidoc/docs.go | 4 ++++ coderd/apidoc/swagger.json | 4 ++++ coderd/workspaces.go | 1 + codersdk/workspaces.go | 1 + docs/api/schemas.md | 3 +++ docs/api/workspaces.md | 5 +++++ site/src/api/typesGenerated.ts | 1 + site/src/testHelpers/entities.ts | 1 + 9 files changed, 21 insertions(+) diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index f680c9e210cbc..2e317f996047b 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -11,6 +11,7 @@ "template_display_name": "", "template_icon": "", "template_allow_user_cancel_workspace_jobs": false, + "template_active_version_id": "[version ID]", "latest_build": { "id": "[workspace build ID]", "created_at": "[timestamp]", diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2668bf41b024d..58624b22a908f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10555,6 +10555,10 @@ const docTemplate = `{ "owner_name": { "type": "string" }, + "template_active_version_id": { + "type": "string", + "format": "uuid" + }, "template_allow_user_cancel_workspace_jobs": { "type": "boolean" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ccf504f4d7cc7..7342e5140598e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9557,6 +9557,10 @@ "owner_name": { "type": "string" }, + "template_active_version_id": { + "type": "string", + "format": "uuid" + }, "template_allow_user_cancel_workspace_jobs": { "type": "boolean" }, diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 92e4c029f3777..707c85200488b 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1187,6 +1187,7 @@ func convertWorkspace( TemplateIcon: template.Icon, TemplateDisplayName: template.DisplayName, TemplateAllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + TemplateActiveVersionID: template.ActiveVersionID, Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(), Name: workspace.Name, AutostartSchedule: autostartSchedule, diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index d7b191c6273b6..05e1a156d1122 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -28,6 +28,7 @@ type Workspace struct { TemplateDisplayName string `json:"template_display_name"` TemplateIcon string `json:"template_icon"` TemplateAllowUserCancelWorkspaceJobs bool `json:"template_allow_user_cancel_workspace_jobs"` + TemplateActiveVersionID uuid.UUID `json:"template_active_version_id" format:"uuid"` LatestBuild WorkspaceBuild `json:"latest_build"` Outdated bool `json:"outdated"` Name string `json:"name"` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index f62da873c5c3d..60bb7a6208c3c 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -5497,6 +5497,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", "template_icon": "string", @@ -5524,6 +5525,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `outdated` | boolean | false | | | | `owner_id` | string | false | | | | `owner_name` | string | false | | | +| `template_active_version_id` | string | false | | | | `template_allow_user_cancel_workspace_jobs` | boolean | false | | | | `template_display_name` | string | false | | | | `template_icon` | string | false | | | @@ -6629,6 +6631,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", "template_icon": "string", diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 7c4e9319cd2b8..ac4eda1069fd3 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -188,6 +188,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", "template_icon": "string", @@ -376,6 +377,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", "template_icon": "string", @@ -563,6 +565,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", "template_icon": "string", @@ -752,6 +755,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", "template_icon": "string", @@ -1020,6 +1024,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "outdated": true, "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", "template_icon": "string", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7315caf7ded97..2cd98259380b0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1303,6 +1303,7 @@ export interface Workspace { readonly template_display_name: string readonly template_icon: string readonly template_allow_user_cancel_workspace_jobs: boolean + readonly template_active_version_id: string readonly latest_build: WorkspaceBuild readonly outdated: boolean readonly name: string diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 2e3438c1fe293..349857550abf5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -848,6 +848,7 @@ export const MockWorkspace: TypesGen.Workspace = { template_display_name: MockTemplate.display_name, template_allow_user_cancel_workspace_jobs: MockTemplate.allow_user_cancel_workspace_jobs, + template_active_version_id: MockTemplate.active_version_id, outdated: false, owner_id: MockUser.id, organization_id: MockOrganization.id, From 61634d482fdf45d0ef4c09ba7c18e5d7f6cc2687 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 27 Aug 2023 11:26:31 -0500 Subject: [PATCH 260/277] fix: truncate websocket close error (#9360) Related #9324 --- coderd/provisionerjobs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 087fd0e367aab..4b49c385c80f4 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -402,7 +402,7 @@ func (f *logFollower) follow() { if f.ctx.Err() == nil && !xerrors.Is(err, io.EOF) { // neither context expiry, nor EOF, close and log f.logger.Error(f.ctx, "failed to query logs", slog.Error(err)) - err = f.conn.Close(websocket.StatusInternalError, err.Error()) + err = f.conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("%s", err.Error())) if err != nil { f.logger.Warn(f.ctx, "failed to close webscoket", slog.Error(err)) } From 4a140536e1c58bfff6aca2542c848d962843ff02 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sun, 27 Aug 2023 11:42:51 -0500 Subject: [PATCH 261/277] ci: lint against `dupl` (#9357) This lint rule should help us keep Go code redundancy under control. --- .golangci.yaml | 5 +++++ cli/update_test.go | 17 +++-------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 156d6649890b3..15c381a682116 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -2,6 +2,10 @@ # Over time we should try tightening some of these. linters-settings: + dupl: + # goal: 100 + threshold: 412 + exhaustruct: include: # Gradually extend to cover more of the codebase. @@ -268,3 +272,4 @@ linters: - typecheck - unconvert - unused + - dupl diff --git a/cli/update_test.go b/cli/update_test.go index 0efa1f997cb69..38b042d2813f0 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -602,13 +602,9 @@ func TestUpdateValidateRichParameters(t *testing.T) { // Update the workspace inv, root = clitest.New(t, "update", "my-workspace") clitest.SetupConfig(t, client, root) - doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) - go func() { - defer close(doneChan) - err := inv.Run() - assert.NoError(t, err) - }() + clitest.Start(t, inv) matches := []string{ stringParameterName, "second_option", @@ -623,7 +619,6 @@ func TestUpdateValidateRichParameters(t *testing.T) { pty.WriteLine(value) } } - <-doneChan }) t.Run("ParameterOptionDisappeared", func(t *testing.T) { @@ -668,13 +663,8 @@ func TestUpdateValidateRichParameters(t *testing.T) { // Update the workspace inv, root = clitest.New(t, "update", "my-workspace") clitest.SetupConfig(t, client, root) - doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) - go func() { - defer close(doneChan) - err := inv.Run() - assert.NoError(t, err) - }() + clitest.Start(t, inv) matches := []string{ stringParameterName, "Third option", @@ -689,7 +679,6 @@ func TestUpdateValidateRichParameters(t *testing.T) { pty.WriteLine(value) } } - <-doneChan }) t.Run("ImmutableRequiredParameterExists_MutableRequiredParameterAdded", func(t *testing.T) { From 173aac959ca734b771b0b5bcfb06532d106e59b3 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Sun, 27 Aug 2023 14:35:06 -0500 Subject: [PATCH 262/277] fix(systemd): use more reasonable restart limit (#9355) --- scripts/linux-pkg/coder.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/linux-pkg/coder.service b/scripts/linux-pkg/coder.service index 4ff2cc260a9bf..32246491880d4 100644 --- a/scripts/linux-pkg/coder.service +++ b/scripts/linux-pkg/coder.service @@ -4,7 +4,7 @@ Documentation=https://coder.com/docs/coder-oss Requires=network-online.target After=network-online.target ConditionFileNotEmpty=/etc/coder.d/coder.env -StartLimitIntervalSec=60 +StartLimitIntervalSec=10 StartLimitBurst=3 [Service] From 6ba92ef924e9ae35cca598bfb1ee7820a44a06b0 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sun, 27 Aug 2023 14:46:44 -0500 Subject: [PATCH 263/277] ci: enable gocognit (#9359) And, bring the server under 300: * Removed the undocumented "disable" STUN address in favor of the --disable-direct flag. --- .golangci.yaml | 12 +- cli/configssh.go | 1 - cli/root.go | 7 + cli/server.go | 476 +++++++++--------- cli/server_test.go | 25 - cli/ssh.go | 1 - coderd/coderd.go | 2 - coderd/coderdtest/coderdtest.go | 2 +- coderd/database/dbfake/dbfake.go | 1 - .../provisionerdserver/provisionerdserver.go | 2 - coderd/workspacebuilds.go | 1 - provisioner/terraform/resources.go | 1 - scripts/apitypings/main.go | 2 - tailnet/derpmap.go | 4 +- 14 files changed, 264 insertions(+), 273 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 15c381a682116..5f474602b2cfd 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -11,7 +11,7 @@ linters-settings: # Gradually extend to cover more of the codebase. - 'httpmw\.\w+' gocognit: - min-complexity: 46 # Min code complexity (def 30). + min-complexity: 300 goconst: min-len: 4 # Min length of string consts (def 3). @@ -122,10 +122,6 @@ linters-settings: goimports: local-prefixes: coder.com,cdr.dev,go.coder.com,github.com/cdr,github.com/coder - gocyclo: - # goal: 30 - min-complexity: 47 - importas: no-unaliased: true @@ -236,7 +232,11 @@ linters: - exportloopref - forcetypeassert - gocritic - - gocyclo + # gocyclo is may be useful in the future when we start caring + # about testing complexity, but for the time being we should + # create a good culture around cognitive complexity. + # - gocyclo + - gocognit - goimports - gomodguard - gosec diff --git a/cli/configssh.go b/cli/configssh.go index d153814013efc..7e9e8109ea554 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -190,7 +190,6 @@ func sshPrepareWorkspaceConfigs(ctx context.Context, client *codersdk.Client) (r } } -//nolint:gocyclo func (r *RootCmd) configSSH() *clibase.Cmd { var ( sshConfigFile string diff --git a/cli/root.go b/cli/root.go index fad5625d909c0..3ab2f0d7f33b9 100644 --- a/cli/root.go +++ b/cli/root.go @@ -831,6 +831,13 @@ func (r *RootCmd) checkWarnings(i *clibase.Invocation, client *codersdk.Client) return nil } +// Verbosef logs a message if verbose mode is enabled. +func (r *RootCmd) Verbosef(inv *clibase.Invocation, fmtStr string, args ...interface{}) { + if r.verbose { + cliui.Infof(inv.Stdout, fmtStr, args...) + } +} + type headerTransport struct { transport http.RoundTripper header http.Header diff --git a/cli/server.go b/cli/server.go index 9d6b4f975cc79..779215f0fce35 100644 --- a/cli/server.go +++ b/cli/server.go @@ -176,18 +176,147 @@ func ReadGitAuthProvidersFromEnv(environ []string) ([]codersdk.GitAuthConfig, er return providers, nil } -// nolint:gocyclo +func createOIDCConfig(ctx context.Context, vals *codersdk.DeploymentValues) (*coderd.OIDCConfig, error) { + if vals.OIDC.ClientID == "" { + return nil, xerrors.Errorf("OIDC client ID must be set!") + } + if vals.OIDC.IssuerURL == "" { + return nil, xerrors.Errorf("OIDC issuer URL must be set!") + } + + oidcProvider, err := oidc.NewProvider( + ctx, vals.OIDC.IssuerURL.String(), + ) + if err != nil { + return nil, xerrors.Errorf("configure oidc provider: %w", err) + } + redirectURL, err := vals.AccessURL.Value().Parse("/api/v2/users/oidc/callback") + if err != nil { + return nil, xerrors.Errorf("parse oidc oauth callback url: %w", err) + } + // If the scopes contain 'groups', we enable group support. + // Do not override any custom value set by the user. + if slice.Contains(vals.OIDC.Scopes, "groups") && vals.OIDC.GroupField == "" { + vals.OIDC.GroupField = "groups" + } + oauthCfg := &oauth2.Config{ + ClientID: vals.OIDC.ClientID.String(), + ClientSecret: vals.OIDC.ClientSecret.String(), + RedirectURL: redirectURL.String(), + Endpoint: oidcProvider.Endpoint(), + Scopes: vals.OIDC.Scopes, + } + + var useCfg httpmw.OAuth2Config = oauthCfg + if vals.OIDC.ClientKeyFile != "" { + // PKI authentication is done in the params. If a + // counter example is found, we can add a config option to + // change this. + oauthCfg.Endpoint.AuthStyle = oauth2.AuthStyleInParams + if vals.OIDC.ClientSecret != "" { + return nil, xerrors.Errorf("cannot specify both oidc client secret and oidc client key file") + } + + pkiCfg, err := configureOIDCPKI(oauthCfg, vals.OIDC.ClientKeyFile.Value(), vals.OIDC.ClientCertFile.Value()) + if err != nil { + return nil, xerrors.Errorf("configure oauth pki authentication: %w", err) + } + useCfg = pkiCfg + } + return &coderd.OIDCConfig{ + OAuth2Config: useCfg, + Provider: oidcProvider, + Verifier: oidcProvider.Verifier(&oidc.Config{ + ClientID: vals.OIDC.ClientID.String(), + }), + EmailDomain: vals.OIDC.EmailDomain, + AllowSignups: vals.OIDC.AllowSignups.Value(), + UsernameField: vals.OIDC.UsernameField.String(), + EmailField: vals.OIDC.EmailField.String(), + AuthURLParams: vals.OIDC.AuthURLParams.Value, + IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(), + GroupField: vals.OIDC.GroupField.String(), + GroupFilter: vals.OIDC.GroupRegexFilter.Value(), + CreateMissingGroups: vals.OIDC.GroupAutoCreate.Value(), + GroupMapping: vals.OIDC.GroupMapping.Value, + UserRoleField: vals.OIDC.UserRoleField.String(), + UserRoleMapping: vals.OIDC.UserRoleMapping.Value, + UserRolesDefault: vals.OIDC.UserRolesDefault.GetSlice(), + SignInText: vals.OIDC.SignInText.String(), + IconURL: vals.OIDC.IconURL.String(), + IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(), + }, nil +} + +func afterCtx(ctx context.Context, fn func()) { + go func() { + <-ctx.Done() + fn() + }() +} + +func enablePrometheus( + ctx context.Context, + logger slog.Logger, + vals *codersdk.DeploymentValues, + options *coderd.Options, +) (closeFn func(), err error) { + options.PrometheusRegistry.MustRegister(collectors.NewGoCollector()) + options.PrometheusRegistry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + + closeUsersFunc, err := prometheusmetrics.ActiveUsers(ctx, options.PrometheusRegistry, options.Database, 0) + if err != nil { + return nil, xerrors.Errorf("register active users prometheus metric: %w", err) + } + afterCtx(ctx, closeUsersFunc) + + closeWorkspacesFunc, err := prometheusmetrics.Workspaces(ctx, options.PrometheusRegistry, options.Database, 0) + if err != nil { + return nil, xerrors.Errorf("register workspaces prometheus metric: %w", err) + } + afterCtx(ctx, closeWorkspacesFunc) + + if vals.Prometheus.CollectAgentStats { + closeAgentStatsFunc, err := prometheusmetrics.AgentStats(ctx, logger, options.PrometheusRegistry, options.Database, time.Now(), 0) + if err != nil { + return nil, xerrors.Errorf("register agent stats prometheus metric: %w", err) + } + afterCtx(ctx, closeAgentStatsFunc) + + metricsAggregator, err := prometheusmetrics.NewMetricsAggregator(logger, options.PrometheusRegistry, 0) + if err != nil { + return nil, xerrors.Errorf("can't initialize metrics aggregator: %w", err) + } + + cancelMetricsAggregator := metricsAggregator.Run(ctx) + afterCtx(ctx, cancelMetricsAggregator) + + options.UpdateAgentMetrics = metricsAggregator.Update + err = options.PrometheusRegistry.Register(metricsAggregator) + if err != nil { + return nil, xerrors.Errorf("can't register metrics aggregator as collector: %w", err) + } + } + + //nolint:revive + return ServeHandler( + ctx, logger, promhttp.InstrumentMetricHandler( + options.PrometheusRegistry, promhttp.HandlerFor(options.PrometheusRegistry, promhttp.HandlerOpts{}), + ), vals.Prometheus.Address.String(), "prometheus", + ), nil +} + func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, io.Closer, error)) *clibase.Cmd { var ( - cfg = new(codersdk.DeploymentValues) - opts = cfg.Options() + vals = new(codersdk.DeploymentValues) + opts = vals.Options() ) serverCmd := &clibase.Cmd{ Use: "server", Short: "Start a Coder server", Options: opts, Middleware: clibase.Chain( - WriteConfigMW(cfg), + WriteConfigMW(vals), PrintDeprecatedOptions(), clibase.RequireNArgs(0), ), @@ -197,32 +326,32 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. ctx, cancel := context.WithCancel(inv.Context()) defer cancel() - if cfg.Config != "" { + if vals.Config != "" { cliui.Warnf(inv.Stderr, "YAML support is experimental and offers no compatibility guarantees.") } go DumpHandler(ctx) // Validate bind addresses. - if cfg.Address.String() != "" { - if cfg.TLS.Enable { - cfg.HTTPAddress = "" - cfg.TLS.Address = cfg.Address + if vals.Address.String() != "" { + if vals.TLS.Enable { + vals.HTTPAddress = "" + vals.TLS.Address = vals.Address } else { - _ = cfg.HTTPAddress.Set(cfg.Address.String()) - cfg.TLS.Address.Host = "" - cfg.TLS.Address.Port = "" + _ = vals.HTTPAddress.Set(vals.Address.String()) + vals.TLS.Address.Host = "" + vals.TLS.Address.Port = "" } } - if cfg.TLS.Enable && cfg.TLS.Address.String() == "" { + if vals.TLS.Enable && vals.TLS.Address.String() == "" { return xerrors.Errorf("TLS address must be set if TLS is enabled") } - if !cfg.TLS.Enable && cfg.HTTPAddress.String() == "" { + if !vals.TLS.Enable && vals.HTTPAddress.String() == "" { return xerrors.Errorf("TLS is disabled. Enable with --tls-enable or specify a HTTP address") } - if cfg.AccessURL.String() != "" && - !(cfg.AccessURL.Scheme == "http" || cfg.AccessURL.Scheme == "https") { + if vals.AccessURL.String() != "" && + !(vals.AccessURL.Scheme == "http" || vals.AccessURL.Scheme == "https") { return xerrors.Errorf("access-url must include a scheme (e.g. 'http://' or 'https://)") } @@ -230,14 +359,14 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // was specified. loginRateLimit := 60 filesRateLimit := 12 - if cfg.RateLimit.DisableAll { - cfg.RateLimit.API = -1 + if vals.RateLimit.DisableAll { + vals.RateLimit.API = -1 loginRateLimit = -1 filesRateLimit = -1 } PrintLogo(inv, "Coder") - logger, logCloser, err := BuildLogger(inv, cfg) + logger, logCloser, err := BuildLogger(inv, vals) if err != nil { return xerrors.Errorf("make logger: %w", err) } @@ -260,7 +389,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. notifyCtx, notifyStop := signal.NotifyContext(ctx, InterruptSignals...) defer notifyStop() - cacheDir := cfg.CacheDir.String() + cacheDir := vals.CacheDir.String() err = os.MkdirAll(cacheDir, 0o700) if err != nil { return xerrors.Errorf("create cache directory: %w", err) @@ -271,14 +400,14 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // which is caught by goleaks. defer http.DefaultClient.CloseIdleConnections() - tracerProvider, sqlDriver, closeTracing := ConfigureTraceProvider(ctx, logger, inv, cfg) + tracerProvider, sqlDriver, closeTracing := ConfigureTraceProvider(ctx, logger, inv, vals) defer func() { logger.Debug(ctx, "closing tracing") traceCloseErr := shutdownWithTimeout(closeTracing, 5*time.Second) logger.Debug(ctx, "tracing closed", slog.Error(traceCloseErr)) }() - httpServers, err := ConfigureHTTPServers(inv, cfg) + httpServers, err := ConfigureHTTPServers(inv, vals) if err != nil { return xerrors.Errorf("configure http(s): %w", err) } @@ -288,7 +417,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. builtinPostgres := false // Only use built-in if PostgreSQL URL isn't specified! - if !cfg.InMemoryDatabase && cfg.PostgresURL == "" { + if !vals.InMemoryDatabase && vals.PostgresURL == "" { var closeFunc func() error cliui.Infof(inv.Stdout, "Using built-in PostgreSQL (%s)", config.PostgresPath()) pgURL, closeFunc, err := startBuiltinPostgres(ctx, config, logger) @@ -296,7 +425,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return err } - err = cfg.PostgresURL.Set(pgURL) + err = vals.PostgresURL.Set(pgURL) if err != nil { return err } @@ -320,9 +449,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. ctx, httpClient, err := ConfigureHTTPClient( ctx, - cfg.TLS.ClientCertFile.String(), - cfg.TLS.ClientKeyFile.String(), - cfg.TLS.ClientCAFile.String(), + vals.TLS.ClientCertFile.String(), + vals.TLS.ClientKeyFile.String(), + vals.TLS.ClientCAFile.String(), ) if err != nil { return xerrors.Errorf("configure http client: %w", err) @@ -334,30 +463,30 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. tunnel *tunnelsdk.Tunnel tunnelDone <-chan struct{} = make(chan struct{}, 1) ) - if cfg.AccessURL.String() == "" { + if vals.AccessURL.String() == "" { cliui.Infof(inv.Stderr, "Opening tunnel so workspaces can connect to your deployment. For production scenarios, specify an external access URL") - tunnel, err = devtunnel.New(ctx, logger.Named("net.devtunnel"), cfg.WgtunnelHost.String()) + tunnel, err = devtunnel.New(ctx, logger.Named("net.devtunnel"), vals.WgtunnelHost.String()) if err != nil { return xerrors.Errorf("create tunnel: %w", err) } defer tunnel.Close() tunnelDone = tunnel.Wait() - cfg.AccessURL = clibase.URL(*tunnel.URL) + vals.AccessURL = clibase.URL(*tunnel.URL) - if cfg.WildcardAccessURL.String() == "" { + if vals.WildcardAccessURL.String() == "" { // Suffixed wildcard access URL. u, err := url.Parse(fmt.Sprintf("*--%s", tunnel.URL.Hostname())) if err != nil { return xerrors.Errorf("parse wildcard url: %w", err) } - cfg.WildcardAccessURL = clibase.URL(*u) + vals.WildcardAccessURL = clibase.URL(*u) } } - _, accessURLPortRaw, _ := net.SplitHostPort(cfg.AccessURL.Host) + _, accessURLPortRaw, _ := net.SplitHostPort(vals.AccessURL.Host) if accessURLPortRaw == "" { accessURLPortRaw = "80" - if cfg.AccessURL.Scheme == "https" { + if vals.AccessURL.Scheme == "https" { accessURLPortRaw = "443" } } @@ -367,8 +496,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("parse access URL port: %w", err) } - // Warn the user if the access URL appears to be a loopback address. - isLocal, err := IsLocalURL(ctx, cfg.AccessURL.Value()) + // Warn the user if the access URL is loopback or unresolvable. + isLocal, err := IsLocalURL(ctx, vals.AccessURL.Value()) if isLocal || err != nil { reason := "could not be resolved" if isLocal { @@ -377,12 +506,12 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. cliui.Warnf( inv.Stderr, "The access URL %s %s, this may cause unexpected problems when creating workspaces. Generate a unique *.try.coder.app URL by not specifying an access URL.\n", - cliui.DefaultStyles.Field.Render(cfg.AccessURL.String()), reason, + cliui.DefaultStyles.Field.Render(vals.AccessURL.String()), reason, ) } // A newline is added before for visibility in terminal output. - cliui.Infof(inv.Stdout, "\nView the Web UI: %s", cfg.AccessURL.String()) + cliui.Infof(inv.Stdout, "\nView the Web UI: %s", vals.AccessURL.String()) // Used for zero-trust instance identity with Google Cloud. googleTokenValidator, err := idtoken.NewValidator(ctx, option.WithoutAuthentication()) @@ -390,51 +519,39 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return err } - sshKeygenAlgorithm, err := gitsshkey.ParseAlgorithm(cfg.SSHKeygenAlgorithm.String()) + sshKeygenAlgorithm, err := gitsshkey.ParseAlgorithm(vals.SSHKeygenAlgorithm.String()) if err != nil { - return xerrors.Errorf("parse ssh keygen algorithm %s: %w", cfg.SSHKeygenAlgorithm, err) + return xerrors.Errorf("parse ssh keygen algorithm %s: %w", vals.SSHKeygenAlgorithm, err) } defaultRegion := &tailcfg.DERPRegion{ EmbeddedRelay: true, - RegionID: int(cfg.DERP.Server.RegionID.Value()), - RegionCode: cfg.DERP.Server.RegionCode.String(), - RegionName: cfg.DERP.Server.RegionName.String(), + RegionID: int(vals.DERP.Server.RegionID.Value()), + RegionCode: vals.DERP.Server.RegionCode.String(), + RegionName: vals.DERP.Server.RegionName.String(), Nodes: []*tailcfg.DERPNode{{ - Name: fmt.Sprintf("%db", cfg.DERP.Server.RegionID), - RegionID: int(cfg.DERP.Server.RegionID.Value()), - HostName: cfg.AccessURL.Value().Hostname(), + Name: fmt.Sprintf("%db", vals.DERP.Server.RegionID), + RegionID: int(vals.DERP.Server.RegionID.Value()), + HostName: vals.AccessURL.Value().Hostname(), DERPPort: accessURLPort, STUNPort: -1, - ForceHTTP: cfg.AccessURL.Scheme == "http", + ForceHTTP: vals.AccessURL.Scheme == "http", }}, } - if !cfg.DERP.Server.Enable { + if !vals.DERP.Server.Enable { defaultRegion = nil } - // HACK: see https://github.com/coder/coder/issues/6791. - for _, addr := range cfg.DERP.Server.STUNAddresses { - if addr != "disable" { - continue - } - err := cfg.DERP.Server.STUNAddresses.Replace(nil) - if err != nil { - panic(err) - } - break - } - derpMap, err := tailnet.NewDERPMap( - ctx, defaultRegion, cfg.DERP.Server.STUNAddresses, - cfg.DERP.Config.URL.String(), cfg.DERP.Config.Path.String(), - cfg.DERP.Config.BlockDirect.Value(), + ctx, defaultRegion, vals.DERP.Server.STUNAddresses, + vals.DERP.Config.URL.String(), vals.DERP.Config.Path.String(), + vals.DERP.Config.BlockDirect.Value(), ) if err != nil { return xerrors.Errorf("create derp map: %w", err) } - appHostname := cfg.WildcardAccessURL.String() + appHostname := vals.WildcardAccessURL.String() var appHostnameRegex *regexp.Regexp if appHostname != "" { appHostnameRegex, err = httpapi.CompileHostnamePattern(appHostname) @@ -448,10 +565,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("read git auth providers from env: %w", err) } - cfg.GitAuthProviders.Value = append(cfg.GitAuthProviders.Value, gitAuthEnv...) + vals.GitAuthProviders.Value = append(vals.GitAuthProviders.Value, gitAuthEnv...) gitAuthConfigs, err := gitauth.ConvertConfig( - cfg.GitAuthProviders.Value, - cfg.AccessURL.Value(), + vals.GitAuthProviders.Value, + vals.AccessURL.Value(), ) if err != nil { return xerrors.Errorf("convert git auth config: %w", err) @@ -463,18 +580,18 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. ) } - realIPConfig, err := httpmw.ParseRealIPConfig(cfg.ProxyTrustedHeaders, cfg.ProxyTrustedOrigins) + realIPConfig, err := httpmw.ParseRealIPConfig(vals.ProxyTrustedHeaders, vals.ProxyTrustedOrigins) if err != nil { return xerrors.Errorf("parse real ip config: %w", err) } - configSSHOptions, err := cfg.SSHConfig.ParseOptions() + configSSHOptions, err := vals.SSHConfig.ParseOptions() if err != nil { - return xerrors.Errorf("parse ssh config options %q: %w", cfg.SSHConfig.SSHConfigOptions.String(), err) + return xerrors.Errorf("parse ssh config options %q: %w", vals.SSHConfig.SSHConfigOptions.String(), err) } options := &coderd.Options{ - AccessURL: cfg.AccessURL.Value(), + AccessURL: vals.AccessURL.Value(), AppHostname: appHostname, AppHostnameRegex: appHostnameRegex, Logger: logger.Named("coderd"), @@ -485,22 +602,22 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. GoogleTokenValidator: googleTokenValidator, GitAuthConfigs: gitAuthConfigs, RealIPConfig: realIPConfig, - SecureAuthCookie: cfg.SecureAuthCookie.Value(), + SecureAuthCookie: vals.SecureAuthCookie.Value(), SSHKeygenAlgorithm: sshKeygenAlgorithm, TracerProvider: tracerProvider, Telemetry: telemetry.NewNoop(), - MetricsCacheRefreshInterval: cfg.MetricsCacheRefreshInterval.Value(), - AgentStatsRefreshInterval: cfg.AgentStatRefreshInterval.Value(), - DeploymentValues: cfg, + MetricsCacheRefreshInterval: vals.MetricsCacheRefreshInterval.Value(), + AgentStatsRefreshInterval: vals.AgentStatRefreshInterval.Value(), + DeploymentValues: vals, PrometheusRegistry: prometheus.NewRegistry(), - APIRateLimit: int(cfg.RateLimit.API.Value()), + APIRateLimit: int(vals.RateLimit.API.Value()), LoginRateLimit: loginRateLimit, FilesRateLimit: filesRateLimit, HTTPClient: httpClient, TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{}, UserQuietHoursScheduleStore: &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{}, SSHConfig: codersdk.SSHConfigResponse{ - HostnamePrefix: cfg.SSHConfig.DeploymentName.String(), + HostnamePrefix: vals.SSHConfig.DeploymentName.String(), SSHConfigOptions: configSSHOptions, }, } @@ -508,16 +625,16 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.TLSCertificates = httpServers.TLSConfig.Certificates } - if cfg.StrictTransportSecurity > 0 { + if vals.StrictTransportSecurity > 0 { options.StrictTransportSecurityCfg, err = httpmw.HSTSConfigOptions( - int(cfg.StrictTransportSecurity.Value()), cfg.StrictTransportSecurityOptions, + int(vals.StrictTransportSecurity.Value()), vals.StrictTransportSecurityOptions, ) if err != nil { - return xerrors.Errorf("coderd: setting hsts header failed (options: %v): %w", cfg.StrictTransportSecurityOptions, err) + return xerrors.Errorf("coderd: setting hsts header failed (options: %v): %w", vals.StrictTransportSecurityOptions, err) } } - if cfg.UpdateCheck { + if vals.UpdateCheck { options.UpdateCheckOptions = &updatecheck.Options{ // Avoid spamming GitHub API checking for updates. Interval: 24 * time.Hour, @@ -536,103 +653,39 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } } - if cfg.OAuth2.Github.ClientSecret != "" { - options.GithubOAuth2Config, err = configureGithubOAuth2(cfg.AccessURL.Value(), - cfg.OAuth2.Github.ClientID.String(), - cfg.OAuth2.Github.ClientSecret.String(), - cfg.OAuth2.Github.AllowSignups.Value(), - cfg.OAuth2.Github.AllowEveryone.Value(), - cfg.OAuth2.Github.AllowedOrgs, - cfg.OAuth2.Github.AllowedTeams, - cfg.OAuth2.Github.EnterpriseBaseURL.String(), + if vals.OAuth2.Github.ClientSecret != "" { + options.GithubOAuth2Config, err = configureGithubOAuth2(vals.AccessURL.Value(), + vals.OAuth2.Github.ClientID.String(), + vals.OAuth2.Github.ClientSecret.String(), + vals.OAuth2.Github.AllowSignups.Value(), + vals.OAuth2.Github.AllowEveryone.Value(), + vals.OAuth2.Github.AllowedOrgs, + vals.OAuth2.Github.AllowedTeams, + vals.OAuth2.Github.EnterpriseBaseURL.String(), ) if err != nil { return xerrors.Errorf("configure github oauth2: %w", err) } } - if cfg.OIDC.ClientKeyFile != "" || cfg.OIDC.ClientSecret != "" { - if cfg.OIDC.ClientID == "" { - return xerrors.Errorf("OIDC client ID must be set!") - } - if cfg.OIDC.IssuerURL == "" { - return xerrors.Errorf("OIDC issuer URL must be set!") - } - - if cfg.OIDC.IgnoreEmailVerified { + if vals.OIDC.ClientKeyFile != "" || vals.OIDC.ClientSecret != "" { + if vals.OIDC.IgnoreEmailVerified { logger.Warn(ctx, "coder will not check email_verified for OIDC logins") } - oidcProvider, err := oidc.NewProvider( - ctx, cfg.OIDC.IssuerURL.String(), - ) - if err != nil { - return xerrors.Errorf("configure oidc provider: %w", err) - } - redirectURL, err := cfg.AccessURL.Value().Parse("/api/v2/users/oidc/callback") + oc, err := createOIDCConfig(ctx, vals) if err != nil { - return xerrors.Errorf("parse oidc oauth callback url: %w", err) - } - // If the scopes contain 'groups', we enable group support. - // Do not override any custom value set by the user. - if slice.Contains(cfg.OIDC.Scopes, "groups") && cfg.OIDC.GroupField == "" { - cfg.OIDC.GroupField = "groups" - } - oauthCfg := &oauth2.Config{ - ClientID: cfg.OIDC.ClientID.String(), - ClientSecret: cfg.OIDC.ClientSecret.String(), - RedirectURL: redirectURL.String(), - Endpoint: oidcProvider.Endpoint(), - Scopes: cfg.OIDC.Scopes, - } - - var useCfg httpmw.OAuth2Config = oauthCfg - if cfg.OIDC.ClientKeyFile != "" { - // PKI authentication is done in the params. If a - // counter example is found, we can add a config option to - // change this. - oauthCfg.Endpoint.AuthStyle = oauth2.AuthStyleInParams - if cfg.OIDC.ClientSecret != "" { - return xerrors.Errorf("cannot specify both oidc client secret and oidc client key file") - } - - pkiCfg, err := configureOIDCPKI(oauthCfg, cfg.OIDC.ClientKeyFile.Value(), cfg.OIDC.ClientCertFile.Value()) - if err != nil { - return xerrors.Errorf("configure oauth pki authentication: %w", err) - } - useCfg = pkiCfg - } - options.OIDCConfig = &coderd.OIDCConfig{ - OAuth2Config: useCfg, - Provider: oidcProvider, - Verifier: oidcProvider.Verifier(&oidc.Config{ - ClientID: cfg.OIDC.ClientID.String(), - }), - EmailDomain: cfg.OIDC.EmailDomain, - AllowSignups: cfg.OIDC.AllowSignups.Value(), - UsernameField: cfg.OIDC.UsernameField.String(), - EmailField: cfg.OIDC.EmailField.String(), - AuthURLParams: cfg.OIDC.AuthURLParams.Value, - IgnoreUserInfo: cfg.OIDC.IgnoreUserInfo.Value(), - GroupField: cfg.OIDC.GroupField.String(), - GroupFilter: cfg.OIDC.GroupRegexFilter.Value(), - CreateMissingGroups: cfg.OIDC.GroupAutoCreate.Value(), - GroupMapping: cfg.OIDC.GroupMapping.Value, - UserRoleField: cfg.OIDC.UserRoleField.String(), - UserRoleMapping: cfg.OIDC.UserRoleMapping.Value, - UserRolesDefault: cfg.OIDC.UserRolesDefault.GetSlice(), - SignInText: cfg.OIDC.SignInText.String(), - IconURL: cfg.OIDC.IconURL.String(), - IgnoreEmailVerified: cfg.OIDC.IgnoreEmailVerified.Value(), + return xerrors.Errorf("create oidc config: %w", err) } + options.OIDCConfig = oc } - if cfg.InMemoryDatabase { + if vals.InMemoryDatabase { // This is only used for testing. options.Database = dbfake.New() options.Pubsub = pubsub.NewInMemory() } else { - sqlDB, err := connectToPostgres(ctx, logger, sqlDriver, cfg.PostgresURL.String()) + sqlDB, err := connectToPostgres(ctx, logger, sqlDriver, vals.PostgresURL.String()) if err != nil { return xerrors.Errorf("connect to postgres: %w", err) } @@ -641,7 +694,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. }() options.Database = database.New(sqlDB) - options.Pubsub, err = pubsub.New(ctx, sqlDB, cfg.PostgresURL.String()) + options.Pubsub, err = pubsub.New(ctx, sqlDB, vals.PostgresURL.String()) if err != nil { return xerrors.Errorf("create pubsub: %w", err) } @@ -748,7 +801,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return err } - if cfg.Telemetry.Enable { + if vals.Telemetry.Enable { gitAuth := make([]telemetry.GitAuth, 0) // TODO: var gitAuthConfigs []codersdk.GitAuthConfig @@ -763,15 +816,15 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. DeploymentID: deploymentID, Database: options.Database, Logger: logger.Named("telemetry"), - URL: cfg.Telemetry.URL.Value(), - Wildcard: cfg.WildcardAccessURL.String() != "", - DERPServerRelayURL: cfg.DERP.Server.RelayURL.String(), + URL: vals.Telemetry.URL.Value(), + Wildcard: vals.WildcardAccessURL.String() != "", + DERPServerRelayURL: vals.DERP.Server.RelayURL.String(), GitAuth: gitAuth, - GitHubOAuth: cfg.OAuth2.Github.ClientID != "", - OIDCAuth: cfg.OIDC.ClientID != "", - OIDCIssuerURL: cfg.OIDC.IssuerURL.String(), - Prometheus: cfg.Prometheus.Enable.Value(), - STUN: len(cfg.DERP.Server.STUNAddresses) != 0, + GitHubOAuth: vals.OAuth2.Github.ClientID != "", + OIDCAuth: vals.OIDC.ClientID != "", + OIDCIssuerURL: vals.OIDC.IssuerURL.String(), + Prometheus: vals.Prometheus.Enable.Value(), + STUN: len(vals.DERP.Server.STUNAddresses) != 0, Tunnel: tunnel != nil, }) if err != nil { @@ -782,56 +835,25 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // This prevents the pprof import from being accidentally deleted. _ = pprof.Handler - if cfg.Pprof.Enable { + if vals.Pprof.Enable { //nolint:revive - defer ServeHandler(ctx, logger, nil, cfg.Pprof.Address.String(), "pprof")() - } - if cfg.Prometheus.Enable { - options.PrometheusRegistry.MustRegister(collectors.NewGoCollector()) - options.PrometheusRegistry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) - - closeUsersFunc, err := prometheusmetrics.ActiveUsers(ctx, options.PrometheusRegistry, options.Database, 0) - if err != nil { - return xerrors.Errorf("register active users prometheus metric: %w", err) - } - defer closeUsersFunc() - - closeWorkspacesFunc, err := prometheusmetrics.Workspaces(ctx, options.PrometheusRegistry, options.Database, 0) + defer ServeHandler(ctx, logger, nil, vals.Pprof.Address.String(), "pprof")() + } + if vals.Prometheus.Enable { + closeFn, err := enablePrometheus( + ctx, + logger.Named("prometheus"), + vals, + options, + ) if err != nil { - return xerrors.Errorf("register workspaces prometheus metric: %w", err) - } - defer closeWorkspacesFunc() - - if cfg.Prometheus.CollectAgentStats { - closeAgentStatsFunc, err := prometheusmetrics.AgentStats(ctx, logger, options.PrometheusRegistry, options.Database, time.Now(), 0) - if err != nil { - return xerrors.Errorf("register agent stats prometheus metric: %w", err) - } - defer closeAgentStatsFunc() - - metricsAggregator, err := prometheusmetrics.NewMetricsAggregator(logger, options.PrometheusRegistry, 0) - if err != nil { - return xerrors.Errorf("can't initialize metrics aggregator: %w", err) - } - - cancelMetricsAggregator := metricsAggregator.Run(ctx) - defer cancelMetricsAggregator() - - options.UpdateAgentMetrics = metricsAggregator.Update - err = options.PrometheusRegistry.Register(metricsAggregator) - if err != nil { - return xerrors.Errorf("can't register metrics aggregator as collector: %w", err) - } + return xerrors.Errorf("enable prometheus: %w", err) } - - //nolint:revive - defer ServeHandler(ctx, logger, promhttp.InstrumentMetricHandler( - options.PrometheusRegistry, promhttp.HandlerFor(options.PrometheusRegistry, promhttp.HandlerOpts{}), - ), cfg.Prometheus.Address.String(), "prometheus")() + defer closeFn() } - if cfg.Swagger.Enable { - options.SwaggerEndpoint = cfg.Swagger.Enable.Value() + if vals.Swagger.Enable { + options.SwaggerEndpoint = vals.Swagger.Enable.Value() } batcher, closeBatcher, err := batchstats.New(ctx, @@ -855,7 +877,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("create coder API: %w", err) } - if cfg.Prometheus.Enable { + if vals.Prometheus.Enable { // Agent metrics require reference to the tailnet coordinator, so must be initiated after Coder API. closeAgentsFunc, err := prometheusmetrics.Agents(ctx, logger, options.PrometheusRegistry, coderAPI.Database, &coderAPI.TailnetCoordinator, coderAPI.DERPMap, coderAPI.Options.AgentInactiveDisconnectTimeout, 0) if err != nil { @@ -903,10 +925,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. var provisionerdWaitGroup sync.WaitGroup defer provisionerdWaitGroup.Wait() provisionerdMetrics := provisionerd.NewMetrics(options.PrometheusRegistry) - for i := int64(0); i < cfg.Provisioner.Daemons.Value(); i++ { + for i := int64(0); i < vals.Provisioner.Daemons.Value(); i++ { daemonCacheDir := filepath.Join(cacheDir, fmt.Sprintf("provisioner-%d", i)) daemon, err := newProvisionerDaemon( - ctx, coderAPI, provisionerdMetrics, logger, cfg, daemonCacheDir, errCh, &provisionerdWaitGroup, + ctx, coderAPI, provisionerdMetrics, logger, vals, daemonCacheDir, errCh, &provisionerdWaitGroup, ) if err != nil { return xerrors.Errorf("create provisioner daemon: %w", err) @@ -925,8 +947,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // Wrap the server in middleware that redirects to the access URL if // the request is not to a local IP. var handler http.Handler = coderAPI.RootHandler - if cfg.RedirectToAccessURL { - handler = redirectToAccessURL(handler, cfg.AccessURL.Value(), tunnel != nil, appHostnameRegex) + if vals.RedirectToAccessURL { + handler = redirectToAccessURL(handler, vals.AccessURL.Value(), tunnel != nil, appHostnameRegex) } // ReadHeaderTimeout is purposefully not enabled. It caused some @@ -983,12 +1005,12 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("notify systemd: %w", err) } - autobuildTicker := time.NewTicker(cfg.AutobuildPollInterval.Value()) + autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value()) defer autobuildTicker.Stop() autobuildExecutor := autobuild.NewExecutor(ctx, options.Database, coderAPI.TemplateScheduleStore, logger, autobuildTicker.C) autobuildExecutor.Run() - hangDetectorTicker := time.NewTicker(cfg.JobHangDetectorInterval.Value()) + hangDetectorTicker := time.NewTicker(vals.JobHangDetectorInterval.Value()) defer hangDetectorTicker.Stop() hangDetector := unhanger.New(ctx, options.Database, options.Pubsub, logger, hangDetectorTicker.C) hangDetector.Start() @@ -1047,9 +1069,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. go func() { defer wg.Done() - if ok, _ := inv.ParsedFlags().GetBool(varVerbose); ok { - cliui.Infof(inv.Stdout, "Shutting down provisioner daemon %d...", id) - } + r.Verbosef(inv, "Shutting down provisioner daemon %d...", id) err := shutdownWithTimeout(provisionerDaemon.Shutdown, 5*time.Second) if err != nil { cliui.Errorf(inv.Stderr, "Failed to shutdown provisioner daemon %d: %s\n", id, err) @@ -1060,9 +1080,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. cliui.Errorf(inv.Stderr, "Close provisioner daemon %d: %s\n", id, err) return } - if ok, _ := inv.ParsedFlags().GetBool(varVerbose); ok { - cliui.Infof(inv.Stdout, "Gracefully shut down provisioner daemon %d", id) - } + r.Verbosef(inv, "Gracefully shut down provisioner daemon %d", id) }() } wg.Wait() diff --git a/cli/server_test.go b/cli/server_test.go index 5609ac76472d8..7b38bb76f9e15 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -1496,31 +1496,6 @@ func TestServer(t *testing.T) { w.RequireSuccess() }) }) - t.Run("DisableDERP", func(t *testing.T) { - t.Parallel() - - // Make sure that $CODER_DERP_SERVER_STUN_ADDRESSES can be set to - // disable STUN. - - inv, cfg := clitest.New(t, - "server", - "--in-memory", - "--http-address", ":0", - "--access-url", "https://example.com", - ) - inv.Environ.Set("CODER_DERP_SERVER_STUN_ADDRESSES", "disable") - ptytest.New(t).Attach(inv) - clitest.Start(t, inv) - gotURL := waitAccessURL(t, cfg) - client := codersdk.New(gotURL) - - ctx := testutil.Context(t, testutil.WaitMedium) - _ = coderdtest.CreateFirstUser(t, client) - gotConfig, err := client.DeploymentConfig(ctx) - require.NoError(t, err) - - require.Len(t, gotConfig.Values.DERP.Server.STUNAddresses, 0) - }) } func TestServer_Production(t *testing.T) { diff --git a/cli/ssh.go b/cli/ssh.go index 9cc86e1d3781a..4455b8987cc5f 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -40,7 +40,6 @@ var ( autostopNotifyCountdown = []time.Duration{30 * time.Minute} ) -//nolint:gocyclo func (r *RootCmd) ssh() *clibase.Cmd { var ( stdio bool diff --git a/coderd/coderd.go b/coderd/coderd.go index 4aac3867b60f9..0338a020eae36 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -184,8 +184,6 @@ type Options struct { // @in header // @name Coder-Session-Token // New constructs a Coder API handler. -// -//nolint:gocyclo func New(options *Options) *API { if options == nil { options = &Options{} diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 08979a67a8c45..5ef17af359ca7 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -329,7 +329,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can t.Cleanup(stunCleanup) stunAddresses = []string{stunAddr.String()} options.DeploymentValues.DERP.Server.STUNAddresses = stunAddresses - } else if dvStunAddresses[0] != "disable" { + } else if dvStunAddresses[0] != tailnet.DisableSTUN { stunAddresses = options.DeploymentValues.DERP.Server.STUNAddresses.Value() } diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index b8be4b2e64ef8..d26be831db122 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -6053,7 +6053,6 @@ func (q *FakeQuerier) GetTemplateUserRoles(_ context.Context, id uuid.UUID) ([]d return users, nil } -//nolint:gocyclo func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesRow, error) { if err := validateDatabaseType(arg); err != nil { return nil, err diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index f61606a1425b9..695892c86c9cc 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -744,8 +744,6 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p } // CompleteJob is triggered by a provision daemon to mark a provisioner job as completed. -// -//nolint:gocyclo func (server *Server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) (*proto.Empty, error) { ctx, span := server.startTrace(ctx, tracing.FuncName()) defer span.End() diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 1914bfa2ee182..6f03d63dff785 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -303,7 +303,6 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ // @Param request body codersdk.CreateWorkspaceBuildRequest true "Create workspace build request" // @Success 200 {object} codersdk.WorkspaceBuild // @Router /workspaces/{workspace}/builds [post] -// nolint:gocyclo func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index 96ef993ffc4d2..d2e1541c7c89b 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -97,7 +97,6 @@ type State struct { // ConvertState consumes Terraform state and a GraphViz representation // produced by `terraform graph` to produce resources consumable by Coder. -// nolint:gocyclo func ConvertState(modules []*tfjson.StateModule, rawGraph string) (*State, error) { parsedGraph, err := gographviz.ParseString(rawGraph) if err != nil { diff --git a/scripts/apitypings/main.go b/scripts/apitypings/main.go index af8d4fcdf2c85..9eeaf5664911e 100644 --- a/scripts/apitypings/main.go +++ b/scripts/apitypings/main.go @@ -655,8 +655,6 @@ type TypescriptType struct { // Eg: // // []byte returns "string" -// -//nolint:gocyclo func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) { switch ty := ty.(type) { case *types.Basic: diff --git a/tailnet/derpmap.go b/tailnet/derpmap.go index f35e48156b768..817ef6a79dd78 100644 --- a/tailnet/derpmap.go +++ b/tailnet/derpmap.go @@ -13,10 +13,12 @@ import ( "tailscale.com/tailcfg" ) +const DisableSTUN = "disable" + func STUNRegions(baseRegionID int, stunAddrs []string) ([]*tailcfg.DERPRegion, error) { regions := make([]*tailcfg.DERPRegion, 0, len(stunAddrs)) for index, stunAddr := range stunAddrs { - if stunAddr == "disable" { + if stunAddr == DisableSTUN { return []*tailcfg.DERPRegion{}, nil } From 594a6aae19634cf8550bd50da36862648e68064e Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sun, 27 Aug 2023 14:51:13 -0500 Subject: [PATCH 264/277] chore: format oidctest (#9362) --- coderd/coderdtest/oidctest/idp.go | 2 +- coderd/coderdtest/oidctest/idp_test.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index 912d9acd7c221..3ca8cadbc9ff9 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -663,7 +663,7 @@ func (f *FakeIDP) HTTPClient(rest *http.Client) *http.Client { return f.fakeCoderd(req) } if rest == nil || rest.Transport == nil { - return nil, fmt.Errorf("unexpected network request to %q", req.URL.Host) + return nil, xerrors.Errorf("unexpected network request to %q", req.URL.Host) } return rest.Transport.RoundTrip(req) } diff --git a/coderd/coderdtest/oidctest/idp_test.go b/coderd/coderdtest/oidctest/idp_test.go index 0dc1149d93fa9..519635b067916 100644 --- a/coderd/coderdtest/oidctest/idp_test.go +++ b/coderd/coderdtest/oidctest/idp_test.go @@ -10,10 +10,11 @@ import ( "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/assert" - "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coreos/go-oidc/v3/oidc" "github.com/stretchr/testify/require" "golang.org/x/oauth2" + + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" ) // TestFakeIDPBasicFlow tests the basic flow of the fake IDP. From 79aba1d5ff75d1fdfe82c347f7d8bd027ea45813 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Mon, 28 Aug 2023 12:21:54 +0300 Subject: [PATCH 265/277] ci: remove redundant groups from dependabot.yaml (#9365) --- .github/dependabot.yaml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 5b9f7a9c6597a..ebd0c3c55ce59 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -117,11 +117,6 @@ updates: - "@eslint*" - "@typescript-eslint/eslint-plugin" - "@typescript-eslint/parser" - jest: - patterns: - - "jest*" - - "@swc/jest" - - "@types/jest" - package-ecosystem: "npm" directory: "/offlinedocs/" @@ -146,20 +141,6 @@ updates: - version-update:semver-major # Update dogfood. - - package-ecosystem: "docker" - directory: "/dogfood/" - schedule: - interval: "weekly" - time: "06:00" - timezone: "America/Chicago" - commit-message: - prefix: "chore" - labels: [] - groups: - dogfood-docker: - patterns: - - "*" - - package-ecosystem: "terraform" directory: "/dogfood/" schedule: From 506b81adeb2ff475197da0f1bff22a346ebba8fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Aug 2023 09:41:05 +0000 Subject: [PATCH 266/277] ci: bump crate-ci/typos@v1.16.6 to crate-ci/typos@v1.16.8 (#9372) bumps crate-ci/typos@v1.16.6 to crate-ci/typos@v1.16.8 --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 66bf59853bb97..17782be96319c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -137,7 +137,7 @@ jobs: # Check for any typos - name: Check for typos - uses: crate-ci/typos@v1.16.6 + uses: crate-ci/typos@v1.16.8 with: config: .github/workflows/typos.toml From b6e808d11628f6c407ca1c025a717edae0533173 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Aug 2023 14:08:32 +0300 Subject: [PATCH 267/277] chore: bump github.com/charmbracelet/lipgloss from 0.7.1 to 0.8.0 (#9370) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 5 ++--- go.sum | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 496f42ebf312d..67aac5fa2cbe9 100644 --- a/go.mod +++ b/go.mod @@ -86,7 +86,7 @@ require ( github.com/charmbracelet/glamour v0.6.0 // In later at least v0.7.1, lipgloss changes its terminal detection // which breaks most of our CLI golden files tests. - github.com/charmbracelet/lipgloss v0.7.1 + github.com/charmbracelet/lipgloss v0.8.0 github.com/cli/safeexec v1.0.1 github.com/codeclysm/extract/v3 v3.1.1 github.com/coder/flog v1.1.0 @@ -116,7 +116,6 @@ require ( github.com/go-playground/validator/v10 v10.15.0 github.com/gofrs/flock v0.8.1 github.com/gohugoio/hugo v0.117.0 - github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-migrate/migrate/v4 v4.16.0 github.com/golang/mock v1.6.0 @@ -185,7 +184,7 @@ require ( golang.org/x/sys v0.11.0 golang.org/x/term v0.11.0 golang.org/x/text v0.12.0 - golang.org/x/time v0.3.0 + golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.12.0 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b diff --git a/go.sum b/go.sum index 3971c3fed5e5f..87a117866bdf6 100644 --- a/go.sum +++ b/go.sum @@ -177,8 +177,8 @@ github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM2 github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= -github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= -github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdyCQk84RU= +github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU= github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -391,8 +391,6 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gohugoio/hugo v0.117.0 h1:VP7MVke7R36zjUkWezg7mEtfnwHm2L5u0JwvMHvcsZQ= github.com/gohugoio/hugo v0.117.0/go.mod h1:b6mjGjvIqRD36x+ePNDhn1ZCXdWBkvu95+dZNTCuoDM= -github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-migrate/migrate/v4 v4.16.0 h1:FU2GR7EdAO0LmhNLcKthfDzuYCtMcWNR7rUbZjsgH3o= From 35d0809830f3a9953a9da9edcf398c531e9d789c Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Mon, 28 Aug 2023 18:20:52 +0300 Subject: [PATCH 268/277] ci: prefix dependabot github-actions PRs with `ci:` (#9376) --- .github/dependabot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index ebd0c3c55ce59..76048b9fe398d 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -8,7 +8,7 @@ updates: timezone: "America/Chicago" labels: [] commit-message: - prefix: "chore" + prefix: "ci" ignore: # These actions deliver the latest versions by updating the major # release tag, so ignore minor and patch versions From 80425c32bf1ce8ac530ee75c244f4f8469d917ea Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 28 Aug 2023 18:22:39 +0200 Subject: [PATCH 269/277] fix(site): workaround: reload page every 3sec (#9387) --- site/e2e/helpers.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 8743b13730b08..1cc442ce72c70 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -38,12 +38,18 @@ export const createWorkspace = async ( await page.getByTestId("form-submit").click() await expect(page).toHaveURL("/@admin/" + name) + + // FIXME: workaround for https://github.com/coder/coder/issues/8566 + const reloadTimer = setInterval(async () => { + await page.reload() + }, 3000) await page.waitForSelector( "span[data-testid='build-status'] >> text=Running", { state: "visible", }, ) + clearInterval(reloadTimer) return name } From ce9b048f06646ce5f368e4b93691aab5200cf4ee Mon Sep 17 00:00:00 2001 From: Kayla Washburn Date: Mon, 28 Aug 2023 11:27:51 -0600 Subject: [PATCH 270/277] feat(site): improve template publishing flow (#9346) --- site/src/components/Alert/Alert.stories.tsx | 8 + .../PublishTemplateVersionDialog.tsx | 14 +- .../TemplateVersionEditor.stories.tsx | 317 +----------------- .../TemplateVersionEditor.tsx | 39 ++- .../TemplateVersionEditorPage.test.tsx | 7 +- .../TemplateVersionEditorPage.tsx | 12 +- site/src/testHelpers/entities.ts | 309 +++++++++++++++++ .../templateVersionEditorXService.ts | 20 +- 8 files changed, 399 insertions(+), 327 deletions(-) diff --git a/site/src/components/Alert/Alert.stories.tsx b/site/src/components/Alert/Alert.stories.tsx index ada32cd994ec9..c5139f7c1c1a1 100644 --- a/site/src/components/Alert/Alert.stories.tsx +++ b/site/src/components/Alert/Alert.stories.tsx @@ -17,6 +17,14 @@ const ExampleAction = ( ) +export const Success: Story = { + args: { + children: "You're doing great!", + severity: "success", + onRetry: undefined, + }, +} + export const Warning: Story = { args: { children: "This is a warning", diff --git a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx index 8fcd96bcce157..a834f4446abd2 100644 --- a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx +++ b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx @@ -11,6 +11,12 @@ import Checkbox from "@mui/material/Checkbox" import FormControlLabel from "@mui/material/FormControlLabel" import { Stack } from "components/Stack/Stack" +export const Language = { + versionNameLabel: "Version name", + messagePlaceholder: "Write a short message about the changes you made...", + defaultCheckboxLabel: "Promote to default version", +} + export type PublishTemplateVersionDialogProps = DialogProps & { defaultName: string isPublishing: boolean @@ -33,7 +39,7 @@ export const PublishTemplateVersionDialog: FC< initialValues: { name: defaultName, message: "", - isActiveVersion: false, + isActiveVersion: true, }, validationSchema: Yup.object({ name: Yup.string().required(), @@ -67,7 +73,7 @@ export const PublishTemplateVersionDialog: FC< @@ -75,7 +81,7 @@ export const PublishTemplateVersionDialog: FC< 0.11.0"...', - }, - { - id: 938500, - created_at: "2023-08-25T19:07:44.668Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: '- Finding kreuzwerker/docker versions matching "~> 3.0.1"...', - }, - { - id: 938501, - created_at: "2023-08-25T19:07:44.722Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "- Using coder/coder v0.11.1 from the shared cache directory", - }, - { - id: 938502, - created_at: "2023-08-25T19:07:44.857Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "- Using kreuzwerker/docker v3.0.2 from the shared cache directory", - }, - { - id: 938503, - created_at: "2023-08-25T19:07:45.081Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "Terraform has created a lock file .terraform.lock.hcl to record the provider", - }, - { - id: 938504, - created_at: "2023-08-25T19:07:45.081Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "selections it made above. Include this file in your version control repository", - }, - { - id: 938505, - created_at: "2023-08-25T19:07:45.081Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "so that Terraform can guarantee to make the same selections by default when", - }, - { - id: 938506, - created_at: "2023-08-25T19:07:45.082Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: 'you run "terraform init" in the future.', - }, - { - id: 938507, - created_at: "2023-08-25T19:07:45.083Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "Terraform has been successfully initialized!", - }, - { - id: 938508, - created_at: "2023-08-25T19:07:45.084Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - 'You may now begin working with Terraform. Try running "terraform plan" to see', - }, - { - id: 938509, - created_at: "2023-08-25T19:07:45.084Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "any changes that are required for your infrastructure. All Terraform commands", - }, - { - id: 938510, - created_at: "2023-08-25T19:07:45.084Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "should now work.", - }, - { - id: 938511, - created_at: "2023-08-25T19:07:45.084Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "If you ever set or change modules or backend configuration for Terraform,", - }, - { - id: 938512, - created_at: "2023-08-25T19:07:45.084Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "rerun this command to reinitialize your working directory. If you forget, other", - }, - { - id: 938513, - created_at: "2023-08-25T19:07:45.084Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "commands will detect it and remind you to do so if necessary.", - }, - { - id: 938514, - created_at: "2023-08-25T19:07:45.143Z", - log_source: "provisioner", - log_level: "info", - stage: "Detecting persistent resources", - output: "Terraform 1.1.9", - }, - { - id: 938515, - created_at: "2023-08-25T19:07:46.297Z", - log_source: "provisioner", - log_level: "warn", - stage: "Detecting persistent resources", - output: "Warning: Argument is deprecated", - }, - { - id: 938516, - created_at: "2023-08-25T19:07:46.297Z", - log_source: "provisioner", - log_level: "warn", - stage: "Detecting persistent resources", - output: 'on devcontainer-on-docker.tf line 15, in provider "coder":', - }, - { - id: 938517, - created_at: "2023-08-25T19:07:46.297Z", - log_source: "provisioner", - log_level: "warn", - stage: "Detecting persistent resources", - output: " 15: feature_use_managed_variables = true", - }, - { - id: 938518, - created_at: "2023-08-25T19:07:46.297Z", - log_source: "provisioner", - log_level: "warn", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 938519, - created_at: "2023-08-25T19:07:46.297Z", - log_source: "provisioner", - log_level: "warn", - stage: "Detecting persistent resources", - output: - "Terraform variables are now exclusively utilized for template-wide variables after the removal of support for legacy parameters.", - }, - { - id: 938520, - created_at: "2023-08-25T19:07:46.3Z", - log_source: "provisioner", - log_level: "error", - stage: "Detecting persistent resources", - output: "Error: ephemeral parameter requires the default property", - }, - { - id: 938521, - created_at: "2023-08-25T19:07:46.3Z", - log_source: "provisioner", - log_level: "error", - stage: "Detecting persistent resources", - output: - 'on devcontainer-on-docker.tf line 27, in data "coder_parameter" "another_one":', - }, - { - id: 938522, - created_at: "2023-08-25T19:07:46.3Z", - log_source: "provisioner", - log_level: "error", - stage: "Detecting persistent resources", - output: ' 27: data "coder_parameter" "another_one" {', - }, - { - id: 938523, - created_at: "2023-08-25T19:07:46.301Z", - log_source: "provisioner", - log_level: "error", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 938524, - created_at: "2023-08-25T19:07:46.301Z", - log_source: "provisioner", - log_level: "error", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 938525, - created_at: "2023-08-25T19:07:46.303Z", - log_source: "provisioner", - log_level: "warn", - stage: "Detecting persistent resources", - output: "Warning: Argument is deprecated", - }, - { - id: 938526, - created_at: "2023-08-25T19:07:46.303Z", - log_source: "provisioner", - log_level: "warn", - stage: "Detecting persistent resources", - output: 'on devcontainer-on-docker.tf line 15, in provider "coder":', - }, - { - id: 938527, - created_at: "2023-08-25T19:07:46.303Z", - log_source: "provisioner", - log_level: "warn", - stage: "Detecting persistent resources", - output: " 15: feature_use_managed_variables = true", - }, - { - id: 938528, - created_at: "2023-08-25T19:07:46.303Z", - log_source: "provisioner", - log_level: "warn", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 938529, - created_at: "2023-08-25T19:07:46.303Z", - log_source: "provisioner", - log_level: "warn", - stage: "Detecting persistent resources", - output: - "Terraform variables are now exclusively utilized for template-wide variables after the removal of support for legacy parameters.", - }, - { - id: 938530, - created_at: "2023-08-25T19:07:46.311Z", - log_source: "provisioner_daemon", - log_level: "info", - stage: "Cleaning Up", - output: "", - }, - ], + buildLogs: MockWorkspaceExtendedBuildLogs, + }, +} + +export const Published = { + args: { + publishedVersion: MockTemplateVersion, }, } diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx index 33577ea4488c4..aca932c29180a 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -64,6 +64,9 @@ export interface TemplateVersionEditorProps { onConfirmPublish: (data: PublishVersionData) => void onCancelPublish: () => void publishingError: unknown + publishedVersion?: TemplateVersion + publishedVersionIsDefault?: boolean + onCreateWorkspace: () => void isAskingPublishParameters: boolean isPromptingMissingVariables: boolean isPublishing: boolean @@ -97,9 +100,12 @@ export const TemplateVersionEditor: FC = ({ onPublish, onConfirmPublish, onCancelPublish, - publishingError, isAskingPublishParameters, isPublishing, + publishingError, + publishedVersion, + publishedVersionIsDefault, + onCreateWorkspace, buildLogs, resources, isPromptingMissingVariables, @@ -107,11 +113,9 @@ export const TemplateVersionEditor: FC = ({ onSubmitMissingVariableValues, onCancelSubmitMissingVariableValues, }) => { - const [selectedTab, setSelectedTab] = useState(() => { - // If resources are provided, show them by default! - // This is for Storybook! - return resources ? 1 : 0 - }) + // If resources are provided, show them by default! + // This is for Storybook! + const [selectedTab, setSelectedTab] = useState(() => (resources ? 1 : 0)) const [fileTree, setFileTree] = useState(defaultFileTree) const [createFileOpen, setCreateFileOpen] = useState(false) const [deleteFileOpen, setDeleteFileOpen] = useState() @@ -204,6 +208,29 @@ export const TemplateVersionEditor: FC = ({
+ {publishedVersion && ( + + Create a workspace + + ) + } + > + Successfully published {publishedVersion.name}! + + )} +
{/* Only start to show the build when a new template version is building */} {templateVersion.id !== firstTemplateVersionOnEditor.current.id && ( diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx index b969363951fc8..0a1fbdcdb25a4 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx @@ -7,6 +7,7 @@ import { MockTemplateVersion, MockWorkspaceBuildLogs, } from "testHelpers/entities" +import { Language } from "../../../components/TemplateVersionEditor/PublishTemplateVersionDialog" // For some reason this component in Jest is throwing a MUI style warning so, // since we don't need it for this test, we can mock it out @@ -67,9 +68,6 @@ test("Use custom name, message and set it as active when publishing", async () = const messageField = within(publishDialog).getByLabelText("Message") await user.clear(messageField) await user.type(messageField, "Informative message") - await user.click( - within(publishDialog).getByLabelText("Promote to default version"), - ) await user.click( within(publishDialog).getByRole("button", { name: "Publish" }), ) @@ -132,6 +130,9 @@ test("Do not mark as active if promote is not checked", async () => { const nameField = within(publishDialog).getByLabelText("Version name") await user.clear(nameField) await user.type(nameField, "v1.0") + await user.click( + within(publishDialog).getByLabelText(Language.defaultCheckboxLabel), + ) await user.click( within(publishDialog).getByRole("button", { name: "Publish" }), ) diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index 36bad25e7ef83..078e14e406cec 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -4,7 +4,7 @@ import { useOrganizationId } from "hooks/useOrganizationId" import { usePermissions } from "hooks/usePermissions" import { FC } from "react" import { Helmet } from "react-helmet-async" -import { useParams } from "react-router-dom" +import { useNavigate, useParams } from "react-router-dom" import { pageTitle } from "utils/page" import { templateVersionEditorMachine } from "xServices/templateVersionEditor/templateVersionEditorXService" import { useTemplateVersionData } from "./data" @@ -15,6 +15,7 @@ type Params = { } export const TemplateVersionEditorPage: FC = () => { + const navigate = useNavigate() const { version: versionName, template: templateName } = useParams() as Params const orgId = useOrganizationId() const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, { @@ -72,8 +73,15 @@ export const TemplateVersionEditorPage: FC = () => { isAskingPublishParameters={editorState.matches( "askPublishParameters", )} - publishingError={editorState.context.publishingError} isPublishing={editorState.matches("publishingVersion")} + publishingError={editorState.context.publishingError} + publishedVersion={editorState.context.lastSuccessfulPublishedVersion} + publishedVersionIsDefault={ + editorState.context.lastSuccessfulPublishIsDefault + } + onCreateWorkspace={() => { + navigate(`/templates/${templateName}/workspace`) + }} disablePreview={editorState.hasTag("loading")} disableUpdate={ editorState.hasTag("loading") || diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 349857550abf5..9ddc58ff2954b 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1407,6 +1407,315 @@ export const MockWorkspaceBuildLogs: TypesGen.ProvisionerJobLog[] = [ }, ] +export const MockWorkspaceExtendedBuildLogs: TypesGen.ProvisionerJobLog[] = [ + { + id: 938494, + created_at: "2023-08-25T19:07:43.331Z", + log_source: "provisioner_daemon", + log_level: "info", + stage: "Setting up", + output: "", + }, + { + id: 938495, + created_at: "2023-08-25T19:07:43.331Z", + log_source: "provisioner_daemon", + log_level: "info", + stage: "Parsing template parameters", + output: "", + }, + { + id: 938496, + created_at: "2023-08-25T19:07:43.339Z", + log_source: "provisioner_daemon", + log_level: "info", + stage: "Detecting persistent resources", + output: "", + }, + { + id: 938497, + created_at: "2023-08-25T19:07:44.15Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "Initializing the backend...", + }, + { + id: 938498, + created_at: "2023-08-25T19:07:44.215Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "Initializing provider plugins...", + }, + { + id: 938499, + created_at: "2023-08-25T19:07:44.216Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: '- Finding coder/coder versions matching "~> 0.11.0"...', + }, + { + id: 938500, + created_at: "2023-08-25T19:07:44.668Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: '- Finding kreuzwerker/docker versions matching "~> 3.0.1"...', + }, + { + id: 938501, + created_at: "2023-08-25T19:07:44.722Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "- Using coder/coder v0.11.1 from the shared cache directory", + }, + { + id: 938502, + created_at: "2023-08-25T19:07:44.857Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "- Using kreuzwerker/docker v3.0.2 from the shared cache directory", + }, + { + id: 938503, + created_at: "2023-08-25T19:07:45.081Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "Terraform has created a lock file .terraform.lock.hcl to record the provider", + }, + { + id: 938504, + created_at: "2023-08-25T19:07:45.081Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "selections it made above. Include this file in your version control repository", + }, + { + id: 938505, + created_at: "2023-08-25T19:07:45.081Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "so that Terraform can guarantee to make the same selections by default when", + }, + { + id: 938506, + created_at: "2023-08-25T19:07:45.082Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: 'you run "terraform init" in the future.', + }, + { + id: 938507, + created_at: "2023-08-25T19:07:45.083Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "Terraform has been successfully initialized!", + }, + { + id: 938508, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + 'You may now begin working with Terraform. Try running "terraform plan" to see', + }, + { + id: 938509, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "any changes that are required for your infrastructure. All Terraform commands", + }, + { + id: 938510, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "should now work.", + }, + { + id: 938511, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "If you ever set or change modules or backend configuration for Terraform,", + }, + { + id: 938512, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: + "rerun this command to reinitialize your working directory. If you forget, other", + }, + { + id: 938513, + created_at: "2023-08-25T19:07:45.084Z", + log_source: "provisioner", + log_level: "debug", + stage: "Detecting persistent resources", + output: "commands will detect it and remind you to do so if necessary.", + }, + { + id: 938514, + created_at: "2023-08-25T19:07:45.143Z", + log_source: "provisioner", + log_level: "info", + stage: "Detecting persistent resources", + output: "Terraform 1.1.9", + }, + { + id: 938515, + created_at: "2023-08-25T19:07:46.297Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: "Warning: Argument is deprecated", + }, + { + id: 938516, + created_at: "2023-08-25T19:07:46.297Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: 'on devcontainer-on-docker.tf line 15, in provider "coder":', + }, + { + id: 938517, + created_at: "2023-08-25T19:07:46.297Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: " 15: feature_use_managed_variables = true", + }, + { + id: 938518, + created_at: "2023-08-25T19:07:46.297Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: "", + }, + { + id: 938519, + created_at: "2023-08-25T19:07:46.297Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: + "Terraform variables are now exclusively utilized for template-wide variables after the removal of support for legacy parameters.", + }, + { + id: 938520, + created_at: "2023-08-25T19:07:46.3Z", + log_source: "provisioner", + log_level: "error", + stage: "Detecting persistent resources", + output: "Error: ephemeral parameter requires the default property", + }, + { + id: 938521, + created_at: "2023-08-25T19:07:46.3Z", + log_source: "provisioner", + log_level: "error", + stage: "Detecting persistent resources", + output: + 'on devcontainer-on-docker.tf line 27, in data "coder_parameter" "another_one":', + }, + { + id: 938522, + created_at: "2023-08-25T19:07:46.3Z", + log_source: "provisioner", + log_level: "error", + stage: "Detecting persistent resources", + output: ' 27: data "coder_parameter" "another_one" {', + }, + { + id: 938523, + created_at: "2023-08-25T19:07:46.301Z", + log_source: "provisioner", + log_level: "error", + stage: "Detecting persistent resources", + output: "", + }, + { + id: 938524, + created_at: "2023-08-25T19:07:46.301Z", + log_source: "provisioner", + log_level: "error", + stage: "Detecting persistent resources", + output: "", + }, + { + id: 938525, + created_at: "2023-08-25T19:07:46.303Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: "Warning: Argument is deprecated", + }, + { + id: 938526, + created_at: "2023-08-25T19:07:46.303Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: 'on devcontainer-on-docker.tf line 15, in provider "coder":', + }, + { + id: 938527, + created_at: "2023-08-25T19:07:46.303Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: " 15: feature_use_managed_variables = true", + }, + { + id: 938528, + created_at: "2023-08-25T19:07:46.303Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: "", + }, + { + id: 938529, + created_at: "2023-08-25T19:07:46.303Z", + log_source: "provisioner", + log_level: "warn", + stage: "Detecting persistent resources", + output: + "Terraform variables are now exclusively utilized for template-wide variables after the removal of support for legacy parameters.", + }, + { + id: 938530, + created_at: "2023-08-25T19:07:46.311Z", + log_source: "provisioner_daemon", + log_level: "info", + stage: "Cleaning Up", + output: "", + }, +] + export const MockCancellationMessage = { message: "Job successfully canceled", } diff --git a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts index 4c4623f5bc374..990479b9c2475 100644 --- a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts +++ b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts @@ -24,13 +24,15 @@ export interface TemplateVersionEditorMachineContext { buildLogs?: ProvisionerJobLog[] tarReader?: TarReader publishingError?: unknown + lastSuccessfulPublishedVersion?: TemplateVersion + lastSuccessfulPublishIsDefault?: boolean missingVariables?: TemplateVersionVariable[] missingVariableValues?: VariableValue[] } export const templateVersionEditorMachine = createMachine( { - /** @xstate-layout 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 */ + /** @xstate-layout N4IgpgJg5mDOIC5QBcwFsAOAbAhqgamAE6wCWA9gHYCiEpy5RAdKZfaTlqQF6tQDEASQByggCqCAggBlBALWoBtAAwBdRKAzkyyCpQ0gAHogC0AFgDsypgDYAnAFZlFpzefKbADgA0IAJ6IAIzKngDMTIF2FmYONoFW9s4Avkm+qJi4BMRkVLT0jCwQWGD8AMIAStSSYtQA+vjU5QDKggDywirqSCBaOnoGxggmoTYOTKEATIEOThNxDmajvgEIgTFjDhaeNpPTZpbRKWno2HhghCR6eQzMpEUlAAoAqgBCsk0AEp0GvexUA6Y5mNlKEzBNPJ5Ap4JjCbEt-EEJsozEw7MoHKEQhYJqFPBYbBYjiB0qcspdcnQboVivwACKCJoAWQZTVqzzeDI+tRekmEwka326v10-26gxMcM8TGcoRGc0CNjMyg8yyCkzsqOUkWcnjMEJhRJJmXO2SulIKOFgAGsHgBXABGXFgAAsHjgiDg0GBUCQyrzStRpGzXu8vmofto-voxaZAvFxnZ9mFonY7NswarVuCLEwLPjQlCPGsJodUsSTsaLjkaObmJabQ6na73Z7vdkyu0AGKCcqM4Mcz6CzSRkXR0CDOM5ta6ya4jHbZR2TNx1O5iHTHEWQLgzyGitnKtm-LMDCN0guviHqj8CBUMAsSgAN3IVvvp8d5+dl9NVCHPRH-QxggSrWOioSOHCdhzCEMzLuCGoFqMkJmGsgRynuGQHj+NbHkw75Nt+5KUPwxBEAUpIAGaMGgeFnhelBQFelB-sKgHjkEcbStESKbGYaZFoEy6LlKcJePE4IzDYEwYaSJpEdcBQAMY4JQilgFwDGCJQDxkVARBwLALy2qQWAQDed4Ps+r5MMpqnqUZJkQCxAGiuxQyhA4UpmLiLhePYaEjMuFgFqibjOPmqbKNJZZGlh8m1kwtrYOQOAQGI7rmZQ96sFZ95JVgKVpe6zl9K5RimFETAljioI4mmdgjBYy7QhsepWE4DVQShMmVthCnMIp+l4HwDmmZl2VPi+96DWAZyjU54ZCi5Y7lcBDgTNKeITGm+LYutNjNRMrV4uii7gRM+w9XF1b9UwADueCKV+DHzdI5BQLA-CSLStLck8gjSL90itAA4iVUYAggjg5o4UxJutkzbEFDgakionbImMKeVdZI3QlD3IE9I3GaZb0ffwLz-YDtS0u0SiLcOpUrYMvlMJJowwgqiZpsumPSoWnjIrEMTBTjcl47hBNEy9JMQGTn2lP6gb1I0LTtODo6QyYeKoidaZxBd0JxMuUnWCjFipiMITeeBYtMbdUvPVAr3vQrlTVHUDTNG0HQM-+TNa3ENi5vEnjQ6m20o4dgS2GC6L1W4upmHbfUJRR3rS4x2HjZZU1MOnhPOkxGtsatUkorqmxWLKFuC4JCIIHDwcuAj60uKCiwp-FuEF5nTE5zlee90X2GKIEXSMxDQHBDsuZah5cbBHYAWZk3uYzJEDVxlJdhdxLVIYGRmDIPg7ocI6cBMAVqV8Iy55kAxp9EOfxSfbeWW59ZsW40eB9HxgJ8z44AvrAK+hVb730vEAkBCBB7KVHJ0EuZVBhhVRAFTwvFI5TFXrVJg3l9Qll1CEGwe9f7kX-oA5+wDX7UhKE0agYhajMiaC0YQIN6iSHKFIN4nsZBPGoE0JBzNEAEgiDuWUippgW28qvdaG0rBrB3iEKKhIYr7h-hSXCh9yDHyfi-S+dwaSK2EAGIMzDWHsPwJw7h0heHSH4YIv2rFkFBEklVUI0ipgNSsPsVeJZg5RCxmhWYkRQikM0VSYe5Q4DkFtEQNSb8LKD2sjAZA0TYCxPiXAIRkNixjDQpCDwgTtz4hNk4WwDgFTKgxHxLcYSiSUHIBAOABhv7izIUQCMAcgImC3MHGUcog4gQOg3VMYx7DREWDEaEkJorHEwhonCVJWDsE4DwPgXSp5uQlCFPpUIkTInsBmBucYPARGiFFSYJYPAFnCUsgohiwCbM1j0gs8jqlgjRKmbcy4PIbTxEnI6BYYIODubdesdoPwujdB6L0Pp4BLW6ds7cGow6eVlOta2YIRkrG3MiTUGIsQ4jxASMFCV8KfkItWZ5pdBgeSlM4GYwtrZOF8ScuM4QkTgX2ASZUaZ6nzNkvbBKtk1IaSgFpHS719KwEMrLGlLihhKgZSUuuQIwg4tcZVYSWp4hSW2GEMluF8qFXSp0xFWzVrDEcNKSY6JDYzxxA4Q64R1ptxBEcw5RqqQzWGjLRyCrhGrEWGzDxVgwjeXWpCHwJzoSuqOkCKEnlwQkLUQs9pESCiO2Jo5eWgbIZwg2nHeeSIrAesOv0o5BIwR6lxLvNNQrU49wzk7Ji+agJeE5QSfyTgUYwjMKvLUGwbl2FGAU7qDberdz-jogBejqEtItS8ty20Y6JkWDCFw0zjkrCxuEFCCxLBxDzF6yd10Ol4QofOkBYCb4MTvrKqBVCQHtrcrKDU66pIlgWJ5HdiAcRKjQQsD1wRtoCvLOm4VWir3QJoY819q0wioo6mjLY2JFg4MVHg6Y4FZzbn7d6goUSYlxISQhicHiEJxCVCMewiotSrwKbYXYwQPAoTxCkFIQA */ predictableActionArguments: true, id: "templateVersionEditor", schema: { @@ -68,7 +70,7 @@ export const templateVersionEditorMachine = createMachine( data: WorkspaceResource[] } publishingVersion: { - data: void + data: { isActiveVersion: boolean } } loadMissingVariables: { data: TemplateVersionVariable[] @@ -108,7 +110,7 @@ export const templateVersionEditorMachine = createMachine( publishingVersion: { tags: "loading", - entry: ["clearPublishingError"], + entry: ["clearPublishingError", "clearLastSuccessfulPublishedVersion"], invoke: { id: "publishingVersion", src: "publishingVersion", @@ -119,6 +121,7 @@ export const templateVersionEditorMachine = createMachine( }, onDone: { + actions: ["assignLastSuccessfulPublishedVersion"], target: ["idle"], }, }, @@ -256,6 +259,12 @@ export const templateVersionEditorMachine = createMachine( assignBuild: assign({ version: (_, event) => event.data, }), + assignLastSuccessfulPublishedVersion: assign({ + lastSuccessfulPublishedVersion: (ctx) => ctx.version, + lastSuccessfulPublishIsDefault: (_, event) => + event.data.isActiveVersion, + version: () => undefined, + }), addBuildLog: assign({ buildLogs: (context, event) => { const previousLogs = context.buildLogs ?? [] @@ -285,6 +294,9 @@ export const templateVersionEditorMachine = createMachine( publishingError: (_, event) => event.data, }), clearPublishingError: assign({ publishingError: (_) => undefined }), + clearLastSuccessfulPublishedVersion: assign({ + lastSuccessfulPublishedVersion: (_) => undefined, + }), assignMissingVariables: assign({ missingVariables: (_, event) => event.data, }), @@ -420,6 +432,8 @@ export const templateVersionEditorMachine = createMachine( }) : Promise.resolve(), ]) + + return { isActiveVersion } }, loadMissingVariables: ({ version }) => { if (!version) { From a2be2f983864ef166dd1925cdc380d42a2bb20a4 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 28 Aug 2023 11:13:19 -0700 Subject: [PATCH 271/277] fix: avoid derp-map updates endpoint leak (#9390) --- coderd/workspaceagents.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index c127b2342d4a3..d921bbfa72bd7 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -878,13 +878,15 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) { }) return } - nconn := websocket.NetConn(ctx, ws, websocket.MessageBinary) + ctx, nconn := websocketNetConn(ctx, ws, websocket.MessageBinary) defer nconn.Close() // Slurp all packets from the connection into io.Discard so pongs get sent - // by the websocket package. + // by the websocket package. We don't do any reads ourselves so this is + // necessary. go func() { _, _ = io.Copy(io.Discard, nconn) + _ = nconn.Close() }() go func(ctx context.Context) { @@ -899,13 +901,11 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) { return } - // We don't need a context that times out here because the ping will - // eventually go through. If the context times out, then other - // websocket read operations will receive an error, obfuscating the - // actual problem. + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) err := ws.Ping(ctx) + cancel() if err != nil { - _ = ws.Close(websocket.StatusInternalError, err.Error()) + _ = nconn.Close() return } } @@ -920,7 +920,7 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) { if lastDERPMap == nil || !tailnet.CompareDERPMaps(lastDERPMap, derpMap) { err := json.NewEncoder(nconn).Encode(derpMap) if err != nil { - _ = ws.Close(websocket.StatusInternalError, err.Error()) + _ = nconn.Close() return } lastDERPMap = derpMap From d138ed73148af4163ab65b33553493f4172f6a3f Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 28 Aug 2023 15:14:17 -0300 Subject: [PATCH 272/277] fix(coderd): send updated workspace data adter ws connection (#9392) --- coderd/workspaces.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 707c85200488b..9384d2b7ecb00 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1028,6 +1028,9 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) { _ = sendEvent(ctx, codersdk.ServerSentEvent{ Type: codersdk.ServerSentEventTypePing, }) + // Send updated workspace info after connection is established. This avoids + // missing updates if the client connects after an update. + sendUpdate(ctx, nil) for { select { From 2167fe16d6278844fcae928dc4d70ad0af1d0ff0 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 28 Aug 2023 15:24:01 -0300 Subject: [PATCH 273/277] chore: remove e2e workaround (#9393) --- site/e2e/helpers.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 1cc442ce72c70..756c307ff59c9 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -39,17 +39,12 @@ export const createWorkspace = async ( await expect(page).toHaveURL("/@admin/" + name) - // FIXME: workaround for https://github.com/coder/coder/issues/8566 - const reloadTimer = setInterval(async () => { - await page.reload() - }, 3000) await page.waitForSelector( "span[data-testid='build-status'] >> text=Running", { state: "visible", }, ) - clearInterval(reloadTimer) return name } From fea8813f13e412e1aca6dfe89c0f178456d0ee0e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 28 Aug 2023 13:33:40 -0500 Subject: [PATCH 274/277] chore: drop 'template plan' unused command (#9386) --- cli/templateplan.go | 18 ------------------ cli/templates.go | 1 - cli/testdata/coder_templates_--help.golden | 1 - docs/cli/templates.md | 1 - docs/cli/templates_plan.md | 11 ----------- docs/manifest.json | 5 ----- 6 files changed, 37 deletions(-) delete mode 100644 cli/templateplan.go delete mode 100644 docs/cli/templates_plan.md diff --git a/cli/templateplan.go b/cli/templateplan.go deleted file mode 100644 index 76710a046d36a..0000000000000 --- a/cli/templateplan.go +++ /dev/null @@ -1,18 +0,0 @@ -package cli - -import ( - "github.com/coder/coder/v2/cli/clibase" -) - -func (*RootCmd) templatePlan() *clibase.Cmd { - return &clibase.Cmd{ - Use: "plan ", - Middleware: clibase.Chain( - clibase.RequireNArgs(1), - ), - Short: "Plan a template push from the current directory", - Handler: func(inv *clibase.Invocation) error { - return nil - }, - } -} diff --git a/cli/templates.go b/cli/templates.go index 7f5f16a4558c9..7ded6a7e5ee2b 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -37,7 +37,6 @@ func (r *RootCmd) templates() *clibase.Cmd { r.templateEdit(), r.templateInit(), r.templateList(), - r.templatePlan(), r.templatePush(), r.templateVersions(), r.templateDelete(), diff --git a/cli/testdata/coder_templates_--help.golden b/cli/testdata/coder_templates_--help.golden index 0bcc6c7978df7..352695e26fb57 100644 --- a/cli/testdata/coder_templates_--help.golden +++ b/cli/testdata/coder_templates_--help.golden @@ -24,7 +24,6 @@ Templates are written in standard Terraform and describe the infrastructure for edit Edit the metadata of a template by name. init Get started with a templated template. list List all the templates available for the organization - plan Plan a template push from the current directory pull Download the latest version of a template to a path. push Push a new template version from the current directory or as specified by flag diff --git a/docs/cli/templates.md b/docs/cli/templates.md index ed459f2d70d82..4426625363ed7 100644 --- a/docs/cli/templates.md +++ b/docs/cli/templates.md @@ -40,7 +40,6 @@ Templates are written in standard Terraform and describe the infrastructure for | [edit](./templates_edit.md) | Edit the metadata of a template by name. | | [init](./templates_init.md) | Get started with a templated template. | | [list](./templates_list.md) | List all the templates available for the organization | -| [plan](./templates_plan.md) | Plan a template push from the current directory | | [pull](./templates_pull.md) | Download the latest version of a template to a path. | | [push](./templates_push.md) | Push a new template version from the current directory or as specified by flag | | [versions](./templates_versions.md) | Manage different versions of the specified template | diff --git a/docs/cli/templates_plan.md b/docs/cli/templates_plan.md deleted file mode 100644 index 06ed7d56c507d..0000000000000 --- a/docs/cli/templates_plan.md +++ /dev/null @@ -1,11 +0,0 @@ - - -# templates plan - -Plan a template push from the current directory - -## Usage - -```console -coder templates plan -``` diff --git a/docs/manifest.json b/docs/manifest.json index 38c6103286346..8c18bb26ad9f5 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -794,11 +794,6 @@ "description": "List all the templates available for the organization", "path": "cli/templates_list.md" }, - { - "title": "templates plan", - "description": "Plan a template push from the current directory", - "path": "cli/templates_plan.md" - }, { "title": "templates pull", "description": "Download the latest version of a template to a path.", From 487bdc2e08e75e4343bf66b7915421c902e2344b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 28 Aug 2023 22:46:42 +0300 Subject: [PATCH 275/277] fix(coderd): allow `workspaceAgentLogs` follow to return on non-latest-build (#9382) --- coderd/workspaceagents.go | 25 +++++++++- coderd/workspaceagents_test.go | 85 ++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index d921bbfa72bd7..7a3b25eee96fc 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -23,6 +23,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "golang.org/x/exp/maps" + "golang.org/x/exp/slices" "golang.org/x/mod/semver" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" @@ -481,6 +482,15 @@ func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { return } + workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace by agent id.", + Detail: err.Error(), + }) + return + } + api.WebsocketWaitMutex.Lock() api.WebsocketWaitGroup.Add(1) api.WebsocketWaitMutex.Unlock() @@ -556,7 +566,8 @@ func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { go func() { defer close(bufferedLogs) - for { + keepGoing := true + for keepGoing { select { case <-ctx.Done(): return @@ -565,6 +576,18 @@ func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) { t.Reset(recheckInterval) } + agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + if xerrors.Is(err, context.Canceled) { + return + } + logger.Warn(ctx, "failed to get workspace agents in latest build", slog.Error(err)) + continue + } + // If the agent is no longer in the latest build, we can stop after + // checking once. + keepGoing = slices.ContainsFunc(agents, func(agent database.WorkspaceAgent) bool { return agent.ID == workspaceAgent.ID }) + logs, err := api.Database.GetWorkspaceAgentLogsAfter(ctx, database.GetWorkspaceAgentLogsAfterParams{ AgentID: workspaceAgent.ID, CreatedAfter: lastSentLogID, diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 43694e95e67a9..2e51687afafa6 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -242,6 +242,91 @@ func TestWorkspaceAgentStartupLogs(t *testing.T) { require.Equal(t, "testing", logChunk[0].Output) require.Equal(t, "testing2", logChunk[1].Output) }) + t.Run("Close logs on outdated build", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + err := agentClient.PatchLogs(ctx, agentsdk.PatchLogs{ + Logs: []agentsdk.Log{ + { + CreatedAt: database.Now(), + Output: "testing", + }, + }, + }) + require.NoError(t, err) + + logs, closer, err := client.WorkspaceAgentLogsAfter(ctx, build.Resources[0].Agents[0].ID, 0, true) + require.NoError(t, err) + defer func() { + _ = closer.Close() + }() + + first := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + assert.Fail(t, "context done while waiting in goroutine") + case <-logs: + close(first) + } + }() + select { + case <-ctx.Done(): + require.FailNow(t, "context done while waiting for first log") + case <-first: + } + + _ = coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart) + + // Send a new log message to trigger a re-check. + err = agentClient.PatchLogs(ctx, agentsdk.PatchLogs{ + Logs: []agentsdk.Log{ + { + CreatedAt: database.Now(), + Output: "testing2", + }, + }, + }) + require.NoError(t, err) + + select { + case <-ctx.Done(): + require.FailNow(t, "context done while waiting for logs close") + case <-logs: + } + }) t.Run("PublishesOnOverflow", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) From be47cc58ffb504e630e6b6fef9b7b523f939d476 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 28 Aug 2023 23:12:45 +0300 Subject: [PATCH 276/277] fix(enterprise/coderd): use `websocketNetConn` in `workspaceProxyCoordinate` to bind context (#9395) --- enterprise/coderd/workspaceproxycoordinate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise/coderd/workspaceproxycoordinate.go b/enterprise/coderd/workspaceproxycoordinate.go index 03e3f132e70f4..ec454d73a870a 100644 --- a/enterprise/coderd/workspaceproxycoordinate.go +++ b/enterprise/coderd/workspaceproxycoordinate.go @@ -68,7 +68,7 @@ func (api *API) workspaceProxyCoordinate(rw http.ResponseWriter, r *http.Request id := uuid.New() sub := (*api.AGPL.TailnetCoordinator.Load()).ServeMultiAgent(id) - nc := websocket.NetConn(ctx, conn, websocket.MessageText) + ctx, nc := websocketNetConn(ctx, conn, websocket.MessageText) defer nc.Close() err = tailnet.ServeWorkspaceProxy(ctx, nc, sub) From eb686843275605b4cc7153e9b418f7350098662b Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Mon, 28 Aug 2023 17:55:09 -0500 Subject: [PATCH 277/277] docs: add v2.1.4 changelog (#9398) * docs: add v2.1.4 changelog * fmt * reorder * clarify --- docs/changelogs/README.md | 6 +++--- docs/changelogs/v2.1.4.md | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 docs/changelogs/v2.1.4.md diff --git a/docs/changelogs/README.md b/docs/changelogs/README.md index b0ffdbd4218b0..7e2493d7a3c3e 100644 --- a/docs/changelogs/README.md +++ b/docs/changelogs/README.md @@ -12,8 +12,8 @@ Run this command to generate release notes: export CODER_IGNORE_MISSING_COMMIT_METADATA=1 export BRANCH=main ./scripts/release/generate_release_notes.sh \ - --old-version=v2.1.3 \ - --new-version=v2.1.4 \ + --old-version=v2.1.4 \ + --new-version=v2.1.5 \ --ref=$(git rev-parse --short "${ref:-origin/$BRANCH}") \ - > ./docs/changelogs/v2.1.4.md + > ./docs/changelogs/v2.1.5.md ``` diff --git a/docs/changelogs/v2.1.4.md b/docs/changelogs/v2.1.4.md new file mode 100644 index 0000000000000..f2abe83d2fc10 --- /dev/null +++ b/docs/changelogs/v2.1.4.md @@ -0,0 +1,41 @@ +## Changelog + +### Features + +- Add `template_active_version_id` to workspaces (#9226) (@kylecarbs) +- Show entity name in DeleteDialog (#9347) (@ammario) +- Improve template publishing flow (#9346) (@aslilac) + +### Bug fixes + +- Fixed 2 bugs contributing to a memory leak in `coderd` (#9364): + - Allow `workspaceAgentLogs` follow to return on non-latest-build (#9382) + (@mafredri) + - Avoid derp-map updates endpoint leak (#9390) (@deansheather) +- Send updated workspace data after ws connection (#9392) (@BrunoQuaresma) +- Fix `coder template pull` on Windows (#9327) (@spikecurtis) +- Truncate websocket close error (#9360) (@kylecarbs) +- Add `--max-ttl`` to template create (#9319) (@ammario) +- Remove rate limits from agent metadata (#9308) (@ammario) +- Use `websocketNetConn` in `workspaceProxyCoordinate` to bind context (#9395) + (@mafredri) +- Fox default ephemeral parameter value on parameters page (#9314) + (@BrunoQuaresma) +- Render variable width unicode characters in terminal (#9259) (@ammario) +- Use WebGL renderer for terminal (#9320) (@ammario) +- 80425c32b fix(site): workaround: reload page every 3sec (#9387) (@mtojek) +- Make right panel scrollable on template editor (#9344) (@BrunoQuaresma) +- Use more reasonable restart limit for systemd service (#9355) (@bpmct) + +Compare: +[`v2.1.3...v2.1.4`](https://github.com/coder/coder/compare/v2.1.3...v2.1.4) + +## Container image + +- `docker pull ghcr.io/coder/coder:v2.1.4` + +## Install/upgrade + +Refer to our docs to [install](https://coder.com/docs/v2/latest/install) or +[upgrade](https://coder.com/docs/v2/latest/admin/upgrade) Coder, or use a +release asset below.