From 10326b458cf19d04c1fb155e805950bfaaaa9be9 Mon Sep 17 00:00:00 2001 From: Stephen Kirby <58410745+stirby@users.noreply.github.com> Date: Mon, 24 Feb 2025 11:03:05 -0600 Subject: [PATCH 01/29] chore(dogfood): add validation on OOM OOD parameters (#16636) --- dogfood/contents/main.tf | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dogfood/contents/main.tf b/dogfood/contents/main.tf index 6e60c58cf1..d5e70b9e32 100644 --- a/dogfood/contents/main.tf +++ b/dogfood/contents/main.tf @@ -91,6 +91,10 @@ data "coder_parameter" "res_mon_memory_threshold" { default = 80 description = "The memory usage threshold used in resources monitoring to trigger notifications." mutable = true + validation { + min = 0 + max = 100 + } } data "coder_parameter" "res_mon_volume_threshold" { @@ -99,6 +103,10 @@ data "coder_parameter" "res_mon_volume_threshold" { default = 80 description = "The volume usage threshold used in resources monitoring to trigger notifications." mutable = true + validation { + min = 0 + max = 100 + } } data "coder_parameter" "res_mon_volume_path" { From dfa33b11d993bb97c5a0fcc90c613ed9fcba3d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B1=E3=82=A4=E3=83=A9?= Date: Mon, 24 Feb 2025 10:43:03 -0700 Subject: [PATCH 02/29] chore: run `make clean` on workspace startup (#16660) --- Makefile | 2 +- dogfood/contents/main.tf | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 29f8461f48..fbd324974f 100644 --- a/Makefile +++ b/Makefile @@ -116,7 +116,7 @@ endif clean: rm -rf build/ site/build/ site/out/ - mkdir -p build/ site/out/bin/ + mkdir -p build/ git restore site/out/ .PHONY: clean diff --git a/dogfood/contents/main.tf b/dogfood/contents/main.tf index d5e70b9e32..998b463f82 100644 --- a/dogfood/contents/main.tf +++ b/dogfood/contents/main.tf @@ -100,7 +100,7 @@ data "coder_parameter" "res_mon_memory_threshold" { data "coder_parameter" "res_mon_volume_threshold" { type = "number" name = "Volume usage threshold" - default = 80 + default = 90 description = "The volume usage threshold used in resources monitoring to trigger notifications." mutable = true validation { @@ -350,6 +350,7 @@ resource "coder_agent" "dev" { while ! [[ -f "${local.repo_dir}/site/package.json" ]]; do sleep 1 done + cd "${local.repo_dir}" && make clean cd "${local.repo_dir}/site" && pnpm install && pnpm playwright:install EOT } From 546a549dcf8e019c2a2a8f5ffc50b4e7f95f4ac5 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 24 Feb 2025 17:59:41 +0000 Subject: [PATCH 03/29] feat: enable soft delete for organizations (#16584) - Add deleted column to organizations table - Add trigger to check for existing workspaces, templates, groups and members in a org before allowing the soft delete --------- Co-authored-by: Steven Masley Co-authored-by: Steven Masley --- coderd/database/db.go | 5 +- coderd/database/dbauthz/dbauthz.go | 18 +- coderd/database/dbauthz/dbauthz_test.go | 13 +- coderd/database/dbmem/dbmem.go | 43 +++-- coderd/database/dbmetrics/querymetrics.go | 28 ++- coderd/database/dbmock/dbmock.go | 44 ++--- coderd/database/dump.sql | 80 ++++++++- .../000296_organization_soft_delete.down.sql | 12 ++ .../000296_organization_soft_delete.up.sql | 85 +++++++++ coderd/database/models.go | 1 + coderd/database/querier.go | 6 +- coderd/database/querier_test.go | 131 ++++++++++++++ coderd/database/queries.sql.go | 162 +++++++++++------- coderd/database/queries/organizations.sql | 108 ++++++------ coderd/database/unique_constraint.go | 4 +- coderd/httpmw/organizationparam.go | 5 +- coderd/idpsync/organization.go | 5 +- coderd/searchquery/search.go | 4 +- coderd/users.go | 10 +- docs/CONTRIBUTING.md | 6 +- docs/admin/security/audit-logs.md | 2 +- enterprise/audit/table.go | 1 + enterprise/coderd/audit_test.go | 4 - enterprise/coderd/groups.go | 5 +- enterprise/coderd/organizations.go | 16 +- site/e2e/tests/organizations.spec.ts | 3 +- .../OrganizationSettingsPage.tsx | 14 +- .../OrganizationSettingsPageView.tsx | 5 +- 28 files changed, 605 insertions(+), 215 deletions(-) create mode 100644 coderd/database/migrations/000296_organization_soft_delete.down.sql create mode 100644 coderd/database/migrations/000296_organization_soft_delete.up.sql diff --git a/coderd/database/db.go b/coderd/database/db.go index 0f923a861e..23ee5028e3 100644 --- a/coderd/database/db.go +++ b/coderd/database/db.go @@ -3,9 +3,8 @@ // Query functions are generated using sqlc. // // To modify the database schema: -// 1. Add a new migration using "create_migration.sh" in database/migrations/ -// 2. Run "make coderd/database/generate" in the root to generate models. -// 3. Add/Edit queries in "query.sql" and run "make coderd/database/generate" to create Go code. +// 1. Add a new migration using "create_migration.sh" in database/migrations/ and run "make gen" to generate models. +// 2. Add/Edit queries in "query.sql" and run "make gen" to create Go code. package database import ( diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 9e616dd79d..5c558aaa0d 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1302,10 +1302,6 @@ func (q *querier) DeleteOldWorkspaceAgentStats(ctx context.Context) error { return q.db.DeleteOldWorkspaceAgentStats(ctx) } -func (q *querier) DeleteOrganization(ctx context.Context, id uuid.UUID) error { - return deleteQ(q.log, q.auth, q.db.GetOrganizationByID, q.db.DeleteOrganization)(ctx, id) -} - func (q *querier) DeleteOrganizationMember(ctx context.Context, arg database.DeleteOrganizationMemberParams) error { return deleteQ[database.OrganizationMember](q.log, q.auth, func(ctx context.Context, arg database.DeleteOrganizationMemberParams) (database.OrganizationMember, error) { member, err := database.ExpectOne(q.OrganizationMembers(ctx, database.OrganizationMembersParams(arg))) @@ -1926,7 +1922,7 @@ func (q *querier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (databa return fetch(q.log, q.auth, q.db.GetOrganizationByID)(ctx, id) } -func (q *querier) GetOrganizationByName(ctx context.Context, name string) (database.Organization, error) { +func (q *querier) GetOrganizationByName(ctx context.Context, name database.GetOrganizationByNameParams) (database.Organization, error) { return fetch(q.log, q.auth, q.db.GetOrganizationByName)(ctx, name) } @@ -1943,7 +1939,7 @@ func (q *querier) GetOrganizations(ctx context.Context, args database.GetOrganiz return fetchWithPostFilter(q.auth, policy.ActionRead, fetch)(ctx, nil) } -func (q *querier) GetOrganizationsByUserID(ctx context.Context, userID uuid.UUID) ([]database.Organization, error) { +func (q *querier) GetOrganizationsByUserID(ctx context.Context, userID database.GetOrganizationsByUserIDParams) ([]database.Organization, error) { return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOrganizationsByUserID)(ctx, userID) } @@ -3737,6 +3733,16 @@ func (q *querier) UpdateOrganization(ctx context.Context, arg database.UpdateOrg return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateOrganization)(ctx, arg) } +func (q *querier) UpdateOrganizationDeletedByID(ctx context.Context, arg database.UpdateOrganizationDeletedByIDParams) error { + deleteF := func(ctx context.Context, id uuid.UUID) error { + return q.db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + ID: id, + UpdatedAt: dbtime.Now(), + }) + } + return deleteQ(q.log, q.auth, q.db.GetOrganizationByID, deleteF)(ctx, arg.ID) +} + func (q *querier) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceProvisionerDaemon); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index c960f06c65..db4e687215 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -815,7 +815,7 @@ func (s *MethodTestSuite) TestOrganization() { })) s.Run("GetOrganizationByName", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) - check.Args(o.Name).Asserts(o, policy.ActionRead).Returns(o) + check.Args(database.GetOrganizationByNameParams{Name: o.Name, Deleted: o.Deleted}).Asserts(o, policy.ActionRead).Returns(o) })) s.Run("GetOrganizationIDsByMemberIDs", s.Subtest(func(db database.Store, check *expects) { oa := dbgen.Organization(s.T(), db, database.Organization{}) @@ -839,7 +839,7 @@ func (s *MethodTestSuite) TestOrganization() { _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{UserID: u.ID, OrganizationID: a.ID}) b := dbgen.Organization(s.T(), db, database.Organization{}) _ = dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{UserID: u.ID, OrganizationID: b.ID}) - check.Args(u.ID).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b)) + check.Args(database.GetOrganizationsByUserIDParams{UserID: u.ID, Deleted: false}).Asserts(a, policy.ActionRead, b, policy.ActionRead).Returns(slice.New(a, b)) })) s.Run("InsertOrganization", s.Subtest(func(db database.Store, check *expects) { check.Args(database.InsertOrganizationParams{ @@ -960,13 +960,14 @@ func (s *MethodTestSuite) TestOrganization() { Name: "something-different", }).Asserts(o, policy.ActionUpdate) })) - s.Run("DeleteOrganization", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpdateOrganizationDeletedByID", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{ Name: "doomed", }) - check.Args( - o.ID, - ).Asserts(o, policy.ActionDelete) + check.Args(database.UpdateOrganizationDeletedByIDParams{ + ID: o.ID, + UpdatedAt: o.UpdatedAt, + }).Asserts(o, policy.ActionDelete).Returns() })) s.Run("OrganizationMembers", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 7f56ea5f46..9488577edc 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -2157,19 +2157,6 @@ func (q *FakeQuerier) DeleteOldWorkspaceAgentStats(_ context.Context) error { return nil } -func (q *FakeQuerier) DeleteOrganization(_ context.Context, id uuid.UUID) error { - q.mutex.Lock() - defer q.mutex.Unlock() - - for i, org := range q.organizations { - if org.ID == id && !org.IsDefault { - q.organizations = append(q.organizations[:i], q.organizations[i+1:]...) - return nil - } - } - return sql.ErrNoRows -} - func (q *FakeQuerier) DeleteOrganizationMember(ctx context.Context, arg database.DeleteOrganizationMemberParams) error { err := validateDatabaseType(arg) if err != nil { @@ -3688,12 +3675,12 @@ func (q *FakeQuerier) GetOrganizationByID(_ context.Context, id uuid.UUID) (data return q.getOrganizationByIDNoLock(id) } -func (q *FakeQuerier) GetOrganizationByName(_ context.Context, name string) (database.Organization, error) { +func (q *FakeQuerier) GetOrganizationByName(_ context.Context, params database.GetOrganizationByNameParams) (database.Organization, error) { q.mutex.RLock() defer q.mutex.RUnlock() for _, organization := range q.organizations { - if organization.Name == name { + if organization.Name == params.Name && organization.Deleted == params.Deleted { return organization, nil } } @@ -3740,17 +3727,17 @@ func (q *FakeQuerier) GetOrganizations(_ context.Context, args database.GetOrgan return tmp, nil } -func (q *FakeQuerier) GetOrganizationsByUserID(_ context.Context, userID uuid.UUID) ([]database.Organization, error) { +func (q *FakeQuerier) GetOrganizationsByUserID(_ context.Context, arg database.GetOrganizationsByUserIDParams) ([]database.Organization, error) { q.mutex.RLock() defer q.mutex.RUnlock() organizations := make([]database.Organization, 0) for _, organizationMember := range q.organizationMembers { - if organizationMember.UserID != userID { + if organizationMember.UserID != arg.UserID { continue } for _, organization := range q.organizations { - if organization.ID != organizationMember.OrganizationID { + if organization.ID != organizationMember.OrganizationID || organization.Deleted != arg.Deleted { continue } organizations = append(organizations, organization) @@ -9822,6 +9809,26 @@ func (q *FakeQuerier) UpdateOrganization(_ context.Context, arg database.UpdateO return database.Organization{}, sql.ErrNoRows } +func (q *FakeQuerier) UpdateOrganizationDeletedByID(_ context.Context, arg database.UpdateOrganizationDeletedByIDParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, organization := range q.organizations { + if organization.ID != arg.ID || organization.IsDefault { + continue + } + organization.Deleted = true + organization.UpdatedAt = arg.UpdatedAt + q.organizations[index] = organization + return nil + } + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateProvisionerDaemonLastSeenAt(_ context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { err := validateDatabaseType(arg) if err != nil { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 665c10658a..90ea140d05 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -77,6 +77,16 @@ func (m queryMetricsStore) InTx(f func(database.Store) error, options *database. return m.dbMetrics.InTx(f, options) } +func (m queryMetricsStore) DeleteOrganization(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + ID: id, + UpdatedAt: time.Now(), + }) + m.queryLatencies.WithLabelValues("DeleteOrganization").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error { start := time.Now() err := m.s.AcquireLock(ctx, pgAdvisoryXactLock) @@ -329,13 +339,6 @@ func (m queryMetricsStore) DeleteOldWorkspaceAgentStats(ctx context.Context) err return err } -func (m queryMetricsStore) DeleteOrganization(ctx context.Context, id uuid.UUID) error { - start := time.Now() - r0 := m.s.DeleteOrganization(ctx, id) - m.queryLatencies.WithLabelValues("DeleteOrganization").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) DeleteOrganizationMember(ctx context.Context, arg database.DeleteOrganizationMemberParams) error { start := time.Now() r0 := m.s.DeleteOrganizationMember(ctx, arg) @@ -945,7 +948,7 @@ func (m queryMetricsStore) GetOrganizationByID(ctx context.Context, id uuid.UUID return organization, err } -func (m queryMetricsStore) GetOrganizationByName(ctx context.Context, name string) (database.Organization, error) { +func (m queryMetricsStore) GetOrganizationByName(ctx context.Context, name database.GetOrganizationByNameParams) (database.Organization, error) { start := time.Now() organization, err := m.s.GetOrganizationByName(ctx, name) m.queryLatencies.WithLabelValues("GetOrganizationByName").Observe(time.Since(start).Seconds()) @@ -966,7 +969,7 @@ func (m queryMetricsStore) GetOrganizations(ctx context.Context, args database.G return organizations, err } -func (m queryMetricsStore) GetOrganizationsByUserID(ctx context.Context, userID uuid.UUID) ([]database.Organization, error) { +func (m queryMetricsStore) GetOrganizationsByUserID(ctx context.Context, userID database.GetOrganizationsByUserIDParams) ([]database.Organization, error) { start := time.Now() organizations, err := m.s.GetOrganizationsByUserID(ctx, userID) m.queryLatencies.WithLabelValues("GetOrganizationsByUserID").Observe(time.Since(start).Seconds()) @@ -2366,6 +2369,13 @@ func (m queryMetricsStore) UpdateOrganization(ctx context.Context, arg database. return r0, r1 } +func (m queryMetricsStore) UpdateOrganizationDeletedByID(ctx context.Context, arg database.UpdateOrganizationDeletedByIDParams) error { + start := time.Now() + r0 := m.s.UpdateOrganizationDeletedByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateOrganizationDeletedByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { start := time.Now() r0 := m.s.UpdateProvisionerDaemonLastSeenAt(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index c7711505d7..38ee52aa76 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -557,20 +557,6 @@ func (mr *MockStoreMockRecorder) DeleteOldWorkspaceAgentStats(ctx any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldWorkspaceAgentStats", reflect.TypeOf((*MockStore)(nil).DeleteOldWorkspaceAgentStats), ctx) } -// DeleteOrganization mocks base method. -func (m *MockStore) DeleteOrganization(ctx context.Context, id uuid.UUID) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteOrganization", ctx, id) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteOrganization indicates an expected call of DeleteOrganization. -func (mr *MockStoreMockRecorder) DeleteOrganization(ctx, id any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOrganization", reflect.TypeOf((*MockStore)(nil).DeleteOrganization), ctx, id) -} - // DeleteOrganizationMember mocks base method. func (m *MockStore) DeleteOrganizationMember(ctx context.Context, arg database.DeleteOrganizationMemberParams) error { m.ctrl.T.Helper() @@ -1942,18 +1928,18 @@ func (mr *MockStoreMockRecorder) GetOrganizationByID(ctx, id any) *gomock.Call { } // GetOrganizationByName mocks base method. -func (m *MockStore) GetOrganizationByName(ctx context.Context, name string) (database.Organization, error) { +func (m *MockStore) GetOrganizationByName(ctx context.Context, arg database.GetOrganizationByNameParams) (database.Organization, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetOrganizationByName", ctx, name) + ret := m.ctrl.Call(m, "GetOrganizationByName", ctx, arg) ret0, _ := ret[0].(database.Organization) ret1, _ := ret[1].(error) return ret0, ret1 } // GetOrganizationByName indicates an expected call of GetOrganizationByName. -func (mr *MockStoreMockRecorder) GetOrganizationByName(ctx, name any) *gomock.Call { +func (mr *MockStoreMockRecorder) GetOrganizationByName(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationByName", reflect.TypeOf((*MockStore)(nil).GetOrganizationByName), ctx, name) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationByName", reflect.TypeOf((*MockStore)(nil).GetOrganizationByName), ctx, arg) } // GetOrganizationIDsByMemberIDs mocks base method. @@ -1987,18 +1973,18 @@ func (mr *MockStoreMockRecorder) GetOrganizations(ctx, arg any) *gomock.Call { } // GetOrganizationsByUserID mocks base method. -func (m *MockStore) GetOrganizationsByUserID(ctx context.Context, userID uuid.UUID) ([]database.Organization, error) { +func (m *MockStore) GetOrganizationsByUserID(ctx context.Context, arg database.GetOrganizationsByUserIDParams) ([]database.Organization, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetOrganizationsByUserID", ctx, userID) + ret := m.ctrl.Call(m, "GetOrganizationsByUserID", ctx, arg) ret0, _ := ret[0].([]database.Organization) ret1, _ := ret[1].(error) return ret0, ret1 } // GetOrganizationsByUserID indicates an expected call of GetOrganizationsByUserID. -func (mr *MockStoreMockRecorder) GetOrganizationsByUserID(ctx, userID any) *gomock.Call { +func (mr *MockStoreMockRecorder) GetOrganizationsByUserID(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationsByUserID", reflect.TypeOf((*MockStore)(nil).GetOrganizationsByUserID), ctx, userID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationsByUserID", reflect.TypeOf((*MockStore)(nil).GetOrganizationsByUserID), ctx, arg) } // GetParameterSchemasByJobID mocks base method. @@ -5039,6 +5025,20 @@ func (mr *MockStoreMockRecorder) UpdateOrganization(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganization", reflect.TypeOf((*MockStore)(nil).UpdateOrganization), ctx, arg) } +// UpdateOrganizationDeletedByID mocks base method. +func (m *MockStore) UpdateOrganizationDeletedByID(ctx context.Context, arg database.UpdateOrganizationDeletedByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateOrganizationDeletedByID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateOrganizationDeletedByID indicates an expected call of UpdateOrganizationDeletedByID. +func (mr *MockStoreMockRecorder) UpdateOrganizationDeletedByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganizationDeletedByID", reflect.TypeOf((*MockStore)(nil).UpdateOrganizationDeletedByID), ctx, arg) +} + // UpdateProvisionerDaemonLastSeenAt mocks base method. func (m *MockStore) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index e699b34bd5..e05d3a06d3 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -438,6 +438,74 @@ BEGIN END; $$; +CREATE FUNCTION protect_deleting_organizations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + workspace_count int; + template_count int; + group_count int; + member_count int; + provisioner_keys_count int; +BEGIN + workspace_count := ( + SELECT count(*) as count FROM workspaces + WHERE + workspaces.organization_id = OLD.id + AND workspaces.deleted = false + ); + + template_count := ( + SELECT count(*) as count FROM templates + WHERE + templates.organization_id = OLD.id + AND templates.deleted = false + ); + + group_count := ( + SELECT count(*) as count FROM groups + WHERE + groups.organization_id = OLD.id + ); + + member_count := ( + SELECT count(*) as count FROM organization_members + WHERE + organization_members.organization_id = OLD.id + ); + + provisioner_keys_count := ( + Select count(*) as count FROM provisioner_keys + WHERE + provisioner_keys.organization_id = OLD.id + ); + + -- Fail the deletion if one of the following: + -- * the organization has 1 or more workspaces + -- * the organization has 1 or more templates + -- * the organization has 1 or more groups other than "Everyone" group + -- * the organization has 1 or more members other than the organization owner + -- * the organization has 1 or more provisioner keys + + IF (workspace_count + template_count + provisioner_keys_count) > 0 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % workspaces, % templates, and % provisioner keys that must be deleted first', workspace_count, template_count, provisioner_keys_count; + END IF; + + IF (group_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % groups that must be deleted first', group_count - 1; + END IF; + + -- Allow 1 member to exist, because you cannot remove yourself. You can + -- remove everyone else. Ideally, we only omit the member that matches + -- the user_id of the caller, however in a trigger, the caller is unknown. + IF (member_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % members that must be deleted first', member_count - 1; + END IF; + + RETURN NEW; +END; +$$; + CREATE FUNCTION provisioner_tagset_contains(provisioner_tags tagset, job_tags tagset) RETURNS boolean LANGUAGE plpgsql AS $$ @@ -967,7 +1035,8 @@ CREATE TABLE organizations ( updated_at timestamp with time zone NOT NULL, is_default boolean DEFAULT false NOT NULL, display_name text NOT NULL, - icon text DEFAULT ''::text NOT NULL + icon text DEFAULT ''::text NOT NULL, + deleted boolean DEFAULT false NOT NULL ); CREATE TABLE parameter_schemas ( @@ -2030,9 +2099,6 @@ ALTER TABLE ONLY oauth2_provider_apps ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_pkey PRIMARY KEY (organization_id, user_id); -ALTER TABLE ONLY organizations - ADD CONSTRAINT organizations_name UNIQUE (name); - ALTER TABLE ONLY organizations ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); @@ -2218,9 +2284,7 @@ CREATE INDEX idx_organization_member_organization_id_uuid ON organization_member CREATE INDEX idx_organization_member_user_id_uuid ON organization_members USING btree (user_id); -CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name); - -CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)); +CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)) WHERE (deleted = false); CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); @@ -2352,6 +2416,8 @@ CREATE OR REPLACE VIEW provisioner_job_stats AS CREATE TRIGGER inhibit_enqueue_if_disabled BEFORE INSERT ON notification_messages FOR EACH ROW EXECUTE FUNCTION inhibit_enqueue_if_disabled(); +CREATE TRIGGER protect_deleting_organizations BEFORE UPDATE ON organizations FOR EACH ROW WHEN (((new.deleted = true) AND (old.deleted = false))) EXECUTE FUNCTION protect_deleting_organizations(); + CREATE TRIGGER remove_organization_member_custom_role BEFORE DELETE ON custom_roles FOR EACH ROW EXECUTE FUNCTION remove_organization_member_role(); COMMENT ON TRIGGER remove_organization_member_custom_role ON custom_roles IS 'When a custom_role is deleted, this trigger removes the role from all organization members.'; diff --git a/coderd/database/migrations/000296_organization_soft_delete.down.sql b/coderd/database/migrations/000296_organization_soft_delete.down.sql new file mode 100644 index 0000000000..3db107e8a7 --- /dev/null +++ b/coderd/database/migrations/000296_organization_soft_delete.down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS idx_organization_name_lower; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_organization_name ON organizations USING btree (name); +CREATE UNIQUE INDEX IF NOT EXISTS idx_organization_name_lower ON organizations USING btree (lower(name)); + +ALTER TABLE ONLY organizations + ADD CONSTRAINT organizations_name UNIQUE (name); + +DROP TRIGGER IF EXISTS protect_deleting_organizations ON organizations; +DROP FUNCTION IF EXISTS protect_deleting_organizations; + +ALTER TABLE organizations DROP COLUMN deleted; diff --git a/coderd/database/migrations/000296_organization_soft_delete.up.sql b/coderd/database/migrations/000296_organization_soft_delete.up.sql new file mode 100644 index 0000000000..34b25139c9 --- /dev/null +++ b/coderd/database/migrations/000296_organization_soft_delete.up.sql @@ -0,0 +1,85 @@ +ALTER TABLE organizations ADD COLUMN deleted boolean DEFAULT FALSE NOT NULL; + +DROP INDEX IF EXISTS idx_organization_name; +DROP INDEX IF EXISTS idx_organization_name_lower; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_organization_name_lower ON organizations USING btree (lower(name)) + where deleted = false; + +ALTER TABLE ONLY organizations + DROP CONSTRAINT IF EXISTS organizations_name; + +CREATE FUNCTION protect_deleting_organizations() + RETURNS TRIGGER AS +$$ +DECLARE + workspace_count int; + template_count int; + group_count int; + member_count int; + provisioner_keys_count int; +BEGIN + workspace_count := ( + SELECT count(*) as count FROM workspaces + WHERE + workspaces.organization_id = OLD.id + AND workspaces.deleted = false + ); + + template_count := ( + SELECT count(*) as count FROM templates + WHERE + templates.organization_id = OLD.id + AND templates.deleted = false + ); + + group_count := ( + SELECT count(*) as count FROM groups + WHERE + groups.organization_id = OLD.id + ); + + member_count := ( + SELECT count(*) as count FROM organization_members + WHERE + organization_members.organization_id = OLD.id + ); + + provisioner_keys_count := ( + Select count(*) as count FROM provisioner_keys + WHERE + provisioner_keys.organization_id = OLD.id + ); + + -- Fail the deletion if one of the following: + -- * the organization has 1 or more workspaces + -- * the organization has 1 or more templates + -- * the organization has 1 or more groups other than "Everyone" group + -- * the organization has 1 or more members other than the organization owner + -- * the organization has 1 or more provisioner keys + + IF (workspace_count + template_count + provisioner_keys_count) > 0 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % workspaces, % templates, and % provisioner keys that must be deleted first', workspace_count, template_count, provisioner_keys_count; + END IF; + + IF (group_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % groups that must be deleted first', group_count - 1; + END IF; + + -- Allow 1 member to exist, because you cannot remove yourself. You can + -- remove everyone else. Ideally, we only omit the member that matches + -- the user_id of the caller, however in a trigger, the caller is unknown. + IF (member_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % members that must be deleted first', member_count - 1; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to protect organizations from being soft deleted with existing resources +CREATE TRIGGER protect_deleting_organizations + BEFORE UPDATE ON organizations + FOR EACH ROW + WHEN (NEW.deleted = true AND OLD.deleted = false) + EXECUTE FUNCTION protect_deleting_organizations(); diff --git a/coderd/database/models.go b/coderd/database/models.go index 5411591eed..4e3353f844 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2675,6 +2675,7 @@ type Organization struct { IsDefault bool `db:"is_default" json:"is_default"` DisplayName string `db:"display_name" json:"display_name"` Icon string `db:"icon" json:"icon"` + Deleted bool `db:"deleted" json:"deleted"` } type OrganizationMember struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 42b88d855e..a5cedde6c4 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -94,7 +94,6 @@ type sqlcQuerier interface { // Logs can take up a lot of space, so it's important we clean up frequently. DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) error DeleteOldWorkspaceAgentStats(ctx context.Context) error - DeleteOrganization(ctx context.Context, id uuid.UUID) error DeleteOrganizationMember(ctx context.Context, arg DeleteOrganizationMemberParams) error DeleteProvisionerKey(ctx context.Context, id uuid.UUID) error DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error @@ -197,10 +196,10 @@ type sqlcQuerier interface { GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]GetOAuth2ProviderAppsByUserIDRow, error) GetOAuthSigningKey(ctx context.Context) (string, error) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) - GetOrganizationByName(ctx context.Context, name string) (Organization, error) + GetOrganizationByName(ctx context.Context, arg GetOrganizationByNameParams) (Organization, error) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error) GetOrganizations(ctx context.Context, arg GetOrganizationsParams) ([]Organization, error) - GetOrganizationsByUserID(ctx context.Context, userID uuid.UUID) ([]Organization, error) + GetOrganizationsByUserID(ctx context.Context, arg GetOrganizationsByUserIDParams) ([]Organization, error) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceBuildID uuid.UUID) (TemplateVersionPreset, error) GetPresetParametersByTemplateVersionID(ctx context.Context, templateVersionID uuid.UUID) ([]TemplateVersionPresetParameter, error) @@ -485,6 +484,7 @@ type sqlcQuerier interface { UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error) UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error) + UpdateOrganizationDeletedByID(ctx context.Context, arg UpdateOrganizationDeletedByIDParams) error UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg UpdateProvisionerDaemonLastSeenAtParams) error UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 00b189967f..b60554de75 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -2916,6 +2917,136 @@ func TestGetUserStatusCounts(t *testing.T) { } } +func TestOrganizationDeleteTrigger(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.SkipNow() + } + + t.Run("WorkspaceExists", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + orgA := dbfake.Organization(t, db).Do() + + user := dbgen.User(t, db, database.User{}) + + dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: orgA.Org.ID, + OwnerID: user.ID, + }).Do() + + ctx := testutil.Context(t, testutil.WaitShort) + err := db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + UpdatedAt: dbtime.Now(), + ID: orgA.Org.ID, + }) + require.Error(t, err) + // cannot delete organization: organization has 1 workspaces and 1 templates that must be deleted first + require.ErrorContains(t, err, "cannot delete organization") + require.ErrorContains(t, err, "has 1 workspaces") + require.ErrorContains(t, err, "1 templates") + }) + + t.Run("TemplateExists", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + orgA := dbfake.Organization(t, db).Do() + + user := dbgen.User(t, db, database.User{}) + + dbgen.Template(t, db, database.Template{ + OrganizationID: orgA.Org.ID, + CreatedBy: user.ID, + }) + + ctx := testutil.Context(t, testutil.WaitShort) + err := db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + UpdatedAt: dbtime.Now(), + ID: orgA.Org.ID, + }) + require.Error(t, err) + // cannot delete organization: organization has 0 workspaces and 1 templates that must be deleted first + require.ErrorContains(t, err, "cannot delete organization") + require.ErrorContains(t, err, "has 0 workspaces") + require.ErrorContains(t, err, "1 templates") + }) + + t.Run("ProvisionerKeyExists", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + orgA := dbfake.Organization(t, db).Do() + + dbgen.ProvisionerKey(t, db, database.ProvisionerKey{ + OrganizationID: orgA.Org.ID, + }) + + ctx := testutil.Context(t, testutil.WaitShort) + err := db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + UpdatedAt: dbtime.Now(), + ID: orgA.Org.ID, + }) + require.Error(t, err) + // cannot delete organization: organization has 1 provisioner keys that must be deleted first + require.ErrorContains(t, err, "cannot delete organization") + require.ErrorContains(t, err, "1 provisioner keys") + }) + + t.Run("GroupExists", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + orgA := dbfake.Organization(t, db).Do() + + dbgen.Group(t, db, database.Group{ + OrganizationID: orgA.Org.ID, + }) + + ctx := testutil.Context(t, testutil.WaitShort) + err := db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + UpdatedAt: dbtime.Now(), + ID: orgA.Org.ID, + }) + require.Error(t, err) + // cannot delete organization: organization has 1 groups that must be deleted first + require.ErrorContains(t, err, "cannot delete organization") + require.ErrorContains(t, err, "has 1 groups") + }) + + t.Run("MemberExists", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + + orgA := dbfake.Organization(t, db).Do() + + userA := dbgen.User(t, db, database.User{}) + userB := dbgen.User(t, db, database.User{}) + + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: orgA.Org.ID, + UserID: userA.ID, + }) + + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: orgA.Org.ID, + UserID: userB.ID, + }) + + ctx := testutil.Context(t, testutil.WaitShort) + err := db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + UpdatedAt: dbtime.Now(), + ID: orgA.Org.ID, + }) + require.Error(t, err) + // cannot delete organization: organization has 1 members that must be deleted first + require.ErrorContains(t, err, "cannot delete organization") + require.ErrorContains(t, err, "has 1 members") + }) +} + func requireUsersMatch(t testing.TB, expected []database.User, found []database.GetUsersRow, msg string) { t.Helper() require.ElementsMatch(t, expected, database.ConvertUserRows(found), msg) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 58722dc152..ea4124d8fc 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5066,28 +5066,15 @@ func (q *sqlQuerier) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRole return i, err } -const deleteOrganization = `-- name: DeleteOrganization :exec -DELETE FROM - organizations -WHERE - id = $1 AND - is_default = false -` - -func (q *sqlQuerier) DeleteOrganization(ctx context.Context, id uuid.UUID) error { - _, err := q.db.ExecContext(ctx, deleteOrganization, id) - return err -} - const getDefaultOrganization = `-- name: GetDefaultOrganization :one SELECT - id, name, description, created_at, updated_at, is_default, display_name, icon + id, name, description, created_at, updated_at, is_default, display_name, icon, deleted FROM - organizations + organizations WHERE - is_default = true + is_default = true LIMIT - 1 + 1 ` func (q *sqlQuerier) GetDefaultOrganization(ctx context.Context) (Organization, error) { @@ -5102,17 +5089,18 @@ func (q *sqlQuerier) GetDefaultOrganization(ctx context.Context) (Organization, &i.IsDefault, &i.DisplayName, &i.Icon, + &i.Deleted, ) return i, err } const getOrganizationByID = `-- name: GetOrganizationByID :one SELECT - id, name, description, created_at, updated_at, is_default, display_name, icon + id, name, description, created_at, updated_at, is_default, display_name, icon, deleted FROM - organizations + organizations WHERE - id = $1 + id = $1 ` func (q *sqlQuerier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) { @@ -5127,23 +5115,31 @@ func (q *sqlQuerier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Org &i.IsDefault, &i.DisplayName, &i.Icon, + &i.Deleted, ) return i, err } const getOrganizationByName = `-- name: GetOrganizationByName :one SELECT - id, name, description, created_at, updated_at, is_default, display_name, icon + id, name, description, created_at, updated_at, is_default, display_name, icon, deleted FROM - organizations + organizations WHERE - LOWER("name") = LOWER($1) + -- Optionally include deleted organizations + deleted = $1 AND + LOWER("name") = LOWER($2) LIMIT - 1 + 1 ` -func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, name string) (Organization, error) { - row := q.db.QueryRowContext(ctx, getOrganizationByName, name) +type GetOrganizationByNameParams struct { + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, arg GetOrganizationByNameParams) (Organization, error) { + row := q.db.QueryRowContext(ctx, getOrganizationByName, arg.Deleted, arg.Name) var i Organization err := row.Scan( &i.ID, @@ -5154,37 +5150,40 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, name string) (Or &i.IsDefault, &i.DisplayName, &i.Icon, + &i.Deleted, ) return i, err } const getOrganizations = `-- name: GetOrganizations :many SELECT - id, name, description, created_at, updated_at, is_default, display_name, icon + id, name, description, created_at, updated_at, is_default, display_name, icon, deleted FROM - organizations + organizations WHERE - true - -- Filter by ids - AND CASE - WHEN array_length($1 :: uuid[], 1) > 0 THEN - id = ANY($1) - ELSE true - END - AND CASE - WHEN $2::text != '' THEN - LOWER("name") = LOWER($2) - ELSE true - END + -- Optionally include deleted organizations + deleted = $1 + -- Filter by ids + AND CASE + WHEN array_length($2 :: uuid[], 1) > 0 THEN + id = ANY($2) + ELSE true + END + AND CASE + WHEN $3::text != '' THEN + LOWER("name") = LOWER($3) + ELSE true + END ` type GetOrganizationsParams struct { - IDs []uuid.UUID `db:"ids" json:"ids"` - Name string `db:"name" json:"name"` + Deleted bool `db:"deleted" json:"deleted"` + IDs []uuid.UUID `db:"ids" json:"ids"` + Name string `db:"name" json:"name"` } func (q *sqlQuerier) GetOrganizations(ctx context.Context, arg GetOrganizationsParams) ([]Organization, error) { - rows, err := q.db.QueryContext(ctx, getOrganizations, pq.Array(arg.IDs), arg.Name) + rows, err := q.db.QueryContext(ctx, getOrganizations, arg.Deleted, pq.Array(arg.IDs), arg.Name) if err != nil { return nil, err } @@ -5201,6 +5200,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context, arg GetOrganizationsP &i.IsDefault, &i.DisplayName, &i.Icon, + &i.Deleted, ); err != nil { return nil, err } @@ -5217,22 +5217,29 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context, arg GetOrganizationsP const getOrganizationsByUserID = `-- name: GetOrganizationsByUserID :many SELECT - id, name, description, created_at, updated_at, is_default, display_name, icon + id, name, description, created_at, updated_at, is_default, display_name, icon, deleted FROM - organizations + organizations WHERE - id = ANY( - SELECT - organization_id - FROM - organization_members - WHERE - user_id = $1 - ) + -- Optionally include deleted organizations + deleted = $2 AND + id = ANY( + SELECT + organization_id + FROM + organization_members + WHERE + user_id = $1 + ) ` -func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, userID uuid.UUID) ([]Organization, error) { - rows, err := q.db.QueryContext(ctx, getOrganizationsByUserID, userID) +type GetOrganizationsByUserIDParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Deleted bool `db:"deleted" json:"deleted"` +} + +func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, arg GetOrganizationsByUserIDParams) ([]Organization, error) { + rows, err := q.db.QueryContext(ctx, getOrganizationsByUserID, arg.UserID, arg.Deleted) if err != nil { return nil, err } @@ -5249,6 +5256,7 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, userID uuid.U &i.IsDefault, &i.DisplayName, &i.Icon, + &i.Deleted, ); err != nil { return nil, err } @@ -5265,10 +5273,10 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, userID uuid.U const insertOrganization = `-- name: InsertOrganization :one INSERT INTO - organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default) + organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default) VALUES - -- If no organizations exist, and this is the first, make it the default. - ($1, $2, $3, $4, $5, $6, $7, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon + -- If no organizations exist, and this is the first, make it the default. + ($1, $2, $3, $4, $5, $6, $7, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted ` type InsertOrganizationParams struct { @@ -5301,22 +5309,23 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat &i.IsDefault, &i.DisplayName, &i.Icon, + &i.Deleted, ) return i, err } const updateOrganization = `-- name: UpdateOrganization :one UPDATE - organizations + organizations SET - updated_at = $1, - name = $2, - display_name = $3, - description = $4, - icon = $5 + updated_at = $1, + name = $2, + display_name = $3, + description = $4, + icon = $5 WHERE - id = $6 -RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon + id = $6 +RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted ` type UpdateOrganizationParams struct { @@ -5347,10 +5356,31 @@ func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizat &i.IsDefault, &i.DisplayName, &i.Icon, + &i.Deleted, ) return i, err } +const updateOrganizationDeletedByID = `-- name: UpdateOrganizationDeletedByID :exec +UPDATE organizations +SET + deleted = true, + updated_at = $1 +WHERE + id = $2 AND + is_default = false +` + +type UpdateOrganizationDeletedByIDParams struct { + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateOrganizationDeletedByID(ctx context.Context, arg UpdateOrganizationDeletedByIDParams) error { + _, err := q.db.ExecContext(ctx, updateOrganizationDeletedByID, arg.UpdatedAt, arg.ID) + return err +} + const getParameterSchemasByJobID = `-- name: GetParameterSchemasByJobID :many SELECT id, created_at, job_id, name, description, default_source_scheme, default_source_value, allow_override_source, default_destination_scheme, allow_override_destination, default_refresh, redisplay_value, validation_error, validation_condition, validation_type_system, validation_value_type, index diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index 3a74170a91..822b51c0aa 100644 --- a/coderd/database/queries/organizations.sql +++ b/coderd/database/queries/organizations.sql @@ -1,89 +1,97 @@ -- name: GetDefaultOrganization :one SELECT - * + * FROM - organizations + organizations WHERE - is_default = true + is_default = true LIMIT - 1; + 1; -- name: GetOrganizations :many SELECT - * + * FROM - organizations + organizations WHERE - true - -- Filter by ids - AND CASE - WHEN array_length(@ids :: uuid[], 1) > 0 THEN - id = ANY(@ids) - ELSE true - END - AND CASE - WHEN @name::text != '' THEN - LOWER("name") = LOWER(@name) - ELSE true - END + -- Optionally include deleted organizations + deleted = @deleted + -- Filter by ids + AND CASE + WHEN array_length(@ids :: uuid[], 1) > 0 THEN + id = ANY(@ids) + ELSE true + END + AND CASE + WHEN @name::text != '' THEN + LOWER("name") = LOWER(@name) + ELSE true + END ; -- name: GetOrganizationByID :one SELECT - * + * FROM - organizations + organizations WHERE - id = $1; + id = $1; -- name: GetOrganizationByName :one SELECT - * + * FROM - organizations + organizations WHERE - LOWER("name") = LOWER(@name) + -- Optionally include deleted organizations + deleted = @deleted AND + LOWER("name") = LOWER(@name) LIMIT - 1; + 1; -- name: GetOrganizationsByUserID :many SELECT - * + * FROM - organizations + organizations WHERE - id = ANY( - SELECT - organization_id - FROM - organization_members - WHERE - user_id = $1 - ); + -- Optionally include deleted organizations + deleted = @deleted AND + id = ANY( + SELECT + organization_id + FROM + organization_members + WHERE + user_id = $1 + ); -- name: InsertOrganization :one INSERT INTO - organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default) + organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default) VALUES - -- If no organizations exist, and this is the first, make it the default. - (@id, @name, @display_name, @description, @icon, @created_at, @updated_at, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING *; + -- If no organizations exist, and this is the first, make it the default. + (@id, @name, @display_name, @description, @icon, @created_at, @updated_at, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING *; -- name: UpdateOrganization :one UPDATE - organizations + organizations SET - updated_at = @updated_at, - name = @name, - display_name = @display_name, - description = @description, - icon = @icon + updated_at = @updated_at, + name = @name, + display_name = @display_name, + description = @description, + icon = @icon WHERE - id = @id + id = @id RETURNING *; --- name: DeleteOrganization :exec -DELETE FROM - organizations +-- name: UpdateOrganizationDeletedByID :exec +UPDATE organizations +SET + deleted = true, + updated_at = @updated_at WHERE - id = $1 AND - is_default = false; + id = @id AND + is_default = false; + diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index ce427cf97c..db68849777 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -38,7 +38,6 @@ const ( UniqueOauth2ProviderAppsNameKey UniqueConstraint = "oauth2_provider_apps_name_key" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_name_key UNIQUE (name); UniqueOauth2ProviderAppsPkey UniqueConstraint = "oauth2_provider_apps_pkey" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_pkey PRIMARY KEY (id); UniqueOrganizationMembersPkey UniqueConstraint = "organization_members_pkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_pkey PRIMARY KEY (organization_id, user_id); - UniqueOrganizationsName UniqueConstraint = "organizations_name" // ALTER TABLE ONLY organizations ADD CONSTRAINT organizations_name UNIQUE (name); UniqueOrganizationsPkey UniqueConstraint = "organizations_pkey" // ALTER TABLE ONLY organizations ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); UniqueParameterSchemasJobIDNameKey UniqueConstraint = "parameter_schemas_job_id_name_key" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_name_key UNIQUE (job_id, name); UniqueParameterSchemasPkey UniqueConstraint = "parameter_schemas_pkey" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_pkey PRIMARY KEY (id); @@ -94,8 +93,7 @@ const ( UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); UniqueIndexCustomRolesNameLower UniqueConstraint = "idx_custom_roles_name_lower" // CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); - UniqueIndexOrganizationName UniqueConstraint = "idx_organization_name" // CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name); - UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)); + UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)) WHERE (deleted = false); UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); diff --git a/coderd/httpmw/organizationparam.go b/coderd/httpmw/organizationparam.go index a72b361b90..2eba0dcedf 100644 --- a/coderd/httpmw/organizationparam.go +++ b/coderd/httpmw/organizationparam.go @@ -73,7 +73,10 @@ func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler if err == nil { organization, dbErr = db.GetOrganizationByID(ctx, id) } else { - organization, dbErr = db.GetOrganizationByName(ctx, arg) + organization, dbErr = db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{ + Name: arg, + Deleted: false, + }) } } if httpapi.Is404Error(dbErr) { diff --git a/coderd/idpsync/organization.go b/coderd/idpsync/organization.go index 6f755529cd..87fd9af5e9 100644 --- a/coderd/idpsync/organization.go +++ b/coderd/idpsync/organization.go @@ -97,7 +97,10 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u return xerrors.Errorf("organization claims: %w", err) } - existingOrgs, err := tx.GetOrganizationsByUserID(ctx, user.ID) + existingOrgs, err := tx.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ + UserID: user.ID, + Deleted: false, + }) if err != nil { return xerrors.Errorf("failed to get user organizations: %w", err) } diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 849dd7f584..103dc80601 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -258,7 +258,9 @@ func parseOrganization(ctx context.Context, db database.Store, parser *httpapi.Q if err == nil { return organizationID, nil } - organization, err := db.GetOrganizationByName(ctx, v) + organization, err := db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{ + Name: v, Deleted: false, + }) if err != nil { return uuid.Nil, xerrors.Errorf("organization %q either does not exist, or you are unauthorized to view it", v) } diff --git a/coderd/users.go b/coderd/users.go index 964f187244..5f8866903b 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1286,7 +1286,10 @@ func (api *API) organizationsByUser(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) - organizations, err := api.Database.GetOrganizationsByUserID(ctx, user.ID) + organizations, err := api.Database.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ + UserID: user.ID, + Deleted: false, + }) if errors.Is(err, sql.ErrNoRows) { err = nil organizations = []database.Organization{} @@ -1324,7 +1327,10 @@ func (api *API) organizationsByUser(rw http.ResponseWriter, r *http.Request) { func (api *API) organizationByUserAndName(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() organizationName := chi.URLParam(r, "organizationname") - organization, err := api.Database.GetOrganizationByName(ctx, organizationName) + organization, err := api.Database.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{ + Name: organizationName, + Deleted: false, + }) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index fdc372c034..4ec303b388 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -159,17 +159,17 @@ Database migrations are managed with To add new migrations, use the following command: ```shell -./coderd/database/migrations/create_fixture.sh my name +./coderd/database/migrations/create_migration.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. - 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. +Run `make gen` to generate models. + #### Database fixtures (for testing migrations) There are two types of fixtures that are used to test that migrations don't diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 5c6a6e6a80..4817ea03f4 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -23,7 +23,7 @@ We track the following resources: | NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| | OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
created_atfalse
icontrue
idfalse
nametrue
updated_atfalse
| | OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| -| Organization
| |
FieldTracked
created_atfalse
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
| +| Organization
| |
FieldTracked
created_atfalse
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
| | OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| | RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| | Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index b9367a6038..53f03dd60a 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -275,6 +275,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "id": ActionIgnore, "name": ActionTrack, "description": ActionTrack, + "deleted": ActionTrack, "created_at": ActionIgnore, "updated_at": ActionTrack, "is_default": ActionTrack, diff --git a/enterprise/coderd/audit_test.go b/enterprise/coderd/audit_test.go index d5616ea388..2716714918 100644 --- a/enterprise/coderd/audit_test.go +++ b/enterprise/coderd/audit_test.go @@ -75,10 +75,6 @@ func TestEnterpriseAuditLogs(t *testing.T) { require.Equal(t, int64(1), alogs.Count) require.Len(t, alogs.AuditLogs, 1) - require.Equal(t, &codersdk.MinimalOrganization{ - ID: o.ID, - }, alogs.AuditLogs[0].Organization) - // OrganizationID is deprecated, but make sure it is set. require.Equal(t, o.ID, alogs.AuditLogs[0].OrganizationID) diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 8d5a7fceef..9771dd9800 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -440,7 +440,10 @@ func (api *API) groups(rw http.ResponseWriter, r *http.Request) { parser := httpapi.NewQueryParamParser() // Organization selector can be an org ID or name filter.OrganizationID = parser.UUIDorName(r.URL.Query(), uuid.Nil, "organization", func(orgName string) (uuid.UUID, error) { - org, err := api.Database.GetOrganizationByName(ctx, orgName) + org, err := api.Database.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{ + Name: orgName, + Deleted: false, + }) if err != nil { return uuid.Nil, xerrors.Errorf("organization %q not found", orgName) } diff --git a/enterprise/coderd/organizations.go b/enterprise/coderd/organizations.go index a7ec4050ee..6cf91ec5b8 100644 --- a/enterprise/coderd/organizations.go +++ b/enterprise/coderd/organizations.go @@ -150,7 +150,16 @@ func (api *API) deleteOrganization(rw http.ResponseWriter, r *http.Request) { return } - err := api.Database.DeleteOrganization(ctx, organization.ID) + err := api.Database.InTx(func(tx database.Store) error { + err := tx.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + ID: organization.ID, + UpdatedAt: dbtime.Now(), + }) + if err != nil { + return xerrors.Errorf("delete organization: %w", err) + } + return nil + }, nil) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error deleting organization.", @@ -204,7 +213,10 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { return } - _, err := api.Database.GetOrganizationByName(ctx, req.Name) + _, err := api.Database.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{ + Name: req.Name, + Deleted: false, + }) if err == nil { httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ Message: "Organization already exists with that name.", diff --git a/site/e2e/tests/organizations.spec.ts b/site/e2e/tests/organizations.spec.ts index 5a1cf4ba82..ff4f5ad993 100644 --- a/site/e2e/tests/organizations.spec.ts +++ b/site/e2e/tests/organizations.spec.ts @@ -52,5 +52,6 @@ test("create and delete organization", async ({ page }) => { const dialog = page.getByTestId("dialog"); await dialog.getByLabel("Name").fill(newName); await dialog.getByRole("button", { name: "Delete" }).click(); - await expect(page.getByText("Organization deleted.")).toBeVisible(); + await page.waitForTimeout(1000); + await expect(page.getByText("Organization deleted")).toBeVisible(); }); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx index 13c339dcc3..3ae72b701c 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -1,9 +1,11 @@ +import { getErrorMessage } from "api/errors"; import { deleteOrganization, updateOrganization, } from "api/queries/organizations"; import { EmptyState } from "components/EmptyState/EmptyState"; import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { displayError } from "components/GlobalSnackbar/utils"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import type { FC } from "react"; import { useMutation, useQueryClient } from "react-query"; @@ -42,10 +44,14 @@ const OrganizationSettingsPage: FC = () => { navigate(`/organizations/${updatedOrganization.name}/settings`); displaySuccess("Organization settings updated."); }} - onDeleteOrganization={() => { - deleteOrganizationMutation.mutate(organization.id); - displaySuccess("Organization deleted."); - navigate("/organizations"); + onDeleteOrganization={async () => { + try { + await deleteOrganizationMutation.mutateAsync(organization.id); + displaySuccess("Organization deleted"); + navigate("/organizations"); + } catch (error) { + displayError(getErrorMessage(error, "Failed to delete organization")); + } }} /> ); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx index 08199c0d65..8ca6c517b2 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx @@ -146,7 +146,10 @@ export const OrganizationSettingsPageView: FC< { + await onDeleteOrganization(); + setIsDeleting(false); + }} onCancel={() => setIsDeleting(false)} entity="organization" name={organization.name} From 8f33c6d8d1b23b2d825d15a48e04141afdeccb3a Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 24 Feb 2025 19:00:26 +0100 Subject: [PATCH 04/29] chore: track users' login methods in telemetry (#16664) Addresses https://github.com/coder/nexus/issues/191. --- coderd/telemetry/telemetry.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 78819b0c65..e3d50da29e 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -947,6 +947,7 @@ func ConvertUser(dbUser database.User) User { CreatedAt: dbUser.CreatedAt, Status: dbUser.Status, GithubComUserID: dbUser.GithubComUserID.Int64, + LoginType: string(dbUser.LoginType), } } @@ -1149,6 +1150,8 @@ type User struct { RBACRoles []string `json:"rbac_roles"` Status database.UserStatus `json:"status"` GithubComUserID int64 `json:"github_com_user_id"` + // Omitempty for backwards compatibility. + LoginType string `json:"login_type,omitempty"` } type Group struct { From e005e4e51d471da9ce80a27443b681e9a2450a4e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 24 Feb 2025 13:31:11 -0600 Subject: [PATCH 05/29] chore: merge provisioner key and provisioner permissions (#16628) Provisioner key permissions were never any different than provisioners. Merging them for a cleaner permission story until they are required (if ever) to be seperate. This removed `ResourceProvisionerKey` from RBAC and just uses the existing `ResourceProvisioner`. --- coderd/apidoc/docs.go | 2 -- coderd/apidoc/swagger.json | 2 -- coderd/database/dbauthz/dbauthz.go | 3 +-- coderd/database/modelmethods.go | 4 ++- coderd/rbac/object_gen.go | 14 ++-------- coderd/rbac/policy/policy.go | 11 ++------ coderd/rbac/roles_test.go | 9 ------- codersdk/rbacresources_gen.go | 2 -- docs/reference/api/members.md | 5 ---- docs/reference/api/schemas.md | 1 - enterprise/coderd/roles.go | 27 ++++++++++++++++--- site/src/api/rbacresourcesGenerated.ts | 9 ++----- site/src/api/typesGenerated.ts | 2 -- .../pages/UsersPage/storybookData/roles.ts | 5 ---- 14 files changed, 34 insertions(+), 62 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 227fb12cb7..0d3910aaa0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -13730,7 +13730,6 @@ const docTemplate = `{ "organization_member", "provisioner_daemon", "provisioner_jobs", - "provisioner_keys", "replicas", "system", "tailnet_coordinator", @@ -13766,7 +13765,6 @@ const docTemplate = `{ "ResourceOrganizationMember", "ResourceProvisionerDaemon", "ResourceProvisionerJobs", - "ResourceProvisionerKeys", "ResourceReplicas", "ResourceSystem", "ResourceTailnetCoordinator", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8615223eba..831ca9fbe3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12419,7 +12419,6 @@ "organization_member", "provisioner_daemon", "provisioner_jobs", - "provisioner_keys", "replicas", "system", "tailnet_coordinator", @@ -12455,7 +12454,6 @@ "ResourceOrganizationMember", "ResourceProvisionerDaemon", "ResourceProvisionerJobs", - "ResourceProvisionerKeys", "ResourceReplicas", "ResourceSystem", "ResourceTailnetCoordinator", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5c558aaa0d..689a6c9322 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -324,7 +324,6 @@ var ( rbac.ResourceOrganization.Type: {policy.ActionCreate, policy.ActionRead}, rbac.ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionDelete, policy.ActionRead}, rbac.ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, - rbac.ResourceProvisionerKeys.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, rbac.ResourceUser.Type: rbac.ResourceUser.AvailableActions(), rbac.ResourceWorkspaceDormant.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStop}, rbac.ResourceWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionSSH}, @@ -3192,7 +3191,7 @@ func (q *querier) InsertProvisionerJobTimings(ctx context.Context, arg database. } func (q *querier) InsertProvisionerKey(ctx context.Context, arg database.InsertProvisionerKeyParams) (database.ProvisionerKey, error) { - return insert(q.log, q.auth, rbac.ResourceProvisionerKeys.InOrg(arg.OrganizationID).WithID(arg.ID), q.db.InsertProvisionerKey)(ctx, arg) + return insert(q.log, q.auth, rbac.ResourceProvisionerDaemon.InOrg(arg.OrganizationID).WithID(arg.ID), q.db.InsertProvisionerKey)(ctx, arg) } func (q *querier) InsertReplica(ctx context.Context, arg database.InsertReplicaParams) (database.Replica, error) { diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 171c045456..803cfbf01c 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -277,8 +277,10 @@ func (p GetEligibleProvisionerDaemonsByProvisionerJobIDsRow) RBACObject() rbac.O return p.ProvisionerDaemon.RBACObject() } +// RBACObject for a provisioner key is the same as a provisioner daemon. +// Keys == provisioners from a RBAC perspective. func (p ProvisionerKey) RBACObject() rbac.Object { - return rbac.ResourceProvisionerKeys. + return rbac.ResourceProvisionerDaemon. WithID(p.ID). InOrg(p.OrganizationID) } diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index e532322512..e1fefada0f 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -206,8 +206,8 @@ var ( // ResourceProvisionerDaemon // Valid Actions - // - "ActionCreate" :: create a provisioner daemon - // - "ActionDelete" :: delete a provisioner daemon + // - "ActionCreate" :: create a provisioner daemon/key + // - "ActionDelete" :: delete a provisioner daemon/key // - "ActionRead" :: read provisioner daemon // - "ActionUpdate" :: update a provisioner daemon ResourceProvisionerDaemon = Object{ @@ -221,15 +221,6 @@ var ( Type: "provisioner_jobs", } - // ResourceProvisionerKeys - // Valid Actions - // - "ActionCreate" :: create a provisioner key - // - "ActionDelete" :: delete a provisioner key - // - "ActionRead" :: read provisioner keys - ResourceProvisionerKeys = Object{ - Type: "provisioner_keys", - } - // ResourceReplicas // Valid Actions // - "ActionRead" :: read replicas @@ -355,7 +346,6 @@ func AllResources() []Objecter { ResourceOrganizationMember, ResourceProvisionerDaemon, ResourceProvisionerJobs, - ResourceProvisionerKeys, ResourceReplicas, ResourceSystem, ResourceTailnetCoordinator, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index c06a2117cb..2aae17badf 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -162,11 +162,11 @@ var RBACPermissions = map[string]PermissionDefinition{ }, "provisioner_daemon": { Actions: map[Action]ActionDefinition{ - ActionCreate: actDef("create a provisioner daemon"), + ActionCreate: actDef("create a provisioner daemon/key"), // TODO: Move to use? ActionRead: actDef("read provisioner daemon"), ActionUpdate: actDef("update a provisioner daemon"), - ActionDelete: actDef("delete a provisioner daemon"), + ActionDelete: actDef("delete a provisioner daemon/key"), }, }, "provisioner_jobs": { @@ -174,13 +174,6 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionRead: actDef("read provisioner jobs"), }, }, - "provisioner_keys": { - Actions: map[Action]ActionDefinition{ - ActionCreate: actDef("create a provisioner key"), - ActionRead: actDef("read provisioner keys"), - ActionDelete: actDef("delete a provisioner key"), - }, - }, "organization": { Actions: map[Action]ActionDefinition{ ActionCreate: actDef("create an organization"), diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 1ac2c4c9e0..b23849229e 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -556,15 +556,6 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, memberMe, userAdmin, orgUserAdmin, orgAuditor}, }, }, - { - Name: "ProvisionerKeys", - Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, - Resource: rbac.ResourceProvisionerKeys.InOrg(orgID), - AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, orgAdmin}, - false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, - }, - }, { Name: "ProvisionerJobs", Actions: []policy.Action{policy.ActionRead}, diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index f4d7790d40..f2751ac033 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -28,7 +28,6 @@ const ( ResourceOrganizationMember RBACResource = "organization_member" ResourceProvisionerDaemon RBACResource = "provisioner_daemon" ResourceProvisionerJobs RBACResource = "provisioner_jobs" - ResourceProvisionerKeys RBACResource = "provisioner_keys" ResourceReplicas RBACResource = "replicas" ResourceSystem RBACResource = "system" ResourceTailnetCoordinator RBACResource = "tailnet_coordinator" @@ -85,7 +84,6 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceOrganizationMember: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceProvisionerDaemon: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceProvisionerJobs: {ActionRead}, - ResourceProvisionerKeys: {ActionCreate, ActionDelete, ActionRead}, ResourceReplicas: {ActionRead}, ResourceSystem: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceTailnetCoordinator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index a3a38457c6..6daaaaeea7 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -203,7 +203,6 @@ Status Code **200** | `resource_type` | `organization_member` | | `resource_type` | `provisioner_daemon` | | `resource_type` | `provisioner_jobs` | -| `resource_type` | `provisioner_keys` | | `resource_type` | `replicas` | | `resource_type` | `system` | | `resource_type` | `tailnet_coordinator` | @@ -366,7 +365,6 @@ Status Code **200** | `resource_type` | `organization_member` | | `resource_type` | `provisioner_daemon` | | `resource_type` | `provisioner_jobs` | -| `resource_type` | `provisioner_keys` | | `resource_type` | `replicas` | | `resource_type` | `system` | | `resource_type` | `tailnet_coordinator` | @@ -529,7 +527,6 @@ Status Code **200** | `resource_type` | `organization_member` | | `resource_type` | `provisioner_daemon` | | `resource_type` | `provisioner_jobs` | -| `resource_type` | `provisioner_keys` | | `resource_type` | `replicas` | | `resource_type` | `system` | | `resource_type` | `tailnet_coordinator` | @@ -661,7 +658,6 @@ Status Code **200** | `resource_type` | `organization_member` | | `resource_type` | `provisioner_daemon` | | `resource_type` | `provisioner_jobs` | -| `resource_type` | `provisioner_keys` | | `resource_type` | `replicas` | | `resource_type` | `system` | | `resource_type` | `tailnet_coordinator` | @@ -925,7 +921,6 @@ Status Code **200** | `resource_type` | `organization_member` | | `resource_type` | `provisioner_daemon` | | `resource_type` | `provisioner_jobs` | -| `resource_type` | `provisioner_keys` | | `resource_type` | `replicas` | | `resource_type` | `system` | | `resource_type` | `tailnet_coordinator` | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 32805725d2..04a0835f67 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5121,7 +5121,6 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `organization_member` | | `provisioner_daemon` | | `provisioner_jobs` | -| `provisioner_keys` | | `replicas` | | `system` | | `tailnet_coordinator` | diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index 227be3e4ce..d5af54a35b 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -147,9 +147,13 @@ func (api *API) putOrgRoles(rw http.ResponseWriter, r *http.Request) { UUID: organization.ID, Valid: true, }, - SitePermissions: db2sdk.List(req.SitePermissions, sdkPermissionToDB), - OrgPermissions: db2sdk.List(req.OrganizationPermissions, sdkPermissionToDB), - UserPermissions: db2sdk.List(req.UserPermissions, sdkPermissionToDB), + // Invalid permissions are filtered out. If this is changed + // to throw an error, then the story of a previously valid role + // now being invalid has to be addressed. Coder can change permissions, + // objects, and actions at any time. + SitePermissions: db2sdk.List(filterInvalidPermissions(req.SitePermissions), sdkPermissionToDB), + OrgPermissions: db2sdk.List(filterInvalidPermissions(req.OrganizationPermissions), sdkPermissionToDB), + UserPermissions: db2sdk.List(filterInvalidPermissions(req.UserPermissions), sdkPermissionToDB), }) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) @@ -247,6 +251,23 @@ func (api *API) deleteOrgRole(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusNoContent, nil) } +func filterInvalidPermissions(permissions []codersdk.Permission) []codersdk.Permission { + // Filter out any invalid permissions + var validPermissions []codersdk.Permission + for _, permission := range permissions { + err := rbac.Permission{ + Negate: permission.Negate, + ResourceType: string(permission.ResourceType), + Action: policy.Action(permission.Action), + }.Valid() + if err != nil { + continue + } + validPermissions = append(validPermissions, permission) + } + return validPermissions +} + func sdkPermissionToDB(p codersdk.Permission) database.CustomRolePermission { return database.CustomRolePermission{ Negate: p.Negate, diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 437f89ec77..483508bc11 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -114,19 +114,14 @@ export const RBACResourceActions: Partial< update: "update an organization member", }, provisioner_daemon: { - create: "create a provisioner daemon", - delete: "delete a provisioner daemon", + create: "create a provisioner daemon/key", + delete: "delete a provisioner daemon/key", read: "read provisioner daemon", update: "update a provisioner daemon", }, provisioner_jobs: { read: "read provisioner jobs", }, - provisioner_keys: { - create: "create a provisioner key", - delete: "delete a provisioner key", - read: "read provisioner keys", - }, replicas: { read: "read replicas", }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d335cce773..3ffdeba45d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1896,7 +1896,6 @@ export type RBACResource = | "organization_member" | "provisioner_daemon" | "provisioner_jobs" - | "provisioner_keys" | "replicas" | "system" | "tailnet_coordinator" @@ -1932,7 +1931,6 @@ export const RBACResources: RBACResource[] = [ "organization_member", "provisioner_daemon", "provisioner_jobs", - "provisioner_keys", "replicas", "system", "tailnet_coordinator", diff --git a/site/src/pages/UsersPage/storybookData/roles.ts b/site/src/pages/UsersPage/storybookData/roles.ts index 069625dbaa..66228a00f7 100644 --- a/site/src/pages/UsersPage/storybookData/roles.ts +++ b/site/src/pages/UsersPage/storybookData/roles.ts @@ -101,11 +101,6 @@ export const MockRoles: (AssignableRoles | Role)[] = [ resource_type: "provisioner_daemon", action: "*" as RBACAction, }, - { - negate: false, - resource_type: "provisioner_keys", - action: "*" as RBACAction, - }, { negate: false, resource_type: "replicas", From 658825cad221fa8f59c4b485c9fdc6c83ecfca95 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 24 Feb 2025 13:38:20 -0600 Subject: [PATCH 06/29] feat: add sourcing secondary claims from access_token (#16517) Niche edge case, assumes access_token is jwt. Some `access_token`s are JWT's with potential useful claims. These claims would be nearly equivalent to `user_info` claims. This is not apart of the oauth spec, so this feature should not be loudly advertised. If using this feature, alternate solutions are preferred. --- cli/server.go | 13 ++- cli/testdata/server-config.yaml.golden | 6 + coderd/apidoc/docs.go | 5 + coderd/apidoc/swagger.json | 5 + coderd/coderdtest/oidctest/idp.go | 31 +++-- coderd/userauth.go | 153 +++++++++++++++++-------- coderd/userauth_test.go | 50 +++++++- codersdk/deployment.go | 49 ++++++-- docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 66 ++++++----- scripts/testidp/main.go | 2 + site/src/api/typesGenerated.ts | 1 + 12 files changed, 282 insertions(+), 100 deletions(-) diff --git a/cli/server.go b/cli/server.go index 328dedda7d..4805bf4b64 100644 --- a/cli/server.go +++ b/cli/server.go @@ -172,6 +172,17 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De groupAllowList[group] = true } + secondaryClaimsSrc := coderd.MergedClaimsSourceUserInfo + if !vals.OIDC.IgnoreUserInfo && vals.OIDC.UserInfoFromAccessToken { + return nil, xerrors.Errorf("to use 'oidc-access-token-claims', 'oidc-ignore-userinfo' must be set to 'false'") + } + if vals.OIDC.IgnoreUserInfo { + secondaryClaimsSrc = coderd.MergedClaimsSourceNone + } + if vals.OIDC.UserInfoFromAccessToken { + secondaryClaimsSrc = coderd.MergedClaimsSourceAccessToken + } + return &coderd.OIDCConfig{ OAuth2Config: useCfg, Provider: oidcProvider, @@ -187,7 +198,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De NameField: vals.OIDC.NameField.String(), EmailField: vals.OIDC.EmailField.String(), AuthURLParams: vals.OIDC.AuthURLParams.Value, - IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(), + SecondaryClaims: secondaryClaimsSrc, SignInText: vals.OIDC.SignInText.String(), SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(), IconURL: vals.OIDC.IconURL.String(), diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index acfcf9f421..1a45d664db 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -329,6 +329,12 @@ oidc: # Ignore the userinfo endpoint and only use the ID token for user information. # (default: false, type: bool) ignoreUserInfo: false + # Source supplemental user claims from the 'access_token'. This assumes the token + # is a jwt signed by the same issuer as the id_token. Using this requires setting + # 'oidc-ignore-userinfo' to true. This setting is not compliant with the OIDC + # specification and is not recommended. Use at your own risk. + # (default: false, type: bool) + accessTokenClaims: false # This field must be set if using the organization sync feature. Set to the claim # to be used for organizations. # (default: , type: string) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0d3910aaa0..69d421b299 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12669,6 +12669,7 @@ const docTemplate = `{ "type": "boolean" }, "ignore_user_info": { + "description": "IgnoreUserInfo \u0026 UserInfoFromAccessToken are mutually exclusive. Only 1\ncan be set to true. Ideally this would be an enum with 3 states, ['none',\n'userinfo', 'access_token']. However, for backward compatibility,\n` + "`" + `ignore_user_info` + "`" + ` must remain. And ` + "`" + `access_token` + "`" + ` is a niche, non-spec\ncompliant edge case. So it's use is rare, and should not be advised.", "type": "boolean" }, "issuer_url": { @@ -12701,6 +12702,10 @@ const docTemplate = `{ "skip_issuer_checks": { "type": "boolean" }, + "source_user_info_from_access_token": { + "description": "UserInfoFromAccessToken as mentioned above is an edge case. This allows\nsourcing the user_info from the access token itself instead of a user_info\nendpoint. This assumes the access token is a valid JWT with a set of claims to\nbe merged with the id_token.", + "type": "boolean" + }, "user_role_field": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 831ca9fbe3..2a40706151 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11405,6 +11405,7 @@ "type": "boolean" }, "ignore_user_info": { + "description": "IgnoreUserInfo \u0026 UserInfoFromAccessToken are mutually exclusive. Only 1\ncan be set to true. Ideally this would be an enum with 3 states, ['none',\n'userinfo', 'access_token']. However, for backward compatibility,\n`ignore_user_info` must remain. And `access_token` is a niche, non-spec\ncompliant edge case. So it's use is rare, and should not be advised.", "type": "boolean" }, "issuer_url": { @@ -11437,6 +11438,10 @@ "skip_issuer_checks": { "type": "boolean" }, + "source_user_info_from_access_token": { + "description": "UserInfoFromAccessToken as mentioned above is an edge case. This allows\nsourcing the user_info from the access token itself instead of a user_info\nendpoint. This assumes the access token is a valid JWT with a set of claims to\nbe merged with the id_token.", + "type": "boolean" + }, "user_role_field": { "type": "string" }, diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index d6c7e6259f..e0fd1bb9b0 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -105,6 +105,7 @@ type FakeIDP struct { // "Authorized Redirect URLs". This can be used to emulate that. hookValidRedirectURL func(redirectURL string) error hookUserInfo func(email string) (jwt.MapClaims, error) + hookAccessTokenJWT func(email string, exp time.Time) jwt.MapClaims // defaultIDClaims is if a new client connects and we didn't preset // some claims. defaultIDClaims jwt.MapClaims @@ -154,6 +155,12 @@ func WithMiddlewares(mws ...func(http.Handler) http.Handler) func(*FakeIDP) { } } +func WithAccessTokenJWTHook(hook func(email string, exp time.Time) jwt.MapClaims) func(*FakeIDP) { + return func(f *FakeIDP) { + f.hookAccessTokenJWT = hook + } +} + func WithHookWellKnown(hook func(r *http.Request, j *ProviderJSON) error) func(*FakeIDP) { return func(f *FakeIDP) { f.hookWellKnown = hook @@ -316,8 +323,7 @@ const ( func NewFakeIDP(t testing.TB, opts ...FakeIDPOpt) *FakeIDP { t.Helper() - block, _ := pem.Decode([]byte(testRSAPrivateKey)) - pkey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + pkey, err := FakeIDPKey() require.NoError(t, err) idp := &FakeIDP{ @@ -676,8 +682,13 @@ func (f *FakeIDP) newCode(state string) string { // newToken enforces the access token exchanged is actually a valid access token // created by the IDP. -func (f *FakeIDP) newToken(email string, expires time.Time) string { +func (f *FakeIDP) newToken(t testing.TB, email string, expires time.Time) string { accessToken := uuid.NewString() + if f.hookAccessTokenJWT != nil { + claims := f.hookAccessTokenJWT(email, expires) + accessToken = f.encodeClaims(t, claims) + } + f.accessTokens.Store(accessToken, token{ issued: time.Now(), email: email, @@ -963,7 +974,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { email := getEmail(claims) refreshToken := f.newRefreshTokens(email) token := map[string]interface{}{ - "access_token": f.newToken(email, exp), + "access_token": f.newToken(t, email, exp), "refresh_token": refreshToken, "token_type": "Bearer", "expires_in": int64((f.defaultExpire).Seconds()), @@ -1465,9 +1476,10 @@ func (f *FakeIDP) internalOIDCConfig(ctx context.Context, t testing.TB, scopes [ Verifier: oidc.NewVerifier(f.provider.Issuer, &oidc.StaticKeySet{ PublicKeys: []crypto.PublicKey{f.key.Public()}, }, verifierConfig), - UsernameField: "preferred_username", - EmailField: "email", - AuthURLParams: map[string]string{"access_type": "offline"}, + UsernameField: "preferred_username", + EmailField: "email", + AuthURLParams: map[string]string{"access_type": "offline"}, + SecondaryClaims: coderd.MergedClaimsSourceUserInfo, } for _, opt := range opts { @@ -1552,3 +1564,8 @@ d8h4Ht09E+f3nhTEc87mODkl7WJZpHL6V2sORfeq/eIkds+H6CJ4hy5w/bSw8tjf sz9Di8sGIaUbLZI2rd0CQQCzlVwEtRtoNCyMJTTrkgUuNufLP19RZ5FpyXxBO5/u QastnN77KfUwdj3SJt44U/uh1jAIv4oSLBr8HYUkbnI8 -----END RSA PRIVATE KEY-----` + +func FakeIDPKey() (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(testRSAPrivateKey)) + return x509.ParsePKCS1PrivateKey(block.Bytes) +} diff --git a/coderd/userauth.go b/coderd/userauth.go index d6931486e6..74a1a718ef 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -46,6 +46,14 @@ import ( "github.com/coder/coder/v2/cryptorand" ) +type MergedClaimsSource string + +var ( + MergedClaimsSourceNone MergedClaimsSource = "none" + MergedClaimsSourceUserInfo MergedClaimsSource = "user_info" + MergedClaimsSourceAccessToken MergedClaimsSource = "access_token" +) + const ( userAuthLoggerName = "userauth" OAuthConvertCookieValue = "coder_oauth_convert_jwt" @@ -1116,11 +1124,13 @@ type OIDCConfig struct { // AuthURLParams are additional parameters to be passed to the OIDC provider // when requesting an access token. AuthURLParams map[string]string - // IgnoreUserInfo causes Coder to only use claims from the ID token to - // process OIDC logins. This is useful if the OIDC provider does not - // support the userinfo endpoint, or if the userinfo endpoint causes - // undesirable behavior. - IgnoreUserInfo bool + // SecondaryClaims indicates where to source additional claim information from. + // The standard is either 'MergedClaimsSourceNone' or 'MergedClaimsSourceUserInfo'. + // + // The OIDC compliant way is to use the userinfo endpoint. This option + // is useful when the userinfo endpoint does not exist or causes undesirable + // behavior. + SecondaryClaims MergedClaimsSource // SignInText is the text to display on the OIDC login button SignInText string // IconURL points to the URL of an icon to display on the OIDC login button @@ -1216,50 +1226,39 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { // Some providers (e.g. ADFS) do not support custom OIDC claims in the // UserInfo endpoint, so we allow users to disable it and only rely on the // ID token. - userInfoClaims := make(map[string]interface{}) + // // If user info is skipped, the idtokenClaims are the claims. mergedClaims := idtokenClaims - if !api.OIDCConfig.IgnoreUserInfo { - userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token)) - if err == nil { - err = userInfo.Claims(&userInfoClaims) - if err != nil { - logger.Error(ctx, "oauth2: unable to unmarshal user info claims", slog.Error(err)) - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to unmarshal user info claims.", - Detail: err.Error(), - }) - return - } - logger.Debug(ctx, "got oidc claims", - slog.F("source", "userinfo"), - slog.F("claim_fields", claimFields(userInfoClaims)), - slog.F("blank", blankFields(userInfoClaims)), - ) - - // Merge the claims from the ID token and the UserInfo endpoint. - // Information from UserInfo takes precedence. - mergedClaims = mergeClaims(idtokenClaims, userInfoClaims) - - // Log all of the field names after merging. - logger.Debug(ctx, "got oidc claims", - slog.F("source", "merged"), - slog.F("claim_fields", claimFields(mergedClaims)), - slog.F("blank", blankFields(mergedClaims)), - ) - } else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") { - logger.Error(ctx, "oauth2: unable to obtain user information claims", slog.Error(err)) - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to obtain user information claims.", - Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(), - }) + supplementaryClaims := make(map[string]interface{}) + switch api.OIDCConfig.SecondaryClaims { + case MergedClaimsSourceUserInfo: + supplementaryClaims, ok = api.userInfoClaims(ctx, rw, state, logger) + if !ok { return - } else { - // The OIDC provider does not support the UserInfo endpoint. - // This is not an error, but we should log it as it may mean - // that some claims are missing. - logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token") } + + // The precedence ordering is userInfoClaims > idTokenClaims. + // Note: Unsure why exactly this is the case. idTokenClaims feels more + // important? + mergedClaims = mergeClaims(idtokenClaims, supplementaryClaims) + case MergedClaimsSourceAccessToken: + supplementaryClaims, ok = api.accessTokenClaims(ctx, rw, state, logger) + if !ok { + return + } + // idTokenClaims take priority over accessTokenClaims. The order should + // not matter. It is just safer to assume idTokenClaims is the truth, + // and accessTokenClaims are supplemental. + mergedClaims = mergeClaims(supplementaryClaims, idtokenClaims) + case MergedClaimsSourceNone: + // noop, keep the userInfoClaims empty + default: + // This should never happen and is a developer error + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Invalid source for secondary user claims.", + Detail: fmt.Sprintf("invalid source: %q", api.OIDCConfig.SecondaryClaims), + }) + return // Invalid MergedClaimsSource } usernameRaw, ok := mergedClaims[api.OIDCConfig.UsernameField] @@ -1413,7 +1412,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { RoleSync: roleSync, UserClaims: database.UserLinkClaims{ IDTokenClaims: idtokenClaims, - UserInfoClaims: userInfoClaims, + UserInfoClaims: supplementaryClaims, MergedClaims: mergedClaims, }, }).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) { @@ -1447,6 +1446,68 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } +func (api *API) accessTokenClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (accessTokenClaims map[string]interface{}, ok bool) { + // Assume the access token is a jwt, and signed by the provider. + accessToken, err := api.OIDCConfig.Verifier.Verify(ctx, state.Token.AccessToken) + if err != nil { + logger.Error(ctx, "oauth2: unable to verify access token as secondary claims source", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to verify access token.", + Detail: fmt.Sprintf("sourcing secondary claims from access token: %s", err.Error()), + }) + return nil, false + } + + rawClaims := make(map[string]any) + err = accessToken.Claims(&rawClaims) + if err != nil { + logger.Error(ctx, "oauth2: unable to unmarshal access token claims", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal access token claims.", + Detail: err.Error(), + }) + return nil, false + } + + return rawClaims, true +} + +func (api *API) userInfoClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (userInfoClaims map[string]interface{}, ok bool) { + userInfoClaims = make(map[string]interface{}) + userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token)) + if err == nil { + err = userInfo.Claims(&userInfoClaims) + if err != nil { + logger.Error(ctx, "oauth2: unable to unmarshal user info claims", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal user info claims.", + Detail: err.Error(), + }) + return nil, false + } + logger.Debug(ctx, "got oidc claims", + slog.F("source", "userinfo"), + slog.F("claim_fields", claimFields(userInfoClaims)), + slog.F("blank", blankFields(userInfoClaims)), + ) + } else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") { + logger.Error(ctx, "oauth2: unable to obtain user information claims", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to obtain user information claims.", + Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(), + }) + return nil, false + } else { + // The OIDC provider does not support the UserInfo endpoint. + // This is not an error, but we should log it as it may mean + // that some claims are missing. + logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token", + slog.Error(err), + ) + } + return userInfoClaims, true +} + // claimFields returns the sorted list of fields in the claims map. func claimFields(claims map[string]interface{}) []string { fields := []string{} diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index b0ada8b9ab..9c32aefadc 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -61,7 +61,7 @@ func TestOIDCOauthLoginWithExisting(t *testing.T) { cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = true - cfg.IgnoreUserInfo = true + cfg.SecondaryClaims = coderd.MergedClaimsSourceNone }) client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ @@ -979,6 +979,7 @@ func TestUserOIDC(t *testing.T) { Name string IDTokenClaims jwt.MapClaims UserInfoClaims jwt.MapClaims + AccessTokenClaims jwt.MapClaims AllowSignups bool EmailDomain []string AssertUser func(t testing.TB, u codersdk.User) @@ -986,6 +987,7 @@ func TestUserOIDC(t *testing.T) { AssertResponse func(t testing.TB, resp *http.Response) IgnoreEmailVerified bool IgnoreUserInfo bool + UseAccessToken bool }{ { Name: "NoSub", @@ -995,6 +997,32 @@ func TestUserOIDC(t *testing.T) { AllowSignups: true, StatusCode: http.StatusBadRequest, }, + { + Name: "AccessTokenMerge", + IDTokenClaims: jwt.MapClaims{ + "sub": uuid.NewString(), + }, + AccessTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + }, + IgnoreUserInfo: true, + AllowSignups: true, + UseAccessToken: true, + StatusCode: http.StatusOK, + AssertUser: func(t testing.TB, u codersdk.User) { + assert.Equal(t, "kyle@kwc.io", u.Email) + }, + }, + { + Name: "AccessTokenMergeNotJWT", + IDTokenClaims: jwt.MapClaims{ + "sub": uuid.NewString(), + }, + IgnoreUserInfo: true, + AllowSignups: true, + UseAccessToken: true, + StatusCode: http.StatusBadRequest, + }, { Name: "EmailOnly", IDTokenClaims: jwt.MapClaims{ @@ -1377,18 +1405,32 @@ func TestUserOIDC(t *testing.T) { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() - fake := oidctest.NewFakeIDP(t, + opts := []oidctest.FakeIDPOpt{ oidctest.WithRefresh(func(_ string) error { return xerrors.New("refreshing token should never occur") }), oidctest.WithServing(), oidctest.WithStaticUserInfo(tc.UserInfoClaims), - ) + } + + if tc.AccessTokenClaims != nil && len(tc.AccessTokenClaims) > 0 { + opts = append(opts, oidctest.WithAccessTokenJWTHook(func(email string, exp time.Time) jwt.MapClaims { + return tc.AccessTokenClaims + })) + } + + fake := oidctest.NewFakeIDP(t, opts...) 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 + cfg.SecondaryClaims = coderd.MergedClaimsSourceUserInfo + if tc.IgnoreUserInfo { + cfg.SecondaryClaims = coderd.MergedClaimsSourceNone + } + if tc.UseAccessToken { + cfg.SecondaryClaims = coderd.MergedClaimsSourceAccessToken + } cfg.NameField = "name" }) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 3aa203da5b..b15dc94274 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -518,17 +518,27 @@ type OIDCConfig struct { ClientID serpent.String `json:"client_id" typescript:",notnull"` ClientSecret serpent.String `json:"client_secret" typescript:",notnull"` // ClientKeyFile & ClientCertFile are used in place of ClientSecret for PKI auth. - ClientKeyFile serpent.String `json:"client_key_file" typescript:",notnull"` - ClientCertFile serpent.String `json:"client_cert_file" typescript:",notnull"` - EmailDomain serpent.StringArray `json:"email_domain" typescript:",notnull"` - IssuerURL serpent.String `json:"issuer_url" typescript:",notnull"` - Scopes serpent.StringArray `json:"scopes" typescript:",notnull"` - IgnoreEmailVerified serpent.Bool `json:"ignore_email_verified" typescript:",notnull"` - UsernameField serpent.String `json:"username_field" typescript:",notnull"` - NameField serpent.String `json:"name_field" typescript:",notnull"` - EmailField serpent.String `json:"email_field" typescript:",notnull"` - AuthURLParams serpent.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"` - IgnoreUserInfo serpent.Bool `json:"ignore_user_info" typescript:",notnull"` + ClientKeyFile serpent.String `json:"client_key_file" typescript:",notnull"` + ClientCertFile serpent.String `json:"client_cert_file" typescript:",notnull"` + EmailDomain serpent.StringArray `json:"email_domain" typescript:",notnull"` + IssuerURL serpent.String `json:"issuer_url" typescript:",notnull"` + Scopes serpent.StringArray `json:"scopes" typescript:",notnull"` + IgnoreEmailVerified serpent.Bool `json:"ignore_email_verified" typescript:",notnull"` + UsernameField serpent.String `json:"username_field" typescript:",notnull"` + NameField serpent.String `json:"name_field" typescript:",notnull"` + EmailField serpent.String `json:"email_field" typescript:",notnull"` + AuthURLParams serpent.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"` + // IgnoreUserInfo & UserInfoFromAccessToken are mutually exclusive. Only 1 + // can be set to true. Ideally this would be an enum with 3 states, ['none', + // 'userinfo', 'access_token']. However, for backward compatibility, + // `ignore_user_info` must remain. And `access_token` is a niche, non-spec + // compliant edge case. So it's use is rare, and should not be advised. + IgnoreUserInfo serpent.Bool `json:"ignore_user_info" typescript:",notnull"` + // UserInfoFromAccessToken as mentioned above is an edge case. This allows + // sourcing the user_info from the access token itself instead of a user_info + // endpoint. This assumes the access token is a valid JWT with a set of claims to + // be merged with the id_token. + UserInfoFromAccessToken serpent.Bool `json:"source_user_info_from_access_token" typescript:",notnull"` OrganizationField serpent.String `json:"organization_field" typescript:",notnull"` OrganizationMapping serpent.Struct[map[string][]uuid.UUID] `json:"organization_mapping" typescript:",notnull"` OrganizationAssignDefault serpent.Bool `json:"organization_assign_default" typescript:",notnull"` @@ -1764,6 +1774,23 @@ func (c *DeploymentValues) Options() serpent.OptionSet { Group: &deploymentGroupOIDC, YAML: "ignoreUserInfo", }, + { + Name: "OIDC Access Token Claims", + // This is a niche edge case that should not be advertised. Alternatives should + // be investigated before turning this on. A properly configured IdP should + // always have a userinfo endpoint which is preferred. + Hidden: true, + Description: "Source supplemental user claims from the 'access_token'. This assumes the " + + "token is a jwt signed by the same issuer as the id_token. Using this requires setting " + + "'oidc-ignore-userinfo' to true. This setting is not compliant with the OIDC specification " + + "and is not recommended. Use at your own risk.", + Flag: "oidc-access-token-claims", + Env: "CODER_OIDC_ACCESS_TOKEN_CLAIMS", + Default: "false", + Value: &c.OIDC.UserInfoFromAccessToken, + Group: &deploymentGroupOIDC, + YAML: "accessTokenClaims", + }, { Name: "OIDC Organization Field", Description: "This field must be set if using the organization sync feature." + diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 5d54993722..7d85388e73 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -376,6 +376,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "sign_in_text": "string", "signups_disabled_text": "string", "skip_issuer_checks": true, + "source_user_info_from_access_token": true, "user_role_field": "string", "user_role_mapping": {}, "user_roles_default": [ diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 04a0835f67..753ee857c0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2025,6 +2025,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "sign_in_text": "string", "signups_disabled_text": "string", "skip_issuer_checks": true, + "source_user_info_from_access_token": true, "user_role_field": "string", "user_role_mapping": {}, "user_roles_default": [ @@ -2496,6 +2497,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "sign_in_text": "string", "signups_disabled_text": "string", "skip_issuer_checks": true, + "source_user_info_from_access_token": true, "user_role_field": "string", "user_role_mapping": {}, "user_roles_default": [ @@ -3994,6 +3996,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith "sign_in_text": "string", "signups_disabled_text": "string", "skip_issuer_checks": true, + "source_user_info_from_access_token": true, "user_role_field": "string", "user_role_mapping": {}, "user_roles_default": [ @@ -4005,37 +4008,38 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------------------------|----------------------------------|----------|--------------|----------------------------------------------------------------------------------| -| `allow_signups` | boolean | false | | | -| `auth_url_params` | object | false | | | -| `client_cert_file` | string | false | | | -| `client_id` | string | false | | | -| `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. | -| `client_secret` | string | false | | | -| `email_domain` | array of string | false | | | -| `email_field` | string | false | | | -| `group_allow_list` | array of string | false | | | -| `group_auto_create` | boolean | false | | | -| `group_mapping` | object | false | | | -| `group_regex_filter` | [serpent.Regexp](#serpentregexp) | false | | | -| `groups_field` | string | false | | | -| `icon_url` | [serpent.URL](#serpenturl) | false | | | -| `ignore_email_verified` | boolean | false | | | -| `ignore_user_info` | boolean | false | | | -| `issuer_url` | string | false | | | -| `name_field` | string | false | | | -| `organization_assign_default` | boolean | false | | | -| `organization_field` | string | false | | | -| `organization_mapping` | object | false | | | -| `scopes` | array of string | false | | | -| `sign_in_text` | string | false | | | -| `signups_disabled_text` | string | false | | | -| `skip_issuer_checks` | boolean | 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_cert_file` | string | false | | | +| `client_id` | string | false | | | +| `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. | +| `client_secret` | string | false | | | +| `email_domain` | array of string | false | | | +| `email_field` | string | false | | | +| `group_allow_list` | array of string | false | | | +| `group_auto_create` | boolean | false | | | +| `group_mapping` | object | false | | | +| `group_regex_filter` | [serpent.Regexp](#serpentregexp) | false | | | +| `groups_field` | string | false | | | +| `icon_url` | [serpent.URL](#serpenturl) | false | | | +| `ignore_email_verified` | boolean | false | | | +| `ignore_user_info` | boolean | false | | Ignore user info & UserInfoFromAccessToken are mutually exclusive. Only 1 can be set to true. Ideally this would be an enum with 3 states, ['none', 'userinfo', 'access_token']. However, for backward compatibility, `ignore_user_info` must remain. And `access_token` is a niche, non-spec compliant edge case. So it's use is rare, and should not be advised. | +| `issuer_url` | string | false | | | +| `name_field` | string | false | | | +| `organization_assign_default` | boolean | false | | | +| `organization_field` | string | false | | | +| `organization_mapping` | object | false | | | +| `scopes` | array of string | false | | | +| `sign_in_text` | string | false | | | +| `signups_disabled_text` | string | false | | | +| `skip_issuer_checks` | boolean | false | | | +| `source_user_info_from_access_token` | boolean | false | | Source user info from access token as mentioned above is an edge case. This allows sourcing the user_info from the access token itself instead of a user_info endpoint. This assumes the access token is a valid JWT with a set of claims to be merged with the id_token. | +| `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/scripts/testidp/main.go b/scripts/testidp/main.go index e1b7a17f34..52b10ab94e 100644 --- a/scripts/testidp/main.go +++ b/scripts/testidp/main.go @@ -11,6 +11,7 @@ import ( "time" "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" "github.com/stretchr/testify/require" "cdr.dev/slog" @@ -88,6 +89,7 @@ func RunIDP() func(t *testing.T) { // This is a static set of auth fields. Might be beneficial to make flags // to allow different values here. This is only required for using the // testIDP as primary auth. External auth does not ever fetch these fields. + "sub": uuid.MustParse("26c6a19c-b9b8-493b-a991-88a4c3310314"), "email": "oidc_member@coder.com", "preferred_username": "oidc_member", "email_verified": true, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3ffdeba45d..a00d3a20cf 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1411,6 +1411,7 @@ export interface OIDCConfig { readonly email_field: string; readonly auth_url_params: SerpentStruct>; readonly ignore_user_info: boolean; + readonly source_user_info_from_access_token: boolean; readonly organization_field: string; readonly organization_mapping: SerpentStruct>; readonly organization_assign_default: boolean; From c8abf58e29a59ec1400bbd5c7f0438f146e863aa Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 24 Feb 2025 20:59:21 +0100 Subject: [PATCH 07/29] chore: reduce prominence of Scratch starter and emphasize Docker in UI (#16665) --- .../CreateTemplateGalleryPage.test.tsx | 4 +- .../CreateTemplateGalleryPage.tsx | 8 +--- .../CreateTemplateGalleryPageView.tsx | 28 ------------- .../StarterTemplates.tsx | 18 +++++++- .../TemplatesPage/CreateTemplateButton.tsx | 8 ---- .../TemplatesPage/TemplatesPage.test.tsx | 42 ------------------- 6 files changed, 20 insertions(+), 88 deletions(-) delete mode 100644 site/src/pages/TemplatesPage/TemplatesPage.test.tsx diff --git a/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPage.test.tsx b/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPage.test.tsx index 49c007724a..61cf4d353e 100644 --- a/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPage.test.tsx +++ b/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPage.test.tsx @@ -10,7 +10,7 @@ import { import { server } from "testHelpers/server"; import CreateTemplateGalleryPage from "./CreateTemplateGalleryPage"; -test("does not display the scratch template", async () => { +test("displays the scratch template", async () => { server.use( http.get("api/v2/templates/examples", () => { return HttpResponse.json([ @@ -49,5 +49,5 @@ test("does not display the scratch template", async () => { await screen.findByText(MockTemplateExample.name); screen.getByText(MockTemplateExample2.name); - expect(screen.queryByText("Scratch")).not.toBeInTheDocument(); + expect(screen.queryByText("Scratch")).toBeInTheDocument(); }); diff --git a/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPage.tsx b/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPage.tsx index 695dd3bfdf..e3f1de37a3 100644 --- a/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPage.tsx +++ b/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPage.tsx @@ -1,5 +1,4 @@ import { templateExamples } from "api/queries/templates"; -import type { TemplateExample } from "api/typesGenerated"; import type { FC } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; @@ -10,8 +9,7 @@ import { CreateTemplateGalleryPageView } from "./CreateTemplateGalleryPageView"; const CreateTemplatesGalleryPage: FC = () => { const templateExamplesQuery = useQuery(templateExamples()); const starterTemplatesByTag = templateExamplesQuery.data - ? // Currently, the scratch template should not be displayed on the starter templates page. - getTemplatesByTag(removeScratchExample(templateExamplesQuery.data)) + ? getTemplatesByTag(templateExamplesQuery.data) : undefined; return ( @@ -27,8 +25,4 @@ const CreateTemplatesGalleryPage: FC = () => { ); }; -const removeScratchExample = (data: TemplateExample[]) => { - return data.filter((example) => example.id !== "scratch"); -}; - export default CreateTemplatesGalleryPage; diff --git a/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPageView.tsx b/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPageView.tsx index d34054e9be..bfa482ac55 100644 --- a/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPageView.tsx +++ b/site/src/pages/CreateTemplateGalleryPage/CreateTemplateGalleryPageView.tsx @@ -41,34 +41,6 @@ export const CreateTemplateGalleryPageView: FC< height: "max-content", }} > - - - - -
- -
-
-

Scratch Template

- - Create a minimal starter template that you can customize - -
-
-
-
-
{ : undefined; }; +const sortVisibleTemplates = (templates: TemplateExample[]) => { + // The docker template should be the first template in the list, + // as it's the easiest way to get started with Coder. + const dockerTemplateId = "docker"; + return templates.sort((a, b) => { + if (a.id === dockerTemplateId) { + return -1; + } + if (b.id === dockerTemplateId) { + return 1; + } + return a.name.localeCompare(b.name); + }); +}; + export interface StarterTemplatesProps { starterTemplatesByTag?: StarterTemplatesByTag; } @@ -34,7 +50,7 @@ export const StarterTemplates: FC = ({ : undefined; const activeTag = urlParams.get("tag") ?? "all"; const visibleTemplates = starterTemplatesByTag - ? starterTemplatesByTag[activeTag] + ? sortVisibleTemplates(starterTemplatesByTag[activeTag]) : undefined; return ( diff --git a/site/src/pages/TemplatesPage/CreateTemplateButton.tsx b/site/src/pages/TemplatesPage/CreateTemplateButton.tsx index c0ba5e2734..069fe2abb7 100644 --- a/site/src/pages/TemplatesPage/CreateTemplateButton.tsx +++ b/site/src/pages/TemplatesPage/CreateTemplateButton.tsx @@ -26,14 +26,6 @@ export const CreateTemplateButton: FC = ({ - { - onNavigate("/templates/new?exampleId=scratch"); - }} - > - - From scratch - { onNavigate("/templates/new"); diff --git a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx deleted file mode 100644 index a2da34e127..0000000000 --- a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { AppProviders } from "App"; -import { RequireAuth } from "contexts/auth/RequireAuth"; -import { RouterProvider, createMemoryRouter } from "react-router-dom"; -import TemplatesPage from "./TemplatesPage"; - -test("create template from scratch", async () => { - const user = userEvent.setup(); - const router = createMemoryRouter( - [ - { - element: , - children: [ - { - path: "/templates", - element: , - }, - { - path: "/templates/new", - element:
, - }, - ], - }, - ], - { initialEntries: ["/templates"] }, - ); - render( - - - , - ); - const createTemplateButton = await screen.findByRole("button", { - name: "Create Template", - }); - await user.click(createTemplateButton); - const fromScratchMenuItem = await screen.findByText("From scratch"); - await user.click(fromScratchMenuItem); - await screen.findByTestId("new-template-page"); - expect(router.state.location.pathname).toBe("/templates/new"); - expect(router.state.location.search).toBe("?exampleId=scratch"); -}); From 754c5dbaa73b3f5c56a70f758411c03a1e8bcc9e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 23:01:56 +0000 Subject: [PATCH 08/29] chore: bump github.com/go-jose/go-jose/v4 from 4.0.2 to 4.0.5 (#16690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) from 4.0.2 to 4.0.5.
Release notes

Sourced from github.com/go-jose/go-jose/v4's releases.

v4.0.5

What's Changed

Fixes https://github.com/go-jose/go-jose/security/advisories/GHSA-c6gw-w398-hv78

Various other dependency updates, small fixes, and documentation updates in the full changelog

New Contributors

Full Changelog: https://github.com/go-jose/go-jose/compare/v4.0.4...v4.0.5

Version 4.0.4

Fixed

  • Reverted "Allow unmarshalling JSONWebKeySets with unsupported key types" as a breaking change. See #136 / #137.

Version 4.0.3

Changed

  • Allow unmarshalling JSONWebKeySets with unsupported key types (#130)
  • Document that OpaqueKeyEncrypter can't be implemented (for now) (#129)
  • Dependency updates
Changelog

Sourced from github.com/go-jose/go-jose/v4's changelog.

v4.0.4

Fixed

  • Reverted "Allow unmarshalling JSONWebKeySets with unsupported key types" as a breaking change. See #136 / #137.

v4.0.3

Changed

  • Allow unmarshalling JSONWebKeySets with unsupported key types (#130)
  • Document that OpaqueKeyEncrypter can't be implemented (for now) (#129)
  • Dependency updates
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/go-jose/go-jose/v4&package-manager=go_modules&previous-version=4.0.2&new-version=4.0.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/coder/coder/network/alerts).
Signed-off-by: dependabot[bot] 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 0d8c51f0c6..5e730b4f2a 100644 --- a/go.mod +++ b/go.mod @@ -117,7 +117,7 @@ require ( github.com/go-chi/cors v1.2.1 github.com/go-chi/httprate v0.14.1 github.com/go-chi/render v1.0.1 - github.com/go-jose/go-jose/v4 v4.0.2 + github.com/go-jose/go-jose/v4 v4.0.5 github.com/go-logr/logr v1.4.2 github.com/go-playground/validator/v10 v10.25.0 github.com/gofrs/flock v0.12.0 diff --git a/go.sum b/go.sum index dbd90148ef..c94a9be8df 100644 --- a/go.sum +++ b/go.sum @@ -365,8 +365,8 @@ 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-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/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= -github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.1/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= From 6bdddd555f9b67c4f24a9d39cbb5abb971b58a6a Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Tue, 25 Feb 2025 13:32:34 +1100 Subject: [PATCH 09/29] chore: show server install.sh on cli version mismatch (#16668) This PR has the CLI show the server's own `install.sh` script if there's a version mismatch, and if the deployment doesn't have an custom upgrade message configured. ``` $ coder ls version mismatch: client {version}, server {version} download {server_version} with: 'curl -fsSL https://dev.coder.com/install.sh | sh' [ ... ] ``` --- cli/root.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/cli/root.go b/cli/root.go index 778cf2c242..09044ad3e2 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1213,9 +1213,14 @@ func wrapTransportWithVersionMismatchCheck(rt http.RoundTripper, inv *serpent.In return } upgradeMessage := defaultUpgradeMessage(semver.Canonical(serverVersion)) - serverInfo, err := getBuildInfo(inv.Context()) - if err == nil && serverInfo.UpgradeMessage != "" { - upgradeMessage = serverInfo.UpgradeMessage + if serverInfo, err := getBuildInfo(inv.Context()); err == nil { + switch { + case serverInfo.UpgradeMessage != "": + upgradeMessage = serverInfo.UpgradeMessage + // The site-local `install.sh` was introduced in v2.19.0 + case serverInfo.DashboardURL != "" && semver.Compare(semver.MajorMinor(serverVersion), "v2.19") >= 0: + upgradeMessage = fmt.Sprintf("download %s with: 'curl -fsSL %s/install.sh | sh'", serverVersion, serverInfo.DashboardURL) + } } fmtWarningText := "version mismatch: client %s, server %s\n%s" fmtWarn := pretty.Sprint(cliui.DefaultStyles.Warn, fmtWarningText) From a2d4b9984e351acb7a2dccd6151bcf2da41e6ead Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 25 Feb 2025 11:30:17 +0100 Subject: [PATCH 10/29] fix: hide app icon if not found (#16684) Fixes: https://github.com/coder/coder/issues/14759 --- .../src/modules/resources/AppLink/AppLink.tsx | 5 ++- .../modules/resources/AppLink/BaseIcon.tsx | 9 ++++- .../pages/WorkspacePage/Workspace.stories.tsx | 37 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/site/src/modules/resources/AppLink/AppLink.tsx b/site/src/modules/resources/AppLink/AppLink.tsx index 15ccfb3d0e..e9d5f7d595 100644 --- a/site/src/modules/resources/AppLink/AppLink.tsx +++ b/site/src/modules/resources/AppLink/AppLink.tsx @@ -37,6 +37,7 @@ export const AppLink: FC = ({ app, workspace, agent }) => { const preferredPathBase = proxy.preferredPathAppURL; const appsHost = proxy.preferredWildcardHostname; const [fetchingSessionToken, setFetchingSessionToken] = useState(false); + const [iconError, setIconError] = useState(false); const theme = useTheme(); const username = workspace.owner_name; @@ -67,7 +68,9 @@ export const AppLink: FC = ({ app, workspace, agent }) => { // To avoid bugs in the healthcheck code locking users out of apps, we no // longer block access to apps if they are unhealthy/initializing. let canClick = true; - let icon = ; + let icon = !iconError && ( + setIconError(true)} /> + ); let primaryTooltip = ""; if (app.health === "initializing") { diff --git a/site/src/modules/resources/AppLink/BaseIcon.tsx b/site/src/modules/resources/AppLink/BaseIcon.tsx index d6cbf145d4..1f2885a49a 100644 --- a/site/src/modules/resources/AppLink/BaseIcon.tsx +++ b/site/src/modules/resources/AppLink/BaseIcon.tsx @@ -4,14 +4,21 @@ import type { FC } from "react"; interface BaseIconProps { app: WorkspaceApp; + onIconPathError?: () => void; } -export const BaseIcon: FC = ({ app }) => { +export const BaseIcon: FC = ({ app, onIconPathError }) => { return app.icon ? ( {`${app.display_name} { + console.warn( + `Application icon for "${app.id}" has invalid source "${app.icon}".`, + ); + onIconPathError?.(); + }} /> ) : ( diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index 6efbeef76e..05a209ab35 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -80,6 +80,43 @@ export const Running: Story = { }, }; +export const AppIcons: Story = { + args: { + ...Running.args, + workspace: { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspace.latest_build, + resources: [ + { + ...Mocks.MockWorkspaceResource, + agents: [ + { + ...Mocks.MockWorkspaceAgent, + apps: [ + { + ...Mocks.MockWorkspaceApp, + id: "test-app-1", + slug: "test-app-1", + display_name: "Default Icon", + }, + { + ...Mocks.MockWorkspaceApp, + id: "test-app-2", + slug: "test-app-2", + display_name: "Broken Icon", + icon: "/foobar/broken.png", + }, + ], + }, + ], + }, + ], + }, + }, + }, +}; + export const Favorite: Story = { args: { ...Running.args, From 4e1e7459125ac00c0cf9a547097c9ad32628c348 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 25 Feb 2025 09:07:48 +0000 Subject: [PATCH 11/29] add prebuild metrics and observability Signed-off-by: Danny Kopping --- coderd/database/dbauthz/dbauthz.go | 7 ++ coderd/database/dbmem/dbmem.go | 4 + coderd/database/dbmetrics/querymetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 15 ++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 85 +++++++++++++++++++ coderd/database/queries/prebuilds.sql | 43 ++++++++++ enterprise/coderd/coderd.go | 7 ++ enterprise/coderd/prebuilds/claim.go | 2 +- enterprise/coderd/prebuilds/controller.go | 6 +- enterprise/coderd/prebuilds/id.go | 2 +- .../coderd/prebuilds/metricscollector.go | 71 ++++++++++++++++ .../coderd/prebuilds/metricscollector_test.go | 74 ++++++++++++++++ 13 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 enterprise/coderd/prebuilds/metricscollector.go create mode 100644 enterprise/coderd/prebuilds/metricscollector_test.go diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 2f1402b1c4..3edf7f6286 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1977,6 +1977,13 @@ func (q *querier) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUI return q.db.GetParameterSchemasByJobID(ctx, jobID) } +func (q *querier) GetPrebuildMetrics(ctx context.Context) ([]database.GetPrebuildMetricsRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTemplate); err != nil { + return nil, err + } + return q.db.GetPrebuildMetrics(ctx) +} + func (q *querier) GetPrebuildsInProgress(ctx context.Context) ([]database.GetPrebuildsInProgressRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTemplate); err != nil { return nil, err diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index c7cc09550f..540c4cf188 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -3784,6 +3784,10 @@ func (q *FakeQuerier) GetParameterSchemasByJobID(_ context.Context, jobID uuid.U return parameters, nil } +func (q *FakeQuerier) GetPrebuildMetrics(ctx context.Context) ([]database.GetPrebuildMetricsRow, error) { + panic("not implemented") +} + func (q *FakeQuerier) GetPrebuildsInProgress(ctx context.Context) ([]database.GetPrebuildsInProgressRow, error) { panic("not implemented") } diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index a971a9f835..2f7334d191 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -987,6 +987,13 @@ func (m queryMetricsStore) GetParameterSchemasByJobID(ctx context.Context, jobID return schemas, err } +func (m queryMetricsStore) GetPrebuildMetrics(ctx context.Context) ([]database.GetPrebuildMetricsRow, error) { + start := time.Now() + r0, r1 := m.s.GetPrebuildMetrics(ctx) + m.queryLatencies.WithLabelValues("GetPrebuildMetrics").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetPrebuildsInProgress(ctx context.Context) ([]database.GetPrebuildsInProgressRow, error) { start := time.Now() r0, r1 := m.s.GetPrebuildsInProgress(ctx) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 27c84e80f1..b8331587ab 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2031,6 +2031,21 @@ func (mr *MockStoreMockRecorder) GetParameterSchemasByJobID(ctx, jobID any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParameterSchemasByJobID", reflect.TypeOf((*MockStore)(nil).GetParameterSchemasByJobID), ctx, jobID) } +// GetPrebuildMetrics mocks base method. +func (m *MockStore) GetPrebuildMetrics(ctx context.Context) ([]database.GetPrebuildMetricsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPrebuildMetrics", ctx) + ret0, _ := ret[0].([]database.GetPrebuildMetricsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPrebuildMetrics indicates an expected call of GetPrebuildMetrics. +func (mr *MockStoreMockRecorder) GetPrebuildMetrics(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrebuildMetrics", reflect.TypeOf((*MockStore)(nil).GetPrebuildMetrics), ctx) +} + // GetPrebuildsInProgress mocks base method. func (m *MockStore) GetPrebuildsInProgress(ctx context.Context) ([]database.GetPrebuildsInProgressRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d5a1ae5a67..7d1e20a7a1 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -204,6 +204,7 @@ type sqlcQuerier interface { GetOrganizations(ctx context.Context, arg GetOrganizationsParams) ([]Organization, error) GetOrganizationsByUserID(ctx context.Context, userID uuid.UUID) ([]Organization, error) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error) + GetPrebuildMetrics(ctx context.Context) ([]GetPrebuildMetricsRow, error) GetPrebuildsInProgress(ctx context.Context) ([]GetPrebuildsInProgressRow, error) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceBuildID uuid.UUID) (TemplateVersionPreset, error) GetPresetParametersByTemplateVersionID(ctx context.Context, templateVersionID uuid.UUID) ([]TemplateVersionPresetParameter, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e6cdb83068..342d400f19 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5442,6 +5442,91 @@ func (q *sqlQuerier) ClaimPrebuild(ctx context.Context, arg ClaimPrebuildParams) return i, err } +const getPrebuildMetrics = `-- name: GetPrebuildMetrics :many +SELECT + t.name as template_name, + tvp.name as preset_name, + COUNT(*) FILTER ( -- created + -- TODO (sasswart): double check which job statuses should be included here + WHERE + pj.initiator_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + AND pj.job_status = 'succeeded'::provisioner_job_status + ) as created, + COUNT(*) FILTER ( -- failed + -- TODO (sasswart): should we count cancelled here? + WHERE pj.initiator_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + AND pj.job_status = 'failed'::provisioner_job_status + ) as failed, + COUNT(*) FILTER ( -- assigned + WHERE pj.initiator_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + AND NOT w.owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + ) as assigned, + COUNT(*) FILTER ( -- exhausted + -- TODO (sasswart): write a filter to count this + -- we should be able to count: + -- - workspace builds + -- - that have a preset id + -- - and that preset has prebuilds enabled + -- - and the job for the prebuild was initiated by a user other than the prebuilds user + WHERE + wb.template_version_preset_id IS NOT NULL + AND w.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + AND wb.initiator_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + ) as exhausted, + COUNT(*) FILTER ( -- used_preset + WHERE wb.template_version_preset_id IS NOT NULL + ) as used_preset +FROM workspace_builds wb +INNER JOIN provisioner_jobs pj ON wb.job_id = pj.id +LEFT JOIN workspaces w ON wb.workspace_id = w.id +LEFT JOIN template_version_presets tvp ON wb.template_version_preset_id = tvp.id +LEFT JOIN template_versions tv ON tv.id = wb.template_version_id +LEFT JOIN templates t ON t.id = tv.template_id +WHERE pj.initiator_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid +GROUP BY t.name, tvp.name +` + +type GetPrebuildMetricsRow struct { + TemplateName sql.NullString `db:"template_name" json:"template_name"` + PresetName sql.NullString `db:"preset_name" json:"preset_name"` + Created int64 `db:"created" json:"created"` + Failed int64 `db:"failed" json:"failed"` + Assigned int64 `db:"assigned" json:"assigned"` + Exhausted int64 `db:"exhausted" json:"exhausted"` + UsedPreset int64 `db:"used_preset" json:"used_preset"` +} + +func (q *sqlQuerier) GetPrebuildMetrics(ctx context.Context) ([]GetPrebuildMetricsRow, error) { + rows, err := q.db.QueryContext(ctx, getPrebuildMetrics) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetPrebuildMetricsRow + for rows.Next() { + var i GetPrebuildMetricsRow + if err := rows.Scan( + &i.TemplateName, + &i.PresetName, + &i.Created, + &i.Failed, + &i.Assigned, + &i.Exhausted, + &i.UsedPreset, + ); 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 getPrebuildsInProgress = `-- name: GetPrebuildsInProgress :many SELECT t.id AS template_id, wpb.template_version_id, wpb.transition, COUNT(wpb.transition) AS count FROM workspace_latest_build wlb diff --git a/coderd/database/queries/prebuilds.sql b/coderd/database/queries/prebuilds.sql index f760b094f3..ef8f4f0779 100644 --- a/coderd/database/queries/prebuilds.sql +++ b/coderd/database/queries/prebuilds.sql @@ -71,3 +71,46 @@ RETURNING w.id, w.name; INSERT INTO template_version_preset_prebuilds (id, preset_id, desired_instances, invalidate_after_secs) VALUES (@id::uuid, @preset_id::uuid, @desired_instances::int, @invalidate_after_secs::int) RETURNING *; + +-- name: GetPrebuildMetrics :many +SELECT + t.name as template_name, + tvp.name as preset_name, + COUNT(*) FILTER ( -- created + -- TODO (sasswart): double check which job statuses should be included here + WHERE + pj.initiator_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + AND pj.job_status = 'succeeded'::provisioner_job_status + ) as created, + COUNT(*) FILTER ( -- failed + -- TODO (sasswart): should we count cancelled here? + WHERE pj.initiator_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + AND pj.job_status = 'failed'::provisioner_job_status + ) as failed, + COUNT(*) FILTER ( -- assigned + WHERE pj.initiator_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + AND NOT w.owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + ) as assigned, + COUNT(*) FILTER ( -- exhausted + -- TODO (sasswart): write a filter to count this + -- we should be able to count: + -- - workspace builds + -- - that have a preset id + -- - and that preset has prebuilds enabled + -- - and the job for the prebuild was initiated by a user other than the prebuilds user + WHERE + wb.template_version_preset_id IS NOT NULL + AND w.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + AND wb.initiator_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid + ) as exhausted, + COUNT(*) FILTER ( -- used_preset + WHERE wb.template_version_preset_id IS NOT NULL + ) as used_preset +FROM workspace_builds wb +INNER JOIN provisioner_jobs pj ON wb.job_id = pj.id +LEFT JOIN workspaces w ON wb.workspace_id = w.id +LEFT JOIN template_version_presets tvp ON wb.template_version_preset_id = tvp.id +LEFT JOIN template_versions tv ON tv.id = wb.template_version_id +LEFT JOIN templates t ON t.id = tv.template_id +WHERE pj.initiator_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid +GROUP BY t.name, tvp.name; diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 9a698856dc..3489f4d770 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -590,6 +590,13 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { } else { api.prebuildsController = prebuilds.NewController(options.Database, options.Pubsub, options.DeploymentValues.Prebuilds, options.Logger.Named("prebuilds.controller")) go api.prebuildsController.Loop(ctx) + + prebuildMetricsCollector := prebuilds.NewMetricsCollector(options.Database, options.Logger) + // should this be api.prebuild... + err = api.PrometheusRegistry.Register(prebuildMetricsCollector) + if err != nil { + return nil, xerrors.Errorf("unable to register prebuilds metrics collector: %w", err) + } } } diff --git a/enterprise/coderd/prebuilds/claim.go b/enterprise/coderd/prebuilds/claim.go index 0cb39c1659..fa4f48a389 100644 --- a/enterprise/coderd/prebuilds/claim.go +++ b/enterprise/coderd/prebuilds/claim.go @@ -52,7 +52,7 @@ func (e EnterpriseClaimer) Claim(ctx context.Context, store database.Store, user } func (e EnterpriseClaimer) Initiator() uuid.UUID { - return ownerID + return OwnerID } var _ prebuilds.Claimer = &EnterpriseClaimer{} diff --git a/enterprise/coderd/prebuilds/controller.go b/enterprise/coderd/prebuilds/controller.go index de4e02508c..7bb862ee7f 100644 --- a/enterprise/coderd/prebuilds/controller.go +++ b/enterprise/coderd/prebuilds/controller.go @@ -321,7 +321,7 @@ func (c *Controller) createPrebuild(ctx context.Context, prebuildID uuid.UUID, t ID: prebuildID, CreatedAt: now, UpdatedAt: now, - OwnerID: ownerID, + OwnerID: OwnerID, OrganizationID: template.OrganizationID, TemplateID: template.ID, Name: name, @@ -382,14 +382,14 @@ func (c *Controller) provision(ctx context.Context, prebuildID uuid.UUID, templa builder := wsbuilder.New(workspace, transition). Reason(database.BuildReasonInitiator). - Initiator(ownerID). + Initiator(OwnerID). ActiveVersion(). VersionID(template.ActiveVersionID). MarkPrebuild(). TemplateVersionPresetID(presetID) // We only inject the required params when the prebuild is being created. - // This mirrors the behaviour of regular workspace deletion (see cli/delete.go). + // This mirrors the behavior of regular workspace deletion (see cli/delete.go). if transition != database.WorkspaceTransitionDelete { builder = builder.RichParameterValues(params) } diff --git a/enterprise/coderd/prebuilds/id.go b/enterprise/coderd/prebuilds/id.go index 6f7ff2dac2..bde76e3f7b 100644 --- a/enterprise/coderd/prebuilds/id.go +++ b/enterprise/coderd/prebuilds/id.go @@ -2,4 +2,4 @@ package prebuilds import "github.com/google/uuid" -var ownerID = uuid.MustParse("c42fdf75-3097-471c-8c33-fb52454d81c0") +var OwnerID = uuid.MustParse("c42fdf75-3097-471c-8c33-fb52454d81c0") diff --git a/enterprise/coderd/prebuilds/metricscollector.go b/enterprise/coderd/prebuilds/metricscollector.go new file mode 100644 index 0000000000..f6a6ee8a4b --- /dev/null +++ b/enterprise/coderd/prebuilds/metricscollector.go @@ -0,0 +1,71 @@ +package prebuilds + +import ( + "context" + "time" + + "cdr.dev/slog" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" +) + +var ( + CreatedPrebuildsDesc = prometheus.NewDesc("coderd_prebuilds_created", "The number of prebuilds created.", []string{"template_name", "preset_name"}, nil) + FailedPrebuildsDesc = prometheus.NewDesc("coderd_prebuilds_failed", "The number of prebuilds that failed.", []string{"template_name", "preset_name"}, nil) + AssignedPrebuildsDesc = prometheus.NewDesc("coderd_prebuilds_assigned", "The number of prebuilds that were assigned to a runner.", []string{"template_name", "preset_name"}, nil) + UsedPresetsDesc = prometheus.NewDesc("coderd_presets_used", "The number of times a preset was used.", []string{"template_name", "preset_name"}, nil) + ExhaustedPrebuildsDesc = prometheus.NewDesc("coderd_prebuilds_exhausted", "The number of prebuilds that were exhausted.", []string{"template_name", "preset_name"}, nil) + DesiredPrebuildsDesc = prometheus.NewDesc("coderd_prebuilds_desired", "The number of desired prebuilds.", []string{"template_name", "preset_name"}, nil) + ActualPrebuildsDesc = prometheus.NewDesc("coderd_prebuilds_actual", "The number of actual prebuilds.", []string{"template_name", "preset_name"}, nil) + EligiblePrebuildsDesc = prometheus.NewDesc("coderd_prebuilds_eligible", "The number of eligible prebuilds.", []string{"template_name", "preset_name"}, nil) +) + +type MetricsCollector struct { + database database.Store + logger slog.Logger +} + +var _ prometheus.Collector = new(MetricsCollector) + +func NewMetricsCollector(db database.Store, logger slog.Logger) *MetricsCollector { + return &MetricsCollector{ + database: db, + logger: logger.Named("prebuilds_metrics_collector"), + } +} + +func (*MetricsCollector) Describe(descCh chan<- *prometheus.Desc) { + descCh <- CreatedPrebuildsDesc + descCh <- FailedPrebuildsDesc + descCh <- AssignedPrebuildsDesc + descCh <- UsedPresetsDesc + descCh <- ExhaustedPrebuildsDesc + descCh <- DesiredPrebuildsDesc + descCh <- ActualPrebuildsDesc + descCh <- EligiblePrebuildsDesc +} + +func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) { + // TODO (sasswart): get a proper actor in here, to deescalate from system + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // nolint:gocritic // just until we get back to this + metrics, err := mc.database.GetPrebuildMetrics(dbauthz.AsSystemRestricted(ctx)) + if err != nil { + mc.logger.Error(ctx, "failed to get prebuild metrics", slog.Error(err)) + return + } + + for _, metric := range metrics { + metricsCh <- prometheus.MustNewConstMetric(CreatedPrebuildsDesc, prometheus.CounterValue, float64(metric.Created), metric.TemplateName.String, metric.PresetName.String) + metricsCh <- prometheus.MustNewConstMetric(FailedPrebuildsDesc, prometheus.CounterValue, float64(metric.Failed), metric.TemplateName.String, metric.PresetName.String) + metricsCh <- prometheus.MustNewConstMetric(AssignedPrebuildsDesc, prometheus.CounterValue, float64(metric.Assigned), metric.TemplateName.String, metric.PresetName.String) + metricsCh <- prometheus.MustNewConstMetric(ExhaustedPrebuildsDesc, prometheus.CounterValue, float64(metric.Exhausted), metric.TemplateName.String, metric.PresetName.String) + metricsCh <- prometheus.MustNewConstMetric(UsedPresetsDesc, prometheus.CounterValue, float64(metric.UsedPreset), metric.TemplateName.String, metric.PresetName.String) + } + + // TODO (sasswart): read gauges from controller +} diff --git a/enterprise/coderd/prebuilds/metricscollector_test.go b/enterprise/coderd/prebuilds/metricscollector_test.go new file mode 100644 index 0000000000..b2231a8a2e --- /dev/null +++ b/enterprise/coderd/prebuilds/metricscollector_test.go @@ -0,0 +1,74 @@ +package prebuilds_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/enterprise/coderd/prebuilds" +) + +func TestMetricsCollector(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } + + db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + template := dbgen.Template(t, db, database.Template{ + CreatedBy: user.ID, + OrganizationID: org.ID, + }) + templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + collector := prebuilds.NewMetricsCollector(db, logger) + + registry := prometheus.NewRegistry() + registry.Register(collector) + + preset, err := db.InsertPreset(context.Background(), database.InsertPresetParams{ + TemplateVersionID: templateVersion.ID, + Name: "test", + }) + require.NoError(t, err) + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateID: template.ID, + }) + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + CompletedAt: sql.NullTime{Time: time.Now(), Valid: true}, + InitiatorID: prebuilds.OwnerID, + }) + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + TemplateVersionID: templateVersion.ID, + TemplateVersionPresetID: uuid.NullUUID{UUID: preset.ID, Valid: true}, + InitiatorID: prebuilds.OwnerID, + JobID: job.ID, + }) + + metrics, err := registry.Gather() + require.NoError(t, err) + require.Equal(t, 5, len(metrics)) +} From 546d915d3241e983f86432012c4c807c4792b3ff Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 25 Feb 2025 14:33:17 +0200 Subject: [PATCH 12/29] chore: install `libgbm-dev` to allow headless chrome e2e tests to run (#16695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this lib, Chrome can’t set up its offscreen rendering buffers - apparently. I've validated this manually in my workspace. Signed-off-by: Danny Kopping --- dogfood/contents/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/dogfood/contents/Dockerfile b/dogfood/contents/Dockerfile index 8c3613f59d..1aac42579b 100644 --- a/dogfood/contents/Dockerfile +++ b/dogfood/contents/Dockerfile @@ -160,6 +160,7 @@ RUN apt-get update --quiet && apt-get install --yes \ kubectl \ language-pack-en \ less \ + libgbm-dev \ libssl-dev \ lsb-release \ man \ From b419b36adada62a34d45634f7d308f6e6b605bb9 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 25 Feb 2025 14:30:50 +0100 Subject: [PATCH 13/29] fix: display banner when no matching templates found (#16696) Fixes: https://github.com/coder/coder/issues/16077 --- site/src/components/Filter/storyHelpers.ts | 4 +++- site/src/pages/TemplatesPage/EmptyTemplates.tsx | 6 ++++++ .../TemplatesPage/TemplatesPageView.stories.tsx | 13 +++++++++++++ site/src/pages/TemplatesPage/TemplatesPageView.tsx | 1 + 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/site/src/components/Filter/storyHelpers.ts b/site/src/components/Filter/storyHelpers.ts index 92285b41e4..9ee1bfaef9 100644 --- a/site/src/components/Filter/storyHelpers.ts +++ b/site/src/components/Filter/storyHelpers.ts @@ -17,17 +17,19 @@ export const getDefaultFilterProps = ({ query = "", values, menus, + used = false, }: { query?: string; values: Record; menus: Record; + used?: boolean; }) => ({ filter: { query, update: () => action("update"), debounceUpdate: action("debounce") as UseFilterResult["debounceUpdate"], - used: false, + used: used, values, }, menus, diff --git a/site/src/pages/TemplatesPage/EmptyTemplates.tsx b/site/src/pages/TemplatesPage/EmptyTemplates.tsx index 3bda4a5c97..5cefe910b1 100644 --- a/site/src/pages/TemplatesPage/EmptyTemplates.tsx +++ b/site/src/pages/TemplatesPage/EmptyTemplates.tsx @@ -38,12 +38,18 @@ const findFeaturedExamples = (examples: TemplateExample[]) => { interface EmptyTemplatesProps { canCreateTemplates: boolean; examples: TemplateExample[]; + isUsingFilter: boolean; } export const EmptyTemplates: FC = ({ canCreateTemplates, examples, + isUsingFilter, }) => { + if (isUsingFilter) { + return ; + } + const featuredExamples = findFeaturedExamples(examples); if (canCreateTemplates) { diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx index f07ad24df1..7572f39b4b 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx @@ -84,6 +84,19 @@ export const MultipleOrganizations: Story = { }, }; +export const WithFilteredAllTemplates: Story = { + args: { + ...WithTemplates.args, + templates: [], + ...getDefaultFilterProps({ + query: "deprecated:false searchnotfound", + menus: {}, + values: {}, + used: true, + }), + }, +}; + export const EmptyCanCreate: Story = { args: { canCreateTemplates: true, diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 6d85aa293b..aa4276f8df 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -246,6 +246,7 @@ export const TemplatesPageView: FC = ({ ) : ( templates?.map((template) => ( From 67d89bb102898d219a0d1f5f06d889ea2cfea29d Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 25 Feb 2025 15:54:38 +0100 Subject: [PATCH 14/29] feat: implement sign up with GitHub for the first user (#16629) Second PR to address https://github.com/coder/coder/issues/16230. See the issue for more context and discussion. It adds a "Continue with GitHub" button to the `/setup` page, so the deployment's admin can sign up with it. It also removes the "Username" and "Full Name" fields to make signing up with email faster. In the email flow, the username is now auto-generated based on the email, and full name is left empty. Screenshot 2025-02-21 at 17 51 22 There's a separate, follow up issue to visually align the `/setup` page with the new design system: https://github.com/coder/coder/issues/16653 --- coderd/userauth.go | 33 +++++++- coderd/userauth_test.go | 64 +++++++++++++--- coderd/users.go | 39 +++++----- site/e2e/setup/addUsersAndLicense.spec.ts | 1 - site/src/pages/SetupPage/SetupPage.test.tsx | 3 - site/src/pages/SetupPage/SetupPage.tsx | 6 +- site/src/pages/SetupPage/SetupPageView.tsx | 84 +++++++++++++++------ 7 files changed, 171 insertions(+), 59 deletions(-) diff --git a/coderd/userauth.go b/coderd/userauth.go index 74a1a718ef..709d22389f 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/cryptokeys" "github.com/coder/coder/v2/coderd/idpsync" "github.com/coder/coder/v2/coderd/jwtutils" + "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/apikey" @@ -1054,6 +1055,10 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { defer params.CommitAuditLogs() if err != nil { if httpErr := idpsync.IsHTTPError(err); httpErr != nil { + // In the device flow, the error page is rendered client-side. + if api.GithubOAuth2Config.DeviceFlowEnabled && httpErr.RenderStaticPage { + httpErr.RenderStaticPage = false + } httpErr.Write(rw, r) return } @@ -1634,7 +1639,17 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C isConvertLoginType = true } - if user.ID == uuid.Nil && !params.AllowSignups { + // nolint:gocritic // Getting user count is a system function. + userCount, err := tx.GetUserCount(dbauthz.AsSystemRestricted(ctx)) + if err != nil { + return xerrors.Errorf("unable to fetch user count: %w", err) + } + + // Allow the first user to sign up with OIDC, regardless of + // whether signups are enabled or not. + allowSignup := userCount == 0 || params.AllowSignups + + if user.ID == uuid.Nil && !allowSignup { signupsDisabledText := "Please contact your Coder administrator to request access." if api.OIDCConfig != nil && api.OIDCConfig.SignupsDisabledText != "" { signupsDisabledText = render.HTMLFromMarkdown(api.OIDCConfig.SignupsDisabledText) @@ -1695,6 +1710,12 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C return xerrors.Errorf("unable to fetch default organization: %w", err) } + rbacRoles := []string{} + // If this is the first user, add the owner role. + if userCount == 0 { + rbacRoles = append(rbacRoles, rbac.RoleOwner().String()) + } + //nolint:gocritic user, err = api.CreateUser(dbauthz.AsSystemRestricted(ctx), tx, CreateUserRequest{ CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{ @@ -1709,10 +1730,20 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C }, LoginType: params.LoginType, accountCreatorName: "oauth", + RBACRoles: rbacRoles, }) if err != nil { return xerrors.Errorf("create user: %w", err) } + + if userCount == 0 { + telemetryUser := telemetry.ConvertUser(user) + // The email is not anonymized for the first user. + telemetryUser.Email = &user.Email + api.Telemetry.Report(&telemetry.Snapshot{ + Users: []telemetry.User{telemetryUser}, + }) + } } // Activate dormant user on sign-in diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 9c32aefadc..ee6ee957ba 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -22,6 +22,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/atomic" "golang.org/x/oauth2" "golang.org/x/xerrors" @@ -254,11 +255,20 @@ func TestUserOAuth2Github(t *testing.T) { }) t.Run("BlockSignups", func(t *testing.T) { t.Parallel() + + db, ps := dbtestutil.NewDB(t) + + id := atomic.NewInt64(100) + login := atomic.NewString("testuser") + email := atomic.NewString("testuser@coder.com") + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: ps, GithubOAuth2Config: &coderd.GithubOAuth2Config{ OAuth2Config: &testutil.OAuth2Config{}, AllowOrganizations: []string{"coder"}, - ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) { + ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) { return []*github.Membership{{ State: &stateActive, Organization: &github.Organization{ @@ -266,16 +276,19 @@ func TestUserOAuth2Github(t *testing.T) { }, }}, nil }, - AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { + AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) { + id := id.Load() + login := login.Load() return &github.User{ - ID: github.Int64(100), - Login: github.String("testuser"), + ID: &id, + Login: &login, Name: github.String("The Right Honorable Sir Test McUser"), }, nil }, - ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) { + ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) { + email := email.Load() return []*github.UserEmail{{ - Email: github.String("testuser@coder.com"), + Email: &email, Verified: github.Bool(true), Primary: github.Bool(true), }}, nil @@ -283,8 +296,23 @@ func TestUserOAuth2Github(t *testing.T) { }, }) + // The first user in a deployment with signups disabled will be allowed to sign up, + // but all the other users will not. resp := oauth2Callback(t, client) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + ctx := testutil.Context(t, testutil.WaitLong) + + // nolint:gocritic // Unit test + count, err := db.GetUserCount(dbauthz.AsSystemRestricted(ctx)) + require.NoError(t, err) + require.Equal(t, int64(1), count) + + id.Store(101) + email.Store("someotheruser@coder.com") + login.Store("someotheruser") + + resp = oauth2Callback(t, client) require.Equal(t, http.StatusForbidden, resp.StatusCode) }) t.Run("MultiLoginNotAllowed", func(t *testing.T) { @@ -988,6 +1016,7 @@ func TestUserOIDC(t *testing.T) { IgnoreEmailVerified bool IgnoreUserInfo bool UseAccessToken bool + PrecreateFirstUser bool }{ { Name: "NoSub", @@ -1150,7 +1179,17 @@ func TestUserOIDC(t *testing.T) { "email_verified": true, "sub": uuid.NewString(), }, - StatusCode: http.StatusForbidden, + StatusCode: http.StatusForbidden, + PrecreateFirstUser: true, + }, + { + Name: "FirstSignup", + IDTokenClaims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + "sub": uuid.NewString(), + }, + StatusCode: http.StatusOK, }, { Name: "UsernameFromEmail", @@ -1443,6 +1482,15 @@ func TestUserOIDC(t *testing.T) { }) numLogs := len(auditor.AuditLogs()) + ctx := testutil.Context(t, testutil.WaitShort) + if tc.PrecreateFirstUser { + owner.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{ + Email: "precreated@coder.com", + Username: "precreated", + Password: "SomeSecurePassword!", + }) + } + client, resp := fake.AttemptLogin(t, owner, tc.IDTokenClaims) numLogs++ // add an audit log for login require.Equal(t, tc.StatusCode, resp.StatusCode) @@ -1450,8 +1498,6 @@ func TestUserOIDC(t *testing.T) { tc.AssertResponse(t, resp) } - ctx := testutil.Context(t, testutil.WaitShort) - if tc.AssertUser != nil { user, err := client.User(ctx, "me") require.NoError(t, err) diff --git a/coderd/users.go b/coderd/users.go index 5f8866903b..bf5b1db763 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -118,6 +118,8 @@ func (api *API) firstUser(rw http.ResponseWriter, r *http.Request) { // @Success 201 {object} codersdk.CreateFirstUserResponse // @Router /users/first [post] func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { + // The first user can also be created via oidc, so if making changes to the flow, + // ensure that the oidc flow is also updated. ctx := r.Context() var createUser codersdk.CreateFirstUserRequest if !httpapi.Read(ctx, rw, r, &createUser) { @@ -198,6 +200,7 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { OrganizationIDs: []uuid.UUID{defaultOrg.ID}, }, LoginType: database.LoginTypePassword, + RBACRoles: []string{rbac.RoleOwner().String()}, accountCreatorName: "coder", }) if err != nil { @@ -225,23 +228,6 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { Users: []telemetry.User{telemetryUser}, }) - // TODO: @emyrk this currently happens outside the database tx used to create - // the user. Maybe I add this ability to grant roles in the createUser api - // and add some rbac bypass when calling api functions this way?? - // Add the admin role to this first user. - //nolint:gocritic // needed to create first user - _, err = api.Database.UpdateUserRoles(dbauthz.AsSystemRestricted(ctx), database.UpdateUserRolesParams{ - GrantedRoles: []string{rbac.RoleOwner().String()}, - ID: user.ID, - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating user's roles.", - Detail: err.Error(), - }) - return - } - httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateFirstUserResponse{ UserID: user.ID, OrganizationID: defaultOrg.ID, @@ -1351,6 +1337,7 @@ type CreateUserRequest struct { LoginType database.LoginType SkipNotifications bool accountCreatorName string + RBACRoles []string } func (api *API) CreateUser(ctx context.Context, store database.Store, req CreateUserRequest) (database.User, error) { @@ -1360,6 +1347,13 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create return database.User{}, xerrors.Errorf("invalid username %q: %w", req.Username, usernameValid) } + // If the caller didn't specify rbac roles, default to + // a member of the site. + rbacRoles := []string{} + if req.RBACRoles != nil { + rbacRoles = req.RBACRoles + } + var user database.User err := store.InTx(func(tx database.Store) error { orgRoles := make([]string, 0) @@ -1376,10 +1370,9 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), HashedPassword: []byte{}, - // All new users are defaulted to members of the site. - RBACRoles: []string{}, - LoginType: req.LoginType, - Status: status, + RBACRoles: rbacRoles, + LoginType: req.LoginType, + Status: status, } // If a user signs up with OAuth, they can have no password! if req.Password != "" { @@ -1437,6 +1430,10 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create } for _, u := range userAdmins { + if u.ID == user.ID { + // If the new user is an admin, don't notify them about themselves. + continue + } if _, err := api.NotificationsEnqueuer.EnqueueWithData( // nolint:gocritic // Need notifier actor to enqueue notifications dbauthz.AsNotifier(ctx), diff --git a/site/e2e/setup/addUsersAndLicense.spec.ts b/site/e2e/setup/addUsersAndLicense.spec.ts index bcaa8c9281..784db4812a 100644 --- a/site/e2e/setup/addUsersAndLicense.spec.ts +++ b/site/e2e/setup/addUsersAndLicense.spec.ts @@ -16,7 +16,6 @@ test("setup deployment", async ({ page }) => { } // Setup first user - await page.getByLabel(Language.usernameLabel).fill(users.admin.username); await page.getByLabel(Language.emailLabel).fill(users.admin.email); await page.getByLabel(Language.passwordLabel).fill(users.admin.password); await page.getByTestId("create").click(); diff --git a/site/src/pages/SetupPage/SetupPage.test.tsx b/site/src/pages/SetupPage/SetupPage.test.tsx index a088948623..47cf1d5874 100644 --- a/site/src/pages/SetupPage/SetupPage.test.tsx +++ b/site/src/pages/SetupPage/SetupPage.test.tsx @@ -13,7 +13,6 @@ import { SetupPage } from "./SetupPage"; import { Language as PageViewLanguage } from "./SetupPageView"; const fillForm = async ({ - username = "someuser", email = "someone@coder.com", password = "password", }: { @@ -21,10 +20,8 @@ const fillForm = async ({ email?: string; password?: string; } = {}) => { - const usernameField = screen.getByLabelText(PageViewLanguage.usernameLabel); const emailField = screen.getByLabelText(PageViewLanguage.emailLabel); const passwordField = screen.getByLabelText(PageViewLanguage.passwordLabel); - await userEvent.type(usernameField, username); await userEvent.type(emailField, email); await userEvent.type(passwordField, password); const submitButton = screen.getByRole("button", { diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index 100c02e213..be81f96615 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -1,5 +1,5 @@ import { buildInfo } from "api/queries/buildInfo"; -import { createFirstUser } from "api/queries/users"; +import { authMethods, createFirstUser } from "api/queries/users"; import { Loader } from "components/Loader/Loader"; import { useAuthContext } from "contexts/auth/AuthProvider"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; @@ -19,6 +19,7 @@ export const SetupPage: FC = () => { isSignedIn, isSigningIn, } = useAuthContext(); + const authMethodsQuery = useQuery(authMethods()); const createFirstUserMutation = useMutation(createFirstUser()); const setupIsComplete = !isConfiguringTheFirstUser; const { metadata } = useEmbeddedMetadata(); @@ -34,7 +35,7 @@ export const SetupPage: FC = () => { }); }, [buildInfoQuery.data]); - if (isLoading) { + if (isLoading || authMethodsQuery.isLoading) { return ; } @@ -54,6 +55,7 @@ export const SetupPage: FC = () => { {pageTitle("Set up your account")} { diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index 3e4ddba46d..5547518ef6 100644 --- a/site/src/pages/SetupPage/SetupPageView.tsx +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -1,6 +1,8 @@ +import GitHubIcon from "@mui/icons-material/GitHub"; import LoadingButton from "@mui/lab/LoadingButton"; import AlertTitle from "@mui/material/AlertTitle"; import Autocomplete from "@mui/material/Autocomplete"; +import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; import Link from "@mui/material/Link"; import MenuItem from "@mui/material/MenuItem"; @@ -15,8 +17,7 @@ import { PasswordField } from "components/PasswordField/PasswordField"; import { SignInLayout } from "components/SignInLayout/SignInLayout"; import { Stack } from "components/Stack/Stack"; import { type FormikContextType, useFormik } from "formik"; -import type { FC } from "react"; -import { useEffect } from "react"; +import { type ChangeEvent, type FC, useCallback } from "react"; import { docs } from "utils/docs"; import { getFormHelpers, @@ -33,7 +34,8 @@ export const Language = { emailInvalid: "Please enter a valid email address.", emailRequired: "Please enter an email address.", passwordRequired: "Please enter a password.", - create: "Create account", + create: "Continue with email", + githubCreate: "Continue with GitHub", welcomeMessage: <>Welcome to Coder, firstNameLabel: "First name", lastNameLabel: "Last name", @@ -50,13 +52,29 @@ export const Language = { developersRequired: "Please select the number of developers in your company.", }; +const usernameValidator = nameValidator(Language.usernameLabel); +const usernameFromEmail = (email: string): string => { + try { + const emailPrefix = email.split("@")[0]; + const username = emailPrefix.toLowerCase().replace(/[^a-z0-9]/g, "-"); + usernameValidator.validateSync(username); + return username; + } catch (error) { + console.warn( + "failed to automatically generate username, defaulting to 'admin'", + error, + ); + return "admin"; + } +}; + const validationSchema = Yup.object({ email: Yup.string() .trim() .email(Language.emailInvalid) .required(Language.emailRequired), password: Yup.string().required(Language.passwordRequired), - username: nameValidator(Language.usernameLabel), + username: usernameValidator, trial: Yup.bool(), trial_info: Yup.object().when("trial", { is: true, @@ -81,16 +99,23 @@ const numberOfDevelopersOptions = [ "2500+", ]; +const iconStyles = { + width: 16, + height: 16, +}; + export interface SetupPageViewProps { onSubmit: (firstUser: TypesGen.CreateFirstUserRequest) => void; error?: unknown; isLoading?: boolean; + authMethods: TypesGen.AuthMethods | undefined; } export const SetupPageView: FC = ({ onSubmit, error, isLoading, + authMethods, }) => { const form: FormikContextType = useFormik({ @@ -112,6 +137,10 @@ export const SetupPageView: FC = ({ }, validationSchema, onSubmit, + // With validate on blur set to true, the form lights up red whenever + // you click out of it. This is a bit jarring. We instead validate + // on submit and change. + validateOnBlur: false, }); const getFieldHelpers = getFormHelpers( form, @@ -142,23 +171,36 @@ export const SetupPageView: FC = ({ - - + {authMethods?.github.enabled && ( + <> + +
+
+
+ or +
+
+
+ + )} { + const email = event.target.value; + const username = usernameFromEmail(email); + form.setFieldValue("username", username); + onChangeTrimmed(form)(event as ChangeEvent); + }} autoComplete="email" fullWidth label={Language.emailLabel} @@ -340,9 +382,7 @@ export const SetupPageView: FC = ({ loading={isLoading} type="submit" data-testid="create" - size="large" - variant="contained" - color="primary" + size="xlarge" > {Language.create} From d3a56ae3eff91dae166857844bbedf736266585f Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 25 Feb 2025 16:31:33 +0100 Subject: [PATCH 15/29] feat: enable GitHub OAuth2 login by default on new deployments (#16662) Third and final PR to address https://github.com/coder/coder/issues/16230. This PR enables GitHub OAuth2 login by default on new deployments. Combined with https://github.com/coder/coder/pull/16629, this will allow the first admin user to sign up with GitHub rather than email and password. We take care not to enable the default on deployments that would upgrade to a Coder version with this change. To disable the default provider an admin can set the `CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER` env variable to false. --- cli/server.go | 155 +++++++++++++----- cli/server_test.go | 141 ++++++++++++++++ cli/testdata/coder_server_--help.golden | 3 + cli/testdata/server-config.yaml.golden | 3 + coderd/apidoc/docs.go | 16 +- coderd/apidoc/swagger.json | 16 +- coderd/database/dbauthz/dbauthz.go | 14 ++ coderd/database/dbauthz/dbauthz_test.go | 6 + coderd/database/dbmem/dbmem.go | 19 +++ coderd/database/dbmetrics/querymetrics.go | 14 ++ coderd/database/dbmock/dbmock.go | 29 ++++ coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 39 +++++ coderd/database/queries/siteconfig.sql | 24 +++ coderd/userauth.go | 7 +- codersdk/deployment.go | 27 ++- codersdk/users.go | 13 +- docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 54 ++++-- docs/reference/api/users.md | 1 + docs/reference/cli/server.md | 11 ++ .../cli/testdata/coder_server_--help.golden | 3 + site/src/api/typesGenerated.ts | 9 +- .../pages/LoginPage/SignInForm.stories.tsx | 12 +- site/src/testHelpers/entities.ts | 8 +- 25 files changed, 544 insertions(+), 83 deletions(-) diff --git a/cli/server.go b/cli/server.go index 4805bf4b64..933ab64ab2 100644 --- a/cli/server.go +++ b/cli/server.go @@ -688,24 +688,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } } - if vals.OAuth2.Github.ClientSecret != "" || vals.OAuth2.Github.DeviceFlow.Value() { - options.GithubOAuth2Config, err = configureGithubOAuth2( - oauthInstrument, - vals.AccessURL.Value(), - vals.OAuth2.Github.ClientID.String(), - vals.OAuth2.Github.ClientSecret.String(), - vals.OAuth2.Github.DeviceFlow.Value(), - 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) - } - } - // As OIDC clients can be confidential or public, // we should only check for a client id being set. // The underlying library handles the case of no @@ -793,6 +775,20 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("set deployment id: %w", err) } + githubOAuth2ConfigParams, err := getGithubOAuth2ConfigParams(ctx, options.Database, vals) + if err != nil { + return xerrors.Errorf("get github oauth2 config params: %w", err) + } + if githubOAuth2ConfigParams != nil { + options.GithubOAuth2Config, err = configureGithubOAuth2( + oauthInstrument, + githubOAuth2ConfigParams, + ) + if err != nil { + return xerrors.Errorf("configure github oauth2: %w", err) + } + } + options.RuntimeConfig = runtimeconfig.NewManager() // This should be output before the logs start streaming. @@ -1843,25 +1839,101 @@ func configureCAPool(tlsClientCAFile string, tlsConfig *tls.Config) error { return nil } -// TODO: convert the argument list to a struct, it's easy to mix up the order of the arguments -// +const ( + // Client ID for https://github.com/apps/coder + GithubOAuth2DefaultProviderClientID = "Iv1.6a2b4b4aec4f4fe7" + GithubOAuth2DefaultProviderAllowEveryone = true + GithubOAuth2DefaultProviderDeviceFlow = true +) + +type githubOAuth2ConfigParams struct { + accessURL *url.URL + clientID string + clientSecret string + deviceFlow bool + allowSignups bool + allowEveryone bool + allowOrgs []string + rawTeams []string + enterpriseBaseURL string +} + +func getGithubOAuth2ConfigParams(ctx context.Context, db database.Store, vals *codersdk.DeploymentValues) (*githubOAuth2ConfigParams, error) { + params := githubOAuth2ConfigParams{ + accessURL: vals.AccessURL.Value(), + clientID: vals.OAuth2.Github.ClientID.String(), + clientSecret: vals.OAuth2.Github.ClientSecret.String(), + deviceFlow: vals.OAuth2.Github.DeviceFlow.Value(), + allowSignups: vals.OAuth2.Github.AllowSignups.Value(), + allowEveryone: vals.OAuth2.Github.AllowEveryone.Value(), + allowOrgs: vals.OAuth2.Github.AllowedOrgs.Value(), + rawTeams: vals.OAuth2.Github.AllowedTeams.Value(), + enterpriseBaseURL: vals.OAuth2.Github.EnterpriseBaseURL.String(), + } + + // If the user manually configured the GitHub OAuth2 provider, + // we won't add the default configuration. + if params.clientID != "" || params.clientSecret != "" || params.enterpriseBaseURL != "" { + return ¶ms, nil + } + + // Check if the user manually disabled the default GitHub OAuth2 provider. + if !vals.OAuth2.Github.DefaultProviderEnable.Value() { + return nil, nil //nolint:nilnil + } + + // Check if the deployment is eligible for the default GitHub OAuth2 provider. + // We want to enable it only for new deployments, and avoid enabling it + // if a deployment was upgraded from an older version. + // nolint:gocritic // Requires system privileges + defaultEligible, err := db.GetOAuth2GithubDefaultEligible(dbauthz.AsSystemRestricted(ctx)) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, xerrors.Errorf("get github default eligible: %w", err) + } + defaultEligibleNotSet := errors.Is(err, sql.ErrNoRows) + + if defaultEligibleNotSet { + // nolint:gocritic // User count requires system privileges + userCount, err := db.GetUserCount(dbauthz.AsSystemRestricted(ctx)) + if err != nil { + return nil, xerrors.Errorf("get user count: %w", err) + } + // We check if a deployment is new by checking if it has any users. + defaultEligible = userCount == 0 + // nolint:gocritic // Requires system privileges + if err := db.UpsertOAuth2GithubDefaultEligible(dbauthz.AsSystemRestricted(ctx), defaultEligible); err != nil { + return nil, xerrors.Errorf("upsert github default eligible: %w", err) + } + } + + if !defaultEligible { + return nil, nil //nolint:nilnil + } + + params.clientID = GithubOAuth2DefaultProviderClientID + params.allowEveryone = GithubOAuth2DefaultProviderAllowEveryone + params.deviceFlow = GithubOAuth2DefaultProviderDeviceFlow + + return ¶ms, nil +} + //nolint:revive // Ignore flag-parameter: parameter 'allowEveryone' seems to be a control flag, avoid control coupling (revive) -func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, clientID, clientSecret string, deviceFlow, allowSignups, allowEveryone bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) { - redirectURL, err := accessURL.Parse("/api/v2/users/oauth2/github/callback") +func configureGithubOAuth2(instrument *promoauth.Factory, params *githubOAuth2ConfigParams) (*coderd.GithubOAuth2Config, error) { + redirectURL, err := params.accessURL.Parse("/api/v2/users/oauth2/github/callback") if err != nil { return nil, xerrors.Errorf("parse github oauth callback url: %w", err) } - if allowEveryone && len(allowOrgs) > 0 { + if params.allowEveryone && len(params.allowOrgs) > 0 { return nil, xerrors.New("allow everyone and allowed orgs cannot be used together") } - if allowEveryone && len(rawTeams) > 0 { + if params.allowEveryone && len(params.rawTeams) > 0 { return nil, xerrors.New("allow everyone and allowed teams cannot be used together") } - if !allowEveryone && len(allowOrgs) == 0 { + if !params.allowEveryone && len(params.allowOrgs) == 0 { return nil, xerrors.New("allowed orgs is empty: must specify at least one org or allow everyone") } - allowTeams := make([]coderd.GithubOAuth2Team, 0, len(rawTeams)) - for _, rawTeam := range rawTeams { + allowTeams := make([]coderd.GithubOAuth2Team, 0, len(params.rawTeams)) + for _, rawTeam := range params.rawTeams { parts := strings.SplitN(rawTeam, "/", 2) if len(parts) != 2 { return nil, xerrors.Errorf("github team allowlist is formatted incorrectly. got %s; wanted /", rawTeam) @@ -1873,8 +1945,8 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl } endpoint := xgithub.Endpoint - if enterpriseBaseURL != "" { - enterpriseURL, err := url.Parse(enterpriseBaseURL) + if params.enterpriseBaseURL != "" { + enterpriseURL, err := url.Parse(params.enterpriseBaseURL) if err != nil { return nil, xerrors.Errorf("parse enterprise base url: %w", err) } @@ -1893,8 +1965,8 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl } instrumentedOauth := instrument.NewGithub("github-login", &oauth2.Config{ - ClientID: clientID, - ClientSecret: clientSecret, + ClientID: params.clientID, + ClientSecret: params.clientSecret, Endpoint: endpoint, RedirectURL: redirectURL.String(), Scopes: []string{ @@ -1906,17 +1978,17 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl createClient := func(client *http.Client, source promoauth.Oauth2Source) (*github.Client, error) { client = instrumentedOauth.InstrumentHTTPClient(client, source) - if enterpriseBaseURL != "" { - return github.NewEnterpriseClient(enterpriseBaseURL, "", client) + if params.enterpriseBaseURL != "" { + return github.NewEnterpriseClient(params.enterpriseBaseURL, "", client) } return github.NewClient(client), nil } var deviceAuth *externalauth.DeviceAuth - if deviceFlow { + if params.deviceFlow { deviceAuth = &externalauth.DeviceAuth{ Config: instrumentedOauth, - ClientID: clientID, + ClientID: params.clientID, TokenURL: endpoint.TokenURL, Scopes: []string{"read:user", "read:org", "user:email"}, CodeURL: endpoint.DeviceAuthURL, @@ -1925,9 +1997,9 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl return &coderd.GithubOAuth2Config{ OAuth2Config: instrumentedOauth, - AllowSignups: allowSignups, - AllowEveryone: allowEveryone, - AllowOrganizations: allowOrgs, + AllowSignups: params.allowSignups, + AllowEveryone: params.allowEveryone, + AllowOrganizations: params.allowOrgs, AllowTeams: allowTeams, AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) { api, err := createClient(client, promoauth.SourceGitAPIAuthUser) @@ -1966,19 +2038,20 @@ func configureGithubOAuth2(instrument *promoauth.Factory, accessURL *url.URL, cl team, _, err := api.Teams.GetTeamMembershipBySlug(ctx, org, teamSlug, username) return team, err }, - DeviceFlowEnabled: deviceFlow, + DeviceFlowEnabled: params.deviceFlow, ExchangeDeviceCode: func(ctx context.Context, deviceCode string) (*oauth2.Token, error) { - if !deviceFlow { + if !params.deviceFlow { return nil, xerrors.New("device flow is not enabled") } return deviceAuth.ExchangeDeviceCode(ctx, deviceCode) }, AuthorizeDevice: func(ctx context.Context) (*codersdk.ExternalAuthDevice, error) { - if !deviceFlow { + if !params.deviceFlow { return nil, xerrors.New("device flow is not enabled") } return deviceAuth.AuthorizeDevice(ctx) }, + DefaultProviderConfigured: params.clientID == GithubOAuth2DefaultProviderClientID, }, nil } diff --git a/cli/server_test.go b/cli/server_test.go index d971637750..d4031faf94 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -45,6 +45,8 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/cli/config" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/migrations" "github.com/coder/coder/v2/coderd/httpapi" @@ -306,6 +308,145 @@ func TestServer(t *testing.T) { require.Less(t, numLines, 20) }) + t.Run("OAuth2GitHubDefaultProvider", func(t *testing.T) { + type testCase struct { + name string + githubDefaultProviderEnabled string + githubClientID string + githubClientSecret string + expectGithubEnabled bool + expectGithubDefaultProviderConfigured bool + createUserPreStart bool + createUserPostRestart bool + } + + runGitHubProviderTest := func(t *testing.T, tc testCase) { + t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("test requires postgres") + } + + ctx, cancelFunc := context.WithCancel(testutil.Context(t, testutil.WaitLong)) + defer cancelFunc() + + dbURL, err := dbtestutil.Open(t) + require.NoError(t, err) + db, _ := dbtestutil.NewDB(t, dbtestutil.WithURL(dbURL)) + + if tc.createUserPreStart { + _ = dbgen.User(t, db, database.User{}) + } + + args := []string{ + "server", + "--postgres-url", dbURL, + "--http-address", ":0", + "--access-url", "https://example.com", + } + if tc.githubClientID != "" { + args = append(args, fmt.Sprintf("--oauth2-github-client-id=%s", tc.githubClientID)) + } + if tc.githubClientSecret != "" { + args = append(args, fmt.Sprintf("--oauth2-github-client-secret=%s", tc.githubClientSecret)) + } + if tc.githubClientID != "" || tc.githubClientSecret != "" { + args = append(args, "--oauth2-github-allow-everyone") + } + if tc.githubDefaultProviderEnabled != "" { + args = append(args, fmt.Sprintf("--oauth2-github-default-provider-enable=%s", tc.githubDefaultProviderEnabled)) + } + + inv, cfg := clitest.New(t, args...) + errChan := make(chan error, 1) + go func() { + errChan <- inv.WithContext(ctx).Run() + }() + accessURLChan := make(chan *url.URL, 1) + go func() { + accessURLChan <- waitAccessURL(t, cfg) + }() + + var accessURL *url.URL + select { + case err := <-errChan: + require.NoError(t, err) + case accessURL = <-accessURLChan: + require.NotNil(t, accessURL) + } + + client := codersdk.New(accessURL) + + authMethods, err := client.AuthMethods(ctx) + require.NoError(t, err) + require.Equal(t, tc.expectGithubEnabled, authMethods.Github.Enabled) + require.Equal(t, tc.expectGithubDefaultProviderConfigured, authMethods.Github.DefaultProviderConfigured) + + cancelFunc() + select { + case err := <-errChan: + require.NoError(t, err) + case <-time.After(testutil.WaitLong): + t.Fatal("server did not exit") + } + + if tc.createUserPostRestart { + _ = dbgen.User(t, db, database.User{}) + } + + // Ensure that it stays at that setting after the server restarts. + inv, cfg = clitest.New(t, args...) + clitest.Start(t, inv) + accessURL = waitAccessURL(t, cfg) + client = codersdk.New(accessURL) + + ctx = testutil.Context(t, testutil.WaitLong) + authMethods, err = client.AuthMethods(ctx) + require.NoError(t, err) + require.Equal(t, tc.expectGithubEnabled, authMethods.Github.Enabled) + require.Equal(t, tc.expectGithubDefaultProviderConfigured, authMethods.Github.DefaultProviderConfigured) + } + + for _, tc := range []testCase{ + { + name: "NewDeployment", + expectGithubEnabled: true, + expectGithubDefaultProviderConfigured: true, + createUserPreStart: false, + createUserPostRestart: true, + }, + { + name: "ExistingDeployment", + expectGithubEnabled: false, + expectGithubDefaultProviderConfigured: false, + createUserPreStart: true, + createUserPostRestart: false, + }, + { + name: "ManuallyDisabled", + githubDefaultProviderEnabled: "false", + expectGithubEnabled: false, + expectGithubDefaultProviderConfigured: false, + }, + { + name: "ConfiguredClientID", + githubClientID: "123", + expectGithubEnabled: true, + expectGithubDefaultProviderConfigured: false, + }, + { + name: "ConfiguredClientSecret", + githubClientSecret: "456", + expectGithubEnabled: true, + expectGithubDefaultProviderConfigured: false, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + runGitHubProviderTest(t, tc) + }) + } + }) + // Validate that a warning is printed that it may not be externally // reachable. t.Run("LocalAccessURL", func(t *testing.T) { diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 73ada6a924..df1f982bc5 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -498,6 +498,9 @@ OAUTH2 / GITHUB OPTIONS: --oauth2-github-client-secret string, $CODER_OAUTH2_GITHUB_CLIENT_SECRET Client secret for Login with GitHub. + --oauth2-github-default-provider-enable bool, $CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE (default: true) + Enable the default GitHub OAuth2 provider managed by Coder. + --oauth2-github-device-flow bool, $CODER_OAUTH2_GITHUB_DEVICE_FLOW (default: false) Enable device flow for Login with GitHub. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 1a45d664db..cffaf65cd3 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -265,6 +265,9 @@ oauth2: # Enable device flow for Login with GitHub. # (default: false, type: bool) deviceFlow: false + # Enable the default GitHub OAuth2 provider managed by Coder. + # (default: true, type: bool) + defaultProviderEnable: true # Organizations the user must be a member of to Login with GitHub. # (default: , type: string-array) allowedOrgs: [] diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 69d421b299..d7e9408eb6 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10331,7 +10331,7 @@ const docTemplate = `{ "type": "object", "properties": { "github": { - "$ref": "#/definitions/codersdk.AuthMethod" + "$ref": "#/definitions/codersdk.GithubAuthMethod" }, "oidc": { "$ref": "#/definitions/codersdk.OIDCAuthMethod" @@ -11857,6 +11857,17 @@ const docTemplate = `{ } } }, + "codersdk.GithubAuthMethod": { + "type": "object", + "properties": { + "default_provider_configured": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + } + }, "codersdk.Group": { "type": "object", "properties": { @@ -12519,6 +12530,9 @@ const docTemplate = `{ "client_secret": { "type": "string" }, + "default_provider_enable": { + "type": "boolean" + }, "device_flow": { "type": "boolean" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2a40706151..ff714e416c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9189,7 +9189,7 @@ "type": "object", "properties": { "github": { - "$ref": "#/definitions/codersdk.AuthMethod" + "$ref": "#/definitions/codersdk.GithubAuthMethod" }, "oidc": { "$ref": "#/definitions/codersdk.OIDCAuthMethod" @@ -10642,6 +10642,17 @@ } } }, + "codersdk.GithubAuthMethod": { + "type": "object", + "properties": { + "default_provider_configured": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + } + }, "codersdk.Group": { "type": "object", "properties": { @@ -11255,6 +11266,9 @@ "client_secret": { "type": "string" }, + "default_provider_enable": { + "type": "boolean" + }, "device_flow": { "type": "boolean" }, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 689a6c9322..fdc9f6504d 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1845,6 +1845,13 @@ func (q *querier) GetNotificationsSettings(ctx context.Context) (string, error) return q.db.GetNotificationsSettings(ctx) } +func (q *querier) GetOAuth2GithubDefaultEligible(ctx context.Context) (bool, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return false, err + } + return q.db.GetOAuth2GithubDefaultEligible(ctx) +} + func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2App); err != nil { return database.OAuth2ProviderApp{}, err @@ -4435,6 +4442,13 @@ func (q *querier) UpsertNotificationsSettings(ctx context.Context, value string) return q.db.UpsertNotificationsSettings(ctx, value) } +func (q *querier) UpsertOAuth2GithubDefaultEligible(ctx context.Context, eligible bool) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertOAuth2GithubDefaultEligible(ctx, eligible) +} + func (q *querier) UpsertOAuthSigningKey(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index db4e687215..108a8166d1 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4405,6 +4405,12 @@ func (s *MethodTestSuite) TestSystemFunctions() { Value: "value", }).Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) + s.Run("GetOAuth2GithubDefaultEligible", s.Subtest(func(db database.Store, check *expects) { + check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Errors(sql.ErrNoRows) + })) + s.Run("UpsertOAuth2GithubDefaultEligible", s.Subtest(func(db database.Store, check *expects) { + check.Args(true).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) } func (s *MethodTestSuite) TestNotifications() { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 9488577edc..058aed6318 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -254,6 +254,7 @@ type data struct { announcementBanners []byte healthSettings []byte notificationsSettings []byte + oauth2GithubDefaultEligible *bool applicationName string logoURL string appSecurityKey string @@ -3515,6 +3516,16 @@ func (q *FakeQuerier) GetNotificationsSettings(_ context.Context) (string, error return string(q.notificationsSettings), nil } +func (q *FakeQuerier) GetOAuth2GithubDefaultEligible(_ context.Context) (bool, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if q.oauth2GithubDefaultEligible == nil { + return false, sql.ErrNoRows + } + return *q.oauth2GithubDefaultEligible, nil +} + func (q *FakeQuerier) GetOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -11154,6 +11165,14 @@ func (q *FakeQuerier) UpsertNotificationsSettings(_ context.Context, data string return nil } +func (q *FakeQuerier) UpsertOAuth2GithubDefaultEligible(_ context.Context, eligible bool) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + q.oauth2GithubDefaultEligible = &eligible + return nil +} + func (q *FakeQuerier) UpsertOAuthSigningKey(_ context.Context, value string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 90ea140d05..31fbcced1b 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -871,6 +871,13 @@ func (m queryMetricsStore) GetNotificationsSettings(ctx context.Context) (string return r0, r1 } +func (m queryMetricsStore) GetOAuth2GithubDefaultEligible(ctx context.Context) (bool, error) { + start := time.Now() + r0, r1 := m.s.GetOAuth2GithubDefaultEligible(ctx) + m.queryLatencies.WithLabelValues("GetOAuth2GithubDefaultEligible").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { start := time.Now() r0, r1 := m.s.GetOAuth2ProviderAppByID(ctx, id) @@ -2817,6 +2824,13 @@ func (m queryMetricsStore) UpsertNotificationsSettings(ctx context.Context, valu return r0 } +func (m queryMetricsStore) UpsertOAuth2GithubDefaultEligible(ctx context.Context, eligible bool) error { + start := time.Now() + r0 := m.s.UpsertOAuth2GithubDefaultEligible(ctx, eligible) + m.queryLatencies.WithLabelValues("UpsertOAuth2GithubDefaultEligible").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpsertOAuthSigningKey(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertOAuthSigningKey(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 38ee52aa76..f92bbf1324 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1762,6 +1762,21 @@ func (mr *MockStoreMockRecorder) GetNotificationsSettings(ctx any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationsSettings", reflect.TypeOf((*MockStore)(nil).GetNotificationsSettings), ctx) } +// GetOAuth2GithubDefaultEligible mocks base method. +func (m *MockStore) GetOAuth2GithubDefaultEligible(ctx context.Context) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOAuth2GithubDefaultEligible", ctx) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOAuth2GithubDefaultEligible indicates an expected call of GetOAuth2GithubDefaultEligible. +func (mr *MockStoreMockRecorder) GetOAuth2GithubDefaultEligible(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2GithubDefaultEligible", reflect.TypeOf((*MockStore)(nil).GetOAuth2GithubDefaultEligible), ctx) +} + // GetOAuth2ProviderAppByID mocks base method. func (m *MockStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { m.ctrl.T.Helper() @@ -5936,6 +5951,20 @@ func (mr *MockStoreMockRecorder) UpsertNotificationsSettings(ctx, value any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationsSettings", reflect.TypeOf((*MockStore)(nil).UpsertNotificationsSettings), ctx, value) } +// UpsertOAuth2GithubDefaultEligible mocks base method. +func (m *MockStore) UpsertOAuth2GithubDefaultEligible(ctx context.Context, eligible bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertOAuth2GithubDefaultEligible", ctx, eligible) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertOAuth2GithubDefaultEligible indicates an expected call of UpsertOAuth2GithubDefaultEligible. +func (mr *MockStoreMockRecorder) UpsertOAuth2GithubDefaultEligible(ctx, eligible any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertOAuth2GithubDefaultEligible", reflect.TypeOf((*MockStore)(nil).UpsertOAuth2GithubDefaultEligible), ctx, eligible) +} + // UpsertOAuthSigningKey mocks base method. func (m *MockStore) UpsertOAuthSigningKey(ctx context.Context, value string) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a5cedde6c4..527ee95581 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -185,6 +185,7 @@ type sqlcQuerier interface { GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (NotificationTemplate, error) GetNotificationTemplatesByKind(ctx context.Context, kind NotificationTemplateKind) ([]NotificationTemplate, error) GetNotificationsSettings(ctx context.Context) (string, error) + GetOAuth2GithubDefaultEligible(ctx context.Context) (bool, error) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error) @@ -553,6 +554,7 @@ type sqlcQuerier interface { // Insert or update notification report generator logs with recent activity. UpsertNotificationReportGeneratorLog(ctx context.Context, arg UpsertNotificationReportGeneratorLogParams) error UpsertNotificationsSettings(ctx context.Context, value string) error + UpsertOAuth2GithubDefaultEligible(ctx context.Context, eligible bool) error UpsertOAuthSigningKey(ctx context.Context, value string) error UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error) UpsertRuntimeConfig(ctx context.Context, arg UpsertRuntimeConfigParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ea4124d8fc..0e2bc0e37f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8100,6 +8100,23 @@ func (q *sqlQuerier) GetNotificationsSettings(ctx context.Context) (string, erro return notifications_settings, err } +const getOAuth2GithubDefaultEligible = `-- name: GetOAuth2GithubDefaultEligible :one +SELECT + CASE + WHEN value = 'true' THEN TRUE + ELSE FALSE + END +FROM site_configs +WHERE key = 'oauth2_github_default_eligible' +` + +func (q *sqlQuerier) GetOAuth2GithubDefaultEligible(ctx context.Context) (bool, error) { + row := q.db.QueryRowContext(ctx, getOAuth2GithubDefaultEligible) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + const getOAuthSigningKey = `-- name: GetOAuthSigningKey :one SELECT value FROM site_configs WHERE key = 'oauth_signing_key' ` @@ -8243,6 +8260,28 @@ func (q *sqlQuerier) UpsertNotificationsSettings(ctx context.Context, value stri return err } +const upsertOAuth2GithubDefaultEligible = `-- name: UpsertOAuth2GithubDefaultEligible :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'oauth2_github_default_eligible', + CASE + WHEN $1::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN $1::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'oauth2_github_default_eligible' +` + +func (q *sqlQuerier) UpsertOAuth2GithubDefaultEligible(ctx context.Context, eligible bool) error { + _, err := q.db.ExecContext(ctx, upsertOAuth2GithubDefaultEligible, eligible) + return err +} + const upsertOAuthSigningKey = `-- name: UpsertOAuthSigningKey :exec INSERT INTO site_configs (key, value) VALUES ('oauth_signing_key', $1) ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'oauth_signing_key' diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index e8d02372e5..ab9fda7969 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -107,3 +107,27 @@ ON CONFLICT (key) DO UPDATE SET value = $2 WHERE site_configs.key = $1; DELETE FROM site_configs WHERE site_configs.key = $1; +-- name: GetOAuth2GithubDefaultEligible :one +SELECT + CASE + WHEN value = 'true' THEN TRUE + ELSE FALSE + END +FROM site_configs +WHERE key = 'oauth2_github_default_eligible'; + +-- name: UpsertOAuth2GithubDefaultEligible :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'oauth2_github_default_eligible', + CASE + WHEN sqlc.arg(eligible)::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN sqlc.arg(eligible)::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'oauth2_github_default_eligible'; diff --git a/coderd/userauth.go b/coderd/userauth.go index 709d22389f..d8f52f79d2 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -765,6 +765,8 @@ type GithubOAuth2Config struct { AllowEveryone bool AllowOrganizations []string AllowTeams []GithubOAuth2Team + + DefaultProviderConfigured bool } func (c *GithubOAuth2Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { @@ -806,7 +808,10 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) { Password: codersdk.AuthMethod{ Enabled: !api.DeploymentValues.DisablePasswordAuth.Value(), }, - Github: codersdk.AuthMethod{Enabled: api.GithubOAuth2Config != nil}, + Github: codersdk.GithubAuthMethod{ + Enabled: api.GithubOAuth2Config != nil, + DefaultProviderConfigured: api.GithubOAuth2Config != nil && api.GithubOAuth2Config.DefaultProviderConfigured, + }, OIDC: codersdk.OIDCAuthMethod{ AuthMethod: codersdk.AuthMethod{Enabled: api.OIDCConfig != nil}, SignInText: signInText, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index b15dc94274..428ebac494 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -503,14 +503,15 @@ type OAuth2Config struct { } type OAuth2GithubConfig struct { - ClientID serpent.String `json:"client_id" typescript:",notnull"` - ClientSecret serpent.String `json:"client_secret" typescript:",notnull"` - DeviceFlow serpent.Bool `json:"device_flow" typescript:",notnull"` - AllowedOrgs serpent.StringArray `json:"allowed_orgs" typescript:",notnull"` - AllowedTeams serpent.StringArray `json:"allowed_teams" typescript:",notnull"` - AllowSignups serpent.Bool `json:"allow_signups" typescript:",notnull"` - AllowEveryone serpent.Bool `json:"allow_everyone" typescript:",notnull"` - EnterpriseBaseURL serpent.String `json:"enterprise_base_url" typescript:",notnull"` + ClientID serpent.String `json:"client_id" typescript:",notnull"` + ClientSecret serpent.String `json:"client_secret" typescript:",notnull"` + DeviceFlow serpent.Bool `json:"device_flow" typescript:",notnull"` + DefaultProviderEnable serpent.Bool `json:"default_provider_enable" typescript:",notnull"` + AllowedOrgs serpent.StringArray `json:"allowed_orgs" typescript:",notnull"` + AllowedTeams serpent.StringArray `json:"allowed_teams" typescript:",notnull"` + AllowSignups serpent.Bool `json:"allow_signups" typescript:",notnull"` + AllowEveryone serpent.Bool `json:"allow_everyone" typescript:",notnull"` + EnterpriseBaseURL serpent.String `json:"enterprise_base_url" typescript:",notnull"` } type OIDCConfig struct { @@ -1593,6 +1594,16 @@ func (c *DeploymentValues) Options() serpent.OptionSet { YAML: "deviceFlow", Default: "false", }, + { + Name: "OAuth2 GitHub Default Provider Enable", + Description: "Enable the default GitHub OAuth2 provider managed by Coder.", + Flag: "oauth2-github-default-provider-enable", + Env: "CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE", + Value: &c.OAuth2.Github.DefaultProviderEnable, + Group: &deploymentGroupOAuth2GitHub, + YAML: "defaultProviderEnable", + Default: "true", + }, { Name: "OAuth2 GitHub Allowed Orgs", Description: "Organizations the user must be a member of to Login with GitHub.", diff --git a/codersdk/users.go b/codersdk/users.go index 4dbdc0d4e4..7177a1bc3e 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -275,10 +275,10 @@ type OAuthConversionResponse struct { // AuthMethods contains authentication method information like whether they are enabled or not or custom text, etc. type AuthMethods struct { - TermsOfServiceURL string `json:"terms_of_service_url,omitempty"` - Password AuthMethod `json:"password"` - Github AuthMethod `json:"github"` - OIDC OIDCAuthMethod `json:"oidc"` + TermsOfServiceURL string `json:"terms_of_service_url,omitempty"` + Password AuthMethod `json:"password"` + Github GithubAuthMethod `json:"github"` + OIDC OIDCAuthMethod `json:"oidc"` } type AuthMethod struct { @@ -289,6 +289,11 @@ type UserLoginType struct { LoginType LoginType `json:"login_type"` } +type GithubAuthMethod struct { + Enabled bool `json:"enabled"` + DefaultProviderConfigured bool `json:"default_provider_configured"` +} + type OIDCAuthMethod struct { AuthMethod SignInText string `json:"signInText"` diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 7d85388e73..2b4a1e36c2 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -328,6 +328,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ ], "client_id": "string", "client_secret": "string", + "default_provider_enable": true, "device_flow": true, "enterprise_base_url": "string" } diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 753ee857c0..99f94e5399 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -787,6 +787,7 @@ ```json { "github": { + "default_provider_configured": true, "enabled": true }, "oidc": { @@ -803,12 +804,12 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|----------------------------------------------------|----------|--------------|-------------| -| `github` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | | -| `oidc` | [codersdk.OIDCAuthMethod](#codersdkoidcauthmethod) | false | | | -| `password` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | | -| `terms_of_service_url` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------|--------------------------------------------------------|----------|--------------|-------------| +| `github` | [codersdk.GithubAuthMethod](#codersdkgithubauthmethod) | false | | | +| `oidc` | [codersdk.OIDCAuthMethod](#codersdkoidcauthmethod) | false | | | +| `password` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | | +| `terms_of_service_url` | string | false | | | ## codersdk.AuthorizationCheck @@ -1977,6 +1978,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ], "client_id": "string", "client_secret": "string", + "default_provider_enable": true, "device_flow": true, "enterprise_base_url": "string" } @@ -2449,6 +2451,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ], "client_id": "string", "client_secret": "string", + "default_provider_enable": true, "device_flow": true, "enterprise_base_url": "string" } @@ -3101,6 +3104,22 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `updated_at` | string | false | | | | `user_id` | string | false | | | +## codersdk.GithubAuthMethod + +```json +{ + "default_provider_configured": true, + "enabled": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------------------|---------|----------|--------------|-------------| +| `default_provider_configured` | boolean | false | | | +| `enabled` | boolean | false | | | + ## codersdk.Group ```json @@ -3807,6 +3826,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ], "client_id": "string", "client_secret": "string", + "default_provider_enable": true, "device_flow": true, "enterprise_base_url": "string" } @@ -3833,6 +3853,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ], "client_id": "string", "client_secret": "string", + "default_provider_enable": true, "device_flow": true, "enterprise_base_url": "string" } @@ -3840,16 +3861,17 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ### Properties -| Name | Type | Required | Restrictions | Description | -|-----------------------|-----------------|----------|--------------|-------------| -| `allow_everyone` | boolean | false | | | -| `allow_signups` | boolean | false | | | -| `allowed_orgs` | array of string | false | | | -| `allowed_teams` | array of string | false | | | -| `client_id` | string | false | | | -| `client_secret` | string | false | | | -| `device_flow` | boolean | false | | | -| `enterprise_base_url` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------------------|-----------------|----------|--------------|-------------| +| `allow_everyone` | boolean | false | | | +| `allow_signups` | boolean | false | | | +| `allowed_orgs` | array of string | false | | | +| `allowed_teams` | array of string | false | | | +| `client_id` | string | false | | | +| `client_secret` | string | false | | | +| `default_provider_enable` | boolean | false | | | +| `device_flow` | boolean | false | | | +| `enterprise_base_url` | string | false | | | ## codersdk.OAuth2ProviderApp diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 4055a4170b..df0a8ca094 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -159,6 +159,7 @@ curl -X GET http://coder-server:8080/api/v2/users/authmethods \ ```json { "github": { + "default_provider_configured": true, "enabled": true }, "oidc": { diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 62af563f17..91d565952d 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -373,6 +373,17 @@ Client secret for Login with GitHub. Enable device flow for Login with GitHub. +### --oauth2-github-default-provider-enable + +| | | +|-------------|-----------------------------------------------------------| +| Type | bool | +| Environment | $CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE | +| YAML | oauth2.github.defaultProviderEnable | +| Default | true | + +Enable the default GitHub OAuth2 provider managed by Coder. + ### --oauth2-github-allowed-orgs | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index d0437fdff6..f0b3e4b0aa 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -499,6 +499,9 @@ OAUTH2 / GITHUB OPTIONS: --oauth2-github-client-secret string, $CODER_OAUTH2_GITHUB_CLIENT_SECRET Client secret for Login with GitHub. + --oauth2-github-default-provider-enable bool, $CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE (default: true) + Enable the default GitHub OAuth2 provider managed by Coder. + --oauth2-github-device-flow bool, $CODER_OAUTH2_GITHUB_DEVICE_FLOW (default: false) Enable device flow for Login with GitHub. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a00d3a20cf..fdda122540 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -200,7 +200,7 @@ export interface AuthMethod { export interface AuthMethods { readonly terms_of_service_url?: string; readonly password: AuthMethod; - readonly github: AuthMethod; + readonly github: GithubAuthMethod; readonly oidc: OIDCAuthMethod; } @@ -916,6 +916,12 @@ export interface GitSSHKey { readonly public_key: string; } +// From codersdk/users.go +export interface GithubAuthMethod { + readonly enabled: boolean; + readonly default_provider_configured: boolean; +} + // From codersdk/groups.go export interface Group { readonly id: string; @@ -1326,6 +1332,7 @@ export interface OAuth2GithubConfig { readonly client_id: string; readonly client_secret: string; readonly device_flow: boolean; + readonly default_provider_enable: boolean; readonly allowed_orgs: string; readonly allowed_teams: string; readonly allow_signups: boolean; diff --git a/site/src/pages/LoginPage/SignInForm.stories.tsx b/site/src/pages/LoginPage/SignInForm.stories.tsx index 8e02ccfb3c..125e912e08 100644 --- a/site/src/pages/LoginPage/SignInForm.stories.tsx +++ b/site/src/pages/LoginPage/SignInForm.stories.tsx @@ -20,7 +20,7 @@ export const SigningIn: Story = { isSigningIn: true, authMethods: { password: { enabled: true }, - github: { enabled: true }, + github: { enabled: true, default_provider_configured: false }, oidc: { enabled: false, signInText: "", iconUrl: "" }, }, }, @@ -44,7 +44,7 @@ export const WithGithub: Story = { args: { authMethods: { password: { enabled: true }, - github: { enabled: true }, + github: { enabled: true, default_provider_configured: false }, oidc: { enabled: false, signInText: "", iconUrl: "" }, }, }, @@ -54,7 +54,7 @@ export const WithOIDC: Story = { args: { authMethods: { password: { enabled: true }, - github: { enabled: false }, + github: { enabled: false, default_provider_configured: false }, oidc: { enabled: true, signInText: "", iconUrl: "" }, }, }, @@ -64,7 +64,7 @@ export const WithOIDCWithoutPassword: Story = { args: { authMethods: { password: { enabled: false }, - github: { enabled: false }, + github: { enabled: false, default_provider_configured: false }, oidc: { enabled: true, signInText: "", iconUrl: "" }, }, }, @@ -74,7 +74,7 @@ export const WithoutAny: Story = { args: { authMethods: { password: { enabled: false }, - github: { enabled: false }, + github: { enabled: false, default_provider_configured: false }, oidc: { enabled: false, signInText: "", iconUrl: "" }, }, }, @@ -84,7 +84,7 @@ export const WithGithubAndOIDC: Story = { args: { authMethods: { password: { enabled: true }, - github: { enabled: true }, + github: { enabled: true, default_provider_configured: false }, oidc: { enabled: true, signInText: "", iconUrl: "" }, }, }, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 74d4de9121..938537c08d 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1684,20 +1684,20 @@ export const MockUserAgent = { export const MockAuthMethodsPasswordOnly: TypesGen.AuthMethods = { password: { enabled: true }, - github: { enabled: false }, + github: { enabled: false, default_provider_configured: true }, oidc: { enabled: false, signInText: "", iconUrl: "" }, }; export const MockAuthMethodsPasswordTermsOfService: TypesGen.AuthMethods = { terms_of_service_url: "https://www.youtube.com/watch?v=C2f37Vb2NAE", password: { enabled: true }, - github: { enabled: false }, + github: { enabled: false, default_provider_configured: true }, oidc: { enabled: false, signInText: "", iconUrl: "" }, }; export const MockAuthMethodsExternal: TypesGen.AuthMethods = { password: { enabled: false }, - github: { enabled: true }, + github: { enabled: true, default_provider_configured: true }, oidc: { enabled: true, signInText: "Google", @@ -1707,7 +1707,7 @@ export const MockAuthMethodsExternal: TypesGen.AuthMethods = { export const MockAuthMethodsAll: TypesGen.AuthMethods = { password: { enabled: true }, - github: { enabled: true }, + github: { enabled: true, default_provider_configured: true }, oidc: { enabled: true, signInText: "Google", From 6acc3a9469685ef9e42cd469a2f17e92b86205af Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 25 Feb 2025 16:32:20 +0100 Subject: [PATCH 16/29] docs: update the quickstart page (#16666) ## Changes 1. Update the `0.0.0.0:3001` web UI address to `localhost:3000`. Coder starts on port 3000 by default. It'd use 3001 only if 3000 was already taken. 2. Update the screenshot of the `/setup` page to reflect how it will look like after merging https://github.com/coder/coder/pull/16662. Note: this PR should be merged only after the other one is. 3. Minor phrasing changes. --------- Co-authored-by: EdwardAngert <17991901+EdwardAngert@users.noreply.github.com> --- .../screenshots/welcome-create-admin-user.png | Bin 47808 -> 73362 bytes docs/tutorials/quickstart.md | 18 +++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/images/screenshots/welcome-create-admin-user.png b/docs/images/screenshots/welcome-create-admin-user.png index 2d4c0b9bb783501a13793a6e65d264c2591f597b..de78b48c7ea2641dc0b716516fc6c96afcd2fbba 100644 GIT binary patch literal 73362 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%0DSQpxv5~>Z7 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 E0G#;N>i_@% literal 47808 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 diff --git a/docs/tutorials/quickstart.md b/docs/tutorials/quickstart.md index 4f66165fd7..feff297107 100644 --- a/docs/tutorials/quickstart.md +++ b/docs/tutorials/quickstart.md @@ -82,18 +82,22 @@ persistent environment from your main device, a tablet, or your phone. ## Configure Coder with a new Workspace -1. If you're running Coder locally, go to . +1. Coder will attempt to open the setup page in your browser. If it doesn't open + automatically, go to . - If you get a browser warning similar to `Secure Site Not Available`, you can ignore the warning and continue to the setup page. - If your Coder server is on a network or cloud device, locate the message in - your terminal that reads, - `View the Web UI: https://..try.coder.app`. The server - begins to stream logs immediately and you might have to scroll up to find it. + If your Coder server is on a network or cloud device, or you are having + trouble viewing the page, locate the web UI URL in Coder logs in your + terminal. It looks like `https://..try.coder.app`. + It's one of the first lines of output, so you might have to scroll up to find + it. -1. On the **Welcome to Coder** page, enter the information to create an admin - user, then select **Create account**. +1. On the **Welcome to Coder** page, to use your GitHub account to log in, + select **Continue with GitHub**. + You can also enter an email and password to create a new admin account on + the Coder deployment: ![Welcome to Coder - Create admin user](../images/screenshots/welcome-create-admin-user.png)_Welcome to Coder - Create admin user_ From 763921bc616490d628b340f44ebe95bcc909c3c6 Mon Sep 17 00:00:00 2001 From: M Atif Ali Date: Tue, 25 Feb 2025 21:08:55 +0500 Subject: [PATCH 17/29] feat: extend OverrideVSCodeConfigs for additional VS Code IDEs (#16654) --- cli/gitauth/vscode.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cli/gitauth/vscode.go b/cli/gitauth/vscode.go index ce3c64081b..fbd2265192 100644 --- a/cli/gitauth/vscode.go +++ b/cli/gitauth/vscode.go @@ -32,6 +32,14 @@ func OverrideVSCodeConfigs(fs afero.Fs) error { filepath.Join(xdg.DataHome, "code-server", "Machine", "settings.json"), // vscode-remote's default configuration path. filepath.Join(home, ".vscode-server", "data", "Machine", "settings.json"), + // vscode-insiders' default configuration path. + filepath.Join(home, ".vscode-insiders-server", "data", "Machine", "settings.json"), + // cursor default configuration path. + filepath.Join(home, ".cursor-server", "data", "Machine", "settings.json"), + // windsurf default configuration path. + filepath.Join(home, ".windsurf-server", "data", "Machine", "settings.json"), + // vscodium default configuration path. + filepath.Join(home, ".vscodium-server", "data", "Machine", "settings.json"), } { _, err := fs.Stat(configPath) if err != nil { From 98dfc70f31372d4c63b61b38cbb60a5e590c527f Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Tue, 25 Feb 2025 11:39:37 -0500 Subject: [PATCH 18/29] fix(coderd/database): remove linux build tags from db package (#16633) Remove linux build tags from database package to make sure we can run tests on Mac OS. --- coderd/database/db_test.go | 2 -- coderd/database/dbtestutil/postgres_test.go | 11 +++++++++-- coderd/database/migrations/migrate_test.go | 2 -- coderd/database/pubsub/pubsub_linux_test.go | 2 -- coderd/database/querier_test.go | 2 -- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/coderd/database/db_test.go b/coderd/database/db_test.go index b4580527c8..68b60a788f 100644 --- a/coderd/database/db_test.go +++ b/coderd/database/db_test.go @@ -1,5 +1,3 @@ -//go:build linux - package database_test import ( diff --git a/coderd/database/dbtestutil/postgres_test.go b/coderd/database/dbtestutil/postgres_test.go index d4aaacdf90..f1b9336d57 100644 --- a/coderd/database/dbtestutil/postgres_test.go +++ b/coderd/database/dbtestutil/postgres_test.go @@ -1,5 +1,3 @@ -//go:build linux - package dbtestutil_test import ( @@ -21,6 +19,9 @@ func TestMain(m *testing.M) { func TestOpen(t *testing.T) { t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } connect, err := dbtestutil.Open(t) require.NoError(t, err) @@ -35,6 +36,9 @@ func TestOpen(t *testing.T) { func TestOpen_InvalidDBFrom(t *testing.T) { t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } _, err := dbtestutil.Open(t, dbtestutil.WithDBFrom("__invalid__")) require.Error(t, err) @@ -44,6 +48,9 @@ func TestOpen_InvalidDBFrom(t *testing.T) { func TestOpen_ValidDBFrom(t *testing.T) { t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } // first check if we can create a new template db dsn, err := dbtestutil.Open(t, dbtestutil.WithDBFrom("")) diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 716ebe398b..bd347af0be 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1,5 +1,3 @@ -//go:build linux - package migrations_test import ( diff --git a/coderd/database/pubsub/pubsub_linux_test.go b/coderd/database/pubsub/pubsub_linux_test.go index fe7933c62c..05bd76232e 100644 --- a/coderd/database/pubsub/pubsub_linux_test.go +++ b/coderd/database/pubsub/pubsub_linux_test.go @@ -1,5 +1,3 @@ -//go:build linux - package pubsub_test import ( diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index b60554de75..5d3e65bb51 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -1,5 +1,3 @@ -//go:build linux - package database_test import ( From 33c9aa0703292985b53055f82303f7018d89c177 Mon Sep 17 00:00:00 2001 From: brettkolodny Date: Tue, 25 Feb 2025 12:16:02 -0500 Subject: [PATCH 19/29] fix: require permissions to view pages related to organization roles (#16688) Closes [this issue](https://github.com/coder/internal/issues/393) This PR adds the`` component to the following routes: - _/organizations/\/roles_ - _/organizations/\/roles/create_ --- .../CustomRolesPage/CreateEditRolePage.tsx | 10 ++++++++-- .../CustomRolesPage/CustomRolesPage.tsx | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index b9adbb44fe..43ae735980 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -8,6 +8,7 @@ import type { CustomRoleRequest } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { displayError } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; +import { RequirePermission } from "contexts/auth/RequirePermission"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import type { FC } from "react"; import { Helmet } from "react-helmet-async"; @@ -45,7 +46,12 @@ export const CreateEditRolePage: FC = () => { } return ( - <> + {pageTitle( @@ -83,7 +89,7 @@ export const CreateEditRolePage: FC = () => { organizationName={organizationName} canAssignOrgRole={organizationPermissions.assignOrgRoles} /> - </> + </RequirePermission> ); }; diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 362448368d..4eee74c6a5 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -6,6 +6,7 @@ import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { SettingsHeader } from "components/SettingsHeader/SettingsHeader"; import { Stack } from "components/Stack/Stack"; +import { RequirePermission } from "contexts/auth/RequirePermission"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import { type FC, useEffect, useState } from "react"; @@ -53,7 +54,12 @@ export const CustomRolesPage: FC = () => { } return ( - <> + <RequirePermission + isFeatureVisible={ + organizationPermissions.assignOrgRoles || + organizationPermissions.createOrgRoles + } + > <Helmet> <title>{pageTitle("Custom Roles")} @@ -100,7 +106,7 @@ export const CustomRolesPage: FC = () => { } }} /> - + ); }; From 64984648d362cac14f5e34f6a517bf02ad25b187 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 25 Feb 2025 14:21:38 -0300 Subject: [PATCH 20/29] refactor: rollback provisioners page to its previous version (#16699) There is still some points to be aligned related to provisioners. I'm going to rollback the latest changes until we are more confident on the design changes so we don't block releases. Screenshot 2025-02-25 at 13 46 35 --- .../OrganizationProvisionersPage.tsx | 48 ++++++ ...ganizationProvisionersPageView.stories.tsx | 142 +++++++++++++++++ .../OrganizationProvisionersPageView.tsx | 148 ++++++++++++++++++ site/src/router.tsx | 5 +- 4 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx create mode 100644 site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.stories.tsx create mode 100644 site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.tsx diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx new file mode 100644 index 0000000000..5a4965c039 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx @@ -0,0 +1,48 @@ +import { buildInfo } from "api/queries/buildInfo"; +import { provisionerDaemonGroups } from "api/queries/organizations"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useQuery } from "react-query"; +import { useParams } from "react-router-dom"; +import { pageTitle } from "utils/page"; +import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView"; + +const OrganizationProvisionersPage: FC = () => { + const { organization: organizationName } = useParams() as { + organization: string; + }; + const { organization } = useOrganizationSettings(); + const { entitlements } = useDashboard(); + const { metadata } = useEmbeddedMetadata(); + const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); + const provisionersQuery = useQuery(provisionerDaemonGroups(organizationName)); + + if (!organization) { + return ; + } + + return ( + <> + + + {pageTitle( + "Provisioners", + organization.display_name || organization.name, + )} + + + + + ); +}; + +export default OrganizationProvisionersPage; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.stories.tsx new file mode 100644 index 0000000000..5bbf6cfe81 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.stories.tsx @@ -0,0 +1,142 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { screen, userEvent } from "@storybook/test"; +import { + MockBuildInfo, + MockProvisioner, + MockProvisioner2, + MockProvisionerBuiltinKey, + MockProvisionerKey, + MockProvisionerPskKey, + MockProvisionerUserAuthKey, + MockProvisionerWithTags, + MockUserProvisioner, + mockApiError, +} from "testHelpers/entities"; +import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView"; + +const meta: Meta = { + title: "pages/OrganizationProvisionersPage", + component: OrganizationProvisionersPageView, + args: { + buildInfo: MockBuildInfo, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Provisioners: Story = { + args: { + provisioners: [ + { + key: MockProvisionerBuiltinKey, + daemons: [MockProvisioner, MockProvisioner2], + }, + { + key: MockProvisionerPskKey, + daemons: [ + MockProvisioner, + MockUserProvisioner, + MockProvisionerWithTags, + ], + }, + { + key: MockProvisionerPskKey, + daemons: [MockProvisioner, MockProvisioner2], + }, + { + key: { ...MockProvisionerKey, id: "ジェイデン", name: "ジェイデン" }, + daemons: [ + MockProvisioner, + { ...MockProvisioner2, tags: { scope: "organization", owner: "" } }, + ], + }, + { + key: { ...MockProvisionerKey, id: "ベン", name: "ベン" }, + daemons: [ + MockProvisioner, + { + ...MockProvisioner2, + version: "2.0.0", + api_version: "1.0", + }, + ], + }, + { + key: { + ...MockProvisionerKey, + id: "ケイラ", + name: "ケイラ", + tags: { + ...MockProvisioner.tags, + 都市: "ユタ", + きっぷ: "yes", + ちいさい: "no", + }, + }, + daemons: Array.from({ length: 117 }, (_, i) => ({ + ...MockProvisioner, + id: `ケイラ-${i}`, + name: `ケイラ-${i}`, + })), + }, + { + key: MockProvisionerUserAuthKey, + daemons: [ + MockUserProvisioner, + { + ...MockUserProvisioner, + id: "mock-user-provisioner-2", + name: "Test User Provisioner 2", + }, + ], + }, + ], + }, + play: async ({ step }) => { + await step("open all details", async () => { + const expandButtons = await screen.findAllByRole("button", { + name: "Show provisioner details", + }); + for (const it of expandButtons) { + await userEvent.click(it); + } + }); + + await step("close uninteresting/large details", async () => { + const collapseButtons = await screen.findAllByRole("button", { + name: "Hide provisioner details", + }); + + await userEvent.click(collapseButtons[2]); + await userEvent.click(collapseButtons[3]); + await userEvent.click(collapseButtons[5]); + }); + + await step("show version popover", async () => { + const outOfDate = await screen.findByText("Out of date"); + await userEvent.hover(outOfDate); + }); + }, +}; + +export const Empty: Story = { + args: { + provisioners: [], + }, +}; + +export const WithError: Story = { + args: { + error: mockApiError({ + message: "Fern is mad", + detail: "Frieren slept in and didn't get groceries", + }), + }, +}; + +export const Paywall: Story = { + args: { + showPaywall: true, + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.tsx new file mode 100644 index 0000000000..649a75836b --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.tsx @@ -0,0 +1,148 @@ +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import Button from "@mui/material/Button"; +import type { + BuildInfoResponse, + ProvisionerKey, + ProvisionerKeyDaemons, +} from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { Loader } from "components/Loader/Loader"; +import { Paywall } from "components/Paywall/Paywall"; +import { SettingsHeader } from "components/SettingsHeader/SettingsHeader"; +import { Stack } from "components/Stack/Stack"; +import { ProvisionerGroup } from "modules/provisioners/ProvisionerGroup"; +import type { FC } from "react"; +import { docs } from "utils/docs"; + +interface OrganizationProvisionersPageViewProps { + /** Determines if the paywall will be shown or not */ + showPaywall?: boolean; + + /** An error to display instead of the page content */ + error?: unknown; + + /** Info about the version of coderd */ + buildInfo?: BuildInfoResponse; + + /** Groups of provisioners, along with their key information */ + provisioners?: readonly ProvisionerKeyDaemons[]; +} + +export const OrganizationProvisionersPageView: FC< + OrganizationProvisionersPageViewProps +> = ({ showPaywall, error, buildInfo, provisioners }) => { + return ( +
+ + + {!showPaywall && ( + + )} + + {showPaywall ? ( + + ) : error ? ( + + ) : !buildInfo || !provisioners ? ( + + ) : ( + + )} +
+ ); +}; + +type ViewContentProps = Required< + Pick +>; + +const ViewContent: FC = ({ buildInfo, provisioners }) => { + const isEmpty = provisioners.every((group) => group.daemons.length === 0); + + const provisionerGroupsCount = provisioners.length; + const provisionersCount = provisioners.reduce( + (a, group) => a + group.daemons.length, + 0, + ); + + return ( + <> + {isEmpty ? ( + } + target="_blank" + href={docs("/admin/provisioners")} + > + Create a provisioner + + } + /> + ) : ( +
({ + margin: 0, + fontSize: 12, + paddingBottom: 18, + color: theme.palette.text.secondary, + })} + > + Showing {provisionerGroupsCount} groups and {provisionersCount}{" "} + provisioners +
+ )} + + {provisioners.map((group) => ( + + ))} + + + ); +}; + +// Ideally these would be generated and appear in typesGenerated.ts, but that is +// not currently the case. In the meantime, these are taken from verbatim from +// the corresponding codersdk declarations. The names remain unchanged to keep +// usage of these special values "grep-able". +// https://github.com/coder/coder/blob/7c77a3cc832fb35d9da4ca27df163c740f786137/codersdk/provisionerdaemons.go#L291-L295 +const ProvisionerKeyIDBuiltIn = "00000000-0000-0000-0000-000000000001"; +const ProvisionerKeyIDUserAuth = "00000000-0000-0000-0000-000000000002"; +const ProvisionerKeyIDPSK = "00000000-0000-0000-0000-000000000003"; + +function getGroupType(key: ProvisionerKey) { + switch (key.id) { + case ProvisionerKeyIDBuiltIn: + return "builtin"; + case ProvisionerKeyIDUserAuth: + return "userAuth"; + case ProvisionerKeyIDPSK: + return "psk"; + default: + return "key"; + } +} diff --git a/site/src/router.tsx b/site/src/router.tsx index 8490c966c8..66d37f92ae 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -267,10 +267,7 @@ const CreateEditRolePage = lazy( ), ); const ProvisionersPage = lazy( - () => - import( - "./pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage" - ), + () => import("./pages/OrganizationSettingsPage/OrganizationProvisionersPage"), ); const TemplateEmbedPage = lazy( () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"), From 38ad8d1f3a18f211037caab048a651140f4a6a55 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 25 Feb 2025 14:27:51 -0300 Subject: [PATCH 21/29] feat: add provisioner tags field on template creation (#16656) Close https://github.com/coder/coder/issues/15426 Demo: https://github.com/user-attachments/assets/a7901908-8714-4a55-8d4f-c27bf7743111 --- site/src/components/Input/Input.tsx | 2 +- .../modules/provisioners/ProvisionerAlert.tsx | 4 +- .../modules/provisioners/ProvisionerTag.tsx | 4 +- .../ProvisionerTagsField.stories.tsx | 108 ++++++++++++ .../provisioners/ProvisionerTagsField.tsx | 164 ++++++++++++++++++ .../CreateTemplatePage/CreateTemplateForm.tsx | 35 +++- .../DuplicateTemplateView.tsx | 1 + .../ImportStarterTemplateView.tsx | 2 +- .../CreateTemplatePage/UploadTemplateView.tsx | 2 +- site/src/pages/CreateTemplatePage/utils.ts | 6 +- .../ProvisionerTagsPopover.stories.tsx | 56 +++++- .../ProvisionerTagsPopover.test.tsx | 119 ------------- .../ProvisionerTagsPopover.tsx | 140 ++++----------- .../TemplateVersionEditor.tsx | 12 +- 14 files changed, 394 insertions(+), 261 deletions(-) create mode 100644 site/src/modules/provisioners/ProvisionerTagsField.stories.tsx create mode 100644 site/src/modules/provisioners/ProvisionerTagsField.tsx delete mode 100644 site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.test.tsx diff --git a/site/src/components/Input/Input.tsx b/site/src/components/Input/Input.tsx index b50d6415a8..9f3896a1f4 100644 --- a/site/src/components/Input/Input.tsx +++ b/site/src/components/Input/Input.tsx @@ -18,7 +18,7 @@ export const Input = forwardRef< file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-content-primary placeholder:text-content-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link - disabled:cursor-not-allowed disabled:opacity-50 md:text-sm`, + disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-inherit`, className, )} ref={ref} diff --git a/site/src/modules/provisioners/ProvisionerAlert.tsx b/site/src/modules/provisioners/ProvisionerAlert.tsx index 86d69796cd..95c4417ba6 100644 --- a/site/src/modules/provisioners/ProvisionerAlert.tsx +++ b/site/src/modules/provisioners/ProvisionerAlert.tsx @@ -52,13 +52,13 @@ export const ProvisionerAlert: FC = ({ {title}
{detail}
- +
{Object.entries(tags ?? {}) .filter(([key]) => key !== "owner") .map(([key, value]) => ( ))} - +
); diff --git a/site/src/modules/provisioners/ProvisionerTag.tsx b/site/src/modules/provisioners/ProvisionerTag.tsx index e174e4222b..f120286b1e 100644 --- a/site/src/modules/provisioners/ProvisionerTag.tsx +++ b/site/src/modules/provisioners/ProvisionerTag.tsx @@ -45,7 +45,6 @@ export const ProvisionerTag: FC = ({ <> {kv} { @@ -53,6 +52,7 @@ export const ProvisionerTag: FC = ({ }} > + Delete {tagName} ) : ( @@ -62,7 +62,7 @@ export const ProvisionerTag: FC = ({ return {content}; } return ( - }> + } data-testid={`tag-${tagName}`}> {content} ); diff --git a/site/src/modules/provisioners/ProvisionerTagsField.stories.tsx b/site/src/modules/provisioners/ProvisionerTagsField.stories.tsx new file mode 100644 index 0000000000..168fb72c21 --- /dev/null +++ b/site/src/modules/provisioners/ProvisionerTagsField.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, within } from "@storybook/test"; +import type { ProvisionerDaemon } from "api/typesGenerated"; +import { type FC, useState } from "react"; +import { ProvisionerTagsField } from "./ProvisionerTagsField"; + +const meta: Meta = { + title: "modules/provisioners/ProvisionerTagsField", + component: ProvisionerTagsField, + args: { + value: {}, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + value: {}, + }, +}; + +export const WithInitialValue: Story = { + args: { + value: { + cluster: "dogfood-2", + env: "gke", + scope: "organization", + }, + }, +}; + +type StatefulProvisionerTagsFieldProps = { + initialValue?: ProvisionerDaemon["tags"]; +}; + +const StatefulProvisionerTagsField: FC = ({ + initialValue = {}, +}) => { + const [value, setValue] = useState(initialValue); + return ; +}; + +export const OnOverwriteOwner: Story = { + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const keyInput = canvas.getByLabelText("Tag key"); + const valueInput = canvas.getByLabelText("Tag value"); + const addButton = canvas.getByRole("button", { name: "Add tag" }); + + await user.type(keyInput, "owner"); + await user.type(valueInput, "dogfood-2"); + await user.click(addButton); + + await canvas.findByText("Cannot override owner tag"); + }, +}; + +export const OnInvalidScope: Story = { + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const keyInput = canvas.getByLabelText("Tag key"); + const valueInput = canvas.getByLabelText("Tag value"); + const addButton = canvas.getByRole("button", { name: "Add tag" }); + + await user.type(keyInput, "scope"); + await user.type(valueInput, "invalid"); + await user.click(addButton); + + await canvas.findByText("Scope value must be 'organization' or 'user'"); + }, +}; + +export const OnAddTag: Story = { + render: () => , + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const keyInput = canvas.getByLabelText("Tag key"); + const valueInput = canvas.getByLabelText("Tag value"); + const addButton = canvas.getByRole("button", { name: "Add tag" }); + + await user.type(keyInput, "cluster"); + await user.type(valueInput, "dogfood-2"); + await user.click(addButton); + + const addedTag = await canvas.findByTestId("tag-cluster"); + await expect(addedTag).toHaveTextContent("cluster dogfood-2"); + }, +}; + +export const OnRemoveTag: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const removeButton = canvas.getByRole("button", { name: "Delete cluster" }); + + await user.click(removeButton); + + await expect(canvas.queryByTestId("tag-cluster")).toBeNull(); + }, +}; diff --git a/site/src/modules/provisioners/ProvisionerTagsField.tsx b/site/src/modules/provisioners/ProvisionerTagsField.tsx new file mode 100644 index 0000000000..26ef7f2ebe --- /dev/null +++ b/site/src/modules/provisioners/ProvisionerTagsField.tsx @@ -0,0 +1,164 @@ +import TextField from "@mui/material/TextField"; +import type { ProvisionerDaemon } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { Input } from "components/Input/Input"; +import { PlusIcon } from "lucide-react"; +import { ProvisionerTag } from "modules/provisioners/ProvisionerTag"; +import { type FC, useRef, useState } from "react"; +import * as Yup from "yup"; + +// Users can't delete these tags +const REQUIRED_TAGS = ["scope", "organization", "user"]; + +// Users can't override these tags +const IMMUTABLE_TAGS = ["owner"]; + +type ProvisionerTagsFieldProps = { + value: ProvisionerDaemon["tags"]; + onChange: (value: ProvisionerDaemon["tags"]) => void; +}; + +export const ProvisionerTagsField: FC = ({ + value: fieldValue, + onChange, +}) => { + return ( +
+
+ {Object.entries(fieldValue) + // Filter out since users cannot override it + .filter(([key]) => !IMMUTABLE_TAGS.includes(key)) + .map(([key, value]) => { + const onDelete = (key: string) => { + const { [key]: _, ...newFieldValue } = fieldValue; + onChange(newFieldValue); + }; + + return ( + + ); + })} +
+ + { + onChange({ ...fieldValue, [tag.key]: tag.value }); + }} + /> +
+ ); +}; + +const newTagSchema = Yup.object({ + key: Yup.string() + .required("Key is required") + .notOneOf(["owner"], "Cannot override owner tag"), + value: Yup.string() + .required("Value is required") + .when("key", ([key], schema) => { + if (key === "scope") { + return schema.oneOf( + ["organization", "scope"], + "Scope value must be 'organization' or 'user'", + ); + } + + return schema; + }), +}); + +type Tag = { key: string; value: string }; + +type NewTagControlProps = { + onAdd: (tag: Tag) => void; +}; + +const NewTagControl: FC = ({ onAdd }) => { + const keyInputRef = useRef(null); + const [error, setError] = useState(); + const [newTag, setNewTag] = useState({ + key: "", + value: "", + }); + + const addNewTag = async () => { + try { + await newTagSchema.validate(newTag); + onAdd(newTag); + setNewTag({ key: "", value: "" }); + keyInputRef.current?.focus(); + } catch (e) { + const isValidationError = e instanceof Yup.ValidationError; + + if (!isValidationError) { + throw e; + } + + if (e instanceof Yup.ValidationError) { + setError(e.errors[0]); + } + } + }; + + const addNewTagOnEnter = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + addNewTag(); + } + }; + + return ( +
+
+ + setNewTag({ ...newTag, key: e.target.value.trim() })} + onKeyDown={addNewTagOnEnter} + /> + + + + setNewTag({ ...newTag, value: e.target.value.trim() }) + } + onKeyDown={addNewTagOnEnter} + /> + + +
+ {error && ( + {error} + )} +
+ ); +}; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 617b7052a2..f5417872b2 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -2,6 +2,7 @@ import Link from "@mui/material/Link"; import TextField from "@mui/material/TextField"; import { provisionerDaemons } from "api/queries/organizations"; import type { + CreateTemplateVersionRequest, Organization, ProvisionerJobLog, ProvisionerType, @@ -24,6 +25,7 @@ import { Spinner } from "components/Spinner/Spinner"; import { useFormik } from "formik"; import camelCase from "lodash/camelCase"; import capitalize from "lodash/capitalize"; +import { ProvisionerTagsField } from "modules/provisioners/ProvisionerTagsField"; import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate"; import { type FC, useState } from "react"; import { useQuery } from "react-query"; @@ -63,6 +65,7 @@ export interface CreateTemplateFormData { allow_everyone_group_access: boolean; provisioner_type: ProvisionerType; organization: string; + tags: CreateTemplateVersionRequest["tags"]; } const validationSchema = Yup.object({ @@ -96,6 +99,7 @@ const defaultInitialValues: CreateTemplateFormData = { allow_everyone_group_access: true, provisioner_type: "terraform", organization: "default", + tags: {}, }; type GetInitialValuesParams = { @@ -217,12 +221,11 @@ export const CreateTemplateForm: FC = (props) => { }); const getFieldHelpers = getFormHelpers(form, error); - const provisionerDaemonsQuery = useQuery( + const { data: provisioners } = useQuery( selectedOrg ? { ...provisionerDaemons(selectedOrg.id), enabled: showOrganizationPicker, - select: (provisioners) => provisioners.length < 1, } : { enabled: false }, ); @@ -233,7 +236,7 @@ export const CreateTemplateForm: FC = (props) => { // form submission**!! A user could easily see this warning, connect a // provisioner, and then not refresh the page. Even if they submit without // a provisioner, it'll just sit in the job queue until they connect one. - const showProvisionerWarning = provisionerDaemonsQuery.data; + const showProvisionerWarning = provisioners ? provisioners.length < 1 : false; return ( @@ -326,6 +329,32 @@ export const CreateTemplateForm: FC = (props) => { + {provisioners && provisioners.length > 0 && ( + + Tags are a way to control which provisioner daemons complete which + build jobs.  + + Learn more... + + + } + > + + form.setFieldValue("tags", tags)} + /> + + + )} + {/* Variables */} {variables && variables.length > 0 && ( = ({ templateVersionQuery.data!.job.file_id, formData.user_variable_values, formData.provisioner_type, + formData.tags, ), template: newTemplate(formData), }); diff --git a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx index e1dcdbcf98..dc611076e4 100644 --- a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx @@ -7,7 +7,6 @@ import { import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; import { useDashboard } from "modules/dashboard/useDashboard"; -import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import type { FC } from "react"; import { useQuery } from "react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; @@ -79,6 +78,7 @@ export const ImportStarterTemplateView: FC = ({ version: firstVersionFromExample( templateExample!, formData.user_variable_values, + formData.tags, ), template: newTemplate(formData), }); diff --git a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx index 8294bfc44e..fea9c0d934 100644 --- a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx @@ -7,7 +7,6 @@ import { } from "api/queries/templates"; import { displayError } from "components/GlobalSnackbar/utils"; import { useDashboard } from "modules/dashboard/useDashboard"; -import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import type { FC } from "react"; import { useMutation, useQuery } from "react-query"; import { useNavigate } from "react-router-dom"; @@ -73,6 +72,7 @@ export const UploadTemplateView: FC = ({ uploadedFile!.hash, formData.user_variable_values, formData.provisioner_type, + formData.tags, ), template: newTemplate(formData), }); diff --git a/site/src/pages/CreateTemplatePage/utils.ts b/site/src/pages/CreateTemplatePage/utils.ts index 48e45fbdaa..a10c52a70c 100644 --- a/site/src/pages/CreateTemplatePage/utils.ts +++ b/site/src/pages/CreateTemplatePage/utils.ts @@ -58,19 +58,21 @@ export const firstVersionFromFile = ( fileId: string, variables: VariableValue[] | undefined, provisionerType: ProvisionerType, + tags: CreateTemplateVersionRequest["tags"], ): CreateTemplateVersionRequest => { return { storage_method: "file" as const, provisioner: provisionerType, user_variable_values: variables, file_id: fileId, - tags: {}, + tags, }; }; export const firstVersionFromExample = ( example: TemplateExample, variables: VariableValue[] | undefined, + tags: CreateTemplateVersionRequest["tags"], ): CreateTemplateVersionRequest => { return { storage_method: "file" as const, @@ -78,6 +80,6 @@ export const firstVersionFromExample = ( provisioner: "terraform", user_variable_values: variables, example_id: example.id, - tags: {}, + tags, }; }; diff --git a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.stories.tsx b/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.stories.tsx index 5ee83a6938..4d9517f42d 100644 --- a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.stories.tsx +++ b/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { userEvent, within } from "@storybook/test"; +import { expect, fn, userEvent, within } from "@storybook/test"; +import { useState } from "react"; import { chromatic } from "testHelpers/chromatic"; import { MockTemplateVersion } from "testHelpers/entities"; import { ProvisionerTagsPopover } from "./ProvisionerTagsPopover"; @@ -19,14 +20,53 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const Example: Story = { - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); +export const Closed: Story = {}; - await step("Open popover", async () => { - await userEvent.click(canvas.getByRole("button")); - }); +export const Open: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); }, }; -export { Example as ProvisionerTagsPopover }; +export const OnTagsChange: Story = { + parameters: { + chromatic: { disableSnapshot: true }, + }, + args: { + tags: {}, + }, + render: (args) => { + const [tags, setTags] = useState(args.tags); + return ; + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + + const expandButton = canvas.getByRole("button", { + name: "Expand provisioner tags", + }); + await userEvent.click(expandButton); + + const keyInput = await canvas.findByLabelText("Tag key"); + const valueInput = await canvas.findByLabelText("Tag value"); + const addButton = await canvas.findByRole("button", { + name: "Add tag", + hidden: true, + }); + + await user.type(keyInput, "cluster"); + await user.type(valueInput, "dogfood-2"); + await user.click(addButton); + const addedTag = await canvas.findByTestId("tag-cluster"); + await expect(addedTag).toHaveTextContent("cluster dogfood-2"); + + const removeButton = canvas.getByRole("button", { + name: "Delete cluster", + hidden: true, + }); + await user.click(removeButton); + await expect(canvas.queryByTestId("tag-cluster")).toBeNull(); + }, +}; diff --git a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.test.tsx b/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.test.tsx deleted file mode 100644 index 71e372b32f..0000000000 --- a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { fireEvent, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MockTemplateVersion } from "testHelpers/entities"; -import { renderComponent } from "testHelpers/renderHelpers"; -import { ProvisionerTagsPopover } from "./ProvisionerTagsPopover"; - -let tags = MockTemplateVersion.job.tags; - -describe("ProvisionerTagsPopover", () => { - describe("click the button", () => { - it("can add a tag", async () => { - const onSubmit = jest.fn().mockImplementation(({ key, value }) => { - tags = { ...tags, [key]: value }; - }); - const onDelete = jest.fn().mockImplementation((key) => { - const newTags = { ...tags }; - delete newTags[key]; - tags = newTags; - }); - const { rerender } = renderComponent( - , - ); - - // Open Popover - const btn = await screen.findByRole("button"); - expect(btn).toBeEnabled(); - await userEvent.click(btn); - - // Check for existing tags - const el = await screen.findByText(/scope/i); - expect(el).toBeInTheDocument(); - - // Add key and value - const el2 = await screen.findByLabelText("Key"); - expect(el2).toBeEnabled(); - fireEvent.change(el2, { target: { value: "foo" } }); - expect(el2).toHaveValue("foo"); - const el3 = await screen.findByLabelText("Value"); - expect(el3).toBeEnabled(); - fireEvent.change(el3, { target: { value: "bar" } }); - expect(el3).toHaveValue("bar"); - - // Submit - const btn2 = await screen.findByRole("button", { - name: /add/i, - hidden: true, - }); - expect(btn2).toBeEnabled(); - await userEvent.click(btn2); - expect(onSubmit).toHaveBeenCalledTimes(1); - - rerender( - , - ); - - // Check for new tag - const fooTag = await screen.findByText(/foo/i); - expect(fooTag).toBeInTheDocument(); - const barValue = await screen.findByText(/bar/i); - expect(barValue).toBeInTheDocument(); - }); - it("can remove a tag", async () => { - const onSubmit = jest.fn().mockImplementation(({ key, value }) => { - tags = { ...tags, [key]: value }; - }); - const onDelete = jest.fn().mockImplementation((key) => { - delete tags[key]; - tags = { ...tags }; - }); - const { rerender } = renderComponent( - , - ); - - // Open Popover - const btn = await screen.findByRole("button"); - expect(btn).toBeEnabled(); - await userEvent.click(btn); - - // Check for existing tags - const el = await screen.findByText(/wowzers/i); - expect(el).toBeInTheDocument(); - - // Find Delete button - const btn2 = await screen.findByRole("button", { - name: /delete-wowzers/i, - hidden: true, - }); - expect(btn2).toBeEnabled(); - - // Delete tag - await userEvent.click(btn2); - expect(onDelete).toHaveBeenCalledTimes(1); - - rerender( - , - ); - - // Expect deleted tag to be gone - const el2 = screen.queryByText(/wowzers/i); - expect(el2).not.toBeInTheDocument(); - }); - }); -}); diff --git a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx b/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx index 49a6480ba2..2d76db8f92 100644 --- a/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx +++ b/site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.tsx @@ -1,68 +1,28 @@ -import AddIcon from "@mui/icons-material/Add"; import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined"; -import Button from "@mui/material/Button"; import Link from "@mui/material/Link"; -import TextField from "@mui/material/TextField"; import useTheme from "@mui/system/useTheme"; -import { FormFields, FormSection, VerticalForm } from "components/Form/Form"; +import type { ProvisionerDaemon } from "api/typesGenerated"; +import { FormSection } from "components/Form/Form"; import { TopbarButton } from "components/FullPageLayout/Topbar"; -import { Stack } from "components/Stack/Stack"; import { Popover, PopoverContent, PopoverTrigger, } from "components/deprecated/Popover/Popover"; -import { useFormik } from "formik"; -import { ProvisionerTag } from "modules/provisioners/ProvisionerTag"; -import { type FC, Fragment } from "react"; +import { ProvisionerTagsField } from "modules/provisioners/ProvisionerTagsField"; +import type { FC } from "react"; import { docs } from "utils/docs"; -import { getFormHelpers, onChangeTrimmed } from "utils/formUtils"; -import * as Yup from "yup"; - -const initialValues = { - key: "", - value: "", -}; - -const validationSchema = Yup.object({ - key: Yup.string() - .required("Required") - .notOneOf(["owner"], "Cannot override owner tag"), - value: Yup.string() - .required("Required") - .when("key", ([key], schema) => { - if (key === "scope") { - return schema.oneOf( - ["organization", "scope"], - "Scope value must be 'organization' or 'user'", - ); - } - - return schema; - }), -}); export interface ProvisionerTagsPopoverProps { - tags: Record; - onSubmit: (values: typeof initialValues) => void; - onDelete: (key: string) => void; + tags: ProvisionerDaemon["tags"]; + onTagsChange: (values: ProvisionerDaemon["tags"]) => void; } export const ProvisionerTagsPopover: FC = ({ tags, - onSubmit, - onDelete, + onTagsChange, }) => { const theme = useTheme(); - const form = useFormik({ - initialValues, - validationSchema, - onSubmit: (values) => { - onSubmit(values); - form.resetForm(); - }, - }); - const getFieldHelpers = getFormHelpers(form); return ( @@ -72,6 +32,7 @@ export const ProvisionerTagsPopover: FC = ({ css={{ paddingLeft: 0, paddingRight: 0, minWidth: "28px !important" }} > + Expand provisioner tags = ({ borderBottom: `1px solid ${theme.palette.divider}`, }} > - - - - Tags are a way to control which provisioner daemons complete - which build jobs.  - - Learn more... - - - } - /> - - {Object.entries(tags) - // filter out owner since you cannot override it - .filter(([key]) => key !== "owner") - .map(([key, value]) => ( - - {key === "scope" ? ( - - ) : ( - - )} - - ))} - - - - - - - - - - - + + Tags are a way to control which provisioner daemons complete + which build jobs.  + + Learn more... + + + } + > + +
diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index eb5f96e654..00fcc5f29e 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -272,17 +272,7 @@ export const TemplateVersionEditor: FC = ({ { - onUpdateProvisionerTags({ - ...provisionerTags, - [key]: value, - }); - }} - onDelete={(key) => { - const newTags = { ...provisionerTags }; - delete newTags[key]; - onUpdateProvisionerTags(newTags); - }} + onTagsChange={onUpdateProvisionerTags} />
From b5ff9faa3427aed73c53c91792570b9a75682023 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 25 Feb 2025 18:03:09 +0000 Subject: [PATCH 22/29] fix: update create template button styling (#16701) resolves #16697 Fix styling of create template button for non-premium users to match new template button for premium users. ## Previous behavior With premium license ![image](https://github.com/user-attachments/assets/41a55a3b-0d4d-4b11-bbda-ae31c09f64b9) Without license ![image](https://github.com/user-attachments/assets/7439d139-9514-4f05-aa93-3701105b2776) --- site/src/pages/TemplatesPage/CreateTemplateButton.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/site/src/pages/TemplatesPage/CreateTemplateButton.tsx b/site/src/pages/TemplatesPage/CreateTemplateButton.tsx index 069fe2abb7..28a45c26b0 100644 --- a/site/src/pages/TemplatesPage/CreateTemplateButton.tsx +++ b/site/src/pages/TemplatesPage/CreateTemplateButton.tsx @@ -1,14 +1,14 @@ -import AddIcon from "@mui/icons-material/AddOutlined"; import Inventory2 from "@mui/icons-material/Inventory2"; import NoteAddOutlined from "@mui/icons-material/NoteAddOutlined"; import UploadOutlined from "@mui/icons-material/UploadOutlined"; -import Button from "@mui/material/Button"; +import { Button } from "components/Button/Button"; import { MoreMenu, MoreMenuContent, MoreMenuItem, MoreMenuTrigger, } from "components/MoreMenu/MoreMenu"; +import { PlusIcon } from "lucide-react"; import type { FC } from "react"; type CreateTemplateButtonProps = { @@ -21,8 +21,9 @@ export const CreateTemplateButton: FC = ({ return ( - From a3223397cb06b6d5e7ee20edd8c7b1528a32344d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=B1=E3=82=A4=E3=83=A9?= Date: Tue, 25 Feb 2025 11:13:44 -0700 Subject: [PATCH 23/29] chore: use tighter permissions in e2e workspace tests (#16687) --- site/e2e/constants.ts | 12 ++++-- site/e2e/helpers.ts | 34 ++++++++--------- .../workspaces/autoCreateWorkspace.spec.ts | 8 ++-- .../tests/workspaces/createWorkspace.spec.ts | 37 +++++++++++-------- .../tests/workspaces/restartWorkspace.spec.ts | 5 ++- .../tests/workspaces/startWorkspace.spec.ts | 5 ++- .../tests/workspaces/updateWorkspace.spec.ts | 12 +++++- 7 files changed, 70 insertions(+), 43 deletions(-) diff --git a/site/e2e/constants.ts b/site/e2e/constants.ts index 4ec0048e69..4fcada0e6d 100644 --- a/site/e2e/constants.ts +++ b/site/e2e/constants.ts @@ -24,16 +24,22 @@ export const users = { password: defaultPassword, email: "admin@coder.com", }, + templateAdmin: { + username: "template-admin", + password: defaultPassword, + email: "templateadmin@coder.com", + roles: ["Template Admin"], + }, auditor: { username: "auditor", password: defaultPassword, email: "auditor@coder.com", roles: ["Template Admin", "Auditor"], }, - user: { - username: "user", + member: { + username: "member", password: defaultPassword, - email: "user@coder.com", + email: "member@coder.com", }, } satisfies Record< string, diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index a2f55ad2c8..5692909355 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -150,7 +150,6 @@ export const createWorkspace = async ( await page.getByRole("button", { name: /create workspace/i }).click(); const user = currentUser(page); - await expectUrl(page).toHavePathName(`/@${user.username}/${name}`); await page.waitForSelector("[data-testid='build-status'] >> text=Running", { @@ -165,12 +164,10 @@ export const verifyParameters = async ( richParameters: RichParameter[], expectedBuildParameters: WorkspaceBuildParameter[], ) => { - await page.goto(`/@admin/${workspaceName}/settings/parameters`, { + const user = currentUser(page); + await page.goto(`/@${user.username}/${workspaceName}/settings/parameters`, { waitUntil: "domcontentloaded", }); - await expectUrl(page).toHavePathName( - `/@admin/${workspaceName}/settings/parameters`, - ); for (const buildParameter of expectedBuildParameters) { const richParameter = richParameters.find( @@ -356,10 +353,10 @@ export const sshIntoWorkspace = async ( }; export const stopWorkspace = async (page: Page, workspaceName: string) => { - await page.goto(`/@admin/${workspaceName}`, { + const user = currentUser(page); + await page.goto(`/@${user.username}/${workspaceName}`, { waitUntil: "domcontentloaded", }); - await expectUrl(page).toHavePathName(`/@admin/${workspaceName}`); await page.getByTestId("workspace-stop-button").click(); @@ -375,10 +372,10 @@ export const buildWorkspaceWithParameters = async ( buildParameters: WorkspaceBuildParameter[] = [], confirm = false, ) => { - await page.goto(`/@admin/${workspaceName}`, { + const user = currentUser(page); + await page.goto(`/@${user.username}/${workspaceName}`, { waitUntil: "domcontentloaded", }); - await expectUrl(page).toHavePathName(`/@admin/${workspaceName}`); await page.getByTestId("build-parameters-button").click(); @@ -993,10 +990,10 @@ export const updateWorkspace = async ( richParameters: RichParameter[] = [], buildParameters: WorkspaceBuildParameter[] = [], ) => { - await page.goto(`/@admin/${workspaceName}`, { + const user = currentUser(page); + await page.goto(`/@${user.username}/${workspaceName}`, { waitUntil: "domcontentloaded", }); - await expectUrl(page).toHavePathName(`/@admin/${workspaceName}`); await page.getByTestId("workspace-update-button").click(); await page.getByTestId("confirm-button").click(); @@ -1015,12 +1012,10 @@ export const updateWorkspaceParameters = async ( richParameters: RichParameter[] = [], buildParameters: WorkspaceBuildParameter[] = [], ) => { - await page.goto(`/@admin/${workspaceName}/settings/parameters`, { + const user = currentUser(page); + await page.goto(`/@${user.username}/${workspaceName}/settings/parameters`, { waitUntil: "domcontentloaded", }); - await expectUrl(page).toHavePathName( - `/@admin/${workspaceName}/settings/parameters`, - ); await fillParameters(page, richParameters, buildParameters); await page.getByRole("button", { name: /submit and restart/i }).click(); @@ -1044,11 +1039,14 @@ export async function openTerminalWindow( // Specify that the shell should be `bash`, to prevent inheriting a shell that // isn't POSIX compatible, such as Fish. + const user = currentUser(page); const commandQuery = `?command=${encodeURIComponent("/usr/bin/env bash")}`; await expectUrl(terminal).toHavePathName( - `/@admin/${workspaceName}.${agentName}/terminal`, + `/@${user.username}/${workspaceName}.${agentName}/terminal`, + ); + await terminal.goto( + `/@${user.username}/${workspaceName}.${agentName}/terminal${commandQuery}`, ); - await terminal.goto(`/@admin/${workspaceName}.dev/terminal${commandQuery}`); return terminal; } @@ -1100,7 +1098,7 @@ export async function createUser( // Give them a role await addedRow.getByLabel("Edit user roles").click(); for (const role of roles) { - await page.getByText(role, { exact: true }).click(); + await page.getByRole("group").getByText(role, { exact: true }).click(); } await page.mouse.click(10, 10); // close the popover by clicking outside of it diff --git a/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts b/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts index 4bf9b26bb2..a6ec00958a 100644 --- a/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts @@ -16,7 +16,7 @@ let template!: string; test.beforeAll(async ({ browser }) => { const page = await (await browser.newContext()).newPage(); - await login(page); + await login(page, users.templateAdmin); const richParameters: RichParameter[] = [ { ...emptyParameter, name: "repo", type: "string" }, @@ -29,7 +29,7 @@ test.beforeAll(async ({ browser }) => { test.beforeEach(async ({ page }) => { beforeCoderTest(page); - await login(page, users.user); + await login(page, users.member); }); test("create workspace in auto mode", async ({ page }) => { @@ -40,7 +40,7 @@ test("create workspace in auto mode", async ({ page }) => { waitUntil: "domcontentloaded", }, ); - await expect(page).toHaveTitle(`${users.user.username}/${name} - Coder`); + await expect(page).toHaveTitle(`${users.member.username}/${name} - Coder`); }); test("use an existing workspace that matches the `match` parameter instead of creating a new one", async ({ @@ -54,7 +54,7 @@ test("use an existing workspace that matches the `match` parameter instead of cr }, ); await expect(page).toHaveTitle( - `${users.user.username}/${prevWorkspace} - Coder`, + `${users.member.username}/${prevWorkspace} - Coder`, ); }); diff --git a/site/e2e/tests/workspaces/createWorkspace.spec.ts b/site/e2e/tests/workspaces/createWorkspace.spec.ts index ce1898a310..49b832d285 100644 --- a/site/e2e/tests/workspaces/createWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/createWorkspace.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { users } from "../../constants"; import { StarterTemplates, createTemplate, @@ -26,27 +27,20 @@ test.describe.configure({ mode: "parallel" }); test.beforeEach(async ({ page }) => { beforeCoderTest(page); - await login(page); }); test("create workspace", async ({ page }) => { + await login(page, users.templateAdmin); const template = await createTemplate(page, { - apply: [ - { - apply: { - resources: [ - { - name: "example", - }, - ], - }, - }, - ], + apply: [{ apply: { resources: [{ name: "example" }] } }], }); + + await login(page, users.member); await createWorkspace(page, template); }); test("create workspace with default immutable parameters", async ({ page }) => { + await login(page, users.templateAdmin); const richParameters: RichParameter[] = [ secondParameter, fourthParameter, @@ -56,6 +50,8 @@ test("create workspace with default immutable parameters", async ({ page }) => { page, echoResponsesWithParameters(richParameters), ); + + await login(page, users.member); const workspaceName = await createWorkspace(page, template); await verifyParameters(page, workspaceName, richParameters, [ { name: secondParameter.name, value: secondParameter.defaultValue }, @@ -65,11 +61,14 @@ test("create workspace with default immutable parameters", async ({ page }) => { }); test("create workspace with default mutable parameters", async ({ page }) => { + await login(page, users.templateAdmin); const richParameters: RichParameter[] = [firstParameter, thirdParameter]; const template = await createTemplate( page, echoResponsesWithParameters(richParameters), ); + + await login(page, users.member); const workspaceName = await createWorkspace(page, template); await verifyParameters(page, workspaceName, richParameters, [ { name: firstParameter.name, value: firstParameter.defaultValue }, @@ -80,6 +79,7 @@ test("create workspace with default mutable parameters", async ({ page }) => { test("create workspace with default and required parameters", async ({ page, }) => { + await login(page, users.templateAdmin); const richParameters: RichParameter[] = [ secondParameter, fourthParameter, @@ -94,6 +94,8 @@ test("create workspace with default and required parameters", async ({ page, echoResponsesWithParameters(richParameters), ); + + await login(page, users.member); const workspaceName = await createWorkspace(page, template, { richParameters, buildParameters, @@ -108,6 +110,7 @@ test("create workspace with default and required parameters", async ({ }); test("create workspace and overwrite default parameters", async ({ page }) => { + await login(page, users.templateAdmin); // We use randParamName to prevent the new values from corrupting user_history // and thus affecting other tests. const richParameters: RichParameter[] = [ @@ -124,6 +127,7 @@ test("create workspace and overwrite default parameters", async ({ page }) => { echoResponsesWithParameters(richParameters), ); + await login(page, users.member); const workspaceName = await createWorkspace(page, template, { richParameters, buildParameters, @@ -132,6 +136,7 @@ test("create workspace and overwrite default parameters", async ({ page }) => { }); test("create workspace with disable_param search params", async ({ page }) => { + await login(page, users.templateAdmin); const richParameters: RichParameter[] = [ firstParameter, // mutable secondParameter, //immutable @@ -142,6 +147,7 @@ test("create workspace with disable_param search params", async ({ page }) => { echoResponsesWithParameters(richParameters), ); + await login(page, users.member); await page.goto( `/templates/${templateName}/workspace?disable_params=first_parameter,second_parameter`, { @@ -157,8 +163,11 @@ test("create workspace with disable_param search params", async ({ page }) => { // the tests are over. test.skip("create docker workspace", async ({ context, page }) => { requireTerraformProvisioner(); + + await login(page, users.templateAdmin); const template = await createTemplate(page, StarterTemplates.STARTER_DOCKER); + await login(page, users.member); const workspaceName = await createWorkspace(page, template); // The workspace agents must be ready before we try to interact with the workspace. @@ -184,8 +193,6 @@ test.skip("create docker workspace", async ({ context, page }) => { ); await terminal.waitForSelector( `//textarea[contains(@class,"xterm-helper-textarea")]`, - { - state: "visible", - }, + { state: "visible" }, ); }); diff --git a/site/e2e/tests/workspaces/restartWorkspace.spec.ts b/site/e2e/tests/workspaces/restartWorkspace.spec.ts index b65fa95208..444ff891f0 100644 --- a/site/e2e/tests/workspaces/restartWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/restartWorkspace.spec.ts @@ -1,4 +1,5 @@ import { test } from "@playwright/test"; +import { users } from "../../constants"; import { buildWorkspaceWithParameters, createTemplate, @@ -13,15 +14,17 @@ import type { RichParameter } from "../../provisionerGenerated"; test.beforeEach(async ({ page }) => { beforeCoderTest(page); - await login(page); }); test("restart workspace with ephemeral parameters", async ({ page }) => { + await login(page, users.templateAdmin); const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption]; const template = await createTemplate( page, echoResponsesWithParameters(richParameters), ); + + await login(page, users.member); const workspaceName = await createWorkspace(page, template); // Verify that build options are default (not selected). diff --git a/site/e2e/tests/workspaces/startWorkspace.spec.ts b/site/e2e/tests/workspaces/startWorkspace.spec.ts index d22c8f4f34..90fac44004 100644 --- a/site/e2e/tests/workspaces/startWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/startWorkspace.spec.ts @@ -1,4 +1,5 @@ import { test } from "@playwright/test"; +import { users } from "../../constants"; import { buildWorkspaceWithParameters, createTemplate, @@ -14,15 +15,17 @@ import type { RichParameter } from "../../provisionerGenerated"; test.beforeEach(async ({ page }) => { beforeCoderTest(page); - await login(page); }); test("start workspace with ephemeral parameters", async ({ page }) => { + await login(page, users.templateAdmin); const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption]; const template = await createTemplate( page, echoResponsesWithParameters(richParameters), ); + + await login(page, users.member); const workspaceName = await createWorkspace(page, template); // Verify that build options are default (not selected). diff --git a/site/e2e/tests/workspaces/updateWorkspace.spec.ts b/site/e2e/tests/workspaces/updateWorkspace.spec.ts index 1db6231646..48c341eb63 100644 --- a/site/e2e/tests/workspaces/updateWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/updateWorkspace.spec.ts @@ -1,4 +1,5 @@ import { test } from "@playwright/test"; +import { users } from "../../constants"; import { createTemplate, createWorkspace, @@ -21,18 +22,19 @@ import type { RichParameter } from "../../provisionerGenerated"; test.beforeEach(async ({ page }) => { beforeCoderTest(page); - await login(page); }); test("update workspace, new optional, immutable parameter added", async ({ page, }) => { + await login(page, users.templateAdmin); const richParameters: RichParameter[] = [firstParameter, secondParameter]; const template = await createTemplate( page, echoResponsesWithParameters(richParameters), ); + await login(page, users.member); const workspaceName = await createWorkspace(page, template); // Verify that parameter values are default. @@ -42,6 +44,7 @@ test("update workspace, new optional, immutable parameter added", async ({ ]); // Push updated template. + await login(page, users.templateAdmin); const updatedRichParameters = [...richParameters, fifthParameter]; await updateTemplate( page, @@ -51,6 +54,7 @@ test("update workspace, new optional, immutable parameter added", async ({ ); // Now, update the workspace, and select the value for immutable parameter. + await login(page, users.member); await updateWorkspace(page, workspaceName, updatedRichParameters, [ { name: fifthParameter.name, value: fifthParameter.options[0].value }, ]); @@ -66,12 +70,14 @@ test("update workspace, new optional, immutable parameter added", async ({ test("update workspace, new required, mutable parameter added", async ({ page, }) => { + await login(page, users.templateAdmin); const richParameters: RichParameter[] = [firstParameter, secondParameter]; const template = await createTemplate( page, echoResponsesWithParameters(richParameters), ); + await login(page, users.member); const workspaceName = await createWorkspace(page, template); // Verify that parameter values are default. @@ -81,6 +87,7 @@ test("update workspace, new required, mutable parameter added", async ({ ]); // Push updated template. + await login(page, users.templateAdmin); const updatedRichParameters = [...richParameters, sixthParameter]; await updateTemplate( page, @@ -90,6 +97,7 @@ test("update workspace, new required, mutable parameter added", async ({ ); // Now, update the workspace, and provide the parameter value. + await login(page, users.member); const buildParameters = [{ name: sixthParameter.name, value: "99" }]; await updateWorkspace( page, @@ -107,12 +115,14 @@ test("update workspace, new required, mutable parameter added", async ({ }); test("update workspace with ephemeral parameter enabled", async ({ page }) => { + await login(page, users.templateAdmin); const richParameters: RichParameter[] = [firstParameter, secondBuildOption]; const template = await createTemplate( page, echoResponsesWithParameters(richParameters), ); + await login(page, users.member); const workspaceName = await createWorkspace(page, template); // Verify that parameter values are default. From 172e52317cd053dcdffc2b7d445a1d390ebbe53b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 26 Feb 2025 09:03:27 +0000 Subject: [PATCH 24/29] feat(agent): wire up agentssh server to allow exec into container (#16638) Builds on top of https://github.com/coder/coder/pull/16623/ and wires up the ReconnectingPTY server. This does nothing to wire up the web terminal yet but the added test demonstrates the functionality working. Other changes: * Refactors and moves the `SystemEnvInfo` interface to the `agent/usershell` package to address follow-up from https://github.com/coder/coder/pull/16623#discussion_r1967580249 * Marks `usershellinfo.Get` as deprecated. Consumers should use the `EnvInfoer` interface instead. --------- Co-authored-by: Mathias Fredriksson Co-authored-by: Danny Kopping --- agent/agent.go | 9 +++ agent/agent_test.go | 78 ++++++++++++++++++- agent/agentcontainers/containers_dockercli.go | 20 +---- .../containers_internal_test.go | 6 +- agent/agentssh/agentssh.go | 66 +++++----------- agent/agentssh/agentssh_test.go | 10 ++- agent/reconnectingpty/server.go | 25 +++++- agent/usershell/usershell.go | 66 ++++++++++++++++ agent/usershell/usershell_darwin.go | 1 + agent/usershell/usershell_other.go | 1 + agent/usershell/usershell_windows.go | 1 + cli/agent.go | 2 + coderd/workspaceapps/proxy.go | 7 +- codersdk/workspacesdk/agentconn.go | 28 ++++++- codersdk/workspacesdk/workspacesdk.go | 22 +++++- 15 files changed, 260 insertions(+), 82 deletions(-) create mode 100644 agent/usershell/usershell.go diff --git a/agent/agent.go b/agent/agent.go index 0b3a6b3ecd..285636cd31 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -88,6 +88,8 @@ type Options struct { BlockFileTransfer bool Execer agentexec.Execer ContainerLister agentcontainers.Lister + + ExperimentalContainersEnabled bool } type Client interface { @@ -188,6 +190,8 @@ func New(options Options) Agent { metrics: newAgentMetrics(prometheusRegistry), execer: options.Execer, lister: options.ContainerLister, + + experimentalDevcontainersEnabled: options.ExperimentalContainersEnabled, } // Initially, we have a closed channel, reflecting the fact that we are not initially connected. // Each time we connect we replace the channel (while holding the closeMutex) with a new one @@ -258,6 +262,8 @@ type agent struct { metrics *agentMetrics execer agentexec.Execer lister agentcontainers.Lister + + experimentalDevcontainersEnabled bool } func (a *agent) TailnetConn() *tailnet.Conn { @@ -297,6 +303,9 @@ func (a *agent) init() { a.sshServer, a.metrics.connectionsTotal, a.metrics.reconnectingPTYErrors, a.reconnectingPTYTimeout, + func(s *reconnectingpty.Server) { + s.ExperimentalContainersEnabled = a.experimentalDevcontainersEnabled + }, ) go a.runLoop() } diff --git a/agent/agent_test.go b/agent/agent_test.go index 834e0a3e68..935309e98d 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -25,8 +25,14 @@ import ( "testing" "time" + "go.uber.org/goleak" + "tailscale.com/net/speedtest" + "tailscale.com/tailcfg" + "github.com/bramvdbogaerde/go-scp" "github.com/google/uuid" + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" "github.com/pion/udp" "github.com/pkg/sftp" "github.com/prometheus/client_golang/prometheus" @@ -34,15 +40,13 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uber.org/goleak" "golang.org/x/crypto/ssh" "golang.org/x/exp/slices" "golang.org/x/xerrors" - "tailscale.com/net/speedtest" - "tailscale.com/tailcfg" "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agentssh" "github.com/coder/coder/v2/agent/agenttest" @@ -1761,6 +1765,74 @@ func TestAgent_ReconnectingPTY(t *testing.T) { } } +// This tests end-to-end functionality of connecting to a running container +// and executing a command. It creates a real Docker container and runs a +// command. As such, it does not run by default in CI. +// You can run it manually as follows: +// +// CODER_TEST_USE_DOCKER=1 go test -count=1 ./agent -run TestAgent_ReconnectingPTYContainer +func TestAgent_ReconnectingPTYContainer(t *testing.T) { + t.Parallel() + if os.Getenv("CODER_TEST_USE_DOCKER") != "1" { + t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test") + } + + ctx := testutil.Context(t, testutil.WaitLong) + + pool, err := dockertest.NewPool("") + require.NoError(t, err, "Could not connect to docker") + ct, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "busybox", + Tag: "latest", + Cmd: []string{"sleep", "infnity"}, + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }) + require.NoError(t, err, "Could not start container") + t.Cleanup(func() { + err := pool.Purge(ct) + require.NoError(t, err, "Could not stop container") + }) + // Wait for container to start + require.Eventually(t, func() bool { + ct, ok := pool.ContainerByName(ct.Container.Name) + return ok && ct.Container.State.Running + }, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time") + + // nolint: dogsled + conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) { + o.ExperimentalContainersEnabled = true + }) + ac, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "/bin/sh", func(arp *workspacesdk.AgentReconnectingPTYInit) { + arp.Container = ct.Container.ID + }) + require.NoError(t, err, "failed to create ReconnectingPTY") + defer ac.Close() + tr := testutil.NewTerminalReader(t, ac) + + require.NoError(t, tr.ReadUntil(ctx, func(line string) bool { + return strings.Contains(line, "#") || strings.Contains(line, "$") + }), "find prompt") + + require.NoError(t, json.NewEncoder(ac).Encode(workspacesdk.ReconnectingPTYRequest{ + Data: "hostname\r", + }), "write hostname") + require.NoError(t, tr.ReadUntil(ctx, func(line string) bool { + return strings.Contains(line, "hostname") + }), "find hostname command") + + require.NoError(t, tr.ReadUntil(ctx, func(line string) bool { + return strings.Contains(line, ct.Container.Config.Hostname) + }), "find hostname output") + require.NoError(t, json.NewEncoder(ac).Encode(workspacesdk.ReconnectingPTYRequest{ + Data: "exit\r", + }), "write exit command") + + // Wait for the connection to close. + require.ErrorIs(t, tr.ReadUntil(ctx, nil), io.EOF) +} + func TestAgent_Dial(t *testing.T) { t.Parallel() diff --git a/agent/agentcontainers/containers_dockercli.go b/agent/agentcontainers/containers_dockercli.go index 64f264c1ba..27e5f835d5 100644 --- a/agent/agentcontainers/containers_dockercli.go +++ b/agent/agentcontainers/containers_dockercli.go @@ -6,7 +6,6 @@ import ( "context" "encoding/json" "fmt" - "os" "os/user" "slices" "sort" @@ -15,6 +14,7 @@ import ( "time" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/agent/usershell" "github.com/coder/coder/v2/codersdk" "golang.org/x/exp/maps" @@ -37,6 +37,7 @@ func NewDocker(execer agentexec.Execer) Lister { // DockerEnvInfoer is an implementation of agentssh.EnvInfoer that returns // information about a container. type DockerEnvInfoer struct { + usershell.SystemEnvInfo container string user *user.User userShell string @@ -122,26 +123,13 @@ func EnvInfo(ctx context.Context, execer agentexec.Execer, container, containerU return &dei, nil } -func (dei *DockerEnvInfoer) CurrentUser() (*user.User, error) { +func (dei *DockerEnvInfoer) User() (*user.User, error) { // Clone the user so that the caller can't modify it u := *dei.user return &u, nil } -func (*DockerEnvInfoer) Environ() []string { - // Return a clone of the environment so that the caller can't modify it - return os.Environ() -} - -func (*DockerEnvInfoer) UserHomeDir() (string, error) { - // We default the working directory of the command to the user's home - // directory. Since this came from inside the container, we cannot guarantee - // that this exists on the host. Return the "real" home directory of the user - // instead. - return os.UserHomeDir() -} - -func (dei *DockerEnvInfoer) UserShell(string) (string, error) { +func (dei *DockerEnvInfoer) Shell(string) (string, error) { return dei.userShell, nil } diff --git a/agent/agentcontainers/containers_internal_test.go b/agent/agentcontainers/containers_internal_test.go index cdda03f9c8..d48b95ebd7 100644 --- a/agent/agentcontainers/containers_internal_test.go +++ b/agent/agentcontainers/containers_internal_test.go @@ -502,15 +502,15 @@ func TestDockerEnvInfoer(t *testing.T) { dei, err := EnvInfo(ctx, agentexec.DefaultExecer, ct.Container.ID, tt.containerUser) require.NoError(t, err, "Expected no error from DockerEnvInfo()") - u, err := dei.CurrentUser() + u, err := dei.User() require.NoError(t, err, "Expected no error from CurrentUser()") require.Equal(t, tt.expectedUsername, u.Username, "Expected username to match") - hd, err := dei.UserHomeDir() + hd, err := dei.HomeDir() require.NoError(t, err, "Expected no error from UserHomeDir()") require.NotEmpty(t, hd, "Expected user homedir to be non-empty") - sh, err := dei.UserShell(tt.containerUser) + sh, err := dei.Shell(tt.containerUser) require.NoError(t, err, "Expected no error from UserShell()") require.Equal(t, tt.expectedUserShell, sh, "Expected user shell to match") diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index a7e028541a..d5fe945c49 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -698,45 +698,6 @@ func (s *Server) sftpHandler(logger slog.Logger, session ssh.Session) { _ = session.Exit(1) } -// EnvInfoer encapsulates external information required by CreateCommand. -type EnvInfoer interface { - // CurrentUser returns the current user. - CurrentUser() (*user.User, error) - // Environ returns the environment variables of the current process. - Environ() []string - // UserHomeDir returns the home directory of the current user. - UserHomeDir() (string, error) - // UserShell returns the shell of the given user. - UserShell(username string) (string, error) -} - -type systemEnvInfoer struct{} - -var defaultEnvInfoer EnvInfoer = &systemEnvInfoer{} - -// DefaultEnvInfoer returns a default implementation of -// EnvInfoer. This reads information using the default Go -// implementations. -func DefaultEnvInfoer() EnvInfoer { - return defaultEnvInfoer -} - -func (systemEnvInfoer) CurrentUser() (*user.User, error) { - return user.Current() -} - -func (systemEnvInfoer) Environ() []string { - return os.Environ() -} - -func (systemEnvInfoer) UserHomeDir() (string, error) { - return userHomeDir() -} - -func (systemEnvInfoer) UserShell(username string) (string, error) { - return usershell.Get(username) -} - // CreateCommand processes raw command input with OpenSSH-like behavior. // If the script provided is empty, it will default to the users shell. // This injects environment variables specified by the user at launch too. @@ -744,17 +705,17 @@ func (systemEnvInfoer) UserShell(username string) (string, error) { // alternative implementations for the dependencies of CreateCommand. // This is useful when creating a command to be run in a separate environment // (for example, a Docker container). Pass in nil to use the default. -func (s *Server) CreateCommand(ctx context.Context, script string, env []string, deps EnvInfoer) (*pty.Cmd, error) { - if deps == nil { - deps = DefaultEnvInfoer() +func (s *Server) CreateCommand(ctx context.Context, script string, env []string, ei usershell.EnvInfoer) (*pty.Cmd, error) { + if ei == nil { + ei = &usershell.SystemEnvInfo{} } - currentUser, err := deps.CurrentUser() + currentUser, err := ei.User() if err != nil { return nil, xerrors.Errorf("get current user: %w", err) } username := currentUser.Username - shell, err := deps.UserShell(username) + shell, err := ei.Shell(username) if err != nil { return nil, xerrors.Errorf("get user shell: %w", err) } @@ -802,7 +763,18 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string, } } - cmd := s.Execer.PTYCommandContext(ctx, name, args...) + // Modify command prior to execution. This will usually be a no-op, but not + // always. For example, to run a command in a Docker container, we need to + // modify the command to be `docker exec -it `. + modifiedName, modifiedArgs := ei.ModifyCommand(name, args...) + // Log if the command was modified. + if modifiedName != name && slices.Compare(modifiedArgs, args) != 0 { + s.logger.Debug(ctx, "modified command", + slog.F("before", append([]string{name}, args...)), + slog.F("after", append([]string{modifiedName}, modifiedArgs...)), + ) + } + cmd := s.Execer.PTYCommandContext(ctx, modifiedName, modifiedArgs...) cmd.Dir = s.config.WorkingDirectory() // If the metadata directory doesn't exist, we run the command @@ -810,13 +782,13 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string, _, err = os.Stat(cmd.Dir) if cmd.Dir == "" || err != nil { // Default to user home if a directory is not set. - homedir, err := deps.UserHomeDir() + homedir, err := ei.HomeDir() if err != nil { return nil, xerrors.Errorf("get home dir: %w", err) } cmd.Dir = homedir } - cmd.Env = append(deps.Environ(), env...) + cmd.Env = append(ei.Environ(), env...) cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username)) // Set SSH connection environment variables (these are also set by OpenSSH diff --git a/agent/agentssh/agentssh_test.go b/agent/agentssh/agentssh_test.go index 378657ebee..6b0706e95d 100644 --- a/agent/agentssh/agentssh_test.go +++ b/agent/agentssh/agentssh_test.go @@ -124,7 +124,7 @@ type fakeEnvInfoer struct { UserShellFn func(string) (string, error) } -func (f *fakeEnvInfoer) CurrentUser() (u *user.User, err error) { +func (f *fakeEnvInfoer) User() (u *user.User, err error) { return f.CurrentUserFn() } @@ -132,14 +132,18 @@ func (f *fakeEnvInfoer) Environ() []string { return f.EnvironFn() } -func (f *fakeEnvInfoer) UserHomeDir() (string, error) { +func (f *fakeEnvInfoer) HomeDir() (string, error) { return f.UserHomeDirFn() } -func (f *fakeEnvInfoer) UserShell(u string) (string, error) { +func (f *fakeEnvInfoer) Shell(u string) (string, error) { return f.UserShellFn(u) } +func (*fakeEnvInfoer) ModifyCommand(cmd string, args ...string) (string, []string) { + return cmd, args +} + func TestNewServer_CloseActiveConnections(t *testing.T) { t.Parallel() diff --git a/agent/reconnectingpty/server.go b/agent/reconnectingpty/server.go index 465667c616..ab4ce854c7 100644 --- a/agent/reconnectingpty/server.go +++ b/agent/reconnectingpty/server.go @@ -14,7 +14,9 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentssh" + "github.com/coder/coder/v2/agent/usershell" "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -26,20 +28,26 @@ type Server struct { connCount atomic.Int64 reconnectingPTYs sync.Map timeout time.Duration + + ExperimentalContainersEnabled bool } // NewServer returns a new ReconnectingPTY server func NewServer(logger slog.Logger, commandCreator *agentssh.Server, connectionsTotal prometheus.Counter, errorsTotal *prometheus.CounterVec, - timeout time.Duration, + timeout time.Duration, opts ...func(*Server), ) *Server { - return &Server{ + s := &Server{ logger: logger, commandCreator: commandCreator, connectionsTotal: connectionsTotal, errorsTotal: errorsTotal, timeout: timeout, } + for _, o := range opts { + o(s) + } + return s } func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr error) { @@ -116,7 +124,7 @@ func (s *Server) handleConn(ctx context.Context, logger slog.Logger, conn net.Co } connectionID := uuid.NewString() - connLogger := logger.With(slog.F("message_id", msg.ID), slog.F("connection_id", connectionID)) + connLogger := logger.With(slog.F("message_id", msg.ID), slog.F("connection_id", connectionID), slog.F("container", msg.Container), slog.F("container_user", msg.ContainerUser)) connLogger.Debug(ctx, "starting handler") defer func() { @@ -158,8 +166,17 @@ func (s *Server) handleConn(ctx context.Context, logger slog.Logger, conn net.Co } }() + var ei usershell.EnvInfoer + if s.ExperimentalContainersEnabled && msg.Container != "" { + dei, err := agentcontainers.EnvInfo(ctx, s.commandCreator.Execer, msg.Container, msg.ContainerUser) + if err != nil { + return xerrors.Errorf("get container env info: %w", err) + } + ei = dei + s.logger.Info(ctx, "got container env info", slog.F("container", msg.Container)) + } // Empty command will default to the users shell! - cmd, err := s.commandCreator.CreateCommand(ctx, msg.Command, nil, nil) + cmd, err := s.commandCreator.CreateCommand(ctx, msg.Command, nil, ei) if err != nil { s.errorsTotal.WithLabelValues("create_command").Add(1) return xerrors.Errorf("create command: %w", err) diff --git a/agent/usershell/usershell.go b/agent/usershell/usershell.go new file mode 100644 index 0000000000..9400dc9167 --- /dev/null +++ b/agent/usershell/usershell.go @@ -0,0 +1,66 @@ +package usershell + +import ( + "os" + "os/user" + + "golang.org/x/xerrors" +) + +// HomeDir returns the home directory of the current user, giving +// priority to the $HOME environment variable. +// Deprecated: use EnvInfoer.HomeDir() instead. +func HomeDir() (string, error) { + // First we check the environment. + homedir, err := os.UserHomeDir() + if err == nil { + return homedir, nil + } + + // As a fallback, we try the user information. + u, err := user.Current() + if err != nil { + return "", xerrors.Errorf("current user: %w", err) + } + return u.HomeDir, nil +} + +// EnvInfoer encapsulates external information about the environment. +type EnvInfoer interface { + // User returns the current user. + User() (*user.User, error) + // Environ returns the environment variables of the current process. + Environ() []string + // HomeDir returns the home directory of the current user. + HomeDir() (string, error) + // Shell returns the shell of the given user. + Shell(username string) (string, error) + // ModifyCommand modifies the command and arguments before execution based on + // the environment. This is useful for executing a command inside a container. + // In the default case, the command and arguments are returned unchanged. + ModifyCommand(name string, args ...string) (string, []string) +} + +// SystemEnvInfo encapsulates the information about the environment +// just using the default Go implementations. +type SystemEnvInfo struct{} + +func (SystemEnvInfo) User() (*user.User, error) { + return user.Current() +} + +func (SystemEnvInfo) Environ() []string { + return os.Environ() +} + +func (SystemEnvInfo) HomeDir() (string, error) { + return HomeDir() +} + +func (SystemEnvInfo) Shell(username string) (string, error) { + return Get(username) +} + +func (SystemEnvInfo) ModifyCommand(name string, args ...string) (string, []string) { + return name, args +} diff --git a/agent/usershell/usershell_darwin.go b/agent/usershell/usershell_darwin.go index 0f5be08f82..5f221bc43e 100644 --- a/agent/usershell/usershell_darwin.go +++ b/agent/usershell/usershell_darwin.go @@ -10,6 +10,7 @@ import ( ) // Get returns the $SHELL environment variable. +// Deprecated: use SystemEnvInfo.UserShell instead. func Get(username string) (string, error) { // This command will output "UserShell: /bin/zsh" if successful, we // can ignore the error since we have fallback behavior. diff --git a/agent/usershell/usershell_other.go b/agent/usershell/usershell_other.go index d015b7ebf4..6ee3ad2368 100644 --- a/agent/usershell/usershell_other.go +++ b/agent/usershell/usershell_other.go @@ -11,6 +11,7 @@ import ( ) // Get returns the /etc/passwd entry for the username provided. +// Deprecated: use SystemEnvInfo.UserShell instead. func Get(username string) (string, error) { contents, err := os.ReadFile("/etc/passwd") if err != nil { diff --git a/agent/usershell/usershell_windows.go b/agent/usershell/usershell_windows.go index e12537bf3a..52823d900d 100644 --- a/agent/usershell/usershell_windows.go +++ b/agent/usershell/usershell_windows.go @@ -3,6 +3,7 @@ package usershell import "os/exec" // Get returns the command prompt binary name. +// Deprecated: use SystemEnvInfo.UserShell instead. func Get(username string) (string, error) { _, err := exec.LookPath("pwsh.exe") if err == nil { diff --git a/cli/agent.go b/cli/agent.go index e8a46a84e0..01d6c36f7a 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -351,6 +351,8 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { BlockFileTransfer: blockFileTransfer, Execer: execer, ContainerLister: containerLister, + + ExperimentalContainersEnabled: devcontainersEnabled, }) promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger) diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index 04c3dec0c6..ab67e6c260 100644 --- a/coderd/workspaceapps/proxy.go +++ b/coderd/workspaceapps/proxy.go @@ -653,6 +653,8 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { reconnect := parser.RequiredNotEmpty("reconnect").UUID(values, uuid.New(), "reconnect") height := parser.UInt(values, 80, "height") width := parser.UInt(values, 80, "width") + container := parser.String(values, "", "container") + containerUser := parser.String(values, "", "container_user") if len(parser.Errors) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid query parameters.", @@ -690,7 +692,10 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { } defer release() log.Debug(ctx, "dialed workspace agent") - ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command")) + ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command"), func(arp *workspacesdk.AgentReconnectingPTYInit) { + arp.Container = container + arp.ContainerUser = containerUser + }) if err != nil { log.Debug(ctx, "dial reconnecting pty server in workspace agent", slog.Error(err)) _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err)) diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index f803f8736a..6fa06c0ab5 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -93,6 +93,24 @@ type AgentReconnectingPTYInit struct { Height uint16 Width uint16 Command string + // Container, if set, will attempt to exec into a running container visible to the agent. + // This should be a unique container ID (implementation-dependent). + Container string + // ContainerUser, if set, will set the target user when execing into a container. + // This can be a username or UID, depending on the underlying implementation. + // This is ignored if Container is not set. + ContainerUser string +} + +// AgentReconnectingPTYInitOption is a functional option for AgentReconnectingPTYInit. +type AgentReconnectingPTYInitOption func(*AgentReconnectingPTYInit) + +// AgentReconnectingPTYInitWithContainer sets the container and container user for the reconnecting PTY session. +func AgentReconnectingPTYInitWithContainer(container, containerUser string) AgentReconnectingPTYInitOption { + return func(init *AgentReconnectingPTYInit) { + init.Container = container + init.ContainerUser = containerUser + } } // ReconnectingPTYRequest is sent from the client to the server @@ -107,7 +125,7 @@ type ReconnectingPTYRequest struct { // ReconnectingPTY spawns a new reconnecting terminal session. // `ReconnectingPTYRequest` should be JSON marshaled and written to the returned net.Conn. // Raw terminal output will be read from the returned net.Conn. -func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string) (net.Conn, error) { +func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -119,12 +137,16 @@ func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, w if err != nil { return nil, err } - data, err := json.Marshal(AgentReconnectingPTYInit{ + rptyInit := AgentReconnectingPTYInit{ ID: id, Height: height, Width: width, Command: command, - }) + } + for _, o := range initOpts { + o(&rptyInit) + } + data, err := json.Marshal(rptyInit) if err != nil { _ = conn.Close() return nil, err diff --git a/codersdk/workspacesdk/workspacesdk.go b/codersdk/workspacesdk/workspacesdk.go index 17b22a363d..9f50622635 100644 --- a/codersdk/workspacesdk/workspacesdk.go +++ b/codersdk/workspacesdk/workspacesdk.go @@ -12,12 +12,14 @@ import ( "strconv" "strings" - "github.com/google/uuid" - "golang.org/x/xerrors" "tailscale.com/tailcfg" "tailscale.com/wgengine/capture" + "github.com/google/uuid" + "golang.org/x/xerrors" + "cdr.dev/slog" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/tailnet" "github.com/coder/coder/v2/tailnet/proto" @@ -305,6 +307,16 @@ type WorkspaceAgentReconnectingPTYOpts struct { // issue-reconnecting-pty-signed-token endpoint. If set, the session token // on the client will not be sent. SignedToken string + + // Experimental: Container, if set, will attempt to exec into a running container + // visible to the agent. This should be a unique container ID + // (implementation-dependent). + // ContainerUser is the user as which to exec into the container. + // NOTE: This feature is currently experimental and is currently "opt-in". + // In order to use this feature, the agent must have the environment variable + // CODER_AGENT_DEVCONTAINERS_ENABLE set to "true". + Container string + ContainerUser string } // AgentReconnectingPTY spawns a PTY that reconnects using the token provided. @@ -320,6 +332,12 @@ func (c *Client) AgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentRe q.Set("width", strconv.Itoa(int(opts.Width))) q.Set("height", strconv.Itoa(int(opts.Height))) q.Set("command", opts.Command) + if opts.Container != "" { + q.Set("container", opts.Container) + } + if opts.ContainerUser != "" { + q.Set("container_user", opts.ContainerUser) + } // If we're using a signed token, set the query parameter. if opts.SignedToken != "" { q.Set(codersdk.SignedAppTokenQueryParameter, opts.SignedToken) From 38c0e8a086bdd977d5cad908b446f79c99cdcc68 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Feb 2025 11:45:35 +0100 Subject: [PATCH 25/29] fix(agent/agentssh): ensure RSA key generation always produces valid keys (#16694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modify the RSA key generation algorithm to check that GCD(e, p-1) = 1 and GCD(e, q-1) = 1 when selecting prime numbers, ensuring that e and φ(n) are coprime. This prevents ModInverse from returning nil, which would cause private key generation to fail and result in a panic when `Precompute` is called. Change-Id: I0a453e1e1f8c638e40e7a4b87a6d0d7299e1cb5d Signed-off-by: Thomas Kosiewski --- agent/agentrsa/key.go | 87 ++++++++++++++++++++++++++++++++++++++ agent/agentrsa/key_test.go | 50 ++++++++++++++++++++++ agent/agentssh/agentssh.go | 74 +------------------------------- 3 files changed, 139 insertions(+), 72 deletions(-) create mode 100644 agent/agentrsa/key.go create mode 100644 agent/agentrsa/key_test.go diff --git a/agent/agentrsa/key.go b/agent/agentrsa/key.go new file mode 100644 index 0000000000..fd70d0b7bf --- /dev/null +++ b/agent/agentrsa/key.go @@ -0,0 +1,87 @@ +package agentrsa + +import ( + "crypto/rsa" + "math/big" + "math/rand" +) + +// GenerateDeterministicKey generates an RSA private key deterministically based on the provided seed. +// This function uses a deterministic random source to generate the primes p and q, ensuring that the +// same seed will always produce the same private key. The generated key is 2048 bits in size. +// +// Reference: https://pkg.go.dev/crypto/rsa#GenerateKey +func GenerateDeterministicKey(seed int64) *rsa.PrivateKey { + // Since the standard lib purposefully does not generate + // deterministic rsa keys, we need to do it ourselves. + + // Create deterministic random source + // nolint: gosec + deterministicRand := rand.New(rand.NewSource(seed)) + + // Use fixed values for p and q based on the seed + p := big.NewInt(0) + q := big.NewInt(0) + e := big.NewInt(65537) // Standard RSA public exponent + + for { + // Generate deterministic primes using the seeded random + // Each prime should be ~1024 bits to get a 2048-bit key + for { + p.SetBit(p, 1024, 1) // Ensure it's large enough + for i := range 1024 { + if deterministicRand.Int63()%2 == 1 { + p.SetBit(p, i, 1) + } else { + p.SetBit(p, i, 0) + } + } + p1 := new(big.Int).Sub(p, big.NewInt(1)) + if p.ProbablyPrime(20) && new(big.Int).GCD(nil, nil, e, p1).Cmp(big.NewInt(1)) == 0 { + break + } + } + + for { + q.SetBit(q, 1024, 1) // Ensure it's large enough + for i := range 1024 { + if deterministicRand.Int63()%2 == 1 { + q.SetBit(q, i, 1) + } else { + q.SetBit(q, i, 0) + } + } + q1 := new(big.Int).Sub(q, big.NewInt(1)) + if q.ProbablyPrime(20) && p.Cmp(q) != 0 && new(big.Int).GCD(nil, nil, e, q1).Cmp(big.NewInt(1)) == 0 { + break + } + } + + // Calculate phi = (p-1) * (q-1) + p1 := new(big.Int).Sub(p, big.NewInt(1)) + q1 := new(big.Int).Sub(q, big.NewInt(1)) + phi := new(big.Int).Mul(p1, q1) + + // Calculate private exponent d + d := new(big.Int).ModInverse(e, phi) + if d != nil { + // Calculate n = p * q + n := new(big.Int).Mul(p, q) + + // Create the private key + privateKey := &rsa.PrivateKey{ + PublicKey: rsa.PublicKey{ + N: n, + E: int(e.Int64()), + }, + D: d, + Primes: []*big.Int{p, q}, + } + + // Compute precomputed values + privateKey.Precompute() + + return privateKey + } + } +} diff --git a/agent/agentrsa/key_test.go b/agent/agentrsa/key_test.go new file mode 100644 index 0000000000..dc561d09d4 --- /dev/null +++ b/agent/agentrsa/key_test.go @@ -0,0 +1,50 @@ +package agentrsa_test + +import ( + "crypto/rsa" + "math/rand/v2" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/agent/agentrsa" +) + +func TestGenerateDeterministicKey(t *testing.T) { + t.Parallel() + + key1 := agentrsa.GenerateDeterministicKey(1234) + key2 := agentrsa.GenerateDeterministicKey(1234) + + assert.Equal(t, key1, key2) + assert.EqualExportedValues(t, key1, key2) +} + +var result *rsa.PrivateKey + +func BenchmarkGenerateDeterministicKey(b *testing.B) { + var r *rsa.PrivateKey + + for range b.N { + // always record the result of DeterministicPrivateKey to prevent + // the compiler eliminating the function call. + r = agentrsa.GenerateDeterministicKey(rand.Int64()) + } + + // always store the result to a package level variable + // so the compiler cannot eliminate the Benchmark itself. + result = r +} + +func FuzzGenerateDeterministicKey(f *testing.F) { + testcases := []int64{0, 1234, 1010101010} + for _, tc := range testcases { + f.Add(tc) // Use f.Add to provide a seed corpus + } + f.Fuzz(func(t *testing.T, seed int64) { + key1 := agentrsa.GenerateDeterministicKey(seed) + key2 := agentrsa.GenerateDeterministicKey(seed) + assert.Equal(t, key1, key2) + assert.EqualExportedValues(t, key1, key2) + }) +} diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index d5fe945c49..3b09df0e38 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -3,12 +3,9 @@ package agentssh import ( "bufio" "context" - "crypto/rsa" "errors" "fmt" "io" - "math/big" - "math/rand" "net" "os" "os/exec" @@ -33,6 +30,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/agent/agentrsa" "github.com/coder/coder/v2/agent/usershell" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty" @@ -1092,75 +1090,7 @@ func CoderSigner(seed int64) (gossh.Signer, error) { // Clients should ignore the host key when connecting. // The agent needs to authenticate with coderd to SSH, // so SSH authentication doesn't improve security. - - // Since the standard lib purposefully does not generate - // deterministic rsa keys, we need to do it ourselves. - coderHostKey := func() *rsa.PrivateKey { - // Create deterministic random source - // nolint: gosec - deterministicRand := rand.New(rand.NewSource(seed)) - - // Use fixed values for p and q based on the seed - p := big.NewInt(0) - q := big.NewInt(0) - e := big.NewInt(65537) // Standard RSA public exponent - - // Generate deterministic primes using the seeded random - // Each prime should be ~1024 bits to get a 2048-bit key - for { - p.SetBit(p, 1024, 1) // Ensure it's large enough - for i := 0; i < 1024; i++ { - if deterministicRand.Int63()%2 == 1 { - p.SetBit(p, i, 1) - } else { - p.SetBit(p, i, 0) - } - } - if p.ProbablyPrime(20) { - break - } - } - - for { - q.SetBit(q, 1024, 1) // Ensure it's large enough - for i := 0; i < 1024; i++ { - if deterministicRand.Int63()%2 == 1 { - q.SetBit(q, i, 1) - } else { - q.SetBit(q, i, 0) - } - } - if q.ProbablyPrime(20) && p.Cmp(q) != 0 { - break - } - } - - // Calculate n = p * q - n := new(big.Int).Mul(p, q) - - // Calculate phi = (p-1) * (q-1) - p1 := new(big.Int).Sub(p, big.NewInt(1)) - q1 := new(big.Int).Sub(q, big.NewInt(1)) - phi := new(big.Int).Mul(p1, q1) - - // Calculate private exponent d - d := new(big.Int).ModInverse(e, phi) - - // Create the private key - privateKey := &rsa.PrivateKey{ - PublicKey: rsa.PublicKey{ - N: n, - E: int(e.Int64()), - }, - D: d, - Primes: []*big.Int{p, q}, - } - - // Compute precomputed values - privateKey.Precompute() - - return privateKey - }() + coderHostKey := agentrsa.GenerateDeterministicKey(seed) coderSigner, err := gossh.NewSignerFromKey(coderHostKey) return coderSigner, err From d6515aea917ea33e26648c0ba9573deae9872151 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 26 Feb 2025 14:55:48 +0200 Subject: [PATCH 26/29] Add integration test for creating and using presets from scratch Signed-off-by: Danny Kopping --- coderd/prebuilds/api.go | 7 +- site/e2e/playwright.config.ts | 4 +- site/e2e/setup/preflight.ts | 2 +- site/e2e/tests/presets/createPreset.spec.ts | 76 ++++++++++ site/e2e/tests/presets/template.zip | Bin 0 -> 4184 bytes site/e2e/tests/presets/template/main.tf | 152 ++++++++++++++++++++ 6 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 site/e2e/tests/presets/createPreset.spec.ts create mode 100644 site/e2e/tests/presets/template.zip create mode 100644 site/e2e/tests/presets/template/main.tf diff --git a/coderd/prebuilds/api.go b/coderd/prebuilds/api.go index 3dc1c97344..7d2276ecc7 100644 --- a/coderd/prebuilds/api.go +++ b/coderd/prebuilds/api.go @@ -3,10 +3,8 @@ package prebuilds import ( "context" - "github.com/google/uuid" - "golang.org/x/xerrors" - "github.com/coder/coder/v2/coderd/database" + "github.com/google/uuid" ) type Claimer interface { @@ -17,7 +15,8 @@ type Claimer interface { type AGPLPrebuildClaimer struct{} func (c AGPLPrebuildClaimer) Claim(context.Context, database.Store, uuid.UUID, string, uuid.UUID) (*uuid.UUID, error) { - return nil, xerrors.Errorf("not entitled to claim prebuilds") + // Not entitled to claim prebuilds in AGPL version. + return nil, nil } func (c AGPLPrebuildClaimer) Initiator() uuid.UUID { diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index 762b7f0158..c5f6cefc29 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -122,6 +122,8 @@ export default defineConfig({ CODER_OIDC_SIGN_IN_TEXT: "Hello", CODER_OIDC_ICON_URL: "/icon/google.svg", }, - reuseExistingServer: false, + reuseExistingServer: process.env.CODER_E2E_REUSE_EXISTING_SERVER + ? Boolean(process.env.CODER_E2E_REUSE_EXISTING_SERVER) + : false, }, }); diff --git a/site/e2e/setup/preflight.ts b/site/e2e/setup/preflight.ts index dedcc195db..0a5eefc68c 100644 --- a/site/e2e/setup/preflight.ts +++ b/site/e2e/setup/preflight.ts @@ -36,7 +36,7 @@ export default function () { throw new Error(msg); } - if (!process.env.CI) { + if (!process.env.CI && !process.env.CODER_E2E_REUSE_EXISTING_SERVER) { console.info("==> make site/e2e/bin/coder"); execSync("make site/e2e/bin/coder", { cwd: path.join(__dirname, "../../../"), diff --git a/site/e2e/tests/presets/createPreset.spec.ts b/site/e2e/tests/presets/createPreset.spec.ts new file mode 100644 index 0000000000..a3b58d572e --- /dev/null +++ b/site/e2e/tests/presets/createPreset.spec.ts @@ -0,0 +1,76 @@ +import {expect, test} from "@playwright/test"; +import {currentUser, login} from "../../helpers"; +import {beforeCoderTest} from "../../hooks"; +import path from "node:path"; + +test.beforeEach(async ({page}) => { + beforeCoderTest(page); + await login(page); +}); + +test("create template with preset and use in workspace", async ({page, baseURL}) => { + test.setTimeout(120_000); + + // Create new template. + await page.goto('/templates/new', {waitUntil: 'domcontentloaded'}); + await page.getByTestId('drop-zone').click(); + + // Select the template file. + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.getByTestId('drop-zone').click() + ]); + await fileChooser.setFiles(path.join(__dirname, 'template.zip')); + + // Set name and submit. + const templateName = generateRandomName(); + await page.locator("input[name=name]").fill(templateName); + await page.getByRole('button', {name: 'Save'}).click(); + + await page.waitForURL(`/templates/${templateName}/files`, { + timeout: 120_000, + }); + + // Visit workspace creation page for new template. + await page.goto(`/templates/default/${templateName}/workspace`, {waitUntil: 'domcontentloaded'}); + + await page.locator('button[aria-label="Preset"]').click(); + + const preset1 = page.getByText('I Like GoLand'); + const preset2 = page.getByText('Some Like PyCharm'); + + await expect(preset1).toBeVisible(); + await expect(preset2).toBeVisible(); + + // Choose the GoLand preset. + await preset1.click(); + + // Validate the preset was applied correctly. + await expect(page.locator('input[value="GO"]')).toBeChecked(); + + // Create a workspace. + const workspaceName = generateRandomName(); + await page.locator("input[name=name]").fill(workspaceName); + await page.getByRole('button', {name: 'Create workspace'}).click(); + + // Wait for the workspace build display to be navigated to. + const user = currentUser(page); + await page.waitForURL(`/@${user.username}/${workspaceName}`, { + timeout: 120_000, // Account for workspace build time. + }); + + // Visit workspace settings page. + await page.goto(`/@${user.username}/${workspaceName}/settings/parameters`); + + // Validate the preset was applied correctly. + await expect(page.locator('input[value="GO"]')).toBeChecked(); +}); + +function generateRandomName() { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + let name = ''; + for (let i = 0; i < 10; i++) { + name += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return name; +} diff --git a/site/e2e/tests/presets/template.zip b/site/e2e/tests/presets/template.zip new file mode 100644 index 0000000000000000000000000000000000000000..0cf58ba1b89a2aecefb6fa07976a6b03284b2492 GIT binary patch literal 4184 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 AGXMYp literal 0 HcmV?d00001 diff --git a/site/e2e/tests/presets/template/main.tf b/site/e2e/tests/presets/template/main.tf new file mode 100644 index 0000000000..c2cf20afe0 --- /dev/null +++ b/site/e2e/tests/presets/template/main.tf @@ -0,0 +1,152 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "2.1.3" + } + docker = { + source = "kreuzwerker/docker" + version = "3.0.2" + } + } +} + +variable "docker_socket" { + default = "" + description = "(Optional) Docker socket URI" + type = string +} + +provider "docker" { + # Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default + host = var.docker_socket != "" ? var.docker_socket : null +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_workspace_preset" "goland" { + name = "I Like GoLand" + parameters = { + "jetbrains_ide" = "GO" + } + prebuilds { + instances = 1 + } +} + +data "coder_workspace_preset" "python" { + name = "Some Like PyCharm" + parameters = { + "jetbrains_ide" = "PY" + } + prebuilds { + instances = 2 + } +} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script = <<-EOT + set -e + + # Prepare user home with default files on first start! + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ + touch ~/.init_done + fi + + # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here + EOT + + # These environment variables allow you to make Git commits right away after creating a + # workspace. Note that they take precedence over configuration defined in ~/.gitconfig! + # You can remove this block if you'd prefer to configure Git manually or using + # dotfiles. (see docs/dotfiles.md) + env = { + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}" + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}" + } + + # 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 = "Is Prebuild" + key = "prebuild" + script = "echo ${data.coder_workspace.me.prebuild_count}" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Hostname" + key = "hostname" + script = "hostname" + interval = 10 + timeout = 1 + } +} + +# See https://registry.coder.com/modules/jetbrains-gateway +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + + # JetBrains IDEs to make available for the user to select + jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] + default = "IU" + + # Default folder to open when starting a JetBrains IDE + folder = "/home/coder" + + # This ensures that the latest version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = ">= 1.0.0" + + agent_id = coder_agent.main.id + agent_name = "main" + order = 2 +} + +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 + } +} + +resource "docker_container" "workspace" { + lifecycle { + ignore_changes = all + } + + network_mode = "host" + + count = data.coder_workspace.me.start_count + image = "codercom/enterprise-base:ubuntu" + # Uses lower() to avoid Docker restriction on container names. + name = "coder-${data.coder_workspace_owner.me.name}-${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/coder" + volume_name = docker_volume.home_volume.name + read_only = false + } +} From 8f051e646d25f2e760489bee4c2882e458dc1a3f Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 27 Feb 2025 13:38:15 +0200 Subject: [PATCH 27/29] Adding basic test for prebuilds, abstracting template import Signed-off-by: Danny Kopping --- site/e2e/helpers.ts | 84 ++++++++++ site/e2e/playwright.config.ts | 2 +- .../main.tf | 12 +- site/e2e/tests/presets/basic-presets/main.tf | 146 ++++++++++++++++++ site/e2e/tests/presets/createPreset.spec.ts | 76 --------- site/e2e/tests/presets/prebuilds.spec.ts | 42 +++++ site/e2e/tests/presets/presets.spec.ts | 57 +++++++ site/e2e/tests/presets/template.zip | Bin 4184 -> 0 bytes 8 files changed, 336 insertions(+), 83 deletions(-) rename site/e2e/tests/presets/{template => basic-presets-with-prebuild}/main.tf (92%) create mode 100644 site/e2e/tests/presets/basic-presets/main.tf delete mode 100644 site/e2e/tests/presets/createPreset.spec.ts create mode 100644 site/e2e/tests/presets/prebuilds.spec.ts create mode 100644 site/e2e/tests/presets/presets.spec.ts delete mode 100644 site/e2e/tests/presets/template.zip diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 5692909355..d39aa26cd6 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -1,6 +1,8 @@ import { type ChildProcess, exec, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; +import fs from "node:fs"; import net from "node:net"; +import * as os from "node:os"; import path from "node:path"; import { Duplex } from "node:stream"; import { type BrowserContext, type Page, expect, test } from "@playwright/test"; @@ -10,6 +12,7 @@ import type { WorkspaceBuildParameter, } from "api/typesGenerated"; import express from "express"; +import JSZip from "jszip"; import capitalize from "lodash/capitalize"; import * as ssh from "ssh2"; import { TarWriter } from "utils/tar"; @@ -1127,3 +1130,84 @@ export async function createOrganization(page: Page): Promise<{ return { name, displayName, description }; } + +// TODO: convert to test fixture and dispose after each test. +export async function importTemplate( + page: Page, + templateName: string, + files: string[], + orgName = defaultOrganizationName, +): Promise { + // Create a ZIP from the given input files. + const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), templateName)); + const templatePath = path.join(tmpdir, `${templateName}.zip`); + await createZIP(templatePath, files); + + // Create new template. + await page.goto("/templates/new", { waitUntil: "domcontentloaded" }); + await page.getByTestId("drop-zone").click(); + + // Select the template file. + const [fileChooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByTestId("drop-zone").click(), + ]); + await fileChooser.setFiles(templatePath); + + // Set name and submit. + await page.locator("input[name=name]").fill(templateName); + + // If the organization picker is present on the page, select the default + // organization. + const orgPicker = page.getByLabel("Belongs to *"); + const organizationsEnabled = await orgPicker.isVisible(); + if (organizationsEnabled) { + if (orgName !== defaultOrganizationName) { + throw new Error( + `No provisioners registered for ${orgName}, creating this template will fail`, + ); + } + + await orgPicker.click(); + await page.getByText(orgName, { exact: true }).click(); + } + + await page.getByRole("button", { name: "Save" }).click(); + + await page.waitForURL(`/templates/${orgName}/${templateName}/files`, { + timeout: 120_000, + }); + return templateName; +} + +async function createZIP( + outpath: string, + inputFiles: string[], +): Promise<{ path: string; length: number }> { + const zip = new JSZip(); + + let found = false; + for (const file of inputFiles) { + if (!fs.existsSync(file)) { + console.warn(`${file} not found, not including in zip`); + continue; + } + found = true; + + const contents = fs.readFileSync(file); + zip.file(path.basename(file), contents); + } + + if (!found) { + throw new Error(`no files found to zip into ${outpath}`); + } + + zip + .generateNodeStream({ type: "nodebuffer", streamFiles: true }) + .pipe(fs.createWriteStream(outpath)); + + return { + path: outpath, + length: zip.length, + }; +} diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index c5f6cefc29..cfad4ca31c 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -111,7 +111,7 @@ export default defineConfig({ gitAuth.validatePath, ), CODER_PPROF_ADDRESS: `127.0.0.1:${coderdPProfPort}`, - CODER_EXPERIMENTS: `${e2eFakeExperiment1},${e2eFakeExperiment2}`, + CODER_EXPERIMENTS: `${e2eFakeExperiment1},${e2eFakeExperiment2},${process.env.CODER_EXPERIMENTS}`, // Tests for Deployment / User Authentication / OIDC CODER_OIDC_ISSUER_URL: "https://accounts.google.com", diff --git a/site/e2e/tests/presets/template/main.tf b/site/e2e/tests/presets/basic-presets-with-prebuild/main.tf similarity index 92% rename from site/e2e/tests/presets/template/main.tf rename to site/e2e/tests/presets/basic-presets-with-prebuild/main.tf index c2cf20afe0..804545274b 100644 --- a/site/e2e/tests/presets/template/main.tf +++ b/site/e2e/tests/presets/basic-presets-with-prebuild/main.tf @@ -1,11 +1,11 @@ terraform { required_providers { coder = { - source = "coder/coder" + source = "coder/coder" version = "2.1.3" } docker = { - source = "kreuzwerker/docker" + source = "kreuzwerker/docker" version = "3.0.2" } } @@ -66,9 +66,9 @@ resource "coder_agent" "main" { # You can remove this block if you'd prefer to configure Git manually or using # dotfiles. (see docs/dotfiles.md) env = { - GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}" - GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}" } @@ -96,12 +96,12 @@ resource "coder_agent" "main" { # See https://registry.coder.com/modules/jetbrains-gateway module "jetbrains_gateway" { - count = data.coder_workspace.me.start_count + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/jetbrains-gateway/coder" # JetBrains IDEs to make available for the user to select jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] - default = "IU" + default = "IU" # Default folder to open when starting a JetBrains IDE folder = "/home/coder" diff --git a/site/e2e/tests/presets/basic-presets/main.tf b/site/e2e/tests/presets/basic-presets/main.tf new file mode 100644 index 0000000000..8daccf9d01 --- /dev/null +++ b/site/e2e/tests/presets/basic-presets/main.tf @@ -0,0 +1,146 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "2.1.3" + } + docker = { + source = "kreuzwerker/docker" + version = "3.0.2" + } + } +} + +variable "docker_socket" { + default = "" + description = "(Optional) Docker socket URI" + type = string +} + +provider "docker" { + # Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default + host = var.docker_socket != "" ? var.docker_socket : null +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_workspace_preset" "goland" { + name = "I Like GoLand" + parameters = { + "jetbrains_ide" = "GO" + } +} + +data "coder_workspace_preset" "python" { + name = "Some Like PyCharm" + parameters = { + "jetbrains_ide" = "PY" + } +} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script = <<-EOT + set -e + + # Prepare user home with default files on first start! + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ + touch ~/.init_done + fi + + # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here + EOT + + # These environment variables allow you to make Git commits right away after creating a + # workspace. Note that they take precedence over configuration defined in ~/.gitconfig! + # You can remove this block if you'd prefer to configure Git manually or using + # dotfiles. (see docs/dotfiles.md) + env = { + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}" + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}" + } + + # 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 = "Is Prebuild" + key = "prebuild" + script = "echo ${data.coder_workspace.me.prebuild_count}" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Hostname" + key = "hostname" + script = "hostname" + interval = 10 + timeout = 1 + } +} + +# See https://registry.coder.com/modules/jetbrains-gateway +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + + # JetBrains IDEs to make available for the user to select + jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] + default = "IU" + + # Default folder to open when starting a JetBrains IDE + folder = "/home/coder" + + # This ensures that the latest version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = ">= 1.0.0" + + agent_id = coder_agent.main.id + agent_name = "main" + order = 2 +} + +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 + } +} + +resource "docker_container" "workspace" { + lifecycle { + ignore_changes = all + } + + network_mode = "host" + + count = data.coder_workspace.me.start_count + image = "codercom/enterprise-base:ubuntu" + # Uses lower() to avoid Docker restriction on container names. + name = "coder-${data.coder_workspace_owner.me.name}-${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/coder" + volume_name = docker_volume.home_volume.name + read_only = false + } +} diff --git a/site/e2e/tests/presets/createPreset.spec.ts b/site/e2e/tests/presets/createPreset.spec.ts deleted file mode 100644 index a3b58d572e..0000000000 --- a/site/e2e/tests/presets/createPreset.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import {expect, test} from "@playwright/test"; -import {currentUser, login} from "../../helpers"; -import {beforeCoderTest} from "../../hooks"; -import path from "node:path"; - -test.beforeEach(async ({page}) => { - beforeCoderTest(page); - await login(page); -}); - -test("create template with preset and use in workspace", async ({page, baseURL}) => { - test.setTimeout(120_000); - - // Create new template. - await page.goto('/templates/new', {waitUntil: 'domcontentloaded'}); - await page.getByTestId('drop-zone').click(); - - // Select the template file. - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - page.getByTestId('drop-zone').click() - ]); - await fileChooser.setFiles(path.join(__dirname, 'template.zip')); - - // Set name and submit. - const templateName = generateRandomName(); - await page.locator("input[name=name]").fill(templateName); - await page.getByRole('button', {name: 'Save'}).click(); - - await page.waitForURL(`/templates/${templateName}/files`, { - timeout: 120_000, - }); - - // Visit workspace creation page for new template. - await page.goto(`/templates/default/${templateName}/workspace`, {waitUntil: 'domcontentloaded'}); - - await page.locator('button[aria-label="Preset"]').click(); - - const preset1 = page.getByText('I Like GoLand'); - const preset2 = page.getByText('Some Like PyCharm'); - - await expect(preset1).toBeVisible(); - await expect(preset2).toBeVisible(); - - // Choose the GoLand preset. - await preset1.click(); - - // Validate the preset was applied correctly. - await expect(page.locator('input[value="GO"]')).toBeChecked(); - - // Create a workspace. - const workspaceName = generateRandomName(); - await page.locator("input[name=name]").fill(workspaceName); - await page.getByRole('button', {name: 'Create workspace'}).click(); - - // Wait for the workspace build display to be navigated to. - const user = currentUser(page); - await page.waitForURL(`/@${user.username}/${workspaceName}`, { - timeout: 120_000, // Account for workspace build time. - }); - - // Visit workspace settings page. - await page.goto(`/@${user.username}/${workspaceName}/settings/parameters`); - - // Validate the preset was applied correctly. - await expect(page.locator('input[value="GO"]')).toBeChecked(); -}); - -function generateRandomName() { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - let name = ''; - for (let i = 0; i < 10; i++) { - name += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return name; -} diff --git a/site/e2e/tests/presets/prebuilds.spec.ts b/site/e2e/tests/presets/prebuilds.spec.ts new file mode 100644 index 0000000000..ea41b1ecad --- /dev/null +++ b/site/e2e/tests/presets/prebuilds.spec.ts @@ -0,0 +1,42 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { + importTemplate, + login, + randomName, + requiresLicense, +} from "../../helpers"; +import { beforeCoderTest } from "../../hooks"; + +test.beforeEach(async ({ page }) => { + beforeCoderTest(page); + await login(page); +}); + +// NOTE: requires the `workspace-prebuilds` experiment enabled! +test("create template with desired prebuilds", async ({ page, baseURL }) => { + requiresLicense(); + + const expectedPrebuilds = 3; + + // Create new template. + const templateName = randomName(); + await importTemplate(page, templateName, [ + path.join(__dirname, "basic-presets-with-prebuild/main.tf"), + path.join(__dirname, "basic-presets-with-prebuild/.terraform.lock.hcl"), + ]); + + await page.goto( + `/workspaces?filter=owner:prebuilds%20template:${templateName}&page=1`, + ); + + // Wait for prebuilds to show up. + const prebuilds = page.getByTestId(/^workspace-.+$/); + await prebuilds.first().waitFor({ state: "visible", timeout: 120_000 }); + expect((await prebuilds.all()).length).toEqual(expectedPrebuilds); + + // Wait for prebuilds to become ready. + const readyPrebuilds = page.getByTestId("build-status"); + await readyPrebuilds.first().waitFor({ state: "visible", timeout: 120_000 }); + expect((await readyPrebuilds.all()).length).toEqual(expectedPrebuilds); +}); diff --git a/site/e2e/tests/presets/presets.spec.ts b/site/e2e/tests/presets/presets.spec.ts new file mode 100644 index 0000000000..4b0a10b3f2 --- /dev/null +++ b/site/e2e/tests/presets/presets.spec.ts @@ -0,0 +1,57 @@ +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { currentUser, importTemplate, login, randomName } from "../../helpers"; +import { beforeCoderTest } from "../../hooks"; + +test.beforeEach(async ({ page }) => { + beforeCoderTest(page); + await login(page); +}); + +test("create template with preset and use in workspace", async ({ + page, + baseURL, +}) => { + // Create new template. + const templateName = randomName(); + await importTemplate(page, templateName, [ + path.join(__dirname, "basic-presets/main.tf"), + path.join(__dirname, "basic-presets/.terraform.lock.hcl"), + ]); + + // Visit workspace creation page for new template. + await page.goto(`/templates/default/${templateName}/workspace`, { + waitUntil: "domcontentloaded", + }); + + await page.locator('button[aria-label="Preset"]').click(); + + const preset1 = page.getByText("I Like GoLand"); + const preset2 = page.getByText("Some Like PyCharm"); + + await expect(preset1).toBeVisible(); + await expect(preset2).toBeVisible(); + + // Choose the GoLand preset. + await preset1.click(); + + // Validate the preset was applied correctly. + await expect(page.locator('input[value="GO"]')).toBeChecked(); + + // Create a workspace. + const workspaceName = randomName(); + await page.locator("input[name=name]").fill(workspaceName); + await page.getByRole("button", { name: "Create workspace" }).click(); + + // Wait for the workspace build display to be navigated to. + const user = currentUser(page); + await page.waitForURL(`/@${user.username}/${workspaceName}`, { + timeout: 120_000, // Account for workspace build time. + }); + + // Visit workspace settings page. + await page.goto(`/@${user.username}/${workspaceName}/settings/parameters`); + + // Validate the preset was applied correctly. + await expect(page.locator('input[value="GO"]')).toBeChecked(); +}); diff --git a/site/e2e/tests/presets/template.zip b/site/e2e/tests/presets/template.zip deleted file mode 100644 index 0cf58ba1b89a2aecefb6fa07976a6b03284b2492..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4184 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 AGXMYp From 8a98fad3f1292d8c19d69b5d3cac0aafe1db1fcc Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 27 Feb 2025 14:53:09 +0200 Subject: [PATCH 28/29] Add e2e test for provisioning and claiming a prebuild Signed-off-by: Danny Kopping --- .../basic-presets-with-prebuild/main.tf | 13 ++- site/e2e/tests/presets/prebuilds.spec.ts | 80 +++++++++++++++++-- site/e2e/tests/presets/presets.spec.ts | 2 + 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/site/e2e/tests/presets/basic-presets-with-prebuild/main.tf b/site/e2e/tests/presets/basic-presets-with-prebuild/main.tf index 804545274b..1f5e4e5991 100644 --- a/site/e2e/tests/presets/basic-presets-with-prebuild/main.tf +++ b/site/e2e/tests/presets/basic-presets-with-prebuild/main.tf @@ -32,7 +32,7 @@ data "coder_workspace_preset" "goland" { "jetbrains_ide" = "GO" } prebuilds { - instances = 1 + instances = 2 } } @@ -41,9 +41,6 @@ data "coder_workspace_preset" "python" { parameters = { "jetbrains_ide" = "PY" } - prebuilds { - instances = 2 - } } resource "coder_agent" "main" { @@ -58,7 +55,9 @@ resource "coder_agent" "main" { touch ~/.init_done fi - # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here + if [[ "${data.coder_workspace.me.prebuild_count}" -eq 1 ]]; then + touch ~/.prebuild_note + fi EOT # These environment variables allow you to make Git commits right away after creating a @@ -78,9 +77,9 @@ resource "coder_agent" "main" { # For basic resources, you can use the `coder stat` command. # If you need more control, you can write your own script. metadata { - display_name = "Is Prebuild" + display_name = "Was Prebuild" key = "prebuild" - script = "echo ${data.coder_workspace.me.prebuild_count}" + script = "[[ -e ~/.prebuild_note ]] && echo 'Yes' || echo 'No'" interval = 10 timeout = 1 } diff --git a/site/e2e/tests/presets/prebuilds.spec.ts b/site/e2e/tests/presets/prebuilds.spec.ts index ea41b1ecad..38a296be96 100644 --- a/site/e2e/tests/presets/prebuilds.spec.ts +++ b/site/e2e/tests/presets/prebuilds.spec.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { expect, test } from "@playwright/test"; import { + currentUser, importTemplate, login, randomName, @@ -17,7 +18,7 @@ test.beforeEach(async ({ page }) => { test("create template with desired prebuilds", async ({ page, baseURL }) => { requiresLicense(); - const expectedPrebuilds = 3; + const expectedPrebuilds = 2; // Create new template. const templateName = randomName(); @@ -28,6 +29,7 @@ test("create template with desired prebuilds", async ({ page, baseURL }) => { await page.goto( `/workspaces?filter=owner:prebuilds%20template:${templateName}&page=1`, + { waitUntil: "domcontentloaded" }, ); // Wait for prebuilds to show up. @@ -35,8 +37,76 @@ test("create template with desired prebuilds", async ({ page, baseURL }) => { await prebuilds.first().waitFor({ state: "visible", timeout: 120_000 }); expect((await prebuilds.all()).length).toEqual(expectedPrebuilds); - // Wait for prebuilds to become ready. - const readyPrebuilds = page.getByTestId("build-status"); - await readyPrebuilds.first().waitFor({ state: "visible", timeout: 120_000 }); - expect((await readyPrebuilds.all()).length).toEqual(expectedPrebuilds); + // Wait for prebuilds to start. + const runningPrebuilds = page.getByTestId("build-status").getByText("Running"); + await runningPrebuilds.first().waitFor({ state: "visible", timeout: 120_000 }); + expect((await runningPrebuilds.all()).length).toEqual(expectedPrebuilds); +}); + +// NOTE: requires the `workspace-prebuilds` experiment enabled! +test("claim prebuild matching selected preset", async ({ page, baseURL }) => { + test.setTimeout(300_000); + + requiresLicense(); + + // Create new template. + const templateName = randomName(); + await importTemplate(page, templateName, [ + path.join(__dirname, "basic-presets-with-prebuild/main.tf"), + path.join(__dirname, "basic-presets-with-prebuild/.terraform.lock.hcl"), + ]); + + await page.goto( + `/workspaces?filter=owner:prebuilds%20template:${templateName}&page=1`, + { waitUntil: "domcontentloaded" }, + ); + + // Wait for prebuilds to show up. + const prebuilds = page.getByTestId(/^workspace-.+$/); + await prebuilds.first().waitFor({ state: "visible", timeout: 120_000 }); + + // Wait for prebuilds to start. + const runningPrebuilds = page.getByTestId("build-status").getByText("Running"); + await runningPrebuilds.first().waitFor({ state: "visible", timeout: 120_000 }); + + // Open the first prebuild. + await runningPrebuilds.first().click(); + await page.waitForURL(/\/@prebuilds\/prebuild-.+/); + + // Wait for the prebuild to become ready so it's eligible to be claimed. + await page.getByTestId("agent-status-ready").waitFor({ timeout: 60_000 }); + + // Create a new workspace using the same preset as one of the prebuilds. + await page.goto(`/templates/coder/${templateName}/workspace`, { + waitUntil: "domcontentloaded", + }); + + // Visit workspace creation page for new template. + await page.goto(`/templates/default/${templateName}/workspace`, { + waitUntil: "domcontentloaded", + }); + + // Choose a preset. + await page.locator('button[aria-label="Preset"]').click(); + // Choose the GoLand preset. + const preset = page.getByText("I Like GoLand"); + await expect(preset).toBeVisible(); + await preset.click(); + + // Create a workspace. + const workspaceName = randomName(); + await page.locator("input[name=name]").fill(workspaceName); + await page.getByRole("button", { name: "Create workspace" }).click(); + + // Wait for the workspace build display to be navigated to. + const user = currentUser(page); + await page.waitForURL(`/@${user.username}/${workspaceName}`, { + timeout: 120_000, // Account for workspace build time. + }); + + // Validate the workspace metadata that it was indeed a claimed prebuild. + const indicator = page.getByText("Was Prebuild"); + await indicator.waitFor({ timeout: 60_000 }); + const text = indicator.locator("xpath=..").getByText("Yes"); + await text.waitFor({ timeout: 30_000 }); }); diff --git a/site/e2e/tests/presets/presets.spec.ts b/site/e2e/tests/presets/presets.spec.ts index 4b0a10b3f2..85266d281d 100644 --- a/site/e2e/tests/presets/presets.spec.ts +++ b/site/e2e/tests/presets/presets.spec.ts @@ -12,6 +12,8 @@ test("create template with preset and use in workspace", async ({ page, baseURL, }) => { + test.setTimeout(300_000); + // Create new template. const templateName = randomName(); await importTemplate(page, templateName, [ From 900cb05e81e4eeaf8a137d410f00fa116ac2700e Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 27 Feb 2025 22:54:58 +0200 Subject: [PATCH 29/29] Improve resilience of e2e test Signed-off-by: Danny Kopping --- site/e2e/playwright.config.ts | 2 +- site/e2e/tests/presets/prebuilds.spec.ts | 84 ++++++++++++++++++------ 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index cfad4ca31c..f6ea421cb4 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ ], reporter: [["./reporter.ts"]], use: { - actionTimeout: 5000, + actionTimeout: 60_000, baseURL: `http://localhost:${coderPort}`, video: "retain-on-failure", ...(wsEndpoint diff --git a/site/e2e/tests/presets/prebuilds.spec.ts b/site/e2e/tests/presets/prebuilds.spec.ts index 38a296be96..d1e78287fb 100644 --- a/site/e2e/tests/presets/prebuilds.spec.ts +++ b/site/e2e/tests/presets/prebuilds.spec.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { expect, test } from "@playwright/test"; +import { type Locator, expect, test } from "@playwright/test"; import { currentUser, importTemplate, @@ -14,18 +14,22 @@ test.beforeEach(async ({ page }) => { await login(page); }); +const waitForBuildTimeout = 120_000; // Builds can take a while, let's give them at most 2m. + +const templateFiles = [ + path.join(__dirname, "basic-presets-with-prebuild/main.tf"), + path.join(__dirname, "basic-presets-with-prebuild/.terraform.lock.hcl"), +]; + +const expectedPrebuilds = 2; + // NOTE: requires the `workspace-prebuilds` experiment enabled! test("create template with desired prebuilds", async ({ page, baseURL }) => { requiresLicense(); - const expectedPrebuilds = 2; - // Create new template. const templateName = randomName(); - await importTemplate(page, templateName, [ - path.join(__dirname, "basic-presets-with-prebuild/main.tf"), - path.join(__dirname, "basic-presets-with-prebuild/.terraform.lock.hcl"), - ]); + await importTemplate(page, templateName, templateFiles); await page.goto( `/workspaces?filter=owner:prebuilds%20template:${templateName}&page=1`, @@ -34,13 +38,13 @@ test("create template with desired prebuilds", async ({ page, baseURL }) => { // Wait for prebuilds to show up. const prebuilds = page.getByTestId(/^workspace-.+$/); - await prebuilds.first().waitFor({ state: "visible", timeout: 120_000 }); - expect((await prebuilds.all()).length).toEqual(expectedPrebuilds); + await waitForExpectedCount(prebuilds, expectedPrebuilds); // Wait for prebuilds to start. - const runningPrebuilds = page.getByTestId("build-status").getByText("Running"); - await runningPrebuilds.first().waitFor({ state: "visible", timeout: 120_000 }); - expect((await runningPrebuilds.all()).length).toEqual(expectedPrebuilds); + const runningPrebuilds = page + .getByTestId("build-status") + .getByText("Running"); + await waitForExpectedCount(runningPrebuilds, expectedPrebuilds); }); // NOTE: requires the `workspace-prebuilds` experiment enabled! @@ -51,10 +55,7 @@ test("claim prebuild matching selected preset", async ({ page, baseURL }) => { // Create new template. const templateName = randomName(); - await importTemplate(page, templateName, [ - path.join(__dirname, "basic-presets-with-prebuild/main.tf"), - path.join(__dirname, "basic-presets-with-prebuild/.terraform.lock.hcl"), - ]); + await importTemplate(page, templateName, templateFiles); await page.goto( `/workspaces?filter=owner:prebuilds%20template:${templateName}&page=1`, @@ -63,11 +64,17 @@ test("claim prebuild matching selected preset", async ({ page, baseURL }) => { // Wait for prebuilds to show up. const prebuilds = page.getByTestId(/^workspace-.+$/); - await prebuilds.first().waitFor({ state: "visible", timeout: 120_000 }); + await waitForExpectedCount(prebuilds, expectedPrebuilds); + + const previousWorkspaceNames = await Promise.all( + (await prebuilds.all()).map((value) => { + return value.getByText(/prebuild-.+/).textContent(); + }), + ); // Wait for prebuilds to start. - const runningPrebuilds = page.getByTestId("build-status").getByText("Running"); - await runningPrebuilds.first().waitFor({ state: "visible", timeout: 120_000 }); + let runningPrebuilds = page.getByTestId("build-status").getByText("Running"); + await waitForExpectedCount(runningPrebuilds, expectedPrebuilds); // Open the first prebuild. await runningPrebuilds.first().click(); @@ -101,7 +108,7 @@ test("claim prebuild matching selected preset", async ({ page, baseURL }) => { // Wait for the workspace build display to be navigated to. const user = currentUser(page); await page.waitForURL(`/@${user.username}/${workspaceName}`, { - timeout: 120_000, // Account for workspace build time. + timeout: waitForBuildTimeout, // Account for workspace build time. }); // Validate the workspace metadata that it was indeed a claimed prebuild. @@ -109,4 +116,41 @@ test("claim prebuild matching selected preset", async ({ page, baseURL }) => { await indicator.waitFor({ timeout: 60_000 }); const text = indicator.locator("xpath=..").getByText("Yes"); await text.waitFor({ timeout: 30_000 }); + + // Navigate back to prebuilds page to see that a new prebuild replaced the claimed one. + await page.goto( + `/workspaces?filter=owner:prebuilds%20template:${templateName}&page=1`, + { waitUntil: "domcontentloaded" }, + ); + + // Wait for prebuilds to show up. + const newPrebuilds = page.getByTestId(/^workspace-.+$/); + await waitForExpectedCount(newPrebuilds, expectedPrebuilds); + + const currentWorkspaceNames = await Promise.all( + (await newPrebuilds.all()).map((value) => { + return value.getByText(/prebuild-.+/).textContent(); + }), + ); + + // Ensure the prebuilds have changed. + expect(currentWorkspaceNames).not.toEqual(previousWorkspaceNames); + + // Wait for prebuilds to start. + runningPrebuilds = page.getByTestId("build-status").getByText("Running"); + await waitForExpectedCount(runningPrebuilds, expectedPrebuilds); }); + +function waitForExpectedCount(prebuilds: Locator, expectedCount: number) { + return expect + .poll( + async () => { + return (await prebuilds.all()).length === expectedCount; + }, + { + intervals: [100], + timeout: waitForBuildTimeout, + }, + ) + .toBe(true); +}