From a55186cd02eed49f8d6cbd7a2f057aec9e01e754 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Thu, 13 Oct 2022 17:38:30 -0500 Subject: [PATCH 01/51] fix(database): remove usage of `String()` for comparing UUIDs (#4547) --- coderd/database/databasefake/databasefake.go | 50 ++++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index bdb4c8e0f0..36bf079231 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -240,7 +240,7 @@ func (q *fakeQuerier) ParameterValue(_ context.Context, id uuid.UUID) (database. defer q.mutex.Unlock() for _, parameterValue := range q.parameterValues { - if parameterValue.ID.String() != id.String() { + if parameterValue.ID != id { continue } return parameterValue, nil @@ -253,7 +253,7 @@ func (q *fakeQuerier) DeleteParameterValueByID(_ context.Context, id uuid.UUID) defer q.mutex.Unlock() for index, parameterValue := range q.parameterValues { - if parameterValue.ID.String() != id.String() { + if parameterValue.ID != id { continue } q.parameterValues[index] = q.parameterValues[len(q.parameterValues)-1] @@ -495,7 +495,7 @@ func (q *fakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]datab users := make([]database.User, 0) for _, user := range q.users { for _, id := range ids { - if user.ID.String() != id.String() { + if user.ID != id { continue } users = append(users, user) @@ -716,7 +716,7 @@ func (q *fakeQuerier) GetWorkspaceByID(_ context.Context, id uuid.UUID) (databas defer q.mutex.RUnlock() for _, workspace := range q.workspaces { - if workspace.ID.String() == id.String() { + if workspace.ID == id { return workspace, nil } } @@ -787,7 +787,7 @@ func (q *fakeQuerier) GetWorkspaceAppsByAgentIDs(_ context.Context, ids []uuid.U apps := make([]database.WorkspaceApp, 0) for _, app := range q.workspaceApps { for _, id := range ids { - if app.AgentID.String() == id.String() { + if app.AgentID == id { apps = append(apps, app) break } @@ -836,7 +836,7 @@ func (q *fakeQuerier) GetWorkspaceBuildByID(_ context.Context, id uuid.UUID) (da defer q.mutex.RUnlock() for _, history := range q.workspaceBuilds { - if history.ID.String() == id.String() { + if history.ID == id { return history, nil } } @@ -848,7 +848,7 @@ func (q *fakeQuerier) GetWorkspaceCountByUserID(_ context.Context, id uuid.UUID) defer q.mutex.RUnlock() var count int64 for _, workspace := range q.workspaces { - if workspace.OwnerID.String() == id.String() { + if workspace.OwnerID == id { if workspace.Deleted { continue } @@ -864,7 +864,7 @@ func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUI defer q.mutex.RUnlock() for _, build := range q.workspaceBuilds { - if build.JobID.String() == jobID.String() { + if build.JobID == jobID { return build, nil } } @@ -953,7 +953,7 @@ func (q *fakeQuerier) GetWorkspaceBuildsByWorkspaceID(_ context.Context, if workspaceBuild.CreatedAt.Before(params.Since) { continue } - if workspaceBuild.WorkspaceID.String() == params.WorkspaceID.String() { + if workspaceBuild.WorkspaceID == params.WorkspaceID { history = append(history, workspaceBuild) } } @@ -1006,7 +1006,7 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(_ context.Con defer q.mutex.RUnlock() for _, workspaceBuild := range q.workspaceBuilds { - if workspaceBuild.WorkspaceID.String() != arg.WorkspaceID.String() { + if workspaceBuild.WorkspaceID != arg.WorkspaceID { continue } if workspaceBuild.BuildNumber != arg.BuildNumber { @@ -1121,7 +1121,7 @@ func (q *fakeQuerier) GetTemplateByID(_ context.Context, id uuid.UUID) (database defer q.mutex.RUnlock() for _, template := range q.templates { - if template.ID.String() == id.String() { + if template.ID == id { return template, nil } } @@ -1217,7 +1217,7 @@ func (q *fakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg dat defer q.mutex.RUnlock() for _, templateVersion := range q.templateVersions { - if templateVersion.TemplateID.UUID.String() != arg.TemplateID.String() { + if templateVersion.TemplateID.UUID != arg.TemplateID { continue } version = append(version, templateVersion) @@ -1305,7 +1305,7 @@ func (q *fakeQuerier) GetTemplateVersionByID(_ context.Context, templateVersionI defer q.mutex.RUnlock() for _, templateVersion := range q.templateVersions { - if templateVersion.ID.String() != templateVersionID.String() { + if templateVersion.ID != templateVersionID { continue } return templateVersion, nil @@ -1318,7 +1318,7 @@ func (q *fakeQuerier) GetTemplateVersionByJobID(_ context.Context, jobID uuid.UU defer q.mutex.RUnlock() for _, templateVersion := range q.templateVersions { - if templateVersion.JobID.String() != jobID.String() { + if templateVersion.JobID != jobID { continue } return templateVersion, nil @@ -1332,7 +1332,7 @@ func (q *fakeQuerier) GetParameterSchemasByJobID(_ context.Context, jobID uuid.U parameters := make([]database.ParameterSchema, 0) for _, parameterSchema := range q.parameterSchemas { - if parameterSchema.JobID.String() != jobID.String() { + if parameterSchema.JobID != jobID { continue } parameters = append(parameters, parameterSchema) @@ -1685,7 +1685,7 @@ func (q *fakeQuerier) GetProvisionerDaemonByID(_ context.Context, id uuid.UUID) defer q.mutex.RUnlock() for _, provisionerDaemon := range q.provisionerDaemons { - if provisionerDaemon.ID.String() != id.String() { + if provisionerDaemon.ID != id { continue } return provisionerDaemon, nil @@ -1711,7 +1711,7 @@ func (q *fakeQuerier) GetWorkspaceResourceByID(_ context.Context, id uuid.UUID) defer q.mutex.RUnlock() for _, resource := range q.provisionerJobResources { - if resource.ID.String() == id.String() { + if resource.ID == id { return resource, nil } } @@ -1724,7 +1724,7 @@ func (q *fakeQuerier) GetWorkspaceResourcesByJobID(_ context.Context, jobID uuid resources := make([]database.WorkspaceResource, 0) for _, resource := range q.provisionerJobResources { - if resource.JobID.String() != jobID.String() { + if resource.JobID != jobID { continue } resources = append(resources, resource) @@ -1791,7 +1791,7 @@ func (q *fakeQuerier) GetWorkspaceResourceMetadataByResourceID(_ context.Context metadata := make([]database.WorkspaceResourceMetadatum, 0) for _, metadatum := range q.provisionerJobResourceMetadata { - if metadatum.WorkspaceResourceID.String() == id.String() { + if metadatum.WorkspaceResourceID == id { metadata = append(metadata, metadatum) } } @@ -1805,7 +1805,7 @@ func (q *fakeQuerier) GetWorkspaceResourceMetadataByResourceIDs(_ context.Contex metadata := make([]database.WorkspaceResourceMetadatum, 0) for _, metadatum := range q.provisionerJobResourceMetadata { for _, id := range ids { - if metadatum.WorkspaceResourceID.String() == id.String() { + if metadatum.WorkspaceResourceID == id { metadata = append(metadata, metadatum) } } @@ -1820,7 +1820,7 @@ func (q *fakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID jobs := make([]database.ProvisionerJob, 0) for _, job := range q.provisionerJobs { for _, id := range ids { - if id.String() == job.ID.String() { + if id == job.ID { jobs = append(jobs, job) break } @@ -1852,7 +1852,7 @@ func (q *fakeQuerier) GetProvisionerLogsByIDBetween(_ context.Context, arg datab logs := make([]database.ProvisionerJobLog, 0) for _, jobLog := range q.provisionerJobLogs { - if jobLog.JobID.String() != arg.JobID.String() { + if jobLog.JobID != arg.JobID { continue } if !arg.CreatedBefore.IsZero() && jobLog.CreatedAt.After(arg.CreatedBefore) { @@ -2663,7 +2663,7 @@ func (q *fakeQuerier) UpdateGitSSHKey(_ context.Context, arg database.UpdateGitS defer q.mutex.Unlock() for index, key := range q.gitSSHKey { - if key.UserID.String() != arg.UserID.String() { + if key.UserID != arg.UserID { continue } key.UpdatedAt = arg.UpdatedAt @@ -2726,7 +2726,7 @@ func (q *fakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error defer q.mutex.Unlock() for index, key := range q.gitSSHKey { - if key.UserID.String() != userID.String() { + if key.UserID != userID { continue } q.gitSSHKey[index] = q.gitSSHKey[len(q.gitSSHKey)-1] @@ -3039,7 +3039,7 @@ func (q *fakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupPar defer q.mutex.RUnlock() for _, group := range q.groups { - if group.OrganizationID.String() == arg.OrganizationID.String() && + if group.OrganizationID == arg.OrganizationID && group.Name == arg.Name { return database.Group{}, errDuplicateKey } From 4e57b9fbdc463460b8c3d954ab19418422a31b5e Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 13 Oct 2022 18:02:52 -0500 Subject: [PATCH 02/51] fix: allow regular users to push files (#4500) - As part of merging support for Template RBAC and user groups a permission check on reading files was relaxed. With the addition of admin roles on individual templates, regular users are now able to push template versions if they have inherited the 'admin' role for a template. In order to do so they need to be able to create and read their own files. Since collisions on hash in the past were ignored, this means that a regular user who pushes a template version with a file hash that collides with an existing hash will not be able to read the file (since it belongs to another user). This commit fixes the underlying problem which was that the files table had a primary key on the 'hash' column. This was not a problem at the time because only template admins and other users with similar elevated roles were able to read all files regardless of ownership. To fix this a new column and primary key 'id' has been introduced to the files table. The unique constraint has been updated to be hash+created_by. Tables (provisioner_jobs) that referenced files.hash have been updated to reference files.id. Relevant API endpoints have also been updated. --- cli/templatecreate.go | 7 +- cli/templatepull.go | 2 +- cli/templatepush.go | 2 +- .../autobuild/executor/lifecycle_executor.go | 2 +- coderd/coderd.go | 2 +- coderd/coderdtest/authorize.go | 4 +- coderd/coderdtest/coderdtest.go | 4 +- coderd/database/databasefake/databasefake.go | 19 ++++- coderd/database/dump.sql | 12 ++- .../migrations/000059_file_id.down.sql | 41 ++++++++++ .../database/migrations/000059_file_id.up.sql | 42 +++++++++++ coderd/database/models.go | 3 +- coderd/database/querier.go | 3 +- coderd/database/queries.sql.go | 74 ++++++++++++++----- coderd/database/queries/files.sql | 19 ++++- coderd/database/queries/provisionerjobs.sql | 2 +- coderd/database/unique_constraint.go | 1 + coderd/files.go | 32 ++++++-- coderd/files_test.go | 5 +- coderd/provisionerdaemons.go | 2 +- coderd/provisionerdaemons_test.go | 4 +- coderd/provisionerjobs.go | 8 +- coderd/templates.go | 3 +- coderd/templateversions.go | 16 ++-- coderd/templateversions_test.go | 8 +- coderd/workspacebuilds.go | 2 +- coderd/workspaces.go | 2 +- codersdk/files.go | 8 +- codersdk/organizations.go | 2 +- codersdk/provisionerdaemons.go | 18 ++--- enterprise/coderd/templates_test.go | 4 +- site/src/api/typesGenerated.ts | 4 +- site/src/testHelpers/entities.ts | 2 +- 33 files changed, 265 insertions(+), 94 deletions(-) create mode 100644 coderd/database/migrations/000059_file_id.down.sql create mode 100644 coderd/database/migrations/000059_file_id.up.sql diff --git a/cli/templatecreate.go b/cli/templatecreate.go index a0f4014f71..de0b8eab8f 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -10,6 +10,7 @@ import ( "unicode/utf8" "github.com/briandowns/spinner" + "github.com/google/uuid" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -91,7 +92,7 @@ func templateCreate() *cobra.Command { Client: client, Organization: organization, Provisioner: database.ProvisionerType(provisioner), - FileHash: resp.Hash, + FileID: resp.ID, ParameterFile: parameterFile, }) if err != nil { @@ -148,7 +149,7 @@ type createValidTemplateVersionArgs struct { Client *codersdk.Client Organization codersdk.Organization Provisioner database.ProvisionerType - FileHash string + FileID uuid.UUID ParameterFile string // Template is only required if updating a template's active version. Template *codersdk.Template @@ -165,7 +166,7 @@ func createValidTemplateVersion(cmd *cobra.Command, args createValidTemplateVers req := codersdk.CreateTemplateVersionRequest{ Name: args.Name, StorageMethod: codersdk.ProvisionerStorageMethodFile, - StorageSource: args.FileHash, + FileID: args.FileID, Provisioner: codersdk.ProvisionerType(args.Provisioner), ParameterValues: parameters, } diff --git a/cli/templatepull.go b/cli/templatepull.go index 5660261c51..09f70c91b8 100644 --- a/cli/templatepull.go +++ b/cli/templatepull.go @@ -66,7 +66,7 @@ func templatePull() *cobra.Command { latest := versions[0] // Download the tar archive. - raw, ctype, err := client.Download(ctx, latest.Job.StorageSource) + raw, ctype, err := client.Download(ctx, latest.Job.FileID) if err != nil { return xerrors.Errorf("download template: %w", err) } diff --git a/cli/templatepush.go b/cli/templatepush.go index f858c6daec..40bafed0ef 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -80,7 +80,7 @@ func templatePush() *cobra.Command { Client: client, Organization: organization, Provisioner: database.ProvisionerType(provisioner), - FileHash: resp.Hash, + FileID: resp.ID, ParameterFile: parameterFile, Template: &template, ReuseParameters: !alwaysPrompt, diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index 22536583b6..f21fde9a8a 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -276,7 +276,7 @@ func build(ctx context.Context, store database.Store, workspace database.Workspa Provisioner: template.Provisioner, Type: database.ProvisionerJobTypeWorkspaceBuild, StorageMethod: priorJob.StorageMethod, - StorageSource: priorJob.StorageSource, + FileID: priorJob.FileID, Input: input, }) if err != nil { diff --git a/coderd/coderd.go b/coderd/coderd.go index 29439a2001..b067035b3a 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -280,7 +280,7 @@ func New(options *Options) *API { // file content is expensive so it should be small. httpmw.RateLimitPerMinute(12), ) - r.Get("/{hash}", api.fileByHash) + r.Get("/{fileID}", api.fileByID) r.Post("/", api.postFile) }) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index fa77898864..a5183f2b6e 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -142,7 +142,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { AssertObject: rbac.ResourceTemplate.InOrg(a.Template.OrganizationID), }, "POST:/api/v2/files": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceFile}, - "GET:/api/v2/files/{hash}": { + "GET:/api/v2/files/{fileID}": { AssertAction: rbac.ActionRead, AssertObject: rbac.ResourceFile.WithOwner(a.Admin.UserID.String()), }, @@ -369,7 +369,7 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a "{workspaceagent}": workspace.LatestBuild.Resources[0].Agents[0].ID.String(), "{buildnumber}": strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10), "{template}": template.ID.String(), - "{hash}": file.Hash, + "{fileID}": file.ID.String(), "{workspaceresource}": workspace.LatestBuild.Resources[0].ID.String(), "{workspaceapp}": workspace.LatestBuild.Resources[0].Agents[0].Apps[0].Name, "{templateversion}": version.ID.String(), diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index d7ac4eb14b..7ec28668ce 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -383,7 +383,7 @@ func CreateTemplateVersion(t *testing.T, client *codersdk.Client, organizationID file, err := client.Upload(context.Background(), codersdk.ContentTypeTar, data) require.NoError(t, err) templateVersion, err := client.CreateTemplateVersion(context.Background(), organizationID, codersdk.CreateTemplateVersionRequest{ - StorageSource: file.Hash, + FileID: file.ID, StorageMethod: codersdk.ProvisionerStorageMethodFile, Provisioner: codersdk.ProvisionerTypeEcho, }) @@ -431,7 +431,7 @@ func UpdateTemplateVersion(t *testing.T, client *codersdk.Client, organizationID require.NoError(t, err) templateVersion, err := client.CreateTemplateVersion(context.Background(), organizationID, codersdk.CreateTemplateVersionRequest{ TemplateID: templateID, - StorageSource: file.Hash, + FileID: file.ID, StorageMethod: codersdk.ProvisionerStorageMethodFile, Provisioner: codersdk.ProvisionerTypeEcho, }) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 36bf079231..2f5d57e5d6 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -316,12 +316,24 @@ func (q *fakeQuerier) DeleteAPIKeyByID(_ context.Context, id string) error { return sql.ErrNoRows } -func (q *fakeQuerier) GetFileByHash(_ context.Context, hash string) (database.File, error) { +func (q *fakeQuerier) GetFileByHashAndCreator(_ context.Context, arg database.GetFileByHashAndCreatorParams) (database.File, error) { q.mutex.RLock() defer q.mutex.RUnlock() for _, file := range q.files { - if file.Hash == hash { + if file.Hash == arg.Hash && file.CreatedBy == arg.CreatedBy { + return file, nil + } + } + return database.File{}, sql.ErrNoRows +} + +func (q *fakeQuerier) GetFileByID(_ context.Context, id uuid.UUID) (database.File, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, file := range q.files { + if file.ID == id { return file, nil } } @@ -1901,6 +1913,7 @@ func (q *fakeQuerier) InsertFile(_ context.Context, arg database.InsertFileParam //nolint:gosimple file := database.File{ + ID: arg.ID, Hash: arg.Hash, CreatedAt: arg.CreatedAt, CreatedBy: arg.CreatedBy, @@ -2085,7 +2098,7 @@ func (q *fakeQuerier) InsertProvisionerJob(_ context.Context, arg database.Inser InitiatorID: arg.InitiatorID, Provisioner: arg.Provisioner, StorageMethod: arg.StorageMethod, - StorageSource: arg.StorageSource, + FileID: arg.FileID, Type: arg.Type, Input: arg.Input, } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index eb16074e90..9345907d04 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -151,7 +151,8 @@ CREATE TABLE files ( created_at timestamp with time zone NOT NULL, created_by uuid NOT NULL, mimetype character varying(64) NOT NULL, - data bytea NOT NULL + data bytea NOT NULL, + id uuid DEFAULT gen_random_uuid() NOT NULL ); CREATE TABLE gitsshkeys ( @@ -270,10 +271,10 @@ CREATE TABLE provisioner_jobs ( initiator_id uuid NOT NULL, provisioner provisioner_type NOT NULL, storage_method provisioner_storage_method NOT NULL, - storage_source text NOT NULL, type provisioner_job_type NOT NULL, input jsonb NOT NULL, - worker_id uuid + worker_id uuid, + file_id uuid NOT NULL ); CREATE TABLE site_configs ( @@ -432,7 +433,10 @@ ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); ALTER TABLE ONLY files - ADD CONSTRAINT files_pkey PRIMARY KEY (hash); + ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); + +ALTER TABLE ONLY files + ADD CONSTRAINT files_pkey PRIMARY KEY (id); ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); diff --git a/coderd/database/migrations/000059_file_id.down.sql b/coderd/database/migrations/000059_file_id.down.sql new file mode 100644 index 0000000000..56dbb13eeb --- /dev/null +++ b/coderd/database/migrations/000059_file_id.down.sql @@ -0,0 +1,41 @@ +BEGIN; + +-- Add back the storage_source column. This must be nullable temporarily. +ALTER TABLE provisioner_jobs ADD COLUMN storage_source text; + +-- Set the storage_source to the hash of the files.id reference. +UPDATE + provisioner_jobs +SET + storage_source=files.hash +FROM + files +WHERE + provisioner_jobs.file_id = files.id; + +-- Now that we've populated storage_source drop the file_id column. +ALTER TABLE provisioner_jobs DROP COLUMN file_id; +-- We can set the storage_source column as NOT NULL now. +ALTER TABLE provisioner_jobs ALTER COLUMN storage_source SET NOT NULL; + +-- Delete all the duplicate rows where hashes collide. +-- We filter on 'id' to ensure only 1 unique row. +DELETE FROM + files a +USING + files b +WHERE + a.created_by < b.created_by +AND + a.hash = b.hash; + +-- Drop the primary key on files.id. +ALTER TABLE files DROP CONSTRAINT files_pkey; +-- Drop the id column. +ALTER TABLE files DROP COLUMN id; +-- Drop the unique constraint on hash + owner. +ALTER TABLE files DROP CONSTRAINT files_hash_created_by_key; +-- Set the primary key back to hash. +ALTER TABLE files ADD PRIMARY KEY (hash); + +COMMIT; diff --git a/coderd/database/migrations/000059_file_id.up.sql b/coderd/database/migrations/000059_file_id.up.sql new file mode 100644 index 0000000000..7e6e919fa9 --- /dev/null +++ b/coderd/database/migrations/000059_file_id.up.sql @@ -0,0 +1,42 @@ +-- This migration updates the files table to move the unique +-- constraint to be hash + created_by. This is necessary to +-- allow regular users who have been granted admin to a specific +-- template to be able to push and read files used for template +-- versions they create. +-- Prior to this collisions on file.hash were not an issue +-- since users who could push files could also read all files. +-- +-- This migration also adds a 'files.id' column as the primary +-- key. As a side effect the provisioner_jobs must now reference +-- the files.id column since the 'hash' column is now ambiguous. +BEGIN; + +-- Drop the primary key on hash. +ALTER TABLE files DROP CONSTRAINT files_pkey; + +-- Add an 'id' column and designate it the primary key. +ALTER TABLE files ADD COLUMN + id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid (); + +-- Update the constraint to include the user who created it. +ALTER TABLE files ADD UNIQUE(hash, created_by); + +-- Update provisioner_jobs to include a file_id column. +-- This must be temporarily nullable. +ALTER TABLE provisioner_jobs ADD COLUMN file_id uuid; + +-- Update all the rows to point to key in the files table. +UPDATE provisioner_jobs +SET + file_id = files.id +FROM + files +WHERE + provisioner_jobs.storage_source = files.hash; + +-- Enforce NOT NULL on file_id now. +ALTER TABLE provisioner_jobs ALTER COLUMN file_id SET NOT NULL; +-- Drop storage_source since it is no longer useful for anything. +ALTER TABLE provisioner_jobs DROP COLUMN storage_source; + +COMMIT; diff --git a/coderd/database/models.go b/coderd/database/models.go index f669b5e618..2e50d2b647 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -404,6 +404,7 @@ type File struct { CreatedBy uuid.UUID `db:"created_by" json:"created_by"` Mimetype string `db:"mimetype" json:"mimetype"` Data []byte `db:"data" json:"data"` + ID uuid.UUID `db:"id" json:"id"` } type GitSSHKey struct { @@ -501,10 +502,10 @@ type ProvisionerJob struct { InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"` Provisioner ProvisionerType `db:"provisioner" json:"provisioner"` StorageMethod ProvisionerStorageMethod `db:"storage_method" json:"storage_method"` - StorageSource string `db:"storage_source" json:"storage_source"` Type ProvisionerJobType `db:"type" json:"type"` Input json.RawMessage `db:"input" json:"input"` WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` + FileID uuid.UUID `db:"file_id" json:"file_id"` } type ProvisionerJobLog struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b58f6abbcc..e9c577bcb1 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -39,7 +39,8 @@ type sqlcQuerier interface { // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) GetDeploymentID(ctx context.Context) (string, error) - GetFileByHash(ctx context.Context, hash string) (File, error) + GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error) + GetFileByID(ctx context.Context, id uuid.UUID) (File, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2ff1805cd4..8673822ca4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -647,19 +647,26 @@ func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParam return i, err } -const getFileByHash = `-- name: GetFileByHash :one +const getFileByHashAndCreator = `-- name: GetFileByHashAndCreator :one SELECT - hash, created_at, created_by, mimetype, data + hash, created_at, created_by, mimetype, data, id FROM files WHERE hash = $1 +AND + created_by = $2 LIMIT 1 ` -func (q *sqlQuerier) GetFileByHash(ctx context.Context, hash string) (File, error) { - row := q.db.QueryRowContext(ctx, getFileByHash, hash) +type GetFileByHashAndCreatorParams struct { + Hash string `db:"hash" json:"hash"` + CreatedBy uuid.UUID `db:"created_by" json:"created_by"` +} + +func (q *sqlQuerier) GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error) { + row := q.db.QueryRowContext(ctx, getFileByHashAndCreator, arg.Hash, arg.CreatedBy) var i File err := row.Scan( &i.Hash, @@ -667,18 +674,45 @@ func (q *sqlQuerier) GetFileByHash(ctx context.Context, hash string) (File, erro &i.CreatedBy, &i.Mimetype, &i.Data, + &i.ID, + ) + return i, err +} + +const getFileByID = `-- name: GetFileByID :one +SELECT + hash, created_at, created_by, mimetype, data, id +FROM + files +WHERE + id = $1 +LIMIT + 1 +` + +func (q *sqlQuerier) GetFileByID(ctx context.Context, id uuid.UUID) (File, error) { + row := q.db.QueryRowContext(ctx, getFileByID, id) + var i File + err := row.Scan( + &i.Hash, + &i.CreatedAt, + &i.CreatedBy, + &i.Mimetype, + &i.Data, + &i.ID, ) return i, err } const insertFile = `-- name: InsertFile :one INSERT INTO - files (hash, created_at, created_by, mimetype, "data") + files (id, hash, created_at, created_by, mimetype, "data") VALUES - ($1, $2, $3, $4, $5) RETURNING hash, created_at, created_by, mimetype, data + ($1, $2, $3, $4, $5, $6) RETURNING hash, created_at, created_by, mimetype, data, id ` type InsertFileParams struct { + ID uuid.UUID `db:"id" json:"id"` Hash string `db:"hash" json:"hash"` CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` @@ -688,6 +722,7 @@ type InsertFileParams struct { func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File, error) { row := q.db.QueryRowContext(ctx, insertFile, + arg.ID, arg.Hash, arg.CreatedAt, arg.CreatedBy, @@ -701,6 +736,7 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File &i.CreatedBy, &i.Mimetype, &i.Data, + &i.ID, ) return i, err } @@ -2237,7 +2273,7 @@ WHERE SKIP LOCKED LIMIT 1 - ) RETURNING id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id + ) RETURNING id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, type, input, worker_id, file_id ` type AcquireProvisionerJobParams struct { @@ -2267,17 +2303,17 @@ func (q *sqlQuerier) AcquireProvisionerJob(ctx context.Context, arg AcquireProvi &i.InitiatorID, &i.Provisioner, &i.StorageMethod, - &i.StorageSource, &i.Type, &i.Input, &i.WorkerID, + &i.FileID, ) return i, err } const getProvisionerJobByID = `-- name: GetProvisionerJobByID :one SELECT - id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id + id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, type, input, worker_id, file_id FROM provisioner_jobs WHERE @@ -2299,17 +2335,17 @@ func (q *sqlQuerier) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (P &i.InitiatorID, &i.Provisioner, &i.StorageMethod, - &i.StorageSource, &i.Type, &i.Input, &i.WorkerID, + &i.FileID, ) return i, err } const getProvisionerJobsByIDs = `-- name: GetProvisionerJobsByIDs :many SELECT - id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id + id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, type, input, worker_id, file_id FROM provisioner_jobs WHERE @@ -2337,10 +2373,10 @@ func (q *sqlQuerier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUI &i.InitiatorID, &i.Provisioner, &i.StorageMethod, - &i.StorageSource, &i.Type, &i.Input, &i.WorkerID, + &i.FileID, ); err != nil { return nil, err } @@ -2356,7 +2392,7 @@ func (q *sqlQuerier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUI } const getProvisionerJobsCreatedAfter = `-- name: GetProvisionerJobsCreatedAfter :many -SELECT id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id FROM provisioner_jobs WHERE created_at > $1 +SELECT id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, type, input, worker_id, file_id FROM provisioner_jobs WHERE created_at > $1 ` func (q *sqlQuerier) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) { @@ -2380,10 +2416,10 @@ func (q *sqlQuerier) GetProvisionerJobsCreatedAfter(ctx context.Context, created &i.InitiatorID, &i.Provisioner, &i.StorageMethod, - &i.StorageSource, &i.Type, &i.Input, &i.WorkerID, + &i.FileID, ); err != nil { return nil, err } @@ -2408,12 +2444,12 @@ INSERT INTO initiator_id, provisioner, storage_method, - storage_source, + file_id, "type", "input" ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, type, input, worker_id, file_id ` type InsertProvisionerJobParams struct { @@ -2424,7 +2460,7 @@ type InsertProvisionerJobParams struct { InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"` Provisioner ProvisionerType `db:"provisioner" json:"provisioner"` StorageMethod ProvisionerStorageMethod `db:"storage_method" json:"storage_method"` - StorageSource string `db:"storage_source" json:"storage_source"` + FileID uuid.UUID `db:"file_id" json:"file_id"` Type ProvisionerJobType `db:"type" json:"type"` Input json.RawMessage `db:"input" json:"input"` } @@ -2438,7 +2474,7 @@ func (q *sqlQuerier) InsertProvisionerJob(ctx context.Context, arg InsertProvisi arg.InitiatorID, arg.Provisioner, arg.StorageMethod, - arg.StorageSource, + arg.FileID, arg.Type, arg.Input, ) @@ -2455,10 +2491,10 @@ func (q *sqlQuerier) InsertProvisionerJob(ctx context.Context, arg InsertProvisi &i.InitiatorID, &i.Provisioner, &i.StorageMethod, - &i.StorageSource, &i.Type, &i.Input, &i.WorkerID, + &i.FileID, ) return i, err } diff --git a/coderd/database/queries/files.sql b/coderd/database/queries/files.sql index a91513d31a..1f54386bb3 100644 --- a/coderd/database/queries/files.sql +++ b/coderd/database/queries/files.sql @@ -1,15 +1,28 @@ --- name: GetFileByHash :one +-- name: GetFileByID :one +SELECT + * +FROM + files +WHERE + id = $1 +LIMIT + 1; + +-- name: GetFileByHashAndCreator :one SELECT * FROM files WHERE hash = $1 +AND + created_by = $2 LIMIT 1; + -- name: InsertFile :one INSERT INTO - files (hash, created_at, created_by, mimetype, "data") + files (id, hash, created_at, created_by, mimetype, "data") VALUES - ($1, $2, $3, $4, $5) RETURNING *; + ($1, $2, $3, $4, $5, $6) RETURNING *; diff --git a/coderd/database/queries/provisionerjobs.sql b/coderd/database/queries/provisionerjobs.sql index 4775d574e2..027bd25bc9 100644 --- a/coderd/database/queries/provisionerjobs.sql +++ b/coderd/database/queries/provisionerjobs.sql @@ -59,7 +59,7 @@ INSERT INTO initiator_id, provisioner, storage_method, - storage_source, + file_id, "type", "input" ) diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 44cfd89d43..b4263c09b4 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -6,6 +6,7 @@ type UniqueConstraint string // UniqueConstraint enums. const ( + UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); diff --git a/coderd/files.go b/coderd/files.go index a0b7be2c8b..2c304921f8 100644 --- a/coderd/files.go +++ b/coderd/files.go @@ -10,6 +10,7 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" @@ -50,15 +51,20 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { } hashBytes := sha256.Sum256(data) hash := hex.EncodeToString(hashBytes[:]) - file, err := api.Database.GetFileByHash(ctx, hash) + file, err := api.Database.GetFileByHashAndCreator(ctx, database.GetFileByHashAndCreatorParams{ + Hash: hash, + CreatedBy: apiKey.UserID, + }) if err == nil { // The file already exists! httpapi.Write(ctx, rw, http.StatusOK, codersdk.UploadResponse{ - Hash: file.Hash, + ID: file.ID, }) return } + id := uuid.New() file, err = api.Database.InsertFile(ctx, database.InsertFileParams{ + ID: id, Hash: hash, CreatedBy: apiKey.UserID, CreatedAt: database.Now(), @@ -74,20 +80,30 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { } httpapi.Write(ctx, rw, http.StatusCreated, codersdk.UploadResponse{ - Hash: file.Hash, + ID: file.ID, }) } -func (api *API) fileByHash(rw http.ResponseWriter, r *http.Request) { +func (api *API) fileByID(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - hash := chi.URLParam(r, "hash") - if hash == "" { + + fileID := chi.URLParam(r, "fileID") + if fileID == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "File hash must be provided in url.", + Message: "File id must be provided in url.", }) return } - file, err := api.Database.GetFileByHash(ctx, hash) + + id, err := uuid.Parse(fileID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "File id must be a valid UUID.", + }) + return + } + + file, err := api.Database.GetFileByID(ctx, id) if errors.Is(err, sql.ErrNoRows) { httpapi.ResourceNotFound(rw) return diff --git a/coderd/files_test.go b/coderd/files_test.go index 28d4a33104..b3a3953a43 100644 --- a/coderd/files_test.go +++ b/coderd/files_test.go @@ -5,6 +5,7 @@ import ( "net/http" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" @@ -64,7 +65,7 @@ func TestDownload(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, _, err := client.Download(ctx, "something") + _, _, err := client.Download(ctx, uuid.New()) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) @@ -80,7 +81,7 @@ func TestDownload(t *testing.T) { resp, err := client.Upload(ctx, codersdk.ContentTypeTar, make([]byte, 1024)) require.NoError(t, err) - data, contentType, err := client.Download(ctx, resp.Hash) + data, contentType, err := client.Download(ctx, resp.ID) require.NoError(t, err) require.Len(t, data, 1024) require.Equal(t, codersdk.ContentTypeTar, contentType) diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index bef1110ec3..1863de54c6 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -315,7 +315,7 @@ func (server *provisionerdServer) AcquireJob(ctx context.Context, _ *proto.Empty } switch job.StorageMethod { case database.ProvisionerStorageMethodFile: - file, err := server.Database.GetFileByHash(ctx, job.StorageSource) + file, err := server.Database.GetFileByID(ctx, job.FileID) if err != nil { return nil, failJob(fmt.Sprintf("get file by hash: %s", err)) } diff --git a/coderd/provisionerdaemons_test.go b/coderd/provisionerdaemons_test.go index 8fe4831480..d3b0be35cd 100644 --- a/coderd/provisionerdaemons_test.go +++ b/coderd/provisionerdaemons_test.go @@ -33,11 +33,11 @@ func TestProvisionerDaemons(t *testing.T) { resp, err := client.Upload(ctx, codersdk.ContentTypeTar, data) require.NoError(t, err) - t.Log(resp.Hash) + t.Log(resp.ID) version, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ StorageMethod: codersdk.ProvisionerStorageMethodFile, - StorageSource: resp.Hash, + FileID: resp.ID, Provisioner: codersdk.ProvisionerTypeEcho, }) require.NoError(t, err) diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index e278411224..294b013e00 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -316,10 +316,10 @@ func convertProvisionerJobLog(provisionerJobLog database.ProvisionerJobLog) code func convertProvisionerJob(provisionerJob database.ProvisionerJob) codersdk.ProvisionerJob { job := codersdk.ProvisionerJob{ - ID: provisionerJob.ID, - CreatedAt: provisionerJob.CreatedAt, - Error: provisionerJob.Error.String, - StorageSource: provisionerJob.StorageSource, + ID: provisionerJob.ID, + CreatedAt: provisionerJob.CreatedAt, + Error: provisionerJob.Error.String, + FileID: provisionerJob.FileID, } // Applying values optional to the struct. if provisionerJob.StartedAt.Valid { diff --git a/coderd/templates.go b/coderd/templates.go index 3366e02b14..fbc25a7b55 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -600,6 +600,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO now = database.Now() ) file, err := tx.InsertFile(ctx, database.InsertFileParams{ + ID: uuid.New(), Hash: hex.EncodeToString(hash[:]), CreatedAt: now, CreatedBy: opts.userID, @@ -639,7 +640,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO InitiatorID: opts.userID, Provisioner: database.ProvisionerTypeTerraform, StorageMethod: database.ProvisionerStorageMethodFile, - StorageSource: file.Hash, + FileID: file.ID, Type: database.ProvisionerJobTypeTemplateVersionImport, Input: []byte{'{', '}'}, }) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 4d5b54b993..ccc1cd9761 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -285,7 +285,7 @@ func (api *API) postTemplateVersionDryRun(rw http.ResponseWriter, r *http.Reques InitiatorID: apiKey.UserID, Provisioner: job.Provisioner, StorageMethod: job.StorageMethod, - StorageSource: job.StorageSource, + FileID: job.FileID, Type: database.ProvisionerJobTypeTemplateVersionDryRun, Input: input, }) @@ -717,7 +717,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht return } - file, err := api.Database.GetFileByHash(ctx, req.StorageSource) + file, err := api.Database.GetFileByID(ctx, req.FileID) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ Message: "File not found.", @@ -732,12 +732,10 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht return } - // TODO(JonA): Readd this check once we update the unique constraint - // on files to be owner + hash. - // if !api.Authorize(r, rbac.ActionRead, file) { - // httpapi.ResourceNotFound(rw) - // return - // } + if !api.Authorize(r, rbac.ActionRead, file) { + httpapi.ResourceNotFound(rw) + return + } var templateVersion database.TemplateVersion var provisionerJob database.ProvisionerJob @@ -814,7 +812,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht InitiatorID: apiKey.UserID, Provisioner: database.ProvisionerType(req.Provisioner), StorageMethod: database.ProvisionerStorageMethodFile, - StorageSource: file.Hash, + FileID: file.ID, Type: database.ProvisionerJobTypeTemplateVersionImport, Input: []byte{'{', '}'}, }) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 5521be8ec3..1bcb5ba8a0 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -66,7 +66,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { _, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ TemplateID: templateID, StorageMethod: codersdk.ProvisionerStorageMethodFile, - StorageSource: "hash", + FileID: uuid.New(), Provisioner: codersdk.ProvisionerTypeEcho, }) var apiErr *codersdk.Error @@ -84,7 +84,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { _, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ StorageMethod: codersdk.ProvisionerStorageMethodFile, - StorageSource: "hash", + FileID: uuid.New(), Provisioner: codersdk.ProvisionerTypeEcho, }) var apiErr *codersdk.Error @@ -112,7 +112,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { version, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ Name: "bananas", StorageMethod: codersdk.ProvisionerStorageMethodFile, - StorageSource: file.Hash, + FileID: file.ID, Provisioner: codersdk.ProvisionerTypeEcho, ParameterValues: []codersdk.CreateParameterRequest{{ Name: "example", @@ -842,7 +842,7 @@ func TestPaginatedTemplateVersions(t *testing.T) { eg.Go(func() error { templateVersion, err := client.CreateTemplateVersion(egCtx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ TemplateID: template.ID, - StorageSource: file.Hash, + FileID: file.ID, StorageMethod: codersdk.ProvisionerStorageMethodFile, Provisioner: codersdk.ProvisionerTypeEcho, }) diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index b2d8424907..6881137cf8 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -458,7 +458,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { Provisioner: template.Provisioner, Type: database.ProvisionerJobTypeWorkspaceBuild, StorageMethod: templateVersionJob.StorageMethod, - StorageSource: templateVersionJob.StorageSource, + FileID: templateVersionJob.FileID, Input: input, }) if err != nil { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index c96967b126..d847af7032 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -435,7 +435,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req Provisioner: template.Provisioner, Type: database.ProvisionerJobTypeWorkspaceBuild, StorageMethod: templateVersionJob.StorageMethod, - StorageSource: templateVersionJob.StorageSource, + FileID: templateVersionJob.FileID, Input: input, }) if err != nil { diff --git a/codersdk/files.go b/codersdk/files.go index 52fcf02150..6d728fbfbc 100644 --- a/codersdk/files.go +++ b/codersdk/files.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + + "github.com/google/uuid" ) const ( @@ -14,7 +16,7 @@ const ( // UploadResponse contains the hash to reference the uploaded file. type UploadResponse struct { - Hash string `json:"hash"` + ID uuid.UUID `json:"hash"` } // Upload uploads an arbitrary file with the content type provided. @@ -35,8 +37,8 @@ func (c *Client) Upload(ctx context.Context, contentType string, content []byte) } // Download fetches a file by uploaded hash. -func (c *Client) Download(ctx context.Context, hash string) ([]byte, string, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/files/%s", hash), nil) +func (c *Client) Download(ctx context.Context, id uuid.UUID) ([]byte, string, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/files/%s", id.String()), nil) if err != nil { return nil, "", err } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 003b9156dd..de5e42122c 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -39,7 +39,7 @@ type CreateTemplateVersionRequest struct { TemplateID uuid.UUID `json:"template_id,omitempty"` StorageMethod ProvisionerStorageMethod `json:"storage_method" validate:"oneof=file,required"` - StorageSource string `json:"storage_source" validate:"required"` + FileID uuid.UUID `json:"file_id" validate:"required"` Provisioner ProvisionerType `json:"provisioner" validate:"oneof=terraform echo,required"` // ParameterValues allows for additional parameters to be provided // during the dry-run provision stage. diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 5eb8872fe6..adce0321be 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -64,15 +64,15 @@ const ( ) type ProvisionerJob struct { - ID uuid.UUID `json:"id"` - CreatedAt time.Time `json:"created_at"` - StartedAt *time.Time `json:"started_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - CanceledAt *time.Time `json:"canceled_at,omitempty"` - Error string `json:"error,omitempty"` - Status ProvisionerJobStatus `json:"status"` - WorkerID *uuid.UUID `json:"worker_id,omitempty"` - StorageSource string `json:"storage_source"` + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CanceledAt *time.Time `json:"canceled_at,omitempty"` + Error string `json:"error,omitempty"` + Status ProvisionerJobStatus `json:"status"` + WorkerID *uuid.UUID `json:"worker_id,omitempty"` + FileID uuid.UUID `json:"file_id"` } type ProvisionerJobLog struct { diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index b4f96eb610..fe6dd6f687 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -286,7 +286,7 @@ func TestTemplateACL(t *testing.T) { _, err = client1.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ Name: "testme", TemplateID: template.ID, - StorageSource: file.Hash, + FileID: file.ID, StorageMethod: codersdk.ProvisionerStorageMethodFile, Provisioner: codersdk.ProvisionerTypeEcho, }) @@ -302,7 +302,7 @@ func TestTemplateACL(t *testing.T) { _, err = client1.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ Name: "testme", TemplateID: template.ID, - StorageSource: file.Hash, + FileID: file.ID, StorageMethod: codersdk.ProvisionerStorageMethodFile, Provisioner: codersdk.ProvisionerTypeEcho, }) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 92088b978f..09c2da80fb 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -206,7 +206,7 @@ export interface CreateTemplateVersionRequest { readonly name?: string readonly template_id?: string readonly storage_method: ProvisionerStorageMethod - readonly storage_source: string + readonly file_id: string readonly provisioner: ProvisionerType readonly parameter_values?: CreateParameterRequest[] } @@ -504,7 +504,7 @@ export interface ProvisionerJob { readonly error?: string readonly status: ProvisionerJobStatus readonly worker_id?: string - readonly storage_source: string + readonly file_id: string } // From codersdk/provisionerdaemons.go diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index fe779f3789..b7c7750158 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -132,7 +132,7 @@ export const MockProvisionerJob: TypesGen.ProvisionerJob = { created_at: "", id: "test-provisioner-job", status: "succeeded", - storage_source: "asdf", + file_id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0", completed_at: "2022-05-17T17:39:01.382927298Z", } From 94db085b51a2334769775783fce5f2f022520afa Mon Sep 17 00:00:00 2001 From: Joe Previte Date: Thu, 13 Oct 2022 16:09:04 -0700 Subject: [PATCH 03/51] chore(site): remove some eslint-disables (#4265) * fix: await promises in WorkspacePage.test.tsx * chore: add eventsourcemock to cspell words * fix: clean up UsersPage.test.tsx * refactor: clean up eventsource mock * revert: remove changes from WorkspacePage.test.tsx --- .vscode/settings.json | 1 + site/src/pages/UsersPage/UsersPage.test.tsx | 4 +--- .../pages/WorkspacePage/WorkspacePage.test.tsx | 17 ++++++++++++----- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index e9a32e850c..8b92ff2228 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,6 +25,7 @@ "drpcserver", "Dsts", "enablements", + "eventsourcemock", "fatih", "Formik", "gitsshkey", diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index 4052c1bd65..a15267ffd9 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-floating-promises */ import { fireEvent, screen, waitFor, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { i18n } from "i18n" @@ -428,8 +427,7 @@ describe("UsersPage", () => { }), ) - // eslint-disable-next-line @typescript-eslint/no-empty-function - await updateUserRole(() => {}, MockAuditorRole) + await updateUserRole(() => null, MockAuditorRole) // Check if the error message is displayed const errorMessage = await screen.findByText("message from the backend") diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 884b4d12fb..3f2df80c35 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import { fireEvent, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" -import EventSource from "eventsourcemock" +import EventSourceMock from "eventsourcemock" import i18next from "i18next" import { rest } from "msw" import * as api from "../../api/api" @@ -75,13 +75,20 @@ const testStatus = async (ws: Workspace, label: string) => { expect(status).toHaveTextContent(label) } +let originalEventSource: typeof window.EventSource + +beforeAll(() => { + originalEventSource = window.EventSource + // mocking out EventSource for SSE + window.EventSource = EventSourceMock +}) + beforeEach(() => { jest.resetAllMocks() +}) - // mocking out EventSource for SSE - Object.defineProperty(window, "EventSource", { - value: EventSource, - }) +afterAll(() => { + window.EventSource = originalEventSource }) describe("WorkspacePage", () => { From b1409831a32f88fcda2b4d7673c9897db08e5654 Mon Sep 17 00:00:00 2001 From: Joe Previte Date: Fri, 14 Oct 2022 05:09:09 -0700 Subject: [PATCH 04/51] refactor: clean up api/error.ts (#4542) This removes an eslint-disable lint and updates the `err` type to `unknown` instead of `any` which is more correct. --- site/src/api/errors.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 19807dccfd..1663e0333d 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -23,8 +23,7 @@ export type ApiError = AxiosError & { response: AxiosResponse } -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any -export const isApiError = (err: any): err is ApiError => { +export const isApiError = (err: unknown): err is ApiError => { if (axios.isAxiosError(err)) { const response = err.response?.data if (!response) { From bf0aca35fa0b8ae1aba85edc8deec322948661db Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 14 Oct 2022 09:47:38 -0400 Subject: [PATCH 05/51] fix: ensure deleting workspace creates audit log (#4537) * fix: ensure deleting workspace creates audit log * getting rid of comments * remove whitespace * pushing failing test * fixed test --- coderd/workspacebuilds.go | 17 +++++++++++++++++ coderd/workspacebuilds_test.go | 8 +++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 6881137cf8..ed136f372b 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -15,6 +15,7 @@ import ( "golang.org/x/exp/slices" "golang.org/x/xerrors" + "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -277,6 +278,22 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } + // we only want to create audit logs for delete builds right now + if action == rbac.ActionDelete { + var ( + auditor = api.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.Workspace](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionDelete, + }) + ) + + defer commitAudit() + aReq.Old = workspace + } + if createBuild.TemplateVersionID == uuid.Nil { latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) if err != nil { diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index c4c3f7d364..983063a190 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" @@ -534,7 +535,8 @@ func TestWorkspaceBuildStatus(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - client, closeDaemon, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + auditor := audit.NewMock() + client, closeDaemon, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor}) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) @@ -575,4 +577,8 @@ func TestWorkspaceBuildStatus(t *testing.T) { workspace, err = client.DeletedWorkspace(ctx, workspace.ID) require.NoError(t, err) require.EqualValues(t, codersdk.WorkspaceStatusDeleted, workspace.LatestBuild.Status) + + // assert an audit log has been created for deletion + require.Len(t, auditor.AuditLogs, 5) + assert.Equal(t, database.AuditActionDelete, auditor.AuditLogs[4].Action) } From 88f7505fdf7648b2f45cab20c2b33ca263ad4376 Mon Sep 17 00:00:00 2001 From: Joe Previte Date: Fri, 14 Oct 2022 09:22:07 -0700 Subject: [PATCH 06/51] feat: upgrade prettier and add --cache (#4543) This uses the `--cache` flag with `prettier --check` to cache the results and speed up subsequent runs. --- site/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/package.json b/site/package.json index 8618c728e6..b38494f51d 100644 --- a/site/package.json +++ b/site/package.json @@ -9,7 +9,7 @@ "check:all": "yarn format:check && yarn lint && yarn test", "chromatic": "chromatic", "dev": "vite", - "format:check": "prettier --check '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'", + "format:check": "prettier --cache --check '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'", "format:types": "prettier --write 'src/api/typesGenerated.ts'", "format:write": "prettier --write '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'", "lint": "jest --selectProjects lint", From 19d7281daf04877f285156bacf9f74f4e86c5aab Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 14 Oct 2022 19:28:47 +0300 Subject: [PATCH 07/51] fix: Fix template create with sub-folders on Windows (#4548) On Windows, files in tar archives were stored with Windows path-separators resulting in them being individual files as opposed to contained in a folder. This commit ensures Unix-based paths (slash) are being used inside tar archives. Exmple of previous output: ``` /tmp/provisionerd673501182/images: /tmp/provisionerd673501182/: README.md images images\base.Dockerfile images\java.Dockerfile images\node.Dockerfile main.tf ``` Fixes #2815 --- provisionersdk/archive.go | 5 +++-- provisionersdk/archive_test.go | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/provisionersdk/archive.go b/provisionersdk/archive.go index e69f57f0f3..2df6b457c2 100644 --- a/provisionersdk/archive.go +++ b/provisionersdk/archive.go @@ -87,7 +87,8 @@ func Tar(directory string, limit int64) ([]byte, error) { // Don't store tfstate! return err } - header.Name = rel + // Use unix paths in the tar archive. + header.Name = filepath.ToSlash(rel) if err := tarWriter.WriteHeader(header); err != nil { return err } @@ -131,7 +132,7 @@ func Untar(directory string, archive []byte) error { return err } // #nosec - target := filepath.Join(directory, header.Name) + target := filepath.Join(directory, filepath.FromSlash(header.Name)) switch header.Typeflag { case tar.TypeDir: if _, err := os.Stat(target); err != nil { diff --git a/provisionersdk/archive_test.go b/provisionersdk/archive_test.go index 9fefa90ca6..4d37dd7ac5 100644 --- a/provisionersdk/archive_test.go +++ b/provisionersdk/archive_test.go @@ -1,7 +1,6 @@ package provisionersdk_test import ( - "fmt" "os" "path/filepath" "testing" @@ -59,6 +58,7 @@ func TestTar(t *testing.T) { }} for _, file := range files { newDir := dir + file.Name = filepath.FromSlash(file.Name) if filepath.Base(file.Name) != file.Name { newDir = filepath.Join(newDir, filepath.Dir(file.Name)) err := os.MkdirAll(newDir, 0755) @@ -70,7 +70,6 @@ func TestTar(t *testing.T) { _ = tmpFile.Close() file.Name, err = filepath.Rel(dir, tmpFile.Name()) require.NoError(t, err) - fmt.Printf("rel") } content, err := provisionersdk.Tar(dir, 1024) require.NoError(t, err) From d898737d6dcb44810d16295d2a46afb4ae11593e Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Sat, 15 Oct 2022 02:46:38 +1000 Subject: [PATCH 08/51] feat: app sharing (now open source!) (#4378) --- cli/tokens.go | 2 +- coderd/apikey.go | 17 + coderd/apikey_test.go | 73 ++- coderd/coderd.go | 20 +- coderd/database/databasefake/databasefake.go | 5 + coderd/database/dump.sql | 9 +- .../000060_app_sharing_level.down.sql | 5 + .../000060_app_sharing_level.up.sql | 12 + coderd/database/models.go | 21 + coderd/database/queries.sql.go | 18 +- coderd/database/queries/workspaceapps.sql | 3 +- coderd/httpmw/apikey.go | 50 ++- coderd/httpmw/organizationparam_test.go | 4 +- coderd/httpmw/userparam.go | 21 +- coderd/httpmw/userparam_test.go | 6 +- coderd/httpmw/workspaceparam_test.go | 2 +- coderd/provisionerdaemons.go | 9 + coderd/users.go | 1 + coderd/users_test.go | 4 +- coderd/workspaceagents.go | 11 +- coderd/workspaceapps.go | 247 +++++++--- coderd/workspaceapps_test.go | 225 +++++++++- codersdk/apikey.go | 56 ++- codersdk/users.go | 5 - codersdk/workspaceapps.go | 11 +- dogfood/main.tf | 13 +- enterprise/coderd/coderd.go | 8 +- .../coderd/coderdenttest/coderdenttest.go | 8 +- enterprise/coderd/workspaceagents_test.go | 49 +- examples/templates/aws-ecs-container/main.tf | 3 +- examples/templates/aws-linux/main.tf | 12 +- examples/templates/aws-windows/main.tf | 2 +- examples/templates/azure-linux/main.tf | 2 +- examples/templates/bare/main.tf | 10 +- examples/templates/do-linux/main.tf | 2 +- examples/templates/docker-code-server/main.tf | 11 +- .../templates/docker-image-builds/main.tf | 12 +- .../templates/docker-with-dotfiles/main.tf | 2 +- examples/templates/docker/main.tf | 13 +- examples/templates/gcp-linux/main.tf | 3 +- examples/templates/gcp-vm-container/main.tf | 3 +- examples/templates/gcp-windows/main.tf | 2 +- examples/templates/kubernetes/main.tf | 3 +- provisioner/terraform/resources.go | 24 +- .../testdata/calling-module/calling-module.tf | 2 +- .../chaining-resources/chaining-resources.tf | 2 +- .../conflicting-resources.tf | 2 +- .../testdata/instance-id/instance-id.tf | 2 +- .../multiple-agents/multiple-agents.tf | 2 +- .../testdata/multiple-apps/multiple-apps.tf | 2 +- .../resource-metadata/resource-metadata.tf | 2 +- provisionersdk/proto/provisioner.pb.go | 425 ++++++++++-------- provisionersdk/proto/provisioner.proto | 7 + site/src/api/typesGenerated.ts | 15 +- site/src/testHelpers/entities.ts | 1 + 55 files changed, 1069 insertions(+), 412 deletions(-) create mode 100644 coderd/database/migrations/000060_app_sharing_level.down.sql create mode 100644 coderd/database/migrations/000060_app_sharing_level.up.sql diff --git a/cli/tokens.go b/cli/tokens.go index 8719ab3434..4c3cb830cd 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -55,7 +55,7 @@ func createToken() *cobra.Command { return xerrors.Errorf("create codersdk client: %w", err) } - res, err := client.CreateToken(cmd.Context(), codersdk.Me) + res, err := client.CreateToken(cmd.Context(), codersdk.Me, codersdk.CreateTokenRequest{}) if err != nil { return xerrors.Errorf("create tokens: %w", err) } diff --git a/coderd/apikey.go b/coderd/apikey.go index 645d660ada..84e936cb22 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -34,12 +34,23 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { return } + var createToken codersdk.CreateTokenRequest + if !httpapi.Read(ctx, rw, r, &createToken) { + return + } + + scope := database.APIKeyScopeAll + if scope != "" { + scope = database.APIKeyScope(createToken.Scope) + } + // tokens last 100 years lifeTime := time.Hour * 876000 cookie, err := api.createAPIKey(ctx, createAPIKeyParams{ UserID: user.ID, LoginType: database.LoginTypeToken, ExpiresAt: database.Now().Add(lifeTime), + Scope: scope, LifetimeSeconds: int64(lifeTime.Seconds()), }) if err != nil { @@ -54,6 +65,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { } // Creates a new session key, used for logging in via the CLI. +// DEPRECATED: use postToken instead. func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) @@ -229,6 +241,11 @@ func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*h if params.Scope != "" { scope = params.Scope } + switch scope { + case database.APIKeyScopeAll, database.APIKeyScopeApplicationConnect: + default: + return nil, xerrors.Errorf("invalid API key scope: %q", scope) + } key, err := api.Database.InsertAPIKey(ctx, database.InsertAPIKeyParams{ ID: keyID, diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index e9163e5c59..f40966b0a2 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -14,30 +14,61 @@ import ( func TestTokens(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - _ = coderdtest.CreateFirstUser(t, client) - keys, err := client.GetTokens(ctx, codersdk.Me) - require.NoError(t, err) - require.Empty(t, keys) - res, err := client.CreateToken(ctx, codersdk.Me) - require.NoError(t, err) - require.Greater(t, len(res.Key), 2) + t.Run("CRUD", func(t *testing.T) { + t.Parallel() - keys, err = client.GetTokens(ctx, codersdk.Me) - require.NoError(t, err) - require.EqualValues(t, len(keys), 1) - require.Contains(t, res.Key, keys[0].ID) - // expires_at must be greater than 50 years - require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300)) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + keys, err := client.GetTokens(ctx, codersdk.Me) + require.NoError(t, err) + require.Empty(t, keys) - err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID) - require.NoError(t, err) - keys, err = client.GetTokens(ctx, codersdk.Me) - require.NoError(t, err) - require.Empty(t, keys) + res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{}) + require.NoError(t, err) + require.Greater(t, len(res.Key), 2) + + keys, err = client.GetTokens(ctx, codersdk.Me) + require.NoError(t, err) + require.EqualValues(t, len(keys), 1) + require.Contains(t, res.Key, keys[0].ID) + // expires_at must be greater than 50 years + require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300)) + require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope) + + // no update + + err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID) + require.NoError(t, err) + keys, err = client.GetTokens(ctx, codersdk.Me) + require.NoError(t, err) + require.Empty(t, keys) + }) + + t.Run("Scoped", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scope: codersdk.APIKeyScopeApplicationConnect, + }) + require.NoError(t, err) + require.Greater(t, len(res.Key), 2) + + keys, err := client.GetTokens(ctx, codersdk.Me) + require.NoError(t, err) + require.EqualValues(t, len(keys), 1) + require.Contains(t, res.Key, keys[0].ID) + // expires_at must be greater than 50 years + require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300)) + require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect) + }) } func TestAPIKey(t *testing.T) { diff --git a/coderd/coderd.go b/coderd/coderd.go index b067035b3a..8b5080346c 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -197,7 +197,7 @@ func New(options *Options) *API { RedirectToLogin: false, Optional: true, }), - httpmw.ExtractUserParam(api.Database), + httpmw.ExtractUserParam(api.Database, false), httpmw.ExtractWorkspaceAndAgentParam(api.Database), ), // Build-Version is helpful for debugging. @@ -214,8 +214,18 @@ func New(options *Options) *API { r.Use( tracing.Middleware(api.TracerProvider), httpmw.RateLimitPerMinute(options.APIRateLimit), - apiKeyMiddlewareRedirect, - httpmw.ExtractUserParam(api.Database), + httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{ + DB: options.Database, + OAuth2Configs: oauthConfigs, + // Optional is true to allow for public apps. If an + // authorization check fails and the user is not authenticated, + // they will be redirected to the login page by the app handler. + RedirectToLogin: false, + Optional: true, + }), + // Redirect to the login page if the user tries to open an app with + // "me" as the username and they are not logged in. + httpmw.ExtractUserParam(api.Database, true), // Extracts the from the url httpmw.ExtractWorkspaceAndAgentParam(api.Database), ) @@ -310,7 +320,7 @@ func New(options *Options) *API { r.Get("/roles", api.assignableOrgRoles) r.Route("/{user}", func(r chi.Router) { r.Use( - httpmw.ExtractUserParam(options.Database), + httpmw.ExtractUserParam(options.Database, false), httpmw.ExtractOrganizationMemberParam(options.Database), ) r.Put("/roles", api.putMemberRoles) @@ -389,7 +399,7 @@ func New(options *Options) *API { r.Get("/", api.assignableSiteRoles) }) r.Route("/{user}", func(r chi.Router) { - r.Use(httpmw.ExtractUserParam(options.Database)) + r.Use(httpmw.ExtractUserParam(options.Database, false)) r.Delete("/", api.deleteUser) r.Get("/", api.userByName) r.Put("/profile", api.putUserProfile) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 2f5d57e5d6..654139e604 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2324,6 +2324,10 @@ func (q *fakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW q.mutex.Lock() defer q.mutex.Unlock() + if arg.SharingLevel == "" { + arg.SharingLevel = database.AppSharingLevelOwner + } + // nolint:gosimple workspaceApp := database.WorkspaceApp{ ID: arg.ID, @@ -2334,6 +2338,7 @@ func (q *fakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW Command: arg.Command, Url: arg.Url, Subdomain: arg.Subdomain, + SharingLevel: arg.SharingLevel, HealthcheckUrl: arg.HealthcheckUrl, HealthcheckInterval: arg.HealthcheckInterval, HealthcheckThreshold: arg.HealthcheckThreshold, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 9345907d04..9e2c68dbf6 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -5,6 +5,12 @@ CREATE TYPE api_key_scope AS ENUM ( 'application_connect' ); +CREATE TYPE app_sharing_level AS ENUM ( + 'owner', + 'authenticated', + 'public' +); + CREATE TYPE audit_action AS ENUM ( 'create', 'write', @@ -371,7 +377,8 @@ CREATE TABLE workspace_apps ( healthcheck_interval integer DEFAULT 0 NOT NULL, healthcheck_threshold integer DEFAULT 0 NOT NULL, health workspace_app_health DEFAULT 'disabled'::public.workspace_app_health NOT NULL, - subdomain boolean DEFAULT false NOT NULL + subdomain boolean DEFAULT false NOT NULL, + sharing_level app_sharing_level DEFAULT 'owner'::public.app_sharing_level NOT NULL ); CREATE TABLE workspace_builds ( diff --git a/coderd/database/migrations/000060_app_sharing_level.down.sql b/coderd/database/migrations/000060_app_sharing_level.down.sql new file mode 100644 index 0000000000..757a7f8792 --- /dev/null +++ b/coderd/database/migrations/000060_app_sharing_level.down.sql @@ -0,0 +1,5 @@ +-- Drop column sharing_level from workspace_apps +ALTER TABLE workspace_apps DROP COLUMN sharing_level; + +-- Drop type app_sharing_level +DROP TYPE app_sharing_level; diff --git a/coderd/database/migrations/000060_app_sharing_level.up.sql b/coderd/database/migrations/000060_app_sharing_level.up.sql new file mode 100644 index 0000000000..b339ab9726 --- /dev/null +++ b/coderd/database/migrations/000060_app_sharing_level.up.sql @@ -0,0 +1,12 @@ +-- Add enum app_sharing_level +CREATE TYPE app_sharing_level AS ENUM ( + -- only the workspace owner can access the app + 'owner', + -- any authenticated user on the site can access the app + 'authenticated', + -- any user can access the app even if they are not authenticated + 'public' +); + +-- Add sharing_level column to workspace_apps table +ALTER TABLE workspace_apps ADD COLUMN sharing_level app_sharing_level NOT NULL DEFAULT 'owner'::app_sharing_level; diff --git a/coderd/database/models.go b/coderd/database/models.go index 2e50d2b647..e30615244e 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -34,6 +34,26 @@ func (e *APIKeyScope) Scan(src interface{}) error { return nil } +type AppSharingLevel string + +const ( + AppSharingLevelOwner AppSharingLevel = "owner" + AppSharingLevelAuthenticated AppSharingLevel = "authenticated" + AppSharingLevelPublic AppSharingLevel = "public" +) + +func (e *AppSharingLevel) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AppSharingLevel(s) + case string: + *e = AppSharingLevel(s) + default: + return fmt.Errorf("unsupported scan type for AppSharingLevel: %T", src) + } + return nil +} + type AuditAction string const ( @@ -626,6 +646,7 @@ type WorkspaceApp struct { HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"` Health WorkspaceAppHealth `db:"health" json:"health"` Subdomain bool `db:"subdomain" json:"subdomain"` + SharingLevel AppSharingLevel `db:"sharing_level" json:"sharing_level"` } type WorkspaceBuild struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 8673822ca4..4b28c60040 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4324,7 +4324,7 @@ func (q *sqlQuerier) UpdateWorkspaceAgentVersionByID(ctx context.Context, arg Up } const getWorkspaceAppByAgentIDAndName = `-- name: GetWorkspaceAppByAgentIDAndName :one -SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE agent_id = $1 AND name = $2 +SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = $1 AND name = $2 ` type GetWorkspaceAppByAgentIDAndNameParams struct { @@ -4348,12 +4348,13 @@ func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndName(ctx context.Context, arg Ge &i.HealthcheckThreshold, &i.Health, &i.Subdomain, + &i.SharingLevel, ) return i, err } const getWorkspaceAppsByAgentID = `-- name: GetWorkspaceAppsByAgentID :many -SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE agent_id = $1 ORDER BY name ASC +SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = $1 ORDER BY name ASC ` func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error) { @@ -4378,6 +4379,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid &i.HealthcheckThreshold, &i.Health, &i.Subdomain, + &i.SharingLevel, ); err != nil { return nil, err } @@ -4393,7 +4395,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid } const getWorkspaceAppsByAgentIDs = `-- name: GetWorkspaceAppsByAgentIDs :many -SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE agent_id = ANY($1 :: uuid [ ]) ORDER BY name ASC +SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = ANY($1 :: uuid [ ]) ORDER BY name ASC ` func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error) { @@ -4418,6 +4420,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid. &i.HealthcheckThreshold, &i.Health, &i.Subdomain, + &i.SharingLevel, ); err != nil { return nil, err } @@ -4433,7 +4436,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid. } const getWorkspaceAppsCreatedAfter = `-- name: GetWorkspaceAppsCreatedAfter :many -SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain FROM workspace_apps WHERE created_at > $1 ORDER BY name ASC +SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE created_at > $1 ORDER BY name ASC ` func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) { @@ -4458,6 +4461,7 @@ func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt &i.HealthcheckThreshold, &i.Health, &i.Subdomain, + &i.SharingLevel, ); err != nil { return nil, err } @@ -4483,13 +4487,14 @@ INSERT INTO command, url, subdomain, + sharing_level, healthcheck_url, healthcheck_interval, healthcheck_threshold, health ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level ` type InsertWorkspaceAppParams struct { @@ -4501,6 +4506,7 @@ type InsertWorkspaceAppParams struct { Command sql.NullString `db:"command" json:"command"` Url sql.NullString `db:"url" json:"url"` Subdomain bool `db:"subdomain" json:"subdomain"` + SharingLevel AppSharingLevel `db:"sharing_level" json:"sharing_level"` HealthcheckUrl string `db:"healthcheck_url" json:"healthcheck_url"` HealthcheckInterval int32 `db:"healthcheck_interval" json:"healthcheck_interval"` HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"` @@ -4517,6 +4523,7 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace arg.Command, arg.Url, arg.Subdomain, + arg.SharingLevel, arg.HealthcheckUrl, arg.HealthcheckInterval, arg.HealthcheckThreshold, @@ -4536,6 +4543,7 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace &i.HealthcheckThreshold, &i.Health, &i.Subdomain, + &i.SharingLevel, ) return i, err } diff --git a/coderd/database/queries/workspaceapps.sql b/coderd/database/queries/workspaceapps.sql index 3336bfda4a..36494a8e9a 100644 --- a/coderd/database/queries/workspaceapps.sql +++ b/coderd/database/queries/workspaceapps.sql @@ -21,13 +21,14 @@ INSERT INTO command, url, subdomain, + sharing_level, healthcheck_url, healthcheck_interval, healthcheck_threshold, health ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; -- name: UpdateWorkspaceAppHealthByID :exec UPDATE diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 5e409a58d7..cc331983ce 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -83,8 +83,8 @@ type OAuth2Configs struct { } const ( - signedOutErrorMessage string = "You are signed out or your session has expired. Please sign in again to continue." - internalErrorMessage string = "An internal error occurred. Please try again or contact the system administrator." + SignedOutErrorMessage = "You are signed out or your session has expired. Please sign in again to continue." + internalErrorMessage = "An internal error occurred. Please try again or contact the system administrator." ) type ExtractAPIKeyConfig struct { @@ -119,21 +119,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { // like workspace applications. write := func(code int, response codersdk.Response) { if cfg.RedirectToLogin { - path := r.URL.Path - if r.URL.RawQuery != "" { - path += "?" + r.URL.RawQuery - } - - q := url.Values{} - q.Add("message", response.Message) - q.Add("redirect", path) - - u := &url.URL{ - Path: "/login", - RawQuery: q.Encode(), - } - - http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect) + RedirectToLogin(rw, r, response.Message) return } @@ -157,7 +143,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { token := apiTokenFromRequest(r) if token == "" { optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: signedOutErrorMessage, + Message: SignedOutErrorMessage, Detail: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenKey), }) return @@ -166,7 +152,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { keyID, keySecret, err := SplitAPIToken(token) if err != nil { optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: signedOutErrorMessage, + Message: SignedOutErrorMessage, Detail: "Invalid API key format: " + err.Error(), }) return @@ -176,7 +162,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { if err != nil { if errors.Is(err, sql.ErrNoRows) { optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: signedOutErrorMessage, + Message: SignedOutErrorMessage, Detail: "API key is invalid.", }) return @@ -192,7 +178,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { hashedSecret := sha256.Sum256([]byte(keySecret)) if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 { optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: signedOutErrorMessage, + Message: SignedOutErrorMessage, Detail: "API key secret is invalid.", }) return @@ -255,7 +241,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { // Checking if the key is expired. if key.ExpiresAt.Before(now) { optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: signedOutErrorMessage, + Message: SignedOutErrorMessage, Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()), }) return @@ -422,3 +408,23 @@ func SplitAPIToken(token string) (id string, secret string, err error) { return keyID, keySecret, nil } + +// RedirectToLogin redirects the user to the login page with the `message` and +// `redirect` query parameters set. +func RedirectToLogin(rw http.ResponseWriter, r *http.Request, message string) { + path := r.URL.Path + if r.URL.RawQuery != "" { + path += "?" + r.URL.RawQuery + } + + q := url.Values{} + q.Add("message", message) + q.Add("redirect", path) + + u := &url.URL{ + Path: "/login", + RawQuery: q.Encode(), + } + + http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect) +} diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index faab86228f..9ad91e7d2c 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -148,7 +148,7 @@ func TestOrganizationParam(t *testing.T) { DB: db, RedirectToLogin: false, }), - httpmw.ExtractUserParam(db), + httpmw.ExtractUserParam(db, false), httpmw.ExtractOrganizationParam(db), httpmw.ExtractOrganizationMemberParam(db), ) @@ -189,7 +189,7 @@ func TestOrganizationParam(t *testing.T) { RedirectToLogin: false, }), httpmw.ExtractOrganizationParam(db), - httpmw.ExtractUserParam(db), + httpmw.ExtractUserParam(db, false), httpmw.ExtractOrganizationMemberParam(db), ) rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/httpmw/userparam.go b/coderd/httpmw/userparam.go index 6b852408c2..74119d503a 100644 --- a/coderd/httpmw/userparam.go +++ b/coderd/httpmw/userparam.go @@ -33,8 +33,11 @@ func UserParam(r *http.Request) database.User { return user } -// ExtractUserParam extracts a user from an ID/username in the {user} URL parameter. -func ExtractUserParam(db database.Store) func(http.Handler) http.Handler { +// ExtractUserParam extracts a user from an ID/username in the {user} URL +// parameter. +// +//nolint:revive +func ExtractUserParam(db database.Store, redirectToLoginOnMe bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { var ( @@ -53,7 +56,19 @@ func ExtractUserParam(db database.Store) func(http.Handler) http.Handler { } if userQuery == "me" { - user, err = db.GetUserByID(ctx, APIKey(r).UserID) + apiKey, ok := APIKeyOptional(r) + if !ok { + if redirectToLoginOnMe { + RedirectToLogin(rw, r, SignedOutErrorMessage) + return + } + + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Cannot use \"me\" without a valid session.", + }) + return + } + user, err = db.GetUserByID(ctx, apiKey.UserID) if xerrors.Is(err, sql.ErrNoRows) { httpapi.ResourceNotFound(rw) return diff --git a/coderd/httpmw/userparam_test.go b/coderd/httpmw/userparam_test.go index edd7faf128..9d283f1ea4 100644 --- a/coderd/httpmw/userparam_test.go +++ b/coderd/httpmw/userparam_test.go @@ -63,7 +63,7 @@ func TestUserParam(t *testing.T) { r = returnedRequest })).ServeHTTP(rw, r) - httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + httpmw.ExtractUserParam(db, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusOK) })).ServeHTTP(rw, r) res := rw.Result() @@ -85,7 +85,7 @@ func TestUserParam(t *testing.T) { routeContext := chi.NewRouteContext() routeContext.URLParams.Add("user", "ben") r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext)) - httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + httpmw.ExtractUserParam(db, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusOK) })).ServeHTTP(rw, r) res := rw.Result() @@ -107,7 +107,7 @@ func TestUserParam(t *testing.T) { routeContext := chi.NewRouteContext() routeContext.URLParams.Add("user", "me") r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeContext)) - httpmw.ExtractUserParam(db)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + httpmw.ExtractUserParam(db, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { _ = httpmw.UserParam(r) rw.WriteHeader(http.StatusOK) })).ServeHTTP(rw, r) diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index bc040b98bc..b44a80c391 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -305,7 +305,7 @@ func TestWorkspaceAgentByNameParam(t *testing.T) { DB: db, RedirectToLogin: true, }), - httpmw.ExtractUserParam(db), + httpmw.ExtractUserParam(db, false), httpmw.ExtractWorkspaceAndAgentParam(db), ) rtr.Get("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 1863de54c6..adb5cb2edf 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -814,6 +814,14 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. health = database.WorkspaceAppHealthInitializing } + sharingLevel := database.AppSharingLevelOwner + switch app.SharingLevel { + case sdkproto.AppSharingLevel_AUTHENTICATED: + sharingLevel = database.AppSharingLevelAuthenticated + case sdkproto.AppSharingLevel_PUBLIC: + sharingLevel = database.AppSharingLevelPublic + } + dbApp, err := db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{ ID: uuid.New(), CreatedAt: database.Now(), @@ -829,6 +837,7 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. Valid: app.Url != "", }, Subdomain: app.Subdomain, + SharingLevel: sharingLevel, HealthcheckUrl: app.Healthcheck.Url, HealthcheckInterval: app.Healthcheck.Interval, HealthcheckThreshold: app.Healthcheck.Threshold, diff --git a/coderd/users.go b/coderd/users.go index f48708e9b5..5b56509786 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1207,6 +1207,7 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey { CreatedAt: k.CreatedAt, UpdatedAt: k.UpdatedAt, LoginType: codersdk.LoginType(k.LoginType), + Scope: codersdk.APIKeyScope(k.Scope), LifetimeSeconds: k.LifetimeSeconds, } } diff --git a/coderd/users_test.go b/coderd/users_test.go index 3a7a11b670..e4e8b7d661 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -286,7 +286,7 @@ func TestPostLogin(t *testing.T) { require.Equal(t, int64(86400), key.LifetimeSeconds, "default should be 86400") // tokens have a longer life - token, err := client.CreateToken(ctx, codersdk.Me) + token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{}) require.NoError(t, err, "make new token api key") split = strings.Split(token.Key, "-") apiKey, err := client.GetAPIKey(ctx, admin.UserID.String(), split[0]) @@ -1202,7 +1202,7 @@ func TestPostTokens(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - apiKey, err := client.CreateToken(ctx, codersdk.Me) + apiKey, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{}) require.NotNil(t, apiKey) require.GreaterOrEqual(t, len(apiKey.Key), 2) require.NoError(t, err) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index f854fc1c29..295beff0d2 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -594,11 +594,12 @@ func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp { apps := make([]codersdk.WorkspaceApp, 0) for _, dbApp := range dbApps { apps = append(apps, codersdk.WorkspaceApp{ - ID: dbApp.ID, - Name: dbApp.Name, - Command: dbApp.Command.String, - Icon: dbApp.Icon, - Subdomain: dbApp.Subdomain, + ID: dbApp.ID, + Name: dbApp.Name, + Command: dbApp.Command.String, + Icon: dbApp.Icon, + Subdomain: dbApp.Subdomain, + SharingLevel: codersdk.WorkspaceAppSharingLevel(dbApp.SharingLevel), Healthcheck: codersdk.Healthcheck{ URL: dbApp.HealthcheckUrl, Interval: dbApp.HealthcheckInterval, diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index 1e3e416861..55f73d2c52 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -16,10 +16,12 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" jose "gopkg.in/square/go-jose.v2" + "cdr.dev/slog" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -32,8 +34,6 @@ import ( const ( // This needs to be a super unique query parameter because we don't want to // conflict with query parameters that users may use. - // TODO: this will make dogfooding harder so come up with a more unique - // solution //nolint:gosec subdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783" redirectURIQueryParam = "redirect_uri" @@ -51,8 +51,32 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) workspace := httpmw.WorkspaceParam(r) agent := httpmw.WorkspaceAgentParam(r) - if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) { - httpapi.ResourceNotFound(rw) + // We do not support port proxying on paths, so lookup the app by name. + appName := chi.URLParam(r, "workspaceapp") + app, ok := api.lookupWorkspaceApp(rw, r, agent.ID, appName) + if !ok { + return + } + + appSharingLevel := database.AppSharingLevelOwner + if app.SharingLevel != "" { + appSharingLevel = app.SharingLevel + } + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel) + if !ok { + return + } + if !authed { + _, hasAPIKey := httpmw.APIKeyOptional(r) + if hasAPIKey { + // The request has a valid API key but insufficient permissions. + renderApplicationNotFound(rw, r, api.AccessURL) + return + } + + // Redirect to login as they don't have permission to access the app and + // they aren't signed in. + httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage) return } @@ -67,10 +91,9 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) api.proxyWorkspaceApplication(proxyApplication{ Workspace: workspace, Agent: agent, - // We do not support port proxying for paths. - AppName: chi.URLParam(r, "workspaceapp"), - Port: 0, - Path: chiPath, + App: &app, + Port: 0, + Path: chiPath, }, rw, r) } @@ -156,16 +179,30 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht workspace := httpmw.WorkspaceParam(r) agent := httpmw.WorkspaceAgentParam(r) + var workspaceAppPtr *database.WorkspaceApp + if app.AppName != "" { + workspaceApp, ok := api.lookupWorkspaceApp(rw, r, agent.ID, app.AppName) + if !ok { + return + } + + workspaceAppPtr = &workspaceApp + } + // Verify application auth. This function will redirect or // return an error page if the user doesn't have permission. - if !api.verifyWorkspaceApplicationAuth(rw, r, workspace, host) { + sharingLevel := database.AppSharingLevelOwner + if workspaceAppPtr != nil && workspaceAppPtr.SharingLevel != "" { + sharingLevel = workspaceAppPtr.SharingLevel + } + if !api.verifyWorkspaceApplicationSubdomainAuth(rw, r, host, workspace, sharingLevel) { return } api.proxyWorkspaceApplication(proxyApplication{ Workspace: workspace, Agent: agent, - AppName: app.AppName, + App: workspaceAppPtr, Port: app.Port, Path: r.URL.Path, }, rw, r) @@ -231,22 +268,139 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt return app, true } -// verifyWorkspaceApplicationAuth checks that the request is authorized to -// access the given application. If the user does not have a app session key, +// lookupWorkspaceApp looks up the workspace application by name in the given +// agent and returns it. If the application is not found or there was a server +// error while looking it up, an HTML error page is returned and false is +// returned so the caller can return early. +func (api *API) lookupWorkspaceApp(rw http.ResponseWriter, r *http.Request, agentID uuid.UUID, appName string) (database.WorkspaceApp, bool) { + app, err := api.Database.GetWorkspaceAppByAgentIDAndName(r.Context(), database.GetWorkspaceAppByAgentIDAndNameParams{ + AgentID: agentID, + Name: appName, + }) + if xerrors.Is(err, sql.ErrNoRows) { + renderApplicationNotFound(rw, r, api.AccessURL) + return database.WorkspaceApp{}, false + } + if err != nil { + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusInternalServerError, + Title: "Internal Server Error", + Description: "Could not fetch workspace application: " + err.Error(), + RetryEnabled: true, + DashboardURL: api.AccessURL.String(), + }) + return database.WorkspaceApp{}, false + } + + return app, true +} + +func (api *API) authorizeWorkspaceApp(r *http.Request, sharingLevel database.AppSharingLevel, workspace database.Workspace) (bool, error) { + ctx := r.Context() + + // Short circuit if not authenticated. + roles, ok := httpmw.UserAuthorizationOptional(r) + if !ok { + // The user is not authenticated, so they can only access the app if it + // is public. + return sharingLevel == database.AppSharingLevelPublic, nil + } + + // Do a standard RBAC check. This accounts for share level "owner" and any + // other RBAC rules that may be in place. + // + // Regardless of share level or whether it's enabled or not, the owner of + // the workspace can always access applications (as long as their API key's + // scope allows it). + err := api.Authorizer.ByRoleName(ctx, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), []string{}, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) + if err == nil { + return true, nil + } + + switch sharingLevel { + case database.AppSharingLevelOwner: + // We essentially already did this above with the regular RBAC check. + // Owners can always access their own apps according to RBAC rules, so + // they have already been returned from this function. + case database.AppSharingLevelAuthenticated: + // The user is authenticated at this point, but we need to make sure + // that they have ApplicationConnect permissions to their own + // workspaces. This ensures that the key's scope has permission to + // connect to workspace apps. + object := rbac.ResourceWorkspaceApplicationConnect.WithOwner(roles.ID.String()) + err := api.Authorizer.ByRoleName(ctx, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), []string{}, rbac.ActionCreate, object) + if err == nil { + return true, nil + } + case database.AppSharingLevelPublic: + // We don't really care about scopes and stuff if it's public anyways. + // Someone with a restricted-scope API key could just not submit the + // API key cookie in the request and access the page. + return true, nil + } + + // No checks were successful. + return false, nil +} + +// fetchWorkspaceApplicationAuth authorizes the user using api.AppAuthorizer +// for a given app share level in the given workspace. The user's authorization +// status is returned. If a server error occurs, a HTML error page is rendered +// and false is returned so the caller can return early. +func (api *API) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, appSharingLevel database.AppSharingLevel) (authed bool, ok bool) { + ok, err := api.authorizeWorkspaceApp(r, appSharingLevel, workspace) + if err != nil { + api.Logger.Error(r.Context(), "authorize workspace app", slog.Error(err)) + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusInternalServerError, + Title: "Internal Server Error", + Description: "Could not verify authorization. Please try again or contact an administrator.", + RetryEnabled: true, + DashboardURL: api.AccessURL.String(), + }) + return false, false + } + + return ok, true +} + +// checkWorkspaceApplicationAuth authorizes the user using api.AppAuthorizer +// for a given app share level in the given workspace. If the user is not +// authorized or a server error occurs, a discrete HTML error page is rendered +// and false is returned so the caller can return early. +func (api *API) checkWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel) + if !ok { + return false + } + if !authed { + renderApplicationNotFound(rw, r, api.AccessURL) + return false + } + + return true +} + +// verifyWorkspaceApplicationSubdomainAuth checks that the request is authorized +// to access the given application. If the user does not have a app session key, // they will be redirected to the route below. If the user does have a session // key but insufficient permissions a static error page will be rendered. -func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, host string) bool { - _, ok := httpmw.APIKeyOptional(r) - if ok { - if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) { - renderApplicationNotFound(rw, r, api.AccessURL) - return false - } - - // Request should be all good to go! +func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter, r *http.Request, host string, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool { + authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspace, appSharingLevel) + if !ok { + return false + } + if authed { return true } + _, hasAPIKey := httpmw.APIKeyOptional(r) + if hasAPIKey { + // The request has a valid API key but insufficient permissions. + renderApplicationNotFound(rw, r, api.AccessURL) + return false + } + // If the request has the special query param then we need to set a cookie // and strip that query parameter. if encryptedAPIKey := r.URL.Query().Get(subdomainProxyAPIKeyParam); encryptedAPIKey != "" { @@ -421,58 +575,49 @@ type proxyApplication struct { Workspace database.Workspace Agent database.WorkspaceAgent - // Either AppName or Port must be set, but not both. - AppName string - Port uint16 + // Either App or Port must be set, but not both. + App *database.WorkspaceApp + Port uint16 + + // SharingLevel MUST be set to database.AppSharingLevelOwner by default for + // ports. + SharingLevel database.AppSharingLevel // Path must either be empty or have a leading slash. Path string } func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !api.Authorize(r, rbac.ActionCreate, proxyApp.Workspace.ApplicationConnectRBAC()) { - httpapi.ResourceNotFound(rw) + + sharingLevel := database.AppSharingLevelOwner + if proxyApp.App != nil && proxyApp.App.SharingLevel != "" { + sharingLevel = proxyApp.App.SharingLevel + } + if !api.checkWorkspaceApplicationAuth(rw, r, proxyApp.Workspace, sharingLevel) { return } // If the app does not exist, but the app name is a port number, then // route to the port as an "anonymous app". We only support HTTP for // port-based URLs. + // + // This is only supported for subdomain-based applications. internalURL := fmt.Sprintf("http://127.0.0.1:%d", proxyApp.Port) // If the app name was used instead, fetch the app from the database so we // can get the internal URL. - if proxyApp.AppName != "" { - app, err := api.Database.GetWorkspaceAppByAgentIDAndName(ctx, database.GetWorkspaceAppByAgentIDAndNameParams{ - AgentID: proxyApp.Agent.ID, - Name: proxyApp.AppName, - }) - if xerrors.Is(err, sql.ErrNoRows) { - renderApplicationNotFound(rw, r, api.AccessURL) - return - } - if err != nil { - site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ - Status: http.StatusInternalServerError, - Title: "Internal Server Error", - Description: "Could not fetch workspace application: " + err.Error(), - RetryEnabled: true, - DashboardURL: api.AccessURL.String(), - }) - return - } - - if !app.Url.Valid { + if proxyApp.App != nil { + if !proxyApp.App.Url.Valid { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusBadRequest, Title: "Bad Request", - Description: fmt.Sprintf("Application %q does not have a URL set.", app.Name), + Description: fmt.Sprintf("Application %q does not have a URL set.", proxyApp.App.Name), RetryEnabled: true, DashboardURL: api.AccessURL.String(), }) return } - internalURL = app.Url.String + internalURL = proxyApp.App.Url.String } appURL, err := url.Parse(internalURL) @@ -692,8 +837,8 @@ func decryptAPIKey(ctx context.Context, db database.Store, encryptedAPIKey strin func renderApplicationNotFound(rw http.ResponseWriter, r *http.Request, accessURL *url.URL) { site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ Status: http.StatusNotFound, - Title: "Application not found", - Description: "The application or workspace you are trying to access does not exist.", + Title: "Application Not Found", + Description: "The application or workspace you are trying to access does not exist or you do not have permission to access it.", RetryEnabled: false, DashboardURL: accessURL.String(), }) diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index c5bc98666f..ed2f536ef2 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -7,6 +7,7 @@ import ( "io" "net" "net/http" + "net/http/httputil" "net/url" "strings" "testing" @@ -28,11 +29,13 @@ import ( ) const ( - proxyTestAgentName = "agent-name" - proxyTestAppName = "example" - proxyTestAppQuery = "query=true" - proxyTestAppBody = "hello world" - proxyTestFakeAppName = "fake" + proxyTestAgentName = "agent-name" + proxyTestAppNameFake = "test-app-fake" + proxyTestAppNameOwner = "test-app-owner" + proxyTestAppNameAuthenticated = "test-app-authenticated" + proxyTestAppNamePublic = "test-app-public" + proxyTestAppQuery = "query=true" + proxyTestAppBody = "hello world" proxyTestSubdomain = "test.coder.com" ) @@ -101,6 +104,8 @@ func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWork }) user := coderdtest.CreateFirstUser(t, client) authToken := uuid.NewString() + + appURL := fmt.Sprintf("http://127.0.0.1:%d?%s", tcpAddr.Port, proxyTestAppQuery) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, ProvisionDryRun: echo.ProvisionComplete, @@ -118,13 +123,26 @@ func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWork }, Apps: []*proto.App{ { - Name: proxyTestAppName, - Url: fmt.Sprintf("http://127.0.0.1:%d?%s", tcpAddr.Port, proxyTestAppQuery), - }, { - Name: proxyTestFakeAppName, + Name: proxyTestAppNameFake, + SharingLevel: proto.AppSharingLevel_OWNER, // Hopefully this IP and port doesn't exist. Url: "http://127.1.0.1:65535", }, + { + Name: proxyTestAppNameOwner, + SharingLevel: proto.AppSharingLevel_OWNER, + Url: appURL, + }, + { + Name: proxyTestAppNameAuthenticated, + SharingLevel: proto.AppSharingLevel_AUTHENTICATED, + Url: appURL, + }, + { + Name: proxyTestAppNamePublic, + SharingLevel: proto.AppSharingLevel_PUBLIC, + Url: appURL, + }, }, }}, }}, @@ -180,7 +198,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example", nil) + resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() @@ -201,7 +219,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := userClient.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example", nil) + resp, err := userClient.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusNotFound, resp.StatusCode) @@ -213,7 +231,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example", nil) + resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) @@ -225,7 +243,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example/", nil) + resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/", workspace.Name, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) @@ -240,7 +258,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/example/?"+proxyTestAppQuery, nil) + resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -255,7 +273,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/fake/", nil) + resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/", workspace.Name, proxyTestAppNameFake), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusBadGateway, resp.StatusCode) @@ -281,7 +299,7 @@ func TestWorkspaceApplicationAuth(t *testing.T) { require.NoError(t, err) // Try to load the application without authentication. - subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppName, proxyTestAgentName, workspace.Name, user.Username) + subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppNameOwner, proxyTestAgentName, workspace.Name, user.Username) u, err := url.Parse(fmt.Sprintf("http://%s.%s/test", subdomain, proxyTestSubdomain)) require.NoError(t, err) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) @@ -607,7 +625,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := userClient.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppName), nil) + resp, err := userClient.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppNameOwner), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusNotFound, resp.StatusCode) @@ -619,7 +637,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - slashlessURL := proxyURL(t, proxyTestAppName, "") + slashlessURL := proxyURL(t, proxyTestAppNameOwner, "") resp, err := client.Request(ctx, http.MethodGet, slashlessURL, nil) require.NoError(t, err) defer resp.Body.Close() @@ -636,7 +654,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - querylessURL := proxyURL(t, proxyTestAppName, "/", "") + querylessURL := proxyURL(t, proxyTestAppNameOwner, "/", "") resp, err := client.Request(ctx, http.MethodGet, querylessURL, nil) require.NoError(t, err) defer resp.Body.Close() @@ -653,7 +671,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppName, "/", proxyTestAppQuery), nil) + resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppNameOwner, "/", proxyTestAppQuery), nil) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -683,7 +701,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestFakeAppName, "/", ""), nil) + resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, proxyTestAppNameFake, "/", ""), nil) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusBadGateway, resp.StatusCode) @@ -708,3 +726,168 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { require.Contains(t, resBody.Message, "Coder reserves ports less than") }) } + +func TestAppSharing(t *testing.T) { + t.Parallel() + + setup := func(t *testing.T) (workspace codersdk.Workspace, agnt codersdk.WorkspaceAgent, user codersdk.User, client *codersdk.Client, clientInOtherOrg *codersdk.Client, clientWithNoAuth *codersdk.Client) { + //nolint:gosec + const password = "password" + + client, _, workspace, _ = setupProxyTest(t) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + + user, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + + // Verify that the apps have the correct sharing levels set. + workspaceBuild, err := client.WorkspaceBuild(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + agnt = workspaceBuild.Resources[0].Agents[0] + found := map[string]codersdk.WorkspaceAppSharingLevel{} + expected := map[string]codersdk.WorkspaceAppSharingLevel{ + proxyTestAppNameFake: codersdk.WorkspaceAppSharingLevelOwner, + proxyTestAppNameOwner: codersdk.WorkspaceAppSharingLevelOwner, + proxyTestAppNameAuthenticated: codersdk.WorkspaceAppSharingLevelAuthenticated, + proxyTestAppNamePublic: codersdk.WorkspaceAppSharingLevelPublic, + } + for _, app := range agnt.Apps { + found[app.Name] = app.SharingLevel + } + require.Equal(t, expected, found, "apps have incorrect sharing levels") + + // Create a user in a different org. + otherOrg, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "a-different-org", + }) + require.NoError(t, err) + userInOtherOrg, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "no-template-access@coder.com", + Username: "no-template-access", + Password: password, + OrganizationID: otherOrg.ID, + }) + require.NoError(t, err) + + clientInOtherOrg = codersdk.New(client.URL) + loginRes, err := clientInOtherOrg.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: userInOtherOrg.Email, + Password: password, + }) + require.NoError(t, err) + clientInOtherOrg.SessionToken = loginRes.SessionToken + clientInOtherOrg.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + // Create an unauthenticated codersdk client. + clientWithNoAuth = codersdk.New(client.URL) + clientWithNoAuth.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + return workspace, agnt, user, client, clientInOtherOrg, clientWithNoAuth + } + + verifyAccess := func(t *testing.T, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // If the client has a session token, we also want to check that a + // scoped key works. + clients := []*codersdk.Client{client} + if client.SessionToken != "" { + token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scope: codersdk.APIKeyScopeApplicationConnect, + }) + require.NoError(t, err) + + scopedClient := codersdk.New(client.URL) + scopedClient.SessionToken = token.Key + scopedClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect + + clients = append(clients, scopedClient) + } + + for i, client := range clients { + msg := fmt.Sprintf("client %d", i) + + appPath := fmt.Sprintf("/@%s/%s.%s/apps/%s/?%s", username, workspaceName, agentName, appName, proxyTestAppQuery) + res, err := client.Request(ctx, http.MethodGet, appPath, nil) + require.NoError(t, err, msg) + + dump, err := httputil.DumpResponse(res, true) + res.Body.Close() + require.NoError(t, err, msg) + t.Logf("response dump: %s", dump) + + if !shouldHaveAccess { + if shouldRedirectToLogin { + assert.Equal(t, http.StatusTemporaryRedirect, res.StatusCode, "should not have access, expected temporary redirect. "+msg) + location, err := res.Location() + require.NoError(t, err, msg) + assert.Equal(t, "/login", location.Path, "should not have access, expected redirect to /login. "+msg) + } else { + // If the user doesn't have access we return 404 to avoid + // leaking information about the existence of the app. + assert.Equal(t, http.StatusNotFound, res.StatusCode, "should not have access, expected not found. "+msg) + } + } + + if shouldHaveAccess { + assert.Equal(t, http.StatusOK, res.StatusCode, "should have access, expected ok. "+msg) + assert.Contains(t, string(dump), "hello world", "should have access, expected hello world. "+msg) + } + } + } + + t.Run("Level", func(t *testing.T) { + t.Parallel() + + workspace, agent, user, client, clientInOtherOrg, clientWithNoAuth := setup(t) + + t.Run("Owner", func(t *testing.T) { + t.Parallel() + + // Owner should be able to access their own workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, client, true, false) + + // Authenticated users should not have access to a workspace that + // they do not own. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false) + + // Unauthenticated user should not have any access. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameOwner, clientWithNoAuth, false, true) + }) + + t.Run("Authenticated", func(t *testing.T) { + t.Parallel() + + // Owner should be able to access their own workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, client, true, false) + + // Authenticated users should be able to access the workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, true, false) + + // Unauthenticated user should not have any access. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true) + }) + + t.Run("Public", func(t *testing.T) { + t.Parallel() + + // Owner should be able to access their own workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, client, true, false) + + // Authenticated users should be able to access the workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientInOtherOrg, true, false) + + // Unauthenticated user should be able to access the workspace. + verifyAccess(t, user.Username, workspace.Name, agent.Name, proxyTestAppNamePublic, clientWithNoAuth, true, false) + }) + }) +} diff --git a/codersdk/apikey.go b/codersdk/apikey.go index 44782dde85..1d22cba345 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -13,13 +13,14 @@ import ( type APIKey struct { ID string `json:"id" validate:"required"` // NOTE: do not ever return the HashedSecret - UserID uuid.UUID `json:"user_id" validate:"required"` - LastUsed time.Time `json:"last_used" validate:"required"` - ExpiresAt time.Time `json:"expires_at" validate:"required"` - CreatedAt time.Time `json:"created_at" validate:"required"` - UpdatedAt time.Time `json:"updated_at" validate:"required"` - LoginType LoginType `json:"login_type" validate:"required"` - LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` + UserID uuid.UUID `json:"user_id" validate:"required"` + LastUsed time.Time `json:"last_used" validate:"required"` + ExpiresAt time.Time `json:"expires_at" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` + LoginType LoginType `json:"login_type" validate:"required"` + Scope APIKeyScope `json:"scope" validate:"required"` + LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` } type LoginType string @@ -31,32 +32,51 @@ const ( LoginTypeToken LoginType = "token" ) +type APIKeyScope string + +const ( + APIKeyScopeAll APIKeyScope = "all" + APIKeyScopeApplicationConnect APIKeyScope = "application_connect" +) + +type CreateTokenRequest struct { + Scope APIKeyScope `json:"scope"` +} + +// GenerateAPIKeyResponse contains an API key for a user. +type GenerateAPIKeyResponse struct { + Key string `json:"key"` +} + // CreateToken generates an API key that doesn't expire. -func (c *Client) CreateToken(ctx context.Context, userID string) (*GenerateAPIKeyResponse, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), nil) +func (c *Client) CreateToken(ctx context.Context, userID string, req CreateTokenRequest) (GenerateAPIKeyResponse, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), req) if err != nil { - return nil, err + return GenerateAPIKeyResponse{}, err } defer res.Body.Close() if res.StatusCode > http.StatusCreated { - return nil, readBodyAsError(res) + return GenerateAPIKeyResponse{}, readBodyAsError(res) } - apiKey := &GenerateAPIKeyResponse{} - return apiKey, json.NewDecoder(res.Body).Decode(apiKey) + + var apiKey GenerateAPIKeyResponse + return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) } // CreateAPIKey generates an API key for the user ID provided. -func (c *Client) CreateAPIKey(ctx context.Context, user string) (*GenerateAPIKeyResponse, error) { +// DEPRECATED: use CreateToken instead. +func (c *Client) CreateAPIKey(ctx context.Context, user string) (GenerateAPIKeyResponse, error) { res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", user), nil) if err != nil { - return nil, err + return GenerateAPIKeyResponse{}, err } defer res.Body.Close() if res.StatusCode > http.StatusCreated { - return nil, readBodyAsError(res) + return GenerateAPIKeyResponse{}, readBodyAsError(res) } - apiKey := &GenerateAPIKeyResponse{} - return apiKey, json.NewDecoder(res.Body).Decode(apiKey) + + var apiKey GenerateAPIKeyResponse + return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) } // GetTokens list machine API keys. diff --git a/codersdk/users.go b/codersdk/users.go index a37e41d84e..b2452284a2 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -96,11 +96,6 @@ type LoginWithPasswordResponse struct { SessionToken string `json:"session_token" validate:"required"` } -// GenerateAPIKeyResponse contains an API key for a user. -type GenerateAPIKeyResponse struct { - Key string `json:"key"` -} - type CreateOrganizationRequest struct { Name string `json:"name" validate:"required,username"` } diff --git a/codersdk/workspaceapps.go b/codersdk/workspaceapps.go index 7de4217bac..6faf4bd3c3 100644 --- a/codersdk/workspaceapps.go +++ b/codersdk/workspaceapps.go @@ -13,6 +13,14 @@ const ( WorkspaceAppHealthUnhealthy WorkspaceAppHealth = "unhealthy" ) +type WorkspaceAppSharingLevel string + +const ( + WorkspaceAppSharingLevelOwner WorkspaceAppSharingLevel = "owner" + WorkspaceAppSharingLevelAuthenticated WorkspaceAppSharingLevel = "authenticated" + WorkspaceAppSharingLevelPublic WorkspaceAppSharingLevel = "public" +) + type WorkspaceApp struct { ID uuid.UUID `json:"id"` // Name is a unique identifier attached to an agent. @@ -25,7 +33,8 @@ type WorkspaceApp struct { // `coder server` or via a hostname-based dev URL. If this is set to true // and there is no app wildcard configured on the server, the app will not // be accessible in the UI. - Subdomain bool `json:"subdomain"` + Subdomain bool `json:"subdomain"` + SharingLevel WorkspaceAppSharingLevel `json:"sharing_level"` // Healthcheck specifies the configuration for checking app health. Healthcheck Healthcheck `json:"healthcheck"` Health WorkspaceAppHealth `json:"health"` diff --git a/dogfood/main.tf b/dogfood/main.tf index 5635223c52..cc65f6b1c6 100644 --- a/dogfood/main.tf +++ b/dogfood/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } docker = { source = "kreuzwerker/docker" @@ -38,10 +38,12 @@ resource "coder_agent" "dev" { } resource "coder_app" "code-server" { - agent_id = coder_agent.dev.id - name = "code-server" - url = "http://localhost:13337/" - icon = "/icon/code.svg" + agent_id = coder_agent.dev.id + name = "code-server" + url = "http://localhost:13337/" + icon = "/icon/code.svg" + subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" @@ -50,7 +52,6 @@ resource "coder_app" "code-server" { } } - resource "docker_volume" "home_volume" { name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}-home" } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 488d5dc033..2c341dd13a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -35,6 +35,12 @@ func New(ctx context.Context, options *Options) (*API, error) { if options.Keys == nil { options.Keys = Keys } + if options.Options == nil { + options.Options = &coderd.Options{} + } + if options.Options.Authorizer == nil { + options.Options.Authorizer = rbac.NewAuthorizer() + } ctx, cancelFunc := context.WithCancel(ctx) api := &API{ AGPL: coderd.New(options.Options), @@ -92,7 +98,7 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Route("/workspace-quota", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Route("/{user}", func(r chi.Router) { - r.Use(httpmw.ExtractUserParam(options.Database)) + r.Use(httpmw.ExtractUserParam(options.Database, false)) r.Get("/", api.workspaceQuota) }) }) diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 84b5422982..75760b3d4f 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -118,19 +118,19 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { if options.GraceAt.IsZero() { options.GraceAt = time.Now().Add(time.Hour) } - auditLog := int64(0) + var auditLog int64 if options.AuditLog { auditLog = 1 } - browserOnly := int64(0) + var browserOnly int64 if options.BrowserOnly { browserOnly = 1 } - scim := int64(0) + var scim int64 if options.SCIM { scim = 1 } - workspaceQuota := int64(0) + var workspaceQuota int64 if options.WorkspaceQuota { workspaceQuota = 1 } diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index 3bb40b75b0..9fe3cfeaa3 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "context" + "fmt" "net/http" "testing" @@ -16,6 +17,14 @@ import ( "github.com/coder/coder/enterprise/coderd/coderdenttest" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/testutil" +) + +// App names for each app sharing level. +const ( + testAppNameOwner = "test-app-owner" + testAppNameAuthenticated = "test-app-authenticated" + testAppNamePublic = "test-app-public" ) func TestBlockNonBrowser(t *testing.T) { @@ -32,8 +41,8 @@ func TestBlockNonBrowser(t *testing.T) { coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ BrowserOnly: true, }) - id := setupWorkspaceAgent(t, client, user) - _, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, id) + _, agent := setupWorkspaceAgent(t, client, user, 0) + _, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, agent.ID) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusConflict, apiErr.StatusCode()) @@ -49,14 +58,14 @@ func TestBlockNonBrowser(t *testing.T) { coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ BrowserOnly: false, }) - id := setupWorkspaceAgent(t, client, user) - conn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, id) + _, agent := setupWorkspaceAgent(t, client, user, 0) + conn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, agent.ID) require.NoError(t, err) _ = conn.Close() }) } -func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse) uuid.UUID { +func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse, appPort uint16) (codersdk.Workspace, codersdk.WorkspaceAgent) { authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -72,6 +81,23 @@ func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.Cr Auth: &proto.Agent_Token{ Token: authToken, }, + Apps: []*proto.App{ + { + Name: testAppNameOwner, + SharingLevel: proto.AppSharingLevel_OWNER, + Url: fmt.Sprintf("http://localhost:%d", appPort), + }, + { + Name: testAppNameAuthenticated, + SharingLevel: proto.AppSharingLevel_AUTHENTICATED, + Url: fmt.Sprintf("http://localhost:%d", appPort), + }, + { + Name: testAppNamePublic, + SharingLevel: proto.AppSharingLevel_PUBLIC, + Url: fmt.Sprintf("http://localhost:%d", appPort), + }, + }, }}, }}, }, @@ -89,9 +115,16 @@ func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.Cr CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet, Logger: slogtest.Make(t, nil).Named("agent"), }) - defer func() { + t.Cleanup(func() { _ = agentCloser.Close() - }() + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - return resources[0].Agents[0].ID + agnt, err := client.WorkspaceAgent(ctx, resources[0].Agents[0].ID) + require.NoError(t, err) + + return workspace, agnt } diff --git a/examples/templates/aws-ecs-container/main.tf b/examples/templates/aws-ecs-container/main.tf index e21381ad54..394bbed6dc 100644 --- a/examples/templates/aws-ecs-container/main.tf +++ b/examples/templates/aws-ecs-container/main.tf @@ -6,7 +6,7 @@ terraform { } coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } @@ -110,6 +110,7 @@ resource "coder_app" "code-server" { icon = "/icon/code.svg" url = "http://localhost:13337?folder=/home/coder" subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/examples/templates/aws-linux/main.tf b/examples/templates/aws-linux/main.tf index 549afaeccb..89b69be247 100644 --- a/examples/templates/aws-linux/main.tf +++ b/examples/templates/aws-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } @@ -86,10 +86,12 @@ resource "coder_agent" "main" { } resource "coder_app" "code-server" { - agent_id = coder_agent.main.id - name = "code-server" - url = "http://localhost:13337/?folder=/home/coder" - icon = "/icon/code.svg" + agent_id = coder_agent.main.id + name = "code-server" + url = "http://localhost:13337/?folder=/home/coder" + icon = "/icon/code.svg" + subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/examples/templates/aws-windows/main.tf b/examples/templates/aws-windows/main.tf index 960f1d88aa..a01ee9a7eb 100644 --- a/examples/templates/aws-windows/main.tf +++ b/examples/templates/aws-windows/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/examples/templates/azure-linux/main.tf b/examples/templates/azure-linux/main.tf index e40fb4f510..aa6698e6bc 100644 --- a/examples/templates/azure-linux/main.tf +++ b/examples/templates/azure-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } azurerm = { source = "hashicorp/azurerm" diff --git a/examples/templates/bare/main.tf b/examples/templates/bare/main.tf index 24bf79b337..b51b3e777c 100644 --- a/examples/templates/bare/main.tf +++ b/examples/templates/bare/main.tf @@ -43,10 +43,12 @@ resource "null_resource" "fake-disk" { resource "coder_app" "fake-app" { # Access :8080 in the workspace from the Coder dashboard. - name = "VS Code" - icon = "/icon/code.svg" - agent_id = "fake-compute" - url = "http://localhost:8080" + name = "VS Code" + icon = "/icon/code.svg" + agent_id = "fake-compute" + url = "http://localhost:8080" + subdomain = false + share = "owner" healthcheck { url = "http://localhost:8080/healthz" diff --git a/examples/templates/do-linux/main.tf b/examples/templates/do-linux/main.tf index 375d61c3a1..9f54de8957 100644 --- a/examples/templates/do-linux/main.tf +++ b/examples/templates/do-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } digitalocean = { source = "digitalocean/digitalocean" diff --git a/examples/templates/docker-code-server/main.tf b/examples/templates/docker-code-server/main.tf index 3ac932a1d0..2e4f4f5b48 100644 --- a/examples/templates/docker-code-server/main.tf +++ b/examples/templates/docker-code-server/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } docker = { source = "kreuzwerker/docker" @@ -38,9 +38,12 @@ resource "coder_agent" "main" { } resource "coder_app" "code-server" { - agent_id = coder_agent.main.id - url = "http://localhost:8080/?folder=/home/coder" - icon = "/icon/code.svg" + agent_id = coder_agent.main.id + name = "code-server" + url = "http://localhost:8080/?folder=/home/coder" + icon = "/icon/code.svg" + subdomain = false + share = "owner" healthcheck { url = "http://localhost:8080/healthz" diff --git a/examples/templates/docker-image-builds/main.tf b/examples/templates/docker-image-builds/main.tf index 4849253a7b..f5290efdfe 100644 --- a/examples/templates/docker-image-builds/main.tf +++ b/examples/templates/docker-image-builds/main.tf @@ -3,7 +3,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } docker = { source = "kreuzwerker/docker" @@ -34,10 +34,12 @@ resource "coder_agent" "main" { } resource "coder_app" "code-server" { - agent_id = coder_agent.main.id - name = "code-server" - url = "http://localhost:13337/?folder=/home/coder" - icon = "/icon/code.svg" + agent_id = coder_agent.main.id + name = "code-server" + url = "http://localhost:13337/?folder=/home/coder" + icon = "/icon/code.svg" + subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/examples/templates/docker-with-dotfiles/main.tf b/examples/templates/docker-with-dotfiles/main.tf index 73ab639f69..750dbed2e0 100644 --- a/examples/templates/docker-with-dotfiles/main.tf +++ b/examples/templates/docker-with-dotfiles/main.tf @@ -9,7 +9,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } docker = { source = "kreuzwerker/docker" diff --git a/examples/templates/docker/main.tf b/examples/templates/docker/main.tf index 2463eeef9f..677dace7f4 100644 --- a/examples/templates/docker/main.tf +++ b/examples/templates/docker/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } docker = { source = "kreuzwerker/docker" @@ -43,10 +43,13 @@ resource "coder_agent" "main" { } resource "coder_app" "code-server" { - agent_id = coder_agent.main.id - name = "code-server" - url = "http://localhost:13337/?folder=/home/coder" - icon = "/icon/code.svg" + agent_id = coder_agent.main.id + name = "code-server" + url = "http://localhost:13337/?folder=/home/coder" + icon = "/icon/code.svg" + subdomain = false + share = "owner" + healthcheck { url = "http://localhost:13337/healthz" interval = 5 diff --git a/examples/templates/gcp-linux/main.tf b/examples/templates/gcp-linux/main.tf index 29e516790c..8e184b17c3 100644 --- a/examples/templates/gcp-linux/main.tf +++ b/examples/templates/gcp-linux/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } google = { source = "hashicorp/google" @@ -65,6 +65,7 @@ resource "coder_app" "code-server" { icon = "/icon/code.svg" url = "http://localhost:13337?folder=/home/coder" subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/examples/templates/gcp-vm-container/main.tf b/examples/templates/gcp-vm-container/main.tf index 508bf4a344..753a2535fe 100644 --- a/examples/templates/gcp-vm-container/main.tf +++ b/examples/templates/gcp-vm-container/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } google = { source = "hashicorp/google" @@ -55,6 +55,7 @@ resource "coder_app" "code-server" { icon = "/icon/code.svg" url = "http://localhost:13337?folder=/home/coder" subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/examples/templates/gcp-windows/main.tf b/examples/templates/gcp-windows/main.tf index e9f65d332c..5f9a65ac1a 100644 --- a/examples/templates/gcp-windows/main.tf +++ b/examples/templates/gcp-windows/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } google = { source = "hashicorp/google" diff --git a/examples/templates/kubernetes/main.tf b/examples/templates/kubernetes/main.tf index a3656df07e..b9d6ebd0ba 100644 --- a/examples/templates/kubernetes/main.tf +++ b/examples/templates/kubernetes/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } kubernetes = { source = "hashicorp/kubernetes" @@ -76,6 +76,7 @@ resource "coder_app" "code-server" { icon = "/icon/code.svg" url = "http://localhost:13337?folder=/home/coder" subdomain = false + share = "owner" healthcheck { url = "http://localhost:13337/healthz" diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index e629625323..604c99c7fb 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -30,6 +30,7 @@ type agentAppAttributes struct { Icon string `mapstructure:"icon"` URL string `mapstructure:"url"` Command string `mapstructure:"command"` + Share string `mapstructure:"share"` Subdomain bool `mapstructure:"subdomain"` Healthcheck []appHealthcheckAttributes `mapstructure:"healthcheck"` } @@ -235,6 +236,16 @@ func ConvertResources(module *tfjson.StateModule, rawGraph string) ([]*proto.Res } } + sharingLevel := proto.AppSharingLevel_OWNER + switch strings.ToLower(attrs.Share) { + case "owner": + sharingLevel = proto.AppSharingLevel_OWNER + case "authenticated": + sharingLevel = proto.AppSharingLevel_AUTHENTICATED + case "public": + sharingLevel = proto.AppSharingLevel_PUBLIC + } + for _, agents := range resourceAgents { for _, agent := range agents { // Find agents with the matching ID and associate them! @@ -242,12 +253,13 @@ func ConvertResources(module *tfjson.StateModule, rawGraph string) ([]*proto.Res continue } agent.Apps = append(agent.Apps, &proto.App{ - Name: attrs.Name, - Command: attrs.Command, - Url: attrs.URL, - Icon: attrs.Icon, - Subdomain: attrs.Subdomain, - Healthcheck: healthcheck, + Name: attrs.Name, + Command: attrs.Command, + Url: attrs.URL, + Icon: attrs.Icon, + Subdomain: attrs.Subdomain, + SharingLevel: sharingLevel, + Healthcheck: healthcheck, }) } } diff --git a/provisioner/terraform/testdata/calling-module/calling-module.tf b/provisioner/terraform/testdata/calling-module/calling-module.tf index 6c6289c30d..6bde4e1fd0 100644 --- a/provisioner/terraform/testdata/calling-module/calling-module.tf +++ b/provisioner/terraform/testdata/calling-module/calling-module.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tf b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tf index 3f7a212667..ce8eea33b1 100644 --- a/provisioner/terraform/testdata/chaining-resources/chaining-resources.tf +++ b/provisioner/terraform/testdata/chaining-resources/chaining-resources.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tf b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tf index 7ae15e8673..2ec5614cd1 100644 --- a/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tf +++ b/provisioner/terraform/testdata/conflicting-resources/conflicting-resources.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/instance-id/instance-id.tf b/provisioner/terraform/testdata/instance-id/instance-id.tf index f474e4993a..767ed45a63 100644 --- a/provisioner/terraform/testdata/instance-id/instance-id.tf +++ b/provisioner/terraform/testdata/instance-id/instance-id.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/multiple-agents/multiple-agents.tf b/provisioner/terraform/testdata/multiple-agents/multiple-agents.tf index 379612d2f3..cae9aac261 100644 --- a/provisioner/terraform/testdata/multiple-agents/multiple-agents.tf +++ b/provisioner/terraform/testdata/multiple-agents/multiple-agents.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tf b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tf index 678c600616..446183a9db 100644 --- a/provisioner/terraform/testdata/multiple-apps/multiple-apps.tf +++ b/provisioner/terraform/testdata/multiple-apps/multiple-apps.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf index 26734569b6..ab94dcfbf7 100644 --- a/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf +++ b/provisioner/terraform/testdata/resource-metadata/resource-metadata.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "0.5.0" + version = "0.5.3" } } } diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index b88fc0ac1d..0e70c8f919 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -76,6 +76,55 @@ func (LogLevel) EnumDescriptor() ([]byte, []int) { return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{0} } +type AppSharingLevel int32 + +const ( + AppSharingLevel_OWNER AppSharingLevel = 0 + AppSharingLevel_AUTHENTICATED AppSharingLevel = 1 + AppSharingLevel_PUBLIC AppSharingLevel = 2 +) + +// Enum value maps for AppSharingLevel. +var ( + AppSharingLevel_name = map[int32]string{ + 0: "OWNER", + 1: "AUTHENTICATED", + 2: "PUBLIC", + } + AppSharingLevel_value = map[string]int32{ + "OWNER": 0, + "AUTHENTICATED": 1, + "PUBLIC": 2, + } +) + +func (x AppSharingLevel) Enum() *AppSharingLevel { + p := new(AppSharingLevel) + *p = x + return p +} + +func (x AppSharingLevel) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AppSharingLevel) Descriptor() protoreflect.EnumDescriptor { + return file_provisionersdk_proto_provisioner_proto_enumTypes[1].Descriptor() +} + +func (AppSharingLevel) Type() protoreflect.EnumType { + return &file_provisionersdk_proto_provisioner_proto_enumTypes[1] +} + +func (x AppSharingLevel) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AppSharingLevel.Descriptor instead. +func (AppSharingLevel) EnumDescriptor() ([]byte, []int) { + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{1} +} + type WorkspaceTransition int32 const ( @@ -109,11 +158,11 @@ func (x WorkspaceTransition) String() string { } func (WorkspaceTransition) Descriptor() protoreflect.EnumDescriptor { - return file_provisionersdk_proto_provisioner_proto_enumTypes[1].Descriptor() + return file_provisionersdk_proto_provisioner_proto_enumTypes[2].Descriptor() } func (WorkspaceTransition) Type() protoreflect.EnumType { - return &file_provisionersdk_proto_provisioner_proto_enumTypes[1] + return &file_provisionersdk_proto_provisioner_proto_enumTypes[2] } func (x WorkspaceTransition) Number() protoreflect.EnumNumber { @@ -122,7 +171,7 @@ func (x WorkspaceTransition) Number() protoreflect.EnumNumber { // Deprecated: Use WorkspaceTransition.Descriptor instead. func (WorkspaceTransition) EnumDescriptor() ([]byte, []int) { - return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{1} + return file_provisionersdk_proto_provisioner_proto_rawDescGZIP(), []int{2} } type ParameterSource_Scheme int32 @@ -152,11 +201,11 @@ func (x ParameterSource_Scheme) String() string { } func (ParameterSource_Scheme) Descriptor() protoreflect.EnumDescriptor { - return file_provisionersdk_proto_provisioner_proto_enumTypes[2].Descriptor() + return file_provisionersdk_proto_provisioner_proto_enumTypes[3].Descriptor() } func (ParameterSource_Scheme) Type() protoreflect.EnumType { - return &file_provisionersdk_proto_provisioner_proto_enumTypes[2] + return &file_provisionersdk_proto_provisioner_proto_enumTypes[3] } func (x ParameterSource_Scheme) Number() protoreflect.EnumNumber { @@ -198,11 +247,11 @@ func (x ParameterDestination_Scheme) String() string { } func (ParameterDestination_Scheme) Descriptor() protoreflect.EnumDescriptor { - return file_provisionersdk_proto_provisioner_proto_enumTypes[3].Descriptor() + return file_provisionersdk_proto_provisioner_proto_enumTypes[4].Descriptor() } func (ParameterDestination_Scheme) Type() protoreflect.EnumType { - return &file_provisionersdk_proto_provisioner_proto_enumTypes[3] + return &file_provisionersdk_proto_provisioner_proto_enumTypes[4] } func (x ParameterDestination_Scheme) Number() protoreflect.EnumNumber { @@ -244,11 +293,11 @@ func (x ParameterSchema_TypeSystem) String() string { } func (ParameterSchema_TypeSystem) Descriptor() protoreflect.EnumDescriptor { - return file_provisionersdk_proto_provisioner_proto_enumTypes[4].Descriptor() + return file_provisionersdk_proto_provisioner_proto_enumTypes[5].Descriptor() } func (ParameterSchema_TypeSystem) Type() protoreflect.EnumType { - return &file_provisionersdk_proto_provisioner_proto_enumTypes[4] + return &file_provisionersdk_proto_provisioner_proto_enumTypes[5] } func (x ParameterSchema_TypeSystem) Number() protoreflect.EnumNumber { @@ -850,12 +899,13 @@ type App struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Command string `protobuf:"bytes,2,opt,name=command,proto3" json:"command,omitempty"` - Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"` - Icon string `protobuf:"bytes,4,opt,name=icon,proto3" json:"icon,omitempty"` - Subdomain bool `protobuf:"varint,5,opt,name=subdomain,proto3" json:"subdomain,omitempty"` - Healthcheck *Healthcheck `protobuf:"bytes,6,opt,name=healthcheck,proto3" json:"healthcheck,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Command string `protobuf:"bytes,2,opt,name=command,proto3" json:"command,omitempty"` + Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"` + Icon string `protobuf:"bytes,4,opt,name=icon,proto3" json:"icon,omitempty"` + Subdomain bool `protobuf:"varint,5,opt,name=subdomain,proto3" json:"subdomain,omitempty"` + Healthcheck *Healthcheck `protobuf:"bytes,6,opt,name=healthcheck,proto3" json:"healthcheck,omitempty"` + SharingLevel AppSharingLevel `protobuf:"varint,7,opt,name=sharing_level,json=sharingLevel,proto3,enum=provisioner.AppSharingLevel" json:"sharing_level,omitempty"` } func (x *App) Reset() { @@ -932,6 +982,13 @@ func (x *App) GetHealthcheck() *Healthcheck { return nil } +func (x *App) GetSharingLevel() AppSharingLevel { + if x != nil { + return x.SharingLevel + } + return AppSharingLevel_OWNER +} + // Healthcheck represents configuration for checking for app readiness. type Healthcheck struct { state protoimpl.MessageState @@ -1952,7 +2009,7 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x0a, 0x08, 0x45, 0x6e, 0x76, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x22, 0xb3, + 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x06, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x22, 0xf6, 0x01, 0x0a, 0x03, 0x41, 0x70, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, @@ -1964,110 +2021,118 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, - 0x68, 0x65, 0x63, 0x6b, 0x22, 0x59, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, - 0x65, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, - 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, - 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, - 0xad, 0x02, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x74, 0x79, 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x03, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, - 0x12, 0x3a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04, - 0x68, 0x69, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x68, 0x69, 0x64, 0x65, - 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x69, 0x63, 0x6f, 0x6e, 0x1a, 0x69, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, - 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x6e, 0x73, - 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6e, - 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, 0x6e, 0x75, 0x6c, - 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x4e, 0x75, 0x6c, 0x6c, 0x22, - 0xfc, 0x01, 0x0a, 0x05, 0x50, 0x61, 0x72, 0x73, 0x65, 0x1a, 0x27, 0x0a, 0x07, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, - 0x72, 0x79, 0x1a, 0x55, 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x49, - 0x0a, 0x11, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x73, 0x63, 0x68, 0x65, - 0x6d, 0x61, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, - 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x52, 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, - 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x1a, 0x73, 0x0a, 0x08, 0x52, 0x65, 0x73, + 0x68, 0x65, 0x63, 0x6b, 0x12, 0x41, 0x0a, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x69, 0x6e, 0x67, 0x5f, + 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, 0x53, 0x68, 0x61, + 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x0c, 0x73, 0x68, 0x61, 0x72, 0x69, + 0x6e, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x22, 0x59, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, + 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x76, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x76, 0x61, 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, + 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, + 0x6c, 0x64, 0x22, 0xad, 0x02, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x73, 0x12, 0x3a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, + 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, + 0x12, 0x0a, 0x04, 0x68, 0x69, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x68, + 0x69, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x1a, 0x69, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, + 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, + 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x73, 0x5f, + 0x6e, 0x75, 0x6c, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x69, 0x73, 0x4e, 0x75, + 0x6c, 0x6c, 0x22, 0xfc, 0x01, 0x0a, 0x05, 0x50, 0x61, 0x72, 0x73, 0x65, 0x1a, 0x27, 0x0a, 0x07, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x6f, 0x72, 0x79, 0x1a, 0x55, 0x0a, 0x08, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, + 0x65, 0x12, 0x49, 0x0a, 0x11, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x73, + 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, + 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x52, 0x10, 0x70, 0x61, 0x72, 0x61, + 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x1a, 0x73, 0x0a, 0x08, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x39, + 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, + 0x61, 0x72, 0x73, 0x65, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, + 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, + 0x65, 0x22, 0xae, 0x07, 0x0a, 0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x1a, + 0xd1, 0x02, 0x0a, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x53, 0x0a, 0x14, 0x77, 0x6f, 0x72, + 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, + 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, + 0x0a, 0x0e, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x21, + 0x0a, 0x0c, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, + 0x64, 0x12, 0x2c, 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, + 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, + 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, + 0x32, 0x0a, 0x15, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, + 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x45, 0x6d, + 0x61, 0x69, 0x6c, 0x1a, 0xd9, 0x01, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x1c, 0x0a, + 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x46, 0x0a, 0x10, 0x70, + 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x52, 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x73, 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, + 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x4d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, + 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x79, 0x5f, 0x72, 0x75, + 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x1a, + 0x08, 0x0a, 0x06, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0x80, 0x01, 0x0a, 0x07, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, + 0x72, 0x74, 0x48, 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x37, 0x0a, 0x06, 0x63, + 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x48, 0x00, 0x52, 0x06, 0x63, 0x61, + 0x6e, 0x63, 0x65, 0x6c, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x1a, 0x6b, 0x0a, 0x08, + 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x14, + 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x1a, 0x77, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x39, 0x0a, 0x08, 0x63, - 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x73, - 0x65, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, 0x6f, - 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xae, - 0x07, 0x0a, 0x09, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0xd1, 0x02, 0x0a, - 0x08, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, 0x64, - 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, - 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x53, 0x0a, 0x14, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, - 0x61, 0x63, 0x65, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, - 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x77, - 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0d, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4e, 0x61, - 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, - 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x77, 0x6f, 0x72, - 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x77, - 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x49, 0x64, 0x12, 0x2c, - 0x0a, 0x12, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, - 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x77, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x32, 0x0a, 0x15, - 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, - 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x6f, 0x72, - 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, - 0x1a, 0xd9, 0x01, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, - 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x46, 0x0a, 0x10, 0x70, 0x61, 0x72, 0x61, - 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, - 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, - 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, - 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x14, 0x0a, - 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, - 0x61, 0x74, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x79, 0x5f, 0x72, 0x75, 0x6e, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x1a, 0x08, 0x0a, 0x06, - 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x1a, 0x80, 0x01, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x48, - 0x00, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x37, 0x0a, 0x06, 0x63, 0x61, 0x6e, 0x63, - 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, - 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x48, 0x00, 0x52, 0x06, 0x63, 0x61, 0x6e, 0x63, 0x65, - 0x6c, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x1a, 0x6b, 0x0a, 0x08, 0x43, 0x6f, 0x6d, - 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x1a, 0x77, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, - 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x3d, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x70, - 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, 0x52, 0x08, 0x63, - 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x2a, - 0x3f, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x54, - 0x52, 0x41, 0x43, 0x45, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, - 0x01, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x57, - 0x41, 0x52, 0x4e, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, + 0x2e, 0x4c, 0x6f, 0x67, 0x48, 0x00, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x12, 0x3d, 0x0a, 0x08, 0x63, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, + 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00, + 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x2a, 0x3f, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, + 0x0a, 0x05, 0x54, 0x52, 0x41, 0x43, 0x45, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, + 0x55, 0x47, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x02, 0x12, 0x08, + 0x0a, 0x04, 0x57, 0x41, 0x52, 0x4e, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, + 0x52, 0x10, 0x04, 0x2a, 0x3b, 0x0a, 0x0f, 0x41, 0x70, 0x70, 0x53, 0x68, 0x61, 0x72, 0x69, 0x6e, + 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, + 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x41, 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, + 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, 0x2a, 0x37, 0x0a, 0x13, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x41, 0x52, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x54, 0x4f, 0x50, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, @@ -2100,72 +2165,74 @@ func file_provisionersdk_proto_provisioner_proto_rawDescGZIP() []byte { return file_provisionersdk_proto_provisioner_proto_rawDescData } -var file_provisionersdk_proto_provisioner_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_provisionersdk_proto_provisioner_proto_enumTypes = make([]protoimpl.EnumInfo, 6) var file_provisionersdk_proto_provisioner_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_provisionersdk_proto_provisioner_proto_goTypes = []interface{}{ (LogLevel)(0), // 0: provisioner.LogLevel - (WorkspaceTransition)(0), // 1: provisioner.WorkspaceTransition - (ParameterSource_Scheme)(0), // 2: provisioner.ParameterSource.Scheme - (ParameterDestination_Scheme)(0), // 3: provisioner.ParameterDestination.Scheme - (ParameterSchema_TypeSystem)(0), // 4: provisioner.ParameterSchema.TypeSystem - (*Empty)(nil), // 5: provisioner.Empty - (*ParameterSource)(nil), // 6: provisioner.ParameterSource - (*ParameterDestination)(nil), // 7: provisioner.ParameterDestination - (*ParameterValue)(nil), // 8: provisioner.ParameterValue - (*ParameterSchema)(nil), // 9: provisioner.ParameterSchema - (*Log)(nil), // 10: provisioner.Log - (*InstanceIdentityAuth)(nil), // 11: provisioner.InstanceIdentityAuth - (*Agent)(nil), // 12: provisioner.Agent - (*App)(nil), // 13: provisioner.App - (*Healthcheck)(nil), // 14: provisioner.Healthcheck - (*Resource)(nil), // 15: provisioner.Resource - (*Parse)(nil), // 16: provisioner.Parse - (*Provision)(nil), // 17: provisioner.Provision - nil, // 18: provisioner.Agent.EnvEntry - (*Resource_Metadata)(nil), // 19: provisioner.Resource.Metadata - (*Parse_Request)(nil), // 20: provisioner.Parse.Request - (*Parse_Complete)(nil), // 21: provisioner.Parse.Complete - (*Parse_Response)(nil), // 22: provisioner.Parse.Response - (*Provision_Metadata)(nil), // 23: provisioner.Provision.Metadata - (*Provision_Start)(nil), // 24: provisioner.Provision.Start - (*Provision_Cancel)(nil), // 25: provisioner.Provision.Cancel - (*Provision_Request)(nil), // 26: provisioner.Provision.Request - (*Provision_Complete)(nil), // 27: provisioner.Provision.Complete - (*Provision_Response)(nil), // 28: provisioner.Provision.Response + (AppSharingLevel)(0), // 1: provisioner.AppSharingLevel + (WorkspaceTransition)(0), // 2: provisioner.WorkspaceTransition + (ParameterSource_Scheme)(0), // 3: provisioner.ParameterSource.Scheme + (ParameterDestination_Scheme)(0), // 4: provisioner.ParameterDestination.Scheme + (ParameterSchema_TypeSystem)(0), // 5: provisioner.ParameterSchema.TypeSystem + (*Empty)(nil), // 6: provisioner.Empty + (*ParameterSource)(nil), // 7: provisioner.ParameterSource + (*ParameterDestination)(nil), // 8: provisioner.ParameterDestination + (*ParameterValue)(nil), // 9: provisioner.ParameterValue + (*ParameterSchema)(nil), // 10: provisioner.ParameterSchema + (*Log)(nil), // 11: provisioner.Log + (*InstanceIdentityAuth)(nil), // 12: provisioner.InstanceIdentityAuth + (*Agent)(nil), // 13: provisioner.Agent + (*App)(nil), // 14: provisioner.App + (*Healthcheck)(nil), // 15: provisioner.Healthcheck + (*Resource)(nil), // 16: provisioner.Resource + (*Parse)(nil), // 17: provisioner.Parse + (*Provision)(nil), // 18: provisioner.Provision + nil, // 19: provisioner.Agent.EnvEntry + (*Resource_Metadata)(nil), // 20: provisioner.Resource.Metadata + (*Parse_Request)(nil), // 21: provisioner.Parse.Request + (*Parse_Complete)(nil), // 22: provisioner.Parse.Complete + (*Parse_Response)(nil), // 23: provisioner.Parse.Response + (*Provision_Metadata)(nil), // 24: provisioner.Provision.Metadata + (*Provision_Start)(nil), // 25: provisioner.Provision.Start + (*Provision_Cancel)(nil), // 26: provisioner.Provision.Cancel + (*Provision_Request)(nil), // 27: provisioner.Provision.Request + (*Provision_Complete)(nil), // 28: provisioner.Provision.Complete + (*Provision_Response)(nil), // 29: provisioner.Provision.Response } var file_provisionersdk_proto_provisioner_proto_depIdxs = []int32{ - 2, // 0: provisioner.ParameterSource.scheme:type_name -> provisioner.ParameterSource.Scheme - 3, // 1: provisioner.ParameterDestination.scheme:type_name -> provisioner.ParameterDestination.Scheme - 3, // 2: provisioner.ParameterValue.destination_scheme:type_name -> provisioner.ParameterDestination.Scheme - 6, // 3: provisioner.ParameterSchema.default_source:type_name -> provisioner.ParameterSource - 7, // 4: provisioner.ParameterSchema.default_destination:type_name -> provisioner.ParameterDestination - 4, // 5: provisioner.ParameterSchema.validation_type_system:type_name -> provisioner.ParameterSchema.TypeSystem + 3, // 0: provisioner.ParameterSource.scheme:type_name -> provisioner.ParameterSource.Scheme + 4, // 1: provisioner.ParameterDestination.scheme:type_name -> provisioner.ParameterDestination.Scheme + 4, // 2: provisioner.ParameterValue.destination_scheme:type_name -> provisioner.ParameterDestination.Scheme + 7, // 3: provisioner.ParameterSchema.default_source:type_name -> provisioner.ParameterSource + 8, // 4: provisioner.ParameterSchema.default_destination:type_name -> provisioner.ParameterDestination + 5, // 5: provisioner.ParameterSchema.validation_type_system:type_name -> provisioner.ParameterSchema.TypeSystem 0, // 6: provisioner.Log.level:type_name -> provisioner.LogLevel - 18, // 7: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry - 13, // 8: provisioner.Agent.apps:type_name -> provisioner.App - 14, // 9: provisioner.App.healthcheck:type_name -> provisioner.Healthcheck - 12, // 10: provisioner.Resource.agents:type_name -> provisioner.Agent - 19, // 11: provisioner.Resource.metadata:type_name -> provisioner.Resource.Metadata - 9, // 12: provisioner.Parse.Complete.parameter_schemas:type_name -> provisioner.ParameterSchema - 10, // 13: provisioner.Parse.Response.log:type_name -> provisioner.Log - 21, // 14: provisioner.Parse.Response.complete:type_name -> provisioner.Parse.Complete - 1, // 15: provisioner.Provision.Metadata.workspace_transition:type_name -> provisioner.WorkspaceTransition - 8, // 16: provisioner.Provision.Start.parameter_values:type_name -> provisioner.ParameterValue - 23, // 17: provisioner.Provision.Start.metadata:type_name -> provisioner.Provision.Metadata - 24, // 18: provisioner.Provision.Request.start:type_name -> provisioner.Provision.Start - 25, // 19: provisioner.Provision.Request.cancel:type_name -> provisioner.Provision.Cancel - 15, // 20: provisioner.Provision.Complete.resources:type_name -> provisioner.Resource - 10, // 21: provisioner.Provision.Response.log:type_name -> provisioner.Log - 27, // 22: provisioner.Provision.Response.complete:type_name -> provisioner.Provision.Complete - 20, // 23: provisioner.Provisioner.Parse:input_type -> provisioner.Parse.Request - 26, // 24: provisioner.Provisioner.Provision:input_type -> provisioner.Provision.Request - 22, // 25: provisioner.Provisioner.Parse:output_type -> provisioner.Parse.Response - 28, // 26: provisioner.Provisioner.Provision:output_type -> provisioner.Provision.Response - 25, // [25:27] is the sub-list for method output_type - 23, // [23:25] is the sub-list for method input_type - 23, // [23:23] is the sub-list for extension type_name - 23, // [23:23] is the sub-list for extension extendee - 0, // [0:23] is the sub-list for field type_name + 19, // 7: provisioner.Agent.env:type_name -> provisioner.Agent.EnvEntry + 14, // 8: provisioner.Agent.apps:type_name -> provisioner.App + 15, // 9: provisioner.App.healthcheck:type_name -> provisioner.Healthcheck + 1, // 10: provisioner.App.sharing_level:type_name -> provisioner.AppSharingLevel + 13, // 11: provisioner.Resource.agents:type_name -> provisioner.Agent + 20, // 12: provisioner.Resource.metadata:type_name -> provisioner.Resource.Metadata + 10, // 13: provisioner.Parse.Complete.parameter_schemas:type_name -> provisioner.ParameterSchema + 11, // 14: provisioner.Parse.Response.log:type_name -> provisioner.Log + 22, // 15: provisioner.Parse.Response.complete:type_name -> provisioner.Parse.Complete + 2, // 16: provisioner.Provision.Metadata.workspace_transition:type_name -> provisioner.WorkspaceTransition + 9, // 17: provisioner.Provision.Start.parameter_values:type_name -> provisioner.ParameterValue + 24, // 18: provisioner.Provision.Start.metadata:type_name -> provisioner.Provision.Metadata + 25, // 19: provisioner.Provision.Request.start:type_name -> provisioner.Provision.Start + 26, // 20: provisioner.Provision.Request.cancel:type_name -> provisioner.Provision.Cancel + 16, // 21: provisioner.Provision.Complete.resources:type_name -> provisioner.Resource + 11, // 22: provisioner.Provision.Response.log:type_name -> provisioner.Log + 28, // 23: provisioner.Provision.Response.complete:type_name -> provisioner.Provision.Complete + 21, // 24: provisioner.Provisioner.Parse:input_type -> provisioner.Parse.Request + 27, // 25: provisioner.Provisioner.Provision:input_type -> provisioner.Provision.Request + 23, // 26: provisioner.Provisioner.Parse:output_type -> provisioner.Parse.Response + 29, // 27: provisioner.Provisioner.Provision:output_type -> provisioner.Provision.Response + 26, // [26:28] is the sub-list for method output_type + 24, // [24:26] is the sub-list for method input_type + 24, // [24:24] is the sub-list for extension type_name + 24, // [24:24] is the sub-list for extension extendee + 0, // [0:24] is the sub-list for field type_name } func init() { file_provisionersdk_proto_provisioner_proto_init() } @@ -2472,7 +2539,7 @@ func file_provisionersdk_proto_provisioner_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_provisionersdk_proto_provisioner_proto_rawDesc, - NumEnums: 5, + NumEnums: 6, NumMessages: 24, NumExtensions: 0, NumServices: 1, diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index 26af34f280..bc6ab711a4 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -87,6 +87,12 @@ message Agent { } } +enum AppSharingLevel { + OWNER = 0; + AUTHENTICATED = 1; + PUBLIC = 2; +} + // App represents a dev-accessible application on the workspace. message App { string name = 1; @@ -95,6 +101,7 @@ message App { string icon = 4; bool subdomain = 5; Healthcheck healthcheck = 6; + AppSharingLevel sharing_level = 7; } // Healthcheck represents configuration for checking for app readiness. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 09c2da80fb..0c9cf3792b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -9,6 +9,7 @@ export interface APIKey { readonly created_at: string readonly updated_at: string readonly login_type: LoginType + readonly scope: APIKeyScope readonly lifetime_seconds: number } @@ -218,6 +219,11 @@ export interface CreateTestAuditLogRequest { readonly resource_id?: string } +// From codersdk/apikey.go +export interface CreateTokenRequest { + readonly scope: APIKeyScope +} + // From codersdk/users.go export interface CreateUserRequest { readonly email: string @@ -344,7 +350,7 @@ export interface Feature { readonly actual?: number } -// From codersdk/users.go +// From codersdk/apikey.go export interface GenerateAPIKeyResponse { readonly key: string } @@ -783,6 +789,7 @@ export interface WorkspaceApp { readonly command?: string readonly icon?: string readonly subdomain: boolean + readonly sharing_level: WorkspaceAppSharingLevel readonly healthcheck: Healthcheck readonly health: WorkspaceAppHealth } @@ -851,6 +858,9 @@ export interface WorkspaceResourceMetadata { readonly sensitive: boolean } +// From codersdk/apikey.go +export type APIKeyScope = "all" | "application_connect" + // From codersdk/audit.go export type AuditAction = "create" | "delete" | "write" @@ -931,6 +941,9 @@ export type WorkspaceAppHealth = | "initializing" | "unhealthy" +// From codersdk/workspaceapps.go +export type WorkspaceAppSharingLevel = "authenticated" | "owner" | "public" + // From codersdk/workspacebuilds.go export type WorkspaceStatus = | "canceled" diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index b7c7750158..6e26a4fee5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -199,6 +199,7 @@ export const MockWorkspaceApp: TypesGen.WorkspaceApp = { icon: "", subdomain: false, health: "disabled", + sharing_level: "owner", healthcheck: { url: "", interval: 0, From ccc008eb5e4ce3b292261b4a66edc185029cb8aa Mon Sep 17 00:00:00 2001 From: Presley Pizzo <1290996+presleyp@users.noreply.github.com> Date: Fri, 14 Oct 2022 13:23:00 -0400 Subject: [PATCH 09/51] fix: redesign schedule bumper to handle multiple hours of change at once (#4535) * Start sketching out new design * Working but ugly * Highlight chosen mode * Format * Set hours field width * Alignment on desktop * Use primary button color * Make 1 the default change * Add stepper max * Fix storybook * Handle undefined deadline * Access deadline correctly * Format * Fix overflow on mobile --- site/src/components/Workspace/Workspace.tsx | 8 +- .../WorkspaceScheduleButton/EditHours.tsx | 53 +++++++ .../WorkspaceScheduleButton.stories.tsx | 7 + .../WorkspaceScheduleButton.test.tsx | 6 +- .../WorkspaceScheduleButton.tsx | 146 +++++++++++++----- .../WorkspaceScheduleLabel.tsx | 49 +++--- site/src/i18n/en/common.json | 4 + site/src/i18n/en/workspacePage.json | 6 +- .../WorkspacePage/WorkspaceReadyPage.tsx | 25 ++- site/src/util/schedule.test.ts | 17 ++ site/src/util/schedule.ts | 12 ++ 11 files changed, 260 insertions(+), 73 deletions(-) create mode 100644 site/src/components/WorkspaceScheduleButton/EditHours.tsx diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index a9849063b6..145858f6e2 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -34,10 +34,12 @@ export interface WorkspaceProps { onExtend: () => void } scheduleProps: { - onDeadlinePlus: () => void - onDeadlineMinus: () => void + onDeadlinePlus: (hours: number) => void + onDeadlineMinus: (hours: number) => void deadlinePlusEnabled: () => boolean deadlineMinusEnabled: () => boolean + maxDeadlineIncrease: number + maxDeadlineDecrease: number } handleStart: () => void handleStop: () => void @@ -121,6 +123,8 @@ export const Workspace: FC> = ({ onDeadlinePlus={scheduleProps.onDeadlinePlus} deadlineMinusEnabled={scheduleProps.deadlineMinusEnabled} deadlinePlusEnabled={scheduleProps.deadlinePlusEnabled} + maxDeadlineDecrease={scheduleProps.maxDeadlineDecrease} + maxDeadlineIncrease={scheduleProps.maxDeadlineIncrease} canUpdateWorkspace={canUpdateWorkspace} /> void + max: number +} + +export const EditHours = ({ + handleSubmit, + max, +}: EditHoursProps): JSX.Element => { + const { t } = useTranslation("workspacePage") + const [hours, setHours] = useState(1) + const styles = useStyles() + + return ( +
handleSubmit(hours)}> + + setHours(parseInt(e.target.value))} + type="number" + /> + + +
+ ) +} + +const useStyles = makeStyles(() => ({ + inputField: { + width: "70px", + "& .MuiOutlinedInput-root": { + height: "30px", + }, + }, + button: { + "&.MuiButton-root": { + minHeight: "30px", + height: "30px", + }, + }, +})) diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx index 043c53da94..033c9698ce 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx @@ -16,6 +16,12 @@ export default { canUpdateWorkspace: { defaultValue: true, }, + deadlineMinusEnabled: { + defaultValue: (): boolean => false, + }, + deadlinePlusEnabled: { + defaultValue: (): boolean => false, + }, }, } @@ -64,6 +70,7 @@ WorkspaceOffShort.args = { export const WorkspaceOffLong = Template.bind({}) WorkspaceOffLong.args = { + deadlinePlusEnabled: () => true, workspace: { ...Mocks.MockWorkspace, diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx index 8e0d4e5358..5ee401f7be 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx @@ -2,7 +2,7 @@ import dayjs from "dayjs" import utc from "dayjs/plugin/utc" import * as TypesGen from "../../api/typesGenerated" import * as Mocks from "../../testHelpers/entities" -import { shouldDisplayPlusMinus } from "./WorkspaceScheduleButton" +import { canEditDeadline } from "./WorkspaceScheduleButton" dayjs.extend(utc) @@ -13,7 +13,7 @@ describe("WorkspaceScheduleButton", () => { const workspace: TypesGen.Workspace = Mocks.MockStoppedWorkspace // Then: shouldDisplayPlusMinus should be false - expect(shouldDisplayPlusMinus(workspace)).toBeFalsy() + expect(canEditDeadline(workspace)).toBeFalsy() }) it("should display if the workspace is running", () => { @@ -21,7 +21,7 @@ describe("WorkspaceScheduleButton", () => { const workspace: TypesGen.Workspace = Mocks.MockWorkspace // Then: shouldDisplayPlusMinus should be false - expect(shouldDisplayPlusMinus(workspace)).toBeTruthy() + expect(canEditDeadline(workspace)).toBeTruthy() }) }) }) diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx index 66e3df5e30..b613786910 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx @@ -1,11 +1,13 @@ import Button from "@material-ui/core/Button" import IconButton from "@material-ui/core/IconButton" import Popover from "@material-ui/core/Popover" -import { makeStyles } from "@material-ui/core/styles" +import { makeStyles, Theme } from "@material-ui/core/styles" import Tooltip from "@material-ui/core/Tooltip" import AddIcon from "@material-ui/icons/Add" import RemoveIcon from "@material-ui/icons/Remove" import ScheduleIcon from "@material-ui/icons/Schedule" +import { Maybe } from "components/Conditionals/Maybe" +import { Stack } from "components/Stack/Stack" import dayjs from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" import duration from "dayjs/plugin/duration" @@ -17,6 +19,7 @@ import { useTranslation } from "react-i18next" import { Workspace } from "../../api/typesGenerated" import { isWorkspaceOn } from "../../util/workspace" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" +import { EditHours } from "./EditHours" import { WorkspaceScheduleLabel } from "./WorkspaceScheduleLabel" // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're @@ -27,12 +30,12 @@ dayjs.extend(duration) dayjs.extend(relativeTime) dayjs.extend(timezone) -export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => { +export const canEditDeadline = (workspace: Workspace): boolean => { return isWorkspaceOn(workspace) && Boolean(workspace.latest_build.deadline) } export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => { - if (shouldDisplayPlusMinus(workspace)) { + if (canEditDeadline(workspace)) { return true } if (isWorkspaceOn(workspace)) { @@ -43,13 +46,17 @@ export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => { export interface WorkspaceScheduleButtonProps { workspace: Workspace - onDeadlinePlus: () => void - onDeadlineMinus: () => void + onDeadlinePlus: (hours: number) => void + onDeadlineMinus: (hours: number) => void deadlineMinusEnabled: () => boolean deadlinePlusEnabled: () => boolean + maxDeadlineIncrease: number + maxDeadlineDecrease: number canUpdateWorkspace: boolean } +export type EditMode = "add" | "subtract" | "off" + export const WorkspaceScheduleButton: React.FC< WorkspaceScheduleButtonProps > = ({ @@ -58,49 +65,93 @@ export const WorkspaceScheduleButton: React.FC< onDeadlineMinus, deadlinePlusEnabled, deadlineMinusEnabled, + maxDeadlineDecrease, + maxDeadlineIncrease, canUpdateWorkspace, }) => { const { t } = useTranslation("workspacePage") const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) + const [editMode, setEditMode] = useState("off") const id = isOpen ? "schedule-popover" : undefined - const styles = useStyles() + const styles = useStyles({ editMode }) const onClose = () => { setIsOpen(false) } + const handleSubmitHours = (hours: number) => { + if (hours !== 0) { + if (editMode === "add") { + onDeadlinePlus(hours) + } + if (editMode === "subtract") { + onDeadlineMinus(hours) + } + } + setEditMode("off") + } + return ( - {shouldDisplayScheduleLabel(workspace) && ( - - - {canUpdateWorkspace && shouldDisplayPlusMinus(workspace) && ( - - - - - - - - - - - - - )} - - )} + + + + + + + { + setEditMode("subtract") + }} + > + + + + + { + setEditMode("add") + }} + > + + + + + + + + + + + + <> ) return ( - + ({ notConfiguredIcon: { color: theme.palette.grey[300], }, + + appName: { + marginRight: theme.spacing(1), + }, })) diff --git a/site/src/components/PortForwardButton/PortForwardButton.tsx b/site/src/components/PortForwardButton/PortForwardButton.tsx index 4662c28597..4a18681f2f 100644 --- a/site/src/components/PortForwardButton/PortForwardButton.tsx +++ b/site/src/components/PortForwardButton/PortForwardButton.tsx @@ -64,7 +64,8 @@ const EnabledView: React.FC = (props) => { Access ports running on the agent with the{" "} port, agent name, workspace name and{" "} - your username URL schema, as shown below. + your username URL schema, as shown below. Port URLs are + only accessible by you. diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index f8833fdc11..9d2e63ac9e 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -203,6 +203,7 @@ export const Resources: FC> = ({ appName={app.name} appCommand={app.command} appSubdomain={app.subdomain} + appSharingLevel={app.sharing_level} username={workspace.owner_name} workspaceName={workspace.name} agentName={agent.name} From 5b7d204b9d85f257826d588c2b527df4710b0218 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Sat, 15 Oct 2022 08:04:52 -0500 Subject: [PATCH 14/51] chore: fix tooltip behavior (#4563) --- site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx index dc9a555357..281c140b00 100644 --- a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx +++ b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx @@ -60,6 +60,9 @@ export const HelpTooltip: React.FC< onMouseEnter={() => { setIsOpen(true) }} + onMouseLeave={() => { + setIsOpen(false) + }} aria-label={Language.ariaLabel} > From e456799f1ab21d6621b0aa4f2886669bdab310a5 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sat, 15 Oct 2022 15:36:50 -0500 Subject: [PATCH 15/51] Add template build time stats (#4557) - Expose time in Template pages - Show progress bar when building a workspace --- coderd/database/databasefake/databasefake.go | 29 ++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 36 +++++ coderd/database/queries/templates.sql | 24 +++ coderd/metricscache/metricscache.go | 43 ++++- coderd/metricscache/metricscache_test.go | 153 +++++++++++++++++- coderd/templates.go | 10 ++ coderd/templates_test.go | 4 +- codersdk/templates.go | 4 +- site/src/api/typesGenerated.ts | 1 + .../TemplateStats/TemplateStats.tsx | 16 +- .../Workspace/Workspace.stories.tsx | 1 + site/src/components/Workspace/Workspace.tsx | 7 + .../WorkspaceBuildProgress.tsx | 94 +++++++++++ .../pages/TemplatesPage/TemplatesPageView.tsx | 19 ++- .../WorkspacePage/WorkspaceReadyPage.tsx | 1 + site/src/testHelpers/entities.ts | 1 + site/src/util/templates.ts | 13 ++ 18 files changed, 445 insertions(+), 12 deletions(-) create mode 100644 site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 87ffc2847b..54314d221d 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -235,6 +235,35 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) ( return rs, nil } +func (q *fakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (float64, error) { + var times []float64 + for _, wb := range q.workspaceBuilds { + if wb.Transition != database.WorkspaceTransitionStart { + continue + } + version, err := q.GetTemplateVersionByID(ctx, wb.TemplateVersionID) + if err != nil { + return -1, err + } + if version.TemplateID != arg.TemplateID { + continue + } + + job, err := q.GetProvisionerJobByID(ctx, wb.JobID) + if err != nil { + return -1, err + } + if job.CompletedAt.Valid { + times = append(times, job.CompletedAt.Time.Sub(job.StartedAt.Time).Seconds()) + } + } + sort.Float64s(times) + if len(times) == 0 { + return -1, nil + } + return times[len(times)/2], nil +} + func (q *fakeQuerier) ParameterValue(_ context.Context, id uuid.UUID) (database.ParameterValue, error) { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 57626fbcca..d3680675d5 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -67,6 +67,7 @@ type sqlcQuerier interface { GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error) + GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (float64, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2f45e1b878..e34c0fa841 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2597,6 +2597,42 @@ func (q *sqlQuerier) InsertDeploymentID(ctx context.Context, value string) error return err } +const getTemplateAverageBuildTime = `-- name: GetTemplateAverageBuildTime :one +WITH build_times AS ( +SELECT + EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec +FROM + workspace_builds +JOIN template_versions ON + workspace_builds.template_version_id = template_versions.id +JOIN provisioner_jobs pj ON + workspace_builds.job_id = pj.id +WHERE + template_versions.template_id = $1 AND + (workspace_builds.transition = 'start') AND + (pj.completed_at IS NOT NULL) AND (pj.started_at IS NOT NULL) AND + (pj.started_at > $2) AND + (pj.canceled_at IS NULL) AND + ((pj.error IS NULL) OR (pj.error = '')) +ORDER BY + workspace_builds.created_at DESC +) +SELECT coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec)), -1)::FLOAT +FROM build_times +` + +type GetTemplateAverageBuildTimeParams struct { + TemplateID uuid.NullUUID `db:"template_id" json:"template_id"` + StartTime sql.NullTime `db:"start_time" json:"start_time"` +} + +func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (float64, error) { + row := q.db.QueryRowContext(ctx, getTemplateAverageBuildTime, arg.TemplateID, arg.StartTime) + var column_1 float64 + err := row.Scan(&column_1) + return column_1, err +} + const getTemplateByID = `-- name: GetTemplateByID :one SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 4d55244335..2bb6f933ee 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -105,3 +105,27 @@ WHERE id = $1 RETURNING *; + +-- name: GetTemplateAverageBuildTime :one +WITH build_times AS ( +SELECT + EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec +FROM + workspace_builds +JOIN template_versions ON + workspace_builds.template_version_id = template_versions.id +JOIN provisioner_jobs pj ON + workspace_builds.job_id = pj.id +WHERE + template_versions.template_id = @template_id AND + (workspace_builds.transition = 'start') AND + (pj.completed_at IS NOT NULL) AND (pj.started_at IS NOT NULL) AND + (pj.started_at > @start_time) AND + (pj.canceled_at IS NULL) AND + ((pj.error IS NULL) OR (pj.error = '')) +ORDER BY + workspace_builds.created_at DESC +) +SELECT coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec)), -1)::FLOAT +FROM build_times +; diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index da38f54c84..1feb36ba23 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -2,6 +2,7 @@ package metricscache import ( "context" + "database/sql" "sync/atomic" "time" @@ -17,7 +18,7 @@ import ( "github.com/coder/retry" ) -// Cache holds the template DAU cache. +// Cache holds the template metrics. // The aggregation queries responsible for these values can take up to a minute // on large deployments. Even in small deployments, aggregation queries can // take a few hundred milliseconds, which would ruin page load times and @@ -26,8 +27,9 @@ type Cache struct { database database.Store log slog.Logger - templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse] - templateUniqueUsers atomic.Pointer[map[uuid.UUID]int] + templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse] + templateUniqueUsers atomic.Pointer[map[uuid.UUID]int] + templateAverageBuildTime atomic.Pointer[map[uuid.UUID]time.Duration] done chan struct{} cancel func() @@ -128,8 +130,9 @@ func (c *Cache) refresh(ctx context.Context) error { } var ( - templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates)) - templateUniqueUsers = make(map[uuid.UUID]int) + templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates)) + templateUniqueUsers = make(map[uuid.UUID]int) + templateAverageBuildTimeSec = make(map[uuid.UUID]time.Duration) ) for _, template := range templates { rows, err := c.database.GetTemplateDAUs(ctx, template.ID) @@ -138,9 +141,24 @@ func (c *Cache) refresh(ctx context.Context) error { } templateDAUs[template.ID] = convertDAUResponse(rows) templateUniqueUsers[template.ID] = countUniqueUsers(rows) + templateAvgBuildTime, err := c.database.GetTemplateAverageBuildTime(ctx, database.GetTemplateAverageBuildTimeParams{ + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + StartTime: sql.NullTime{ + Time: database.Time(time.Now().AddDate(0, -30, 0)), + Valid: true, + }, + }) + if err != nil { + return err + } + templateAverageBuildTimeSec[template.ID] = time.Duration(float64(time.Second) * templateAvgBuildTime) } c.templateDAUResponses.Store(&templateDAUs) c.templateUniqueUsers.Store(&templateUniqueUsers) + c.templateAverageBuildTime.Store(&templateAverageBuildTimeSec) return nil } @@ -220,3 +238,18 @@ func (c *Cache) TemplateUniqueUsers(id uuid.UUID) (int, bool) { } return resp, true } + +func (c *Cache) TemplateAverageBuildTime(id uuid.UUID) (time.Duration, bool) { + m := c.templateAverageBuildTime.Load() + if m == nil { + // Data loading. + return -1, false + } + + resp, ok := (*m)[id] + if !ok || resp <= 0 { + // No data or not enough builds. + return -1, false + } + return resp, true +} diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index 70d926702e..3ea2748331 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -2,6 +2,7 @@ package metricscache_test import ( "context" + "database/sql" "testing" "time" @@ -20,7 +21,7 @@ func date(year, month, day int) time.Time { return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) } -func TestCache(t *testing.T) { +func TestCache_TemplateUsers(t *testing.T) { t.Parallel() var ( @@ -197,3 +198,153 @@ func TestCache(t *testing.T) { }) } } + +func clockTime(t time.Time, hour, minute, sec int) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), hour, minute, sec, t.Nanosecond(), t.Location()) +} + +func TestCache_BuildTime(t *testing.T) { + t.Parallel() + + someDay := date(2022, 10, 1) + + type jobParams struct { + startedAt time.Time + completedAt time.Time + } + + type args struct { + rows []jobParams + } + type want struct { + buildTime time.Duration + } + tests := []struct { + name string + args args + want want + }{ + {"empty", args{}, want{-1}}, + {"one", args{ + rows: []jobParams{ + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 10), + }, + }, + }, want{time.Second * 10}, + }, + {"two", args{ + rows: []jobParams{ + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 10), + }, + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 50), + }, + }, + }, want{time.Second * 50}, + }, + {"three", args{ + rows: []jobParams{ + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 10), + }, + { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 50), + }, { + startedAt: clockTime(someDay, 10, 1, 0), + completedAt: clockTime(someDay, 10, 1, 20), + }, + }, + }, want{time.Second * 20}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + + var ( + db = databasefake.New() + cache = metricscache.New(db, slogtest.Make(t, nil), testutil.IntervalFast) + ) + + defer cache.Close() + + template, err := db.InsertTemplate(ctx, database.InsertTemplateParams{ + ID: uuid.New(), + }) + require.NoError(t, err) + + templateVersion, err := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ + ID: uuid.New(), + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + }) + require.NoError(t, err) + + gotBuildTime, ok := cache.TemplateAverageBuildTime(template.ID) + require.False(t, ok, "template shouldn't have loaded yet") + require.EqualValues(t, -1, gotBuildTime) + + for _, row := range tt.args.rows { + _, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: uuid.New(), + Provisioner: database.ProvisionerTypeEcho, + }) + require.NoError(t, err) + + job, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + StartedAt: sql.NullTime{Time: row.startedAt, Valid: true}, + Types: []database.ProvisionerType{ + database.ProvisionerTypeEcho, + }, + }) + require.NoError(t, err) + + _, err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + TemplateVersionID: templateVersion.ID, + JobID: job.ID, + Transition: database.WorkspaceTransitionStart, + }) + require.NoError(t, err) + + err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ + ID: job.ID, + CompletedAt: sql.NullTime{Time: row.completedAt, Valid: true}, + }) + require.NoError(t, err) + } + + if tt.want.buildTime > 0 { + require.Eventuallyf(t, func() bool { + _, ok := cache.TemplateAverageBuildTime(template.ID) + return ok + }, testutil.WaitShort, testutil.IntervalMedium, + "TemplateDAUs never populated", + ) + + gotBuildTime, ok = cache.TemplateAverageBuildTime(template.ID) + require.True(t, ok) + require.Equal(t, tt.want.buildTime, gotBuildTime) + } else { + require.Never(t, func() bool { + _, ok := cache.TemplateAverageBuildTime(template.ID) + return ok + }, testutil.WaitShort/2, testutil.IntervalMedium, + "TemplateDAUs never populated", + ) + + gotBuildTime, ok = cache.TemplateAverageBuildTime(template.ID) + require.False(t, ok) + require.Less(t, gotBuildTime, time.Duration(0)) + } + }) + } +} diff --git a/coderd/templates.go b/coderd/templates.go index fbc25a7b55..79fdc2091f 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -773,6 +773,15 @@ func (api *API) convertTemplate( template database.Template, workspaceOwnerCount uint32, createdByName string, ) codersdk.Template { activeCount, _ := api.metricsCache.TemplateUniqueUsers(template.ID) + + var averageBuildTimeMillis int64 + averageBuildTime, ok := api.metricsCache.TemplateAverageBuildTime(template.ID) + if !ok { + averageBuildTimeMillis = -1 + } else { + averageBuildTimeMillis = int64(averageBuildTime / time.Millisecond) + } + return codersdk.Template{ ID: template.ID, CreatedAt: template.CreatedAt, @@ -783,6 +792,7 @@ func (api *API) convertTemplate( ActiveVersionID: template.ActiveVersionID, WorkspaceOwnerCount: workspaceOwnerCount, ActiveUserCount: activeCount, + AverageBuildTimeMillis: averageBuildTimeMillis, Description: template.Description, Icon: template.Icon, MaxTTLMillis: time.Duration(template.MaxTtl).Milliseconds(), diff --git a/coderd/templates_test.go b/coderd/templates_test.go index bf547c4d0e..b384108387 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -561,7 +561,7 @@ func TestDeleteTemplate(t *testing.T) { }) } -func TestTemplateDAUs(t *testing.T) { +func TestTemplateMetrics(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ @@ -594,6 +594,7 @@ func TestTemplateDAUs(t *testing.T) { }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) require.Equal(t, -1, template.ActiveUserCount) + require.EqualValues(t, -1, template.AverageBuildTimeMillis) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) @@ -660,6 +661,7 @@ func TestTemplateDAUs(t *testing.T) { template, err = client.Template(ctx, template.ID) require.NoError(t, err) require.Equal(t, 1, template.ActiveUserCount) + require.Greater(t, template.AverageBuildTimeMillis, int64(1)) workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) diff --git a/codersdk/templates.go b/codersdk/templates.go index 7065fed2bd..887c648b42 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -23,7 +23,9 @@ type Template struct { ActiveVersionID uuid.UUID `json:"active_version_id"` WorkspaceOwnerCount uint32 `json:"workspace_owner_count"` // ActiveUserCount is set to -1 when loading. - ActiveUserCount int `json:"active_user_count"` + ActiveUserCount int `json:"active_user_count"` + // AverageBuildTimeMillis is set to -1 when there aren't enough recent builds. + AverageBuildTimeMillis int64 `json:"average_build_time_ms"` Description string `json:"description"` Icon string `json:"icon"` MaxTTLMillis int64 `json:"max_ttl_ms"` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0c9cf3792b..519b828d30 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -586,6 +586,7 @@ export interface Template { readonly active_version_id: string readonly workspace_owner_count: number readonly active_user_count: number + readonly average_build_time_ms: number readonly description: string readonly icon: string readonly max_ttl_ms: number diff --git a/site/src/components/TemplateStats/TemplateStats.tsx b/site/src/components/TemplateStats/TemplateStats.tsx index 6065c5a0ec..dfdc0db40b 100644 --- a/site/src/components/TemplateStats/TemplateStats.tsx +++ b/site/src/components/TemplateStats/TemplateStats.tsx @@ -1,12 +1,16 @@ import { makeStyles } from "@material-ui/core/styles" import { FC } from "react" import { createDayString } from "util/createDayString" -import { formatTemplateActiveDevelopers } from "util/templates" +import { + formatTemplateBuildTime, + formatTemplateActiveDevelopers, +} from "util/templates" import { Template, TemplateVersion } from "../../api/typesGenerated" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" const Language = { usedByLabel: "Used by", + buildTimeLabel: "Build time", activeVersionLabel: "Active version", lastUpdateLabel: "Last updated", developerPlural: "developers", @@ -38,6 +42,14 @@ export const TemplateStats: FC = ({
+
+ {Language.buildTimeLabel} + + + {formatTemplateBuildTime(template.active_user_count)}{" "} + +
+
{Language.activeVersionLabel} {activeVersion.name} @@ -75,7 +87,7 @@ const useStyles = makeStyles((theme) => ({ }, statItem: { - minWidth: "20%", + minWidth: "15%", padding: theme.spacing(2), paddingTop: theme.spacing(1.75), }, diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index f8430ee947..273bb3bdd1 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -49,6 +49,7 @@ Running.args = { canUpdateWorkspace: true, workspaceErrors: {}, buildInfo: Mocks.MockBuildInfo, + template: Mocks.MockTemplate, } export const WithoutUpdateAccess = Template.bind({}) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 145858f6e2..6388b0ce12 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -20,6 +20,7 @@ import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" import { AlertBanner } from "../AlertBanner/AlertBanner" import { useTranslation } from "react-i18next" +import { WorkspaceBuildProgress } from "components/WorkspaceBuildProgress/WorkspaceBuildProgress" export enum WorkspaceErrors { GET_RESOURCES_ERROR = "getResourcesError", @@ -55,6 +56,7 @@ export interface WorkspaceProps { workspaceErrors: Partial> buildInfo?: TypesGen.BuildInfoResponse applicationsHost?: string + template?: TypesGen.Template } /** @@ -77,6 +79,7 @@ export const Workspace: FC> = ({ hideSSHButton, buildInfo, applicationsHost, + template, }) => { const { t } = useTranslation("workspacePage") const styles = useStyles() @@ -183,6 +186,10 @@ export const Workspace: FC> = ({ + {workspace.latest_build.status === "starting" && ( + + )} + {typeof resources !== "undefined" && resources.length > 0 && ( { + // Buffer the template average to prevent the progress bar from waiting at end. + // Over-promise, under-deliver. + templateAverage *= 1.2 + + const realPercentage = dayjs().diff(startedAt) / templateAverage + // Showing a full bar is frustrating. + const displayPercentage = Math.min(realPercentage, 0.95) + + if (realPercentage > 1) { + return [displayPercentage, "Any moment now..."] + } + + return [ + displayPercentage, + `${dayjs + .duration((1 - realPercentage) * templateAverage) + .humanize()} remaining...`, + ] +} + +export const WorkspaceBuildProgress: FC<{ + workspace: Workspace + template?: Template +}> = ({ workspace, template }) => { + const styles = useStyles() + + // Template stats not loaded or non-existent + if (!template || template.average_build_time_ms <= 0) { + return <> + } + + const job = workspace.latest_build.job + const status = job.status + + return ( +
+ +
+
{`Build ${status}`}
+
+ {status === "running" && + estimateFinish( + dayjs(job.started_at), + template.average_build_time_ms, + )[1]} +
+
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + stack: { + paddingLeft: theme.spacing(0.2), + paddingRight: theme.spacing(0.2), + }, + barHelpers: { + display: "flex", + justifyContent: "space-between", + }, + label: { + fontFamily: MONOSPACE_FONT_FAMILY, + fontSize: 12, + textTransform: "uppercase", + display: "block", + fontWeight: 600, + color: theme.palette.text.secondary, + }, +})) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index ac5ee78c8c..b1904c58fe 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -15,7 +15,10 @@ import { FC } from "react" import { useTranslation } from "react-i18next" import { useNavigate } from "react-router-dom" import { createDayString } from "util/createDayString" -import { formatTemplateActiveDevelopers } from "util/templates" +import { + formatTemplateBuildTime, + formatTemplateActiveDevelopers, +} from "util/templates" import * as TypesGen from "../../api/typesGenerated" import { AvatarData } from "../../components/AvatarData/AvatarData" import { CodeExample } from "../../components/CodeExample/CodeExample" @@ -44,6 +47,7 @@ export const Language = { }` }, nameLabel: "Name", + buildTimeLabel: "Build time", usedByLabel: "Used by", lastUpdatedLabel: "Last updated", emptyViewNoPerms: @@ -155,8 +159,9 @@ export const TemplatesPageView: FC< - {Language.nameLabel} + {Language.nameLabel} {Language.usedByLabel} + {Language.buildTimeLabel} {Language.lastUpdatedLabel} {Language.createdByLabel} @@ -227,6 +232,16 @@ export const TemplatesPageView: FC< + + + {formatTemplateBuildTime( + template.average_build_time_ms, + )} + + + { if (num === undefined || num < 0) { // Loading @@ -5,3 +12,9 @@ export const formatTemplateActiveDevelopers = (num?: number): string => { } return num.toString() } + +export const formatTemplateBuildTime = (buildTimeMs: number): string => { + return buildTimeMs < 0 + ? "Unknown" + : dayjs.duration(buildTimeMs, "milliseconds").humanize() +} From de5ba4755781bd03058b197f481463bfe03f8a3d Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sat, 15 Oct 2022 16:30:51 -0500 Subject: [PATCH 16/51] site: minor build stats fixes (#4569) - Correct variable used in the template stats summary - Don't use dayjs.humanize (it thinks 30 seconds is "a few seconds") --- .../components/TemplateStats/TemplateStats.tsx | 2 +- .../WorkspaceBuildProgress.tsx | 15 +++++++-------- site/src/util/templates.ts | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/site/src/components/TemplateStats/TemplateStats.tsx b/site/src/components/TemplateStats/TemplateStats.tsx index dfdc0db40b..ba548937e5 100644 --- a/site/src/components/TemplateStats/TemplateStats.tsx +++ b/site/src/components/TemplateStats/TemplateStats.tsx @@ -46,7 +46,7 @@ export const TemplateStats: FC = ({ {Language.buildTimeLabel} - {formatTemplateBuildTime(template.active_user_count)}{" "} + {formatTemplateBuildTime(template.average_build_time_ms)}{" "}
diff --git a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx index 3d51f5d20c..53ea70d1dc 100644 --- a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx +++ b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx @@ -18,18 +18,17 @@ const estimateFinish = ( templateAverage *= 1.2 const realPercentage = dayjs().diff(startedAt) / templateAverage - // Showing a full bar is frustrating. - const displayPercentage = Math.min(realPercentage, 0.95) - if (realPercentage > 1) { - return [displayPercentage, "Any moment now..."] + // Showing a full bar is frustrating. + if (realPercentage > 0.95) { + return [0.95, "Any moment now..."] } return [ - displayPercentage, - `${dayjs - .duration((1 - realPercentage) * templateAverage) - .humanize()} remaining...`, + realPercentage, + `~${Math.ceil( + dayjs.duration((1 - realPercentage) * templateAverage).asSeconds(), + )} seconds remaining...`, ] } diff --git a/site/src/util/templates.ts b/site/src/util/templates.ts index efa9e0ccc0..ebc3d8ae4a 100644 --- a/site/src/util/templates.ts +++ b/site/src/util/templates.ts @@ -16,5 +16,5 @@ export const formatTemplateActiveDevelopers = (num?: number): string => { export const formatTemplateBuildTime = (buildTimeMs: number): string => { return buildTimeMs < 0 ? "Unknown" - : dayjs.duration(buildTimeMs, "milliseconds").humanize() + : `${Math.round(dayjs.duration(buildTimeMs, "milliseconds").asSeconds())}s` } From efdd5d5a0cbd6a4b274a3d69677e61f620c21558 Mon Sep 17 00:00:00 2001 From: unknowndevQwQ Date: Sun, 16 Oct 2022 10:18:30 +0800 Subject: [PATCH 17/51] feat: add doas support (#4531) * feat: add doas support Some people may have some reason to drop sudo and switch to doas * chore: doas at the end Just because it is relatively cold :-( Co-authored-by: Kyle Carberry * chore(CI): add doas to pass CI * fix syntax error Co-authored-by: Kyle Carberry Co-authored-by: Ben --- .github/workflows/typos.toml | 2 ++ install.sh | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index 90b0ea1cc9..f3d22513ac 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -5,6 +5,8 @@ IST = "IST" MacOS = "macOS" [default.extend-words] +# do as sudo replacement +doas = "doas" [files] extend-exclude = [ diff --git a/install.sh b/install.sh index bbecb4d598..bac333cf93 100755 --- a/install.sh +++ b/install.sh @@ -531,6 +531,8 @@ sh_c() { sudo_sh_c() { if [ "$(id -u)" = 0 ]; then sh_c "$@" + elif command_exists doas; then + sh_c "doas $*" elif command_exists sudo; then sh_c "sudo $*" elif command_exists su; then @@ -539,7 +541,7 @@ sudo_sh_c() { echoh echoerr "This script needs to run the following command as root." echoerr " $*" - echoerr "Please install sudo or su." + echoerr "Please install sudo, su, or doas." exit 1 fi } From ee2c29d5206af315c402b6a867caff7c12587c84 Mon Sep 17 00:00:00 2001 From: Phorcys <57866459+phorcys420@users.noreply.github.com> Date: Mon, 17 Oct 2022 02:37:02 +0200 Subject: [PATCH 18/51] Rename the noVNC icon to match the other icons (#4574) --- site/static/icon/{novnc-icon.svg => novnc.svg} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename site/static/icon/{novnc-icon.svg => novnc.svg} (100%) diff --git a/site/static/icon/novnc-icon.svg b/site/static/icon/novnc.svg similarity index 100% rename from site/static/icon/novnc-icon.svg rename to site/static/icon/novnc.svg From dc3519e973d1a58e3ad55898e872ec64d8f9cff6 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sun, 16 Oct 2022 23:34:03 -0500 Subject: [PATCH 19/51] Support all transitions in build progress bar (#4575) * Use null types instead of -1 for simplicity * Fix pgcrypto bug in migration 59 * Add stories * Fix visual stutter --- coderd/database/databasefake/databasefake.go | 41 ++++--- coderd/database/dump.sql | 4 + .../database/migrations/000059_file_id.up.sql | 11 +- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 25 +++-- coderd/database/queries/templates.sql | 11 +- coderd/metricscache/metricscache.go | 39 +++++-- coderd/metricscache/metricscache_test.go | 69 +++++++----- coderd/templates.go | 10 +- coderd/templates_test.go | 5 +- codersdk/templates.go | 23 ++-- site/src/api/typesGenerated.ts | 9 +- .../TemplateStats/TemplateStats.tsx | 2 +- site/src/components/Workspace/Workspace.tsx | 21 +++- .../WorkspaceBuildProgress.stories.tsx | 49 +++++++++ .../WorkspaceBuildProgress.tsx | 100 ++++++++++++------ .../pages/TemplatesPage/TemplatesPageView.tsx | 2 +- site/src/testHelpers/entities.ts | 6 +- site/src/util/templates.ts | 4 +- 19 files changed, 309 insertions(+), 124 deletions(-) create mode 100644 site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.stories.tsx diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 54314d221d..63239bdf4d 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -235,15 +235,17 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) ( return rs, nil } -func (q *fakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (float64, error) { - var times []float64 +func (q *fakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) { + var emptyRow database.GetTemplateAverageBuildTimeRow + var ( + startTimes []float64 + stopTimes []float64 + deleteTimes []float64 + ) for _, wb := range q.workspaceBuilds { - if wb.Transition != database.WorkspaceTransitionStart { - continue - } version, err := q.GetTemplateVersionByID(ctx, wb.TemplateVersionID) if err != nil { - return -1, err + return emptyRow, err } if version.TemplateID != arg.TemplateID { continue @@ -251,17 +253,32 @@ func (q *fakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg datab job, err := q.GetProvisionerJobByID(ctx, wb.JobID) if err != nil { - return -1, err + return emptyRow, err } if job.CompletedAt.Valid { - times = append(times, job.CompletedAt.Time.Sub(job.StartedAt.Time).Seconds()) + took := job.CompletedAt.Time.Sub(job.StartedAt.Time).Seconds() + if wb.Transition == database.WorkspaceTransitionStart { + startTimes = append(startTimes, took) + } else if wb.Transition == database.WorkspaceTransitionStop { + stopTimes = append(stopTimes, took) + } else if wb.Transition == database.WorkspaceTransitionDelete { + deleteTimes = append(deleteTimes, took) + } } } - sort.Float64s(times) - if len(times) == 0 { - return -1, nil + + tryMedian := func(fs []float64) float64 { + if len(fs) == 0 { + return -1 + } + sort.Float64s(fs) + return fs[len(fs)/2] } - return times[len(times)/2], nil + var row database.GetTemplateAverageBuildTimeRow + row.DeleteMedian = tryMedian(deleteTimes) + row.StopMedian = tryMedian(stopTimes) + row.StartMedian = tryMedian(startTimes) + return row, nil } func (q *fakeQuerier) ParameterValue(_ context.Context, id uuid.UUID) (database.ParameterValue, error) { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 9e2c68dbf6..de2d352a6a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1,5 +1,9 @@ -- Code generated by 'make coderd/database/generate'. DO NOT EDIT. +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; + +COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; + CREATE TYPE api_key_scope AS ENUM ( 'all', 'application_connect' diff --git a/coderd/database/migrations/000059_file_id.up.sql b/coderd/database/migrations/000059_file_id.up.sql index 7e6e919fa9..640876e7b7 100644 --- a/coderd/database/migrations/000059_file_id.up.sql +++ b/coderd/database/migrations/000059_file_id.up.sql @@ -4,8 +4,8 @@ -- template to be able to push and read files used for template -- versions they create. -- Prior to this collisions on file.hash were not an issue --- since users who could push files could also read all files. --- +-- since users who could push files could also read all files. +-- -- This migration also adds a 'files.id' column as the primary -- key. As a side effect the provisioner_jobs must now reference -- the files.id column since the 'hash' column is now ambiguous. @@ -14,10 +14,13 @@ BEGIN; -- Drop the primary key on hash. ALTER TABLE files DROP CONSTRAINT files_pkey; +-- This extension is required by gen_random_uuid +CREATE EXTENSION IF NOT EXISTS pgcrypto; + -- Add an 'id' column and designate it the primary key. -ALTER TABLE files ADD COLUMN +ALTER TABLE files ADD COLUMN id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid (); - + -- Update the constraint to include the user who created it. ALTER TABLE files ADD UNIQUE(hash, created_by); diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d3680675d5..ad26413873 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -67,7 +67,7 @@ type sqlcQuerier interface { GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error) - GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (float64, error) + GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e34c0fa841..41eb029b59 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2600,7 +2600,8 @@ func (q *sqlQuerier) InsertDeploymentID(ctx context.Context, value string) error const getTemplateAverageBuildTime = `-- name: GetTemplateAverageBuildTime :one WITH build_times AS ( SELECT - EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec + EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec, + workspace_builds.transition FROM workspace_builds JOIN template_versions ON @@ -2609,7 +2610,6 @@ JOIN provisioner_jobs pj ON workspace_builds.job_id = pj.id WHERE template_versions.template_id = $1 AND - (workspace_builds.transition = 'start') AND (pj.completed_at IS NOT NULL) AND (pj.started_at IS NOT NULL) AND (pj.started_at > $2) AND (pj.canceled_at IS NULL) AND @@ -2617,7 +2617,12 @@ WHERE ORDER BY workspace_builds.created_at DESC ) -SELECT coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec)), -1)::FLOAT +SELECT + -- Postgres offers no clear way to DRY this short of a function or other + -- complexities. + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'start')), -1)::FLOAT AS start_median, + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'stop')), -1)::FLOAT AS stop_median, + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'delete')), -1)::FLOAT AS delete_median FROM build_times ` @@ -2626,11 +2631,17 @@ type GetTemplateAverageBuildTimeParams struct { StartTime sql.NullTime `db:"start_time" json:"start_time"` } -func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (float64, error) { +type GetTemplateAverageBuildTimeRow struct { + StartMedian float64 `db:"start_median" json:"start_median"` + StopMedian float64 `db:"stop_median" json:"stop_median"` + DeleteMedian float64 `db:"delete_median" json:"delete_median"` +} + +func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) { row := q.db.QueryRowContext(ctx, getTemplateAverageBuildTime, arg.TemplateID, arg.StartTime) - var column_1 float64 - err := row.Scan(&column_1) - return column_1, err + var i GetTemplateAverageBuildTimeRow + err := row.Scan(&i.StartMedian, &i.StopMedian, &i.DeleteMedian) + return i, err } const getTemplateByID = `-- name: GetTemplateByID :one diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 2bb6f933ee..06570fffb0 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -109,7 +109,8 @@ RETURNING -- name: GetTemplateAverageBuildTime :one WITH build_times AS ( SELECT - EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec + EXTRACT(EPOCH FROM (pj.completed_at - pj.started_at))::FLOAT AS exec_time_sec, + workspace_builds.transition FROM workspace_builds JOIN template_versions ON @@ -118,7 +119,6 @@ JOIN provisioner_jobs pj ON workspace_builds.job_id = pj.id WHERE template_versions.template_id = @template_id AND - (workspace_builds.transition = 'start') AND (pj.completed_at IS NOT NULL) AND (pj.started_at IS NOT NULL) AND (pj.started_at > @start_time) AND (pj.canceled_at IS NULL) AND @@ -126,6 +126,11 @@ WHERE ORDER BY workspace_builds.created_at DESC ) -SELECT coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec)), -1)::FLOAT +SELECT + -- Postgres offers no clear way to DRY this short of a function or other + -- complexities. + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'start')), -1)::FLOAT AS start_median, + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'stop')), -1)::FLOAT AS stop_median, + coalesce((PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'delete')), -1)::FLOAT AS delete_median FROM build_times ; diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index 1feb36ba23..0adf509596 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -29,7 +29,7 @@ type Cache struct { templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse] templateUniqueUsers atomic.Pointer[map[uuid.UUID]int] - templateAverageBuildTime atomic.Pointer[map[uuid.UUID]time.Duration] + templateAverageBuildTime atomic.Pointer[map[uuid.UUID]database.GetTemplateAverageBuildTimeRow] done chan struct{} cancel func() @@ -130,9 +130,9 @@ func (c *Cache) refresh(ctx context.Context) error { } var ( - templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates)) - templateUniqueUsers = make(map[uuid.UUID]int) - templateAverageBuildTimeSec = make(map[uuid.UUID]time.Duration) + templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates)) + templateUniqueUsers = make(map[uuid.UUID]int) + templateAverageBuildTimes = make(map[uuid.UUID]database.GetTemplateAverageBuildTimeRow) ) for _, template := range templates { rows, err := c.database.GetTemplateDAUs(ctx, template.ID) @@ -141,6 +141,7 @@ func (c *Cache) refresh(ctx context.Context) error { } templateDAUs[template.ID] = convertDAUResponse(rows) templateUniqueUsers[template.ID] = countUniqueUsers(rows) + templateAvgBuildTime, err := c.database.GetTemplateAverageBuildTime(ctx, database.GetTemplateAverageBuildTimeParams{ TemplateID: uuid.NullUUID{ UUID: template.ID, @@ -151,14 +152,15 @@ func (c *Cache) refresh(ctx context.Context) error { Valid: true, }, }) + if err != nil { return err } - templateAverageBuildTimeSec[template.ID] = time.Duration(float64(time.Second) * templateAvgBuildTime) + templateAverageBuildTimes[template.ID] = templateAvgBuildTime } c.templateDAUResponses.Store(&templateDAUs) c.templateUniqueUsers.Store(&templateUniqueUsers) - c.templateAverageBuildTime.Store(&templateAverageBuildTimeSec) + c.templateAverageBuildTime.Store(&templateAverageBuildTimes) return nil } @@ -239,17 +241,32 @@ func (c *Cache) TemplateUniqueUsers(id uuid.UUID) (int, bool) { return resp, true } -func (c *Cache) TemplateAverageBuildTime(id uuid.UUID) (time.Duration, bool) { +func (c *Cache) TemplateBuildTimeStats(id uuid.UUID) codersdk.TemplateBuildTimeStats { + var unknown codersdk.TemplateBuildTimeStats + m := c.templateAverageBuildTime.Load() if m == nil { // Data loading. - return -1, false + return unknown } resp, ok := (*m)[id] - if !ok || resp <= 0 { + if !ok { // No data or not enough builds. - return -1, false + return unknown + } + + convertMedian := func(m float64) *int64 { + if m <= 0 { + return nil + } + i := int64(m * 1000) + return &i + } + + return codersdk.TemplateBuildTimeStats{ + StartMillis: convertMedian(resp.StartMedian), + StopMillis: convertMedian(resp.StopMedian), + DeleteMillis: convertMedian(resp.DeleteMedian), } - return resp, true } diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index 3ea2748331..98e43c067f 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" @@ -214,27 +215,30 @@ func TestCache_BuildTime(t *testing.T) { } type args struct { - rows []jobParams + rows []jobParams + transition database.WorkspaceTransition } type want struct { - buildTime time.Duration + buildTimeMs int64 + loads bool } tests := []struct { name string args args want want }{ - {"empty", args{}, want{-1}}, - {"one", args{ + {"empty", args{}, want{-1, false}}, + {"one/start", args{ rows: []jobParams{ { startedAt: clockTime(someDay, 10, 1, 0), completedAt: clockTime(someDay, 10, 1, 10), }, }, - }, want{time.Second * 10}, + transition: database.WorkspaceTransitionStart, + }, want{10 * 1000, true}, }, - {"two", args{ + {"two/stop", args{ rows: []jobParams{ { startedAt: clockTime(someDay, 10, 1, 0), @@ -245,9 +249,10 @@ func TestCache_BuildTime(t *testing.T) { completedAt: clockTime(someDay, 10, 1, 50), }, }, - }, want{time.Second * 50}, + transition: database.WorkspaceTransitionStop, + }, want{50 * 1000, true}, }, - {"three", args{ + {"three/delete", args{ rows: []jobParams{ { startedAt: clockTime(someDay, 10, 1, 0), @@ -261,7 +266,8 @@ func TestCache_BuildTime(t *testing.T) { completedAt: clockTime(someDay, 10, 1, 20), }, }, - }, want{time.Second * 20}, + transition: database.WorkspaceTransitionDelete, + }, want{20 * 1000, true}, }, } @@ -289,9 +295,8 @@ func TestCache_BuildTime(t *testing.T) { }) require.NoError(t, err) - gotBuildTime, ok := cache.TemplateAverageBuildTime(template.ID) - require.False(t, ok, "template shouldn't have loaded yet") - require.EqualValues(t, -1, gotBuildTime) + gotStats := cache.TemplateBuildTimeStats(template.ID) + require.Empty(t, gotStats, "should not have loaded yet") for _, row := range tt.args.rows { _, err := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ @@ -311,7 +316,7 @@ func TestCache_BuildTime(t *testing.T) { _, err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ TemplateVersionID: templateVersion.ID, JobID: job.ID, - Transition: database.WorkspaceTransitionStart, + Transition: tt.args.transition, }) require.NoError(t, err) @@ -322,28 +327,38 @@ func TestCache_BuildTime(t *testing.T) { require.NoError(t, err) } - if tt.want.buildTime > 0 { + if tt.want.loads { require.Eventuallyf(t, func() bool { - _, ok := cache.TemplateAverageBuildTime(template.ID) - return ok + stats := cache.TemplateBuildTimeStats(template.ID) + return assert.NotEmpty(t, stats) }, testutil.WaitShort, testutil.IntervalMedium, - "TemplateDAUs never populated", + "BuildTime never populated", ) - gotBuildTime, ok = cache.TemplateAverageBuildTime(template.ID) - require.True(t, ok) - require.Equal(t, tt.want.buildTime, gotBuildTime) + gotStats = cache.TemplateBuildTimeStats(template.ID) + + if tt.args.transition == database.WorkspaceTransitionDelete { + require.Nil(t, gotStats.StopMillis) + require.Nil(t, gotStats.StartMillis) + require.Equal(t, tt.want.buildTimeMs, *gotStats.DeleteMillis) + } + if tt.args.transition == database.WorkspaceTransitionStart { + require.Nil(t, gotStats.StopMillis) + require.Nil(t, gotStats.DeleteMillis) + require.Equal(t, tt.want.buildTimeMs, *gotStats.StartMillis) + } + if tt.args.transition == database.WorkspaceTransitionStop { + require.Nil(t, gotStats.StartMillis) + require.Nil(t, gotStats.DeleteMillis) + require.Equal(t, tt.want.buildTimeMs, *gotStats.StopMillis) + } } else { require.Never(t, func() bool { - _, ok := cache.TemplateAverageBuildTime(template.ID) - return ok + stats := cache.TemplateBuildTimeStats(template.ID) + return !assert.Empty(t, stats) }, testutil.WaitShort/2, testutil.IntervalMedium, - "TemplateDAUs never populated", + "BuildTimeStats populated", ) - - gotBuildTime, ok = cache.TemplateAverageBuildTime(template.ID) - require.False(t, ok) - require.Less(t, gotBuildTime, time.Duration(0)) } }) } diff --git a/coderd/templates.go b/coderd/templates.go index 79fdc2091f..7b08303dea 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -774,13 +774,7 @@ func (api *API) convertTemplate( ) codersdk.Template { activeCount, _ := api.metricsCache.TemplateUniqueUsers(template.ID) - var averageBuildTimeMillis int64 - averageBuildTime, ok := api.metricsCache.TemplateAverageBuildTime(template.ID) - if !ok { - averageBuildTimeMillis = -1 - } else { - averageBuildTimeMillis = int64(averageBuildTime / time.Millisecond) - } + buildTimeStats := api.metricsCache.TemplateBuildTimeStats(template.ID) return codersdk.Template{ ID: template.ID, @@ -792,7 +786,7 @@ func (api *API) convertTemplate( ActiveVersionID: template.ActiveVersionID, WorkspaceOwnerCount: workspaceOwnerCount, ActiveUserCount: activeCount, - AverageBuildTimeMillis: averageBuildTimeMillis, + BuildTimeStats: buildTimeStats, Description: template.Description, Icon: template.Icon, MaxTTLMillis: time.Duration(template.MaxTtl).Milliseconds(), diff --git a/coderd/templates_test.go b/coderd/templates_test.go index b384108387..637ced633c 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -594,7 +594,7 @@ func TestTemplateMetrics(t *testing.T) { }) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) require.Equal(t, -1, template.ActiveUserCount) - require.EqualValues(t, -1, template.AverageBuildTimeMillis) + require.Empty(t, template.BuildTimeStats) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) @@ -661,7 +661,8 @@ func TestTemplateMetrics(t *testing.T) { template, err = client.Template(ctx, template.ID) require.NoError(t, err) require.Equal(t, 1, template.ActiveUserCount) - require.Greater(t, template.AverageBuildTimeMillis, int64(1)) + require.NotNil(t, template.BuildTimeStats.StartMillis, template.BuildTimeStats) + require.Greater(t, *template.BuildTimeStats.StartMillis, int64(1)) workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) diff --git a/codersdk/templates.go b/codersdk/templates.go index 887c648b42..22e707050a 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -23,15 +23,20 @@ type Template struct { ActiveVersionID uuid.UUID `json:"active_version_id"` WorkspaceOwnerCount uint32 `json:"workspace_owner_count"` // ActiveUserCount is set to -1 when loading. - ActiveUserCount int `json:"active_user_count"` - // AverageBuildTimeMillis is set to -1 when there aren't enough recent builds. - AverageBuildTimeMillis int64 `json:"average_build_time_ms"` - Description string `json:"description"` - Icon string `json:"icon"` - MaxTTLMillis int64 `json:"max_ttl_ms"` - MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms"` - CreatedByID uuid.UUID `json:"created_by_id"` - CreatedByName string `json:"created_by_name"` + ActiveUserCount int `json:"active_user_count"` + BuildTimeStats TemplateBuildTimeStats `json:"build_time_stats"` + Description string `json:"description"` + Icon string `json:"icon"` + MaxTTLMillis int64 `json:"max_ttl_ms"` + MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms"` + CreatedByID uuid.UUID `json:"created_by_id"` + CreatedByName string `json:"created_by_name"` +} + +type TemplateBuildTimeStats struct { + StartMillis *int64 `json:"start_ms"` + StopMillis *int64 `json:"stop_ms"` + DeleteMillis *int64 `json:"delete_ms"` } type UpdateActiveTemplateVersion struct { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 519b828d30..5347613e77 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -586,7 +586,7 @@ export interface Template { readonly active_version_id: string readonly workspace_owner_count: number readonly active_user_count: number - readonly average_build_time_ms: number + readonly build_time_stats: TemplateBuildTimeStats readonly description: string readonly icon: string readonly max_ttl_ms: number @@ -601,6 +601,13 @@ export interface TemplateACL { readonly group: TemplateGroup[] } +// From codersdk/templates.go +export interface TemplateBuildTimeStats { + readonly start_ms?: number + readonly stop_ms?: number + readonly delete_ms?: number +} + // From codersdk/templates.go export interface TemplateDAUsResponse { readonly entries: DAUEntry[] diff --git a/site/src/components/TemplateStats/TemplateStats.tsx b/site/src/components/TemplateStats/TemplateStats.tsx index ba548937e5..3bc9434795 100644 --- a/site/src/components/TemplateStats/TemplateStats.tsx +++ b/site/src/components/TemplateStats/TemplateStats.tsx @@ -46,7 +46,7 @@ export const TemplateStats: FC = ({ {Language.buildTimeLabel} - {formatTemplateBuildTime(template.average_build_time_ms)}{" "} + {formatTemplateBuildTime(template.build_time_stats.start_ms)}{" "}
diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 6388b0ce12..f2c22d3864 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -20,7 +20,10 @@ import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" import { AlertBanner } from "../AlertBanner/AlertBanner" import { useTranslation } from "react-i18next" -import { WorkspaceBuildProgress } from "components/WorkspaceBuildProgress/WorkspaceBuildProgress" +import { + EstimateTransitionTime, + WorkspaceBuildProgress, +} from "components/WorkspaceBuildProgress/WorkspaceBuildProgress" export enum WorkspaceErrors { GET_RESOURCES_ERROR = "getResourcesError", @@ -115,6 +118,15 @@ export const Workspace: FC> = ({ /> ) + let buildTimeEstimate: number | undefined = undefined + let isTransitioning: boolean | undefined = undefined + if (template !== undefined) { + ;[buildTimeEstimate, isTransitioning] = EstimateTransitionTime( + template, + workspace, + ) + } + return ( > = ({ - {workspace.latest_build.status === "starting" && ( - + {isTransitioning !== undefined && isTransitioning && ( + )} {typeof resources !== "undefined" && resources.length > 0 && ( diff --git a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.stories.tsx b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.stories.tsx new file mode 100644 index 0000000000..40f4a1d8ec --- /dev/null +++ b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.stories.tsx @@ -0,0 +1,49 @@ +import { ComponentMeta, Story } from "@storybook/react" +import dayjs from "dayjs" +import { + MockProvisionerJob, + MockStartingWorkspace, + MockWorkspaceBuild, +} from "../../testHelpers/renderHelpers" +import { + WorkspaceBuildProgress, + WorkspaceBuildProgressProps, +} from "./WorkspaceBuildProgress" + +export default { + title: "components/WorkspaceBuildProgress", + component: WorkspaceBuildProgress, +} as ComponentMeta + +const Template: Story = (args) => ( + +) + +export const Starting = Template.bind({}) +Starting.args = { + buildEstimate: 10000, + workspace: { + ...MockStartingWorkspace, + latest_build: { + ...MockWorkspaceBuild, + status: "starting", + job: { + ...MockProvisionerJob, + started_at: dayjs().add(-5, "second").format(), + status: "running", + }, + }, + }, +} + +export const StartingUnknown = Template.bind({}) +StartingUnknown.args = { + ...Starting.args, + buildEstimate: undefined, +} + +export const StartingPassedEstimate = Template.bind({}) +StartingPassedEstimate.args = { + ...Starting.args, + buildEstimate: 1000, +} diff --git a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx index 53ea70d1dc..f9685b8951 100644 --- a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx +++ b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx @@ -2,7 +2,7 @@ import LinearProgress from "@material-ui/core/LinearProgress" import makeStyles from "@material-ui/core/styles/makeStyles" import { Template, Workspace } from "api/typesGenerated" import dayjs, { Dayjs } from "dayjs" -import { FC } from "react" +import { FC, useEffect, useState } from "react" import { MONOSPACE_FONT_FAMILY } from "theme/constants" import duration from "dayjs/plugin/duration" @@ -11,17 +11,17 @@ dayjs.extend(duration) const estimateFinish = ( startedAt: Dayjs, - templateAverage: number, + templateAverage?: number, ): [number, string] => { - // Buffer the template average to prevent the progress bar from waiting at end. - // Over-promise, under-deliver. - templateAverage *= 1.2 - + if (templateAverage === undefined) { + return [0, "Unknown"] + } const realPercentage = dayjs().diff(startedAt) / templateAverage // Showing a full bar is frustrating. - if (realPercentage > 0.95) { - return [0.95, "Any moment now..."] + const maxPercentage = 0.99 + if (realPercentage > maxPercentage) { + return [maxPercentage, "Any moment now..."] } return [ @@ -32,41 +32,79 @@ const estimateFinish = ( ] } -export const WorkspaceBuildProgress: FC<{ +export interface WorkspaceBuildProgressProps { workspace: Workspace - template?: Template -}> = ({ workspace, template }) => { - const styles = useStyles() + buildEstimate?: number +} - // Template stats not loaded or non-existent - if (!template || template.average_build_time_ms <= 0) { - return <> +// EstimateTransitionTime gets the build estimate for the workspace, +// if it is in a transition state. +export const EstimateTransitionTime = ( + template: Template, + workspace: Workspace, +): [number | undefined, boolean] => { + switch (workspace.latest_build.status) { + case "starting": + return [template.build_time_stats.start_ms, true] + case "stopping": + return [template.build_time_stats.stop_ms, true] + case "deleting": + return [template.build_time_stats.delete_ms, true] + default: + // Not in a transition state + return [undefined, false] } +} +export const WorkspaceBuildProgress: FC = ({ + workspace, + buildEstimate, +}) => { + const styles = useStyles() const job = workspace.latest_build.job - const status = job.status + const [progressValue, setProgressValue] = useState(0) + + // By default workspace is updated every second, which can cause visual stutter + // when the build estimate is a few seconds. The timer ensures no observable + // stutter in all cases. + useEffect(() => { + const updateProgress = () => { + if (job.status !== "running") { + setProgressValue(0) + return + } + setProgressValue( + estimateFinish(dayjs(job.started_at), buildEstimate)[0] * 100, + ) + } + setTimeout(updateProgress, 100) + }, [progressValue, job, buildEstimate]) + + // buildEstimate may be undefined if the template is new or coderd hasn't + // finished initial metrics collection. + if (buildEstimate === undefined) { + return ( +
+ +
+
{`Build ${job.status}`}
+
Unknown ETA
+
+
+ ) + } return (
-
{`Build ${status}`}
+
{`Build ${job.status}`}
- {status === "running" && - estimateFinish( - dayjs(job.started_at), - template.average_build_time_ms, - )[1]} + {job.status === "running" && + estimateFinish(dayjs(job.started_at), buildEstimate)[1]}
diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index b1904c58fe..cbfe68d550 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -237,7 +237,7 @@ export const TemplatesPageView: FC< style={{ color: theme.palette.text.secondary }} > {formatTemplateBuildTime( - template.average_build_time_ms, + template.build_time_stats.start_ms, )} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 3362e968cf..59abb4a913 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -185,7 +185,11 @@ export const MockTemplate: TypesGen.Template = { active_version_id: MockTemplateVersion.id, workspace_owner_count: 2, active_user_count: 1, - average_build_time_ms: 123, + build_time_stats: { + start_ms: 1000, + stop_ms: 2000, + delete_ms: 3000, + }, description: "This is a test description.", max_ttl_ms: 24 * 60 * 60 * 1000, min_autostart_interval_ms: 60 * 60 * 1000, diff --git a/site/src/util/templates.ts b/site/src/util/templates.ts index ebc3d8ae4a..3f9aae69e5 100644 --- a/site/src/util/templates.ts +++ b/site/src/util/templates.ts @@ -13,8 +13,8 @@ export const formatTemplateActiveDevelopers = (num?: number): string => { return num.toString() } -export const formatTemplateBuildTime = (buildTimeMs: number): string => { - return buildTimeMs < 0 +export const formatTemplateBuildTime = (buildTimeMs?: number): string => { + return buildTimeMs === undefined ? "Unknown" : `${Math.round(dayjs.duration(buildTimeMs, "milliseconds").asSeconds())}s` } From 2ba4a62a0de717a7cd2799bc55c3dc558984d8a5 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 08:43:30 -0500 Subject: [PATCH 20/51] feat: Add high availability for multiple replicas (#4555) * feat: HA tailnet coordinator * fixup! feat: HA tailnet coordinator * fixup! feat: HA tailnet coordinator * remove printlns * close all connections on coordinator * impelement high availability feature * fixup! impelement high availability feature * fixup! impelement high availability feature * fixup! impelement high availability feature * fixup! impelement high availability feature * Add replicas * Add DERP meshing to arbitrary addresses * Move packages to highavailability folder * Move coordinator to high availability package * Add flags for HA * Rename to replicasync * Denest packages for replicas * Add test for multiple replicas * Fix coordination test * Add HA to the helm chart * Rename function pointer * Add warnings for HA * Add the ability to block endpoints * Add flag to disable P2P connections * Wow, I made the tests pass * Add replicas endpoint * Ensure close kills replica * Update sql * Add database latency to high availability * Pipe TLS to DERP mesh * Fix DERP mesh with TLS * Add tests for TLS * Fix replica sync TLS * Fix RootCA for replica meshing * Remove ID from replicasync * Fix getting certificates for meshing * Remove excessive locking * Fix linting * Store mesh key in the database * Fix replica key for tests * Fix types gen * Fix unlocking unlocked * Fix race in tests * Update enterprise/derpmesh/derpmesh.go Co-authored-by: Colin Adler * Rename to syncReplicas * Reuse http client * Delete old replicas on a CRON * Fix race condition in connection tests * Fix linting * Fix nil type * Move pubsub to in-memory for twenty test * Add comment for configuration tweaking * Fix leak with transport * Fix close leak in derpmesh * Fix race when creating server * Remove handler update * Skip test on Windows * Fix DERP mesh test * Wrap HTTP handler replacement in mutex * Fix error message for relay * Fix API handler for normal tests * Fix speedtest * Fix replica resend * Fix derpmesh send * Ping async * Increase wait time of template version jobd * Fix race when closing replica sync * Add name to client * Log the derpmap being used * Don't connect if DERP is empty * Improve agent coordinator logging * Fix lock in coordinator * Fix relay addr * Fix race when updating durations * Fix client publish race * Run pubsub loop in a queue * Store agent nodes in order * Fix coordinator locking * Check for closed pipe Co-authored-by: Colin Adler --- .vscode/settings.json | 3 + agent/agent.go | 1 + agent/agent_test.go | 6 +- cli/agent_test.go | 14 +- cli/config/file.go | 5 + cli/configssh_test.go | 3 +- cli/deployment/flags.go | 7 + cli/portforward.go | 5 +- cli/root.go | 6 +- cli/server.go | 23 +- cli/speedtest.go | 6 +- cli/ssh.go | 4 +- coderd/activitybump_test.go | 6 +- coderd/coderd.go | 20 +- coderd/coderdtest/coderdtest.go | 144 +++-- coderd/database/databasefake/databasefake.go | 88 +++ coderd/database/db.go | 9 + coderd/database/dump.sql | 17 +- .../migrations/000061_replicas.down.sql | 2 + .../migrations/000061_replicas.up.sql | 28 + coderd/database/models.go | 15 + coderd/database/pubsub_memory.go | 3 +- coderd/database/querier.go | 6 + coderd/database/queries.sql.go | 189 +++++- coderd/database/queries/replicas.sql | 31 + coderd/database/queries/siteconfig.sql | 6 + coderd/provisionerjobs.go | 2 +- coderd/rbac/object.go | 4 + coderd/templates_test.go | 4 +- coderd/workspaceagents.go | 20 +- coderd/workspaceagents_test.go | 8 +- coderd/workspacebuilds.go | 2 +- coderd/wsconncache/wsconncache_test.go | 4 +- codersdk/agentconn.go | 6 +- codersdk/features.go | 15 +- codersdk/flags.go | 1 + codersdk/replicas.go | 44 ++ codersdk/workspaceagents.go | 34 +- enterprise/cli/features_test.go | 4 +- enterprise/cli/server.go | 55 +- enterprise/coderd/authorize_test.go | 2 +- enterprise/coderd/coderd.go | 112 +++- enterprise/coderd/coderd_test.go | 8 +- .../coderd/coderdenttest/coderdenttest.go | 65 +- .../coderdenttest/coderdenttest_test.go | 6 +- enterprise/coderd/groups_test.go | 36 +- enterprise/coderd/license/license.go | 62 +- enterprise/coderd/license/license_test.go | 113 +++- enterprise/coderd/licenses_test.go | 50 +- enterprise/coderd/replicas.go | 37 ++ enterprise/coderd/replicas_test.go | 138 +++++ enterprise/coderd/templates_test.go | 34 +- enterprise/coderd/workspaceagents_test.go | 14 +- enterprise/coderd/workspaces_test.go | 2 +- enterprise/derpmesh/derpmesh.go | 165 +++++ enterprise/derpmesh/derpmesh_test.go | 219 +++++++ enterprise/replicasync/replicasync.go | 391 ++++++++++++ enterprise/replicasync/replicasync_test.go | 239 ++++++++ enterprise/tailnet/coordinator.go | 575 ++++++++++++++++++ enterprise/tailnet/coordinator_test.go | 261 ++++++++ go.mod | 2 +- go.sum | 4 +- helm/templates/coder.yaml | 12 +- helm/templates/service.yaml | 1 + helm/values.yaml | 8 +- site/src/api/api.ts | 1 + site/src/api/typesGenerated.ts | 13 + .../LicenseBanner/LicenseBanner.tsx | 6 +- .../LicenseBannerView.stories.tsx | 10 + .../LicenseBanner/LicenseBannerView.tsx | 66 +- site/src/testHelpers/entities.ts | 3 + .../entitlements/entitlementsXService.ts | 1 + tailnet/conn.go | 30 +- tailnet/coordinator.go | 248 +++++--- tailnet/coordinator_test.go | 4 +- testutil/certificate.go | 53 ++ 76 files changed, 3437 insertions(+), 404 deletions(-) create mode 100644 coderd/database/migrations/000061_replicas.down.sql create mode 100644 coderd/database/migrations/000061_replicas.up.sql create mode 100644 coderd/database/queries/replicas.sql create mode 100644 codersdk/replicas.go create mode 100644 enterprise/coderd/replicas.go create mode 100644 enterprise/coderd/replicas_test.go create mode 100644 enterprise/derpmesh/derpmesh.go create mode 100644 enterprise/derpmesh/derpmesh_test.go create mode 100644 enterprise/replicasync/replicasync.go create mode 100644 enterprise/replicasync/replicasync_test.go create mode 100644 enterprise/tailnet/coordinator.go create mode 100644 enterprise/tailnet/coordinator_test.go create mode 100644 testutil/certificate.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 8b92ff2228..9771a27a0d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,6 +19,7 @@ "derphttp", "derpmap", "devel", + "dflags", "drpc", "drpcconn", "drpcmux", @@ -86,8 +87,10 @@ "ptytest", "quickstart", "reconfig", + "replicasync", "retrier", "rpty", + "SCIM", "sdkproto", "sdktrace", "Signup", diff --git a/agent/agent.go b/agent/agent.go index 6d0a9a952f..f7c5598b7b 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -170,6 +170,7 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) { if a.isClosed() { return } + a.logger.Debug(ctx, "running tailnet with derpmap", slog.F("derpmap", derpMap)) if a.network != nil { a.network.SetDERPMap(derpMap) return diff --git a/agent/agent_test.go b/agent/agent_test.go index 06a33598b7..e10eee7f11 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -465,7 +465,7 @@ func TestAgent(t *testing.T) { conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0) require.Eventually(t, func() bool { - _, err := conn.Ping() + _, err := conn.Ping(context.Background()) return err == nil }, testutil.WaitMedium, testutil.IntervalFast) conn1, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String()) @@ -483,9 +483,7 @@ func TestAgent(t *testing.T) { t.Run("Speedtest", func(t *testing.T) { t.Parallel() - if testing.Short() { - t.Skip("The minimum duration for a speedtest is hardcoded in Tailscale to 5s!") - } + t.Skip("This test is relatively flakey because of Tailscale's speedtest code...") derpMap := tailnettest.RunDERPAndSTUN(t) conn, _ := setupAgent(t, codersdk.WorkspaceAgentMetadata{ DERPMap: derpMap, diff --git a/cli/agent_test.go b/cli/agent_test.go index dd0cb1d789..f487ebfc00 100644 --- a/cli/agent_test.go +++ b/cli/agent_test.go @@ -7,8 +7,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "cdr.dev/slog" - "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/provisioner/echo" @@ -67,11 +65,11 @@ func TestWorkspaceAgent(t *testing.T) { if assert.NotEmpty(t, workspace.LatestBuild.Resources) && assert.NotEmpty(t, resources[0].Agents) { assert.NotEmpty(t, resources[0].Agents[0].Version) } - dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) require.NoError(t, err) defer dialer.Close() require.Eventually(t, func() bool { - _, err := dialer.Ping() + _, err := dialer.Ping(ctx) return err == nil }, testutil.WaitMedium, testutil.IntervalFast) cancelFunc() @@ -128,11 +126,11 @@ func TestWorkspaceAgent(t *testing.T) { if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) { assert.NotEmpty(t, resources[0].Agents[0].Version) } - dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) require.NoError(t, err) defer dialer.Close() require.Eventually(t, func() bool { - _, err := dialer.Ping() + _, err := dialer.Ping(ctx) return err == nil }, testutil.WaitMedium, testutil.IntervalFast) cancelFunc() @@ -189,11 +187,11 @@ func TestWorkspaceAgent(t *testing.T) { if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) { assert.NotEmpty(t, resources[0].Agents[0].Version) } - dialer, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) require.NoError(t, err) defer dialer.Close() require.Eventually(t, func() bool { - _, err := dialer.Ping() + _, err := dialer.Ping(ctx) return err == nil }, testutil.WaitMedium, testutil.IntervalFast) cancelFunc() diff --git a/cli/config/file.go b/cli/config/file.go index a98237afed..388ce0881f 100644 --- a/cli/config/file.go +++ b/cli/config/file.go @@ -13,6 +13,11 @@ func (r Root) Session() File { return File(filepath.Join(string(r), "session")) } +// ReplicaID is a unique identifier for the Coder server. +func (r Root) ReplicaID() File { + return File(filepath.Join(string(r), "replica_id")) +} + func (r Root) URL() File { return File(filepath.Join(string(r), "url")) } diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 3e1512a0c3..4553cbe431 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -19,7 +19,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" @@ -115,7 +114,7 @@ func TestConfigSSH(t *testing.T) { _ = agentCloser.Close() }() resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - agentConn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, resources[0].Agents[0].ID) + agentConn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil) require.NoError(t, err) defer agentConn.Close() diff --git a/cli/deployment/flags.go b/cli/deployment/flags.go index df18e95027..714365cc8e 100644 --- a/cli/deployment/flags.go +++ b/cli/deployment/flags.go @@ -85,6 +85,13 @@ func Flags() *codersdk.DeploymentFlags { Description: "Addresses for STUN servers to establish P2P connections. Set empty to disable P2P connections.", Default: []string{"stun.l.google.com:19302"}, }, + DerpServerRelayAddress: &codersdk.StringFlag{ + Name: "DERP Server Relay Address", + Flag: "derp-server-relay-address", + EnvVar: "CODER_DERP_SERVER_RELAY_ADDRESS", + Description: "An HTTP address that is accessible by other replicas to relay DERP traffic. Required for high availability.", + Enterprise: true, + }, DerpConfigURL: &codersdk.StringFlag{ Name: "DERP Config URL", Flag: "derp-config-url", diff --git a/cli/portforward.go b/cli/portforward.go index 476809d601..5a6f4391dd 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -16,7 +16,6 @@ import ( "github.com/spf13/cobra" "golang.org/x/xerrors" - "cdr.dev/slog" "github.com/coder/coder/agent" "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" @@ -96,7 +95,7 @@ func portForward() *cobra.Command { return xerrors.Errorf("await agent: %w", err) } - conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, workspaceAgent.ID) + conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, nil) if err != nil { return err } @@ -156,7 +155,7 @@ func portForward() *cobra.Command { case <-ticker.C: } - _, err = conn.Ping() + _, err = conn.Ping(ctx) if err != nil { continue } diff --git a/cli/root.go b/cli/root.go index e7104e6428..91d4551916 100644 --- a/cli/root.go +++ b/cli/root.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "io" "net/http" "net/url" "os" @@ -100,8 +101,9 @@ func Core() []*cobra.Command { } func AGPL() []*cobra.Command { - all := append(Core(), Server(deployment.Flags(), func(_ context.Context, o *coderd.Options) (*coderd.API, error) { - return coderd.New(o), nil + all := append(Core(), Server(deployment.Flags(), func(_ context.Context, o *coderd.Options) (*coderd.API, io.Closer, error) { + api := coderd.New(o) + return api, api, nil })) return all } diff --git a/cli/server.go b/cli/server.go index 9d828abbea..c2dbeac07e 100644 --- a/cli/server.go +++ b/cli/server.go @@ -69,7 +69,7 @@ import ( ) // nolint:gocyclo -func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *coderd.Options) (*coderd.API, error)) *cobra.Command { +func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *coderd.Options) (*coderd.API, io.Closer, error)) *cobra.Command { root := &cobra.Command{ Use: "server", Short: "Start a Coder server", @@ -167,9 +167,10 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code } defer listener.Close() + var tlsConfig *tls.Config if dflags.TLSEnable.Value { - listener, err = configureServerTLS( - listener, dflags.TLSMinVersion.Value, + tlsConfig, err = configureTLS( + dflags.TLSMinVersion.Value, dflags.TLSClientAuth.Value, dflags.TLSCertFiles.Value, dflags.TLSKeyFiles.Value, @@ -178,6 +179,7 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code if err != nil { return xerrors.Errorf("configure tls: %w", err) } + listener = tls.NewListener(listener, tlsConfig) } tcpAddr, valid := listener.Addr().(*net.TCPAddr) @@ -328,6 +330,9 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code Experimental: ExperimentalEnabled(cmd), DeploymentFlags: dflags, } + if tlsConfig != nil { + options.TLSCertificates = tlsConfig.Certificates + } if dflags.OAuth2GithubClientSecret.Value != "" { options.GithubOAuth2Config, err = configureGithubOAuth2(accessURLParsed, @@ -471,11 +476,14 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code ), dflags.PromAddress.Value, "prometheus")() } - coderAPI, err := newAPI(ctx, options) + // We use a separate closer so the Enterprise API + // can have it's own close functions. This is cleaner + // than abstracting the Coder API itself. + coderAPI, closer, err := newAPI(ctx, options) if err != nil { return err } - defer coderAPI.Close() + defer closer.Close() client := codersdk.New(localURL) if dflags.TLSEnable.Value { @@ -893,7 +901,7 @@ func loadCertificates(tlsCertFiles, tlsKeyFiles []string) ([]tls.Certificate, er return certs, nil } -func configureServerTLS(listener net.Listener, tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles []string, tlsClientCAFile string) (net.Listener, error) { +func configureTLS(tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles []string, tlsClientCAFile string) (*tls.Config, error) { tlsConfig := &tls.Config{ MinVersion: tls.VersionTLS12, } @@ -929,6 +937,7 @@ func configureServerTLS(listener net.Listener, tlsMinVersion, tlsClientAuth stri if err != nil { return nil, xerrors.Errorf("load certificates: %w", err) } + tlsConfig.Certificates = certs tlsConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { // If there's only one certificate, return it. if len(certs) == 1 { @@ -963,7 +972,7 @@ func configureServerTLS(listener net.Listener, tlsMinVersion, tlsClientAuth stri tlsConfig.ClientCAs = caPool } - return tls.NewListener(listener, tlsConfig), nil + return tlsConfig, nil } func configureGithubOAuth2(accessURL *url.URL, clientID, clientSecret string, allowSignups bool, allowOrgs []string, rawTeams []string, enterpriseBaseURL string) (*coderd.GithubOAuth2Config, error) { diff --git a/cli/speedtest.go b/cli/speedtest.go index 357048f63e..f6c06641ec 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -55,7 +55,9 @@ func speedtest() *cobra.Command { if cliflag.IsSetBool(cmd, varVerbose) { logger = logger.Leveled(slog.LevelDebug) } - conn, err := client.DialWorkspaceAgentTailnet(ctx, logger, workspaceAgent.ID) + conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: logger, + }) if err != nil { return err } @@ -68,7 +70,7 @@ func speedtest() *cobra.Command { return ctx.Err() case <-ticker.C: } - dur, err := conn.Ping() + dur, err := conn.Ping(ctx) if err != nil { continue } diff --git a/cli/ssh.go b/cli/ssh.go index ef8538764e..b4d4f6420d 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -20,8 +20,6 @@ import ( "golang.org/x/term" "golang.org/x/xerrors" - "cdr.dev/slog" - "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/coderd/autobuild/notify" @@ -86,7 +84,7 @@ func ssh() *cobra.Command { return xerrors.Errorf("await agent: %w", err) } - conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, workspaceAgent.ID) + conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, nil) if err != nil { return err } diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index cd43b774d5..e498b98fa0 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -72,7 +72,7 @@ func TestWorkspaceActivityBump(t *testing.T) { "deadline %v never updated", firstDeadline, ) - require.WithinDuration(t, database.Now().Add(time.Hour), workspace.LatestBuild.Deadline.Time, time.Second) + require.WithinDuration(t, database.Now().Add(time.Hour), workspace.LatestBuild.Deadline.Time, 3*time.Second) } } @@ -82,7 +82,9 @@ func TestWorkspaceActivityBump(t *testing.T) { client, workspace, assertBumped := setupActivityTest(t) resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - conn, err := client.DialWorkspaceAgentTailnet(ctx, slogtest.Make(t, nil), resources[0].Agents[0].ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: slogtest.Make(t, nil), + }) require.NoError(t, err) defer conn.Close() diff --git a/coderd/coderd.go b/coderd/coderd.go index 992ae6c7f5..cf8a20d373 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1,6 +1,7 @@ package coderd import ( + "crypto/tls" "crypto/x509" "io" "net/http" @@ -82,7 +83,10 @@ type Options struct { TracerProvider trace.TracerProvider AutoImportTemplates []AutoImportTemplate - TailnetCoordinator *tailnet.Coordinator + // TLSCertificates is used to mesh DERP servers securely. + TLSCertificates []tls.Certificate + TailnetCoordinator tailnet.Coordinator + DERPServer *derp.Server DERPMap *tailcfg.DERPMap MetricsCacheRefreshInterval time.Duration @@ -130,6 +134,9 @@ func New(options *Options) *API { if options.TailnetCoordinator == nil { options.TailnetCoordinator = tailnet.NewCoordinator() } + if options.DERPServer == nil { + options.DERPServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger.Named("derp"))) + } if options.Auditor == nil { options.Auditor = audit.NewNop() } @@ -168,7 +175,7 @@ func New(options *Options) *API { api.Auditor.Store(&options.Auditor) api.WorkspaceQuotaEnforcer.Store(&options.WorkspaceQuotaEnforcer) api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0) - api.derpServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger)) + api.TailnetCoordinator.Store(&options.TailnetCoordinator) oauthConfigs := &httpmw.OAuth2Configs{ Github: options.GithubOAuth2Config, OIDC: options.OIDCConfig, @@ -246,7 +253,7 @@ func New(options *Options) *API { r.Route("/%40{user}/{workspace_and_agent}/apps/{workspaceapp}", apps) r.Route("/@{user}/{workspace_and_agent}/apps/{workspaceapp}", apps) r.Route("/derp", func(r chi.Router) { - r.Get("/", derphttp.Handler(api.derpServer).ServeHTTP) + r.Get("/", derphttp.Handler(api.DERPServer).ServeHTTP) // This is used when UDP is blocked, and latency must be checked via HTTP(s). r.Get("/latency-check", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -550,6 +557,7 @@ type API struct { Auditor atomic.Pointer[audit.Auditor] WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool] WorkspaceQuotaEnforcer atomic.Pointer[workspacequota.Enforcer] + TailnetCoordinator atomic.Pointer[tailnet.Coordinator] HTTPAuth *HTTPAuthorizer // APIHandler serves "/api/v2" @@ -557,7 +565,6 @@ type API struct { // RootHandler serves "/" RootHandler chi.Router - derpServer *derp.Server metricsCache *metricscache.Cache siteHandler http.Handler websocketWaitMutex sync.Mutex @@ -572,7 +579,10 @@ func (api *API) Close() error { api.websocketWaitMutex.Unlock() api.metricsCache.Close() - + coordinator := api.TailnetCoordinator.Load() + if coordinator != nil { + _ = (*coordinator).Close() + } return api.workspaceAgentCache.Close() } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index f8695deb04..5cf307d842 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -7,6 +7,7 @@ import ( "crypto/rand" "crypto/rsa" "crypto/sha256" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/base64" @@ -23,6 +24,7 @@ import ( "regexp" "strconv" "strings" + "sync" "testing" "time" @@ -37,8 +39,10 @@ import ( "golang.org/x/xerrors" "google.golang.org/api/idtoken" "google.golang.org/api/option" + "tailscale.com/derp" "tailscale.com/net/stun/stuntest" "tailscale.com/tailcfg" + "tailscale.com/types/key" "tailscale.com/types/nettype" "cdr.dev/slog" @@ -60,6 +64,7 @@ import ( "github.com/coder/coder/provisionerd" "github.com/coder/coder/provisionersdk" "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/tailnet" "github.com/coder/coder/testutil" ) @@ -77,12 +82,19 @@ type Options struct { AutobuildTicker <-chan time.Time AutobuildStats chan<- executor.Stats Auditor audit.Auditor + TLSCertificates []tls.Certificate // IncludeProvisionerDaemon when true means to start an in-memory provisionerD IncludeProvisionerDaemon bool MetricsCacheRefreshInterval time.Duration AgentStatsRefreshInterval time.Duration DeploymentFlags *codersdk.DeploymentFlags + + // Overriding the database is heavily discouraged. + // It should only be used in cases where multiple Coder + // test instances are running against the same database. + Database database.Store + Pubsub database.Pubsub } // New constructs a codersdk client connected to an in-memory API instance. @@ -116,7 +128,7 @@ func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer) return client, closer } -func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.CancelFunc, *coderd.Options) { +func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.CancelFunc, *coderd.Options) { if options == nil { options = &Options{} } @@ -137,23 +149,40 @@ func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.Cance close(options.AutobuildStats) }) } - - db, pubsub := dbtestutil.NewDB(t) + if options.Database == nil { + options.Database, options.Pubsub = dbtestutil.NewDB(t) + } ctx, cancelFunc := context.WithCancel(context.Background()) lifecycleExecutor := executor.New( ctx, - db, + options.Database, slogtest.Make(t, nil).Named("autobuild.executor").Leveled(slog.LevelDebug), options.AutobuildTicker, ).WithStatsChannel(options.AutobuildStats) lifecycleExecutor.Run() - srv := httptest.NewUnstartedServer(nil) + var mutex sync.RWMutex + var handler http.Handler + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mutex.RLock() + defer mutex.RUnlock() + if handler != nil { + handler.ServeHTTP(w, r) + } + })) srv.Config.BaseContext = func(_ net.Listener) context.Context { return ctx } - srv.Start() + if options.TLSCertificates != nil { + srv.TLS = &tls.Config{ + Certificates: options.TLSCertificates, + MinVersion: tls.VersionTLS12, + } + srv.StartTLS() + } else { + srv.Start() + } t.Cleanup(srv.Close) tcpAddr, ok := srv.Listener.Addr().(*net.TCPAddr) @@ -169,6 +198,9 @@ func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.Cance stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{}) t.Cleanup(stunCleanup) + derpServer := derp.NewServer(key.NewNode(), tailnet.Logger(slogtest.Make(t, nil).Named("derp"))) + derpServer.SetMeshKey("test-key") + // match default with cli default if options.SSHKeygenAlgorithm == "" { options.SSHKeygenAlgorithm = gitsshkey.AlgorithmEd25519 @@ -181,53 +213,59 @@ func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.Cance require.NoError(t, err) } - return srv, cancelFunc, &coderd.Options{ - AgentConnectionUpdateFrequency: 150 * time.Millisecond, - // Force a long disconnection timeout to ensure - // agents are not marked as disconnected during slow tests. - AgentInactiveDisconnectTimeout: testutil.WaitShort, - AccessURL: serverURL, - AppHostname: options.AppHostname, - AppHostnameRegex: appHostnameRegex, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - CacheDir: t.TempDir(), - Database: db, - Pubsub: pubsub, + return func(h http.Handler) { + mutex.Lock() + defer mutex.Unlock() + handler = h + }, cancelFunc, &coderd.Options{ + AgentConnectionUpdateFrequency: 150 * time.Millisecond, + // Force a long disconnection timeout to ensure + // agents are not marked as disconnected during slow tests. + AgentInactiveDisconnectTimeout: testutil.WaitShort, + AccessURL: serverURL, + AppHostname: options.AppHostname, + AppHostnameRegex: appHostnameRegex, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + CacheDir: t.TempDir(), + Database: options.Database, + Pubsub: options.Pubsub, - Auditor: options.Auditor, - AWSCertificates: options.AWSCertificates, - AzureCertificates: options.AzureCertificates, - GithubOAuth2Config: options.GithubOAuth2Config, - OIDCConfig: options.OIDCConfig, - GoogleTokenValidator: options.GoogleTokenValidator, - SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, - APIRateLimit: options.APIRateLimit, - Authorizer: options.Authorizer, - Telemetry: telemetry.NewNoop(), - DERPMap: &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - EmbeddedRelay: true, - RegionID: 1, - RegionCode: "coder", - RegionName: "Coder", - Nodes: []*tailcfg.DERPNode{{ - Name: "1a", - RegionID: 1, - IPv4: "127.0.0.1", - DERPPort: derpPort, - STUNPort: stunAddr.Port, - InsecureForTests: true, - ForceHTTP: true, - }}, + Auditor: options.Auditor, + AWSCertificates: options.AWSCertificates, + AzureCertificates: options.AzureCertificates, + GithubOAuth2Config: options.GithubOAuth2Config, + OIDCConfig: options.OIDCConfig, + GoogleTokenValidator: options.GoogleTokenValidator, + SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, + DERPServer: derpServer, + APIRateLimit: options.APIRateLimit, + Authorizer: options.Authorizer, + Telemetry: telemetry.NewNoop(), + TLSCertificates: options.TLSCertificates, + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + EmbeddedRelay: true, + RegionID: 1, + RegionCode: "coder", + RegionName: "Coder", + Nodes: []*tailcfg.DERPNode{{ + Name: "1a", + RegionID: 1, + IPv4: "127.0.0.1", + DERPPort: derpPort, + STUNPort: stunAddr.Port, + InsecureForTests: true, + ForceHTTP: options.TLSCertificates == nil, + }}, + }, }, }, - }, - AutoImportTemplates: options.AutoImportTemplates, - MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval, - AgentStatsRefreshInterval: options.AgentStatsRefreshInterval, - DeploymentFlags: options.DeploymentFlags, - } + AutoImportTemplates: options.AutoImportTemplates, + MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval, + AgentStatsRefreshInterval: options.AgentStatsRefreshInterval, + DeploymentFlags: options.DeploymentFlags, + } } // NewWithAPI constructs an in-memory API instance and returns a client to talk to it. @@ -237,10 +275,10 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c if options == nil { options = &Options{} } - srv, cancelFunc, newOptions := NewOptions(t, options) + setHandler, cancelFunc, newOptions := NewOptions(t, options) // We set the handler after server creation for the access URL. coderAPI := coderd.New(newOptions) - srv.Config.Handler = coderAPI.RootHandler + setHandler(coderAPI.RootHandler) var provisionerCloser io.Closer = nopcloser{} if options.IncludeProvisionerDaemon { provisionerCloser = NewProvisionerDaemon(t, coderAPI) @@ -459,7 +497,7 @@ func AwaitTemplateVersionJob(t *testing.T, client *codersdk.Client, version uuid var err error templateVersion, err = client.TemplateVersion(context.Background(), version) return assert.NoError(t, err) && templateVersion.Job.CompletedAt != nil - }, testutil.WaitShort, testutil.IntervalFast) + }, testutil.WaitMedium, testutil.IntervalFast) return templateVersion } diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 63239bdf4d..65043d2412 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -107,11 +107,17 @@ type data struct { workspaceApps []database.WorkspaceApp workspaces []database.Workspace licenses []database.License + replicas []database.Replica deploymentID string + derpMeshKey string lastLicenseID int32 } +func (*fakeQuerier) Ping(_ context.Context) (time.Duration, error) { + return 0, nil +} + // InTx doesn't rollback data properly for in-memory yet. func (q *fakeQuerier) InTx(fn func(database.Store) error) error { q.mutex.Lock() @@ -2931,6 +2937,21 @@ func (q *fakeQuerier) GetDeploymentID(_ context.Context) (string, error) { return q.deploymentID, nil } +func (q *fakeQuerier) InsertDERPMeshKey(_ context.Context, id string) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + q.derpMeshKey = id + return nil +} + +func (q *fakeQuerier) GetDERPMeshKey(_ context.Context) (string, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + return q.derpMeshKey, nil +} + func (q *fakeQuerier) InsertLicense( _ context.Context, arg database.InsertLicenseParams, ) (database.License, error) { @@ -3196,3 +3217,70 @@ func (q *fakeQuerier) DeleteGroupByID(_ context.Context, id uuid.UUID) error { return sql.ErrNoRows } + +func (q *fakeQuerier) DeleteReplicasUpdatedBefore(_ context.Context, before time.Time) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, replica := range q.replicas { + if replica.UpdatedAt.Before(before) { + q.replicas = append(q.replicas[:i], q.replicas[i+1:]...) + } + } + + return nil +} + +func (q *fakeQuerier) InsertReplica(_ context.Context, arg database.InsertReplicaParams) (database.Replica, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + replica := database.Replica{ + ID: arg.ID, + CreatedAt: arg.CreatedAt, + StartedAt: arg.StartedAt, + UpdatedAt: arg.UpdatedAt, + Hostname: arg.Hostname, + RegionID: arg.RegionID, + RelayAddress: arg.RelayAddress, + Version: arg.Version, + DatabaseLatency: arg.DatabaseLatency, + } + q.replicas = append(q.replicas, replica) + return replica, nil +} + +func (q *fakeQuerier) UpdateReplica(_ context.Context, arg database.UpdateReplicaParams) (database.Replica, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, replica := range q.replicas { + if replica.ID != arg.ID { + continue + } + replica.Hostname = arg.Hostname + replica.StartedAt = arg.StartedAt + replica.StoppedAt = arg.StoppedAt + replica.UpdatedAt = arg.UpdatedAt + replica.RelayAddress = arg.RelayAddress + replica.RegionID = arg.RegionID + replica.Version = arg.Version + replica.Error = arg.Error + replica.DatabaseLatency = arg.DatabaseLatency + q.replicas[index] = replica + return replica, nil + } + return database.Replica{}, sql.ErrNoRows +} + +func (q *fakeQuerier) GetReplicasUpdatedAfter(_ context.Context, updatedAt time.Time) ([]database.Replica, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + replicas := make([]database.Replica, 0) + for _, replica := range q.replicas { + if replica.UpdatedAt.After(updatedAt) && !replica.StoppedAt.Valid { + replicas = append(replicas, replica) + } + } + return replicas, nil +} diff --git a/coderd/database/db.go b/coderd/database/db.go index 4cbbdb399f..020000888f 100644 --- a/coderd/database/db.go +++ b/coderd/database/db.go @@ -12,6 +12,7 @@ import ( "context" "database/sql" "errors" + "time" "github.com/jmoiron/sqlx" "golang.org/x/xerrors" @@ -24,6 +25,7 @@ type Store interface { // customQuerier contains custom queries that are not generated. customQuerier + Ping(ctx context.Context) (time.Duration, error) InTx(func(Store) error) error } @@ -58,6 +60,13 @@ type sqlQuerier struct { db DBTX } +// Ping returns the time it takes to ping the database. +func (q *sqlQuerier) Ping(ctx context.Context) (time.Duration, error) { + start := time.Now() + err := q.sdb.PingContext(ctx) + return time.Since(start), err +} + // InTx performs database operations inside a transaction. func (q *sqlQuerier) InTx(function func(Store) error) error { if _, ok := q.db.(*sqlx.Tx); ok { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index de2d352a6a..b946a1130e 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -256,7 +256,8 @@ CREATE TABLE provisioner_daemons ( created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone, name character varying(64) NOT NULL, - provisioners provisioner_type[] NOT NULL + provisioners provisioner_type[] NOT NULL, + replica_id uuid ); CREATE TABLE provisioner_job_logs ( @@ -287,6 +288,20 @@ CREATE TABLE provisioner_jobs ( file_id uuid NOT NULL ); +CREATE TABLE replicas ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + started_at timestamp with time zone NOT NULL, + stopped_at timestamp with time zone, + updated_at timestamp with time zone NOT NULL, + hostname text NOT NULL, + region_id integer NOT NULL, + relay_address text NOT NULL, + database_latency integer NOT NULL, + version text NOT NULL, + error text DEFAULT ''::text NOT NULL +); + CREATE TABLE site_configs ( key character varying(256) NOT NULL, value character varying(8192) NOT NULL diff --git a/coderd/database/migrations/000061_replicas.down.sql b/coderd/database/migrations/000061_replicas.down.sql new file mode 100644 index 0000000000..4cca6615d4 --- /dev/null +++ b/coderd/database/migrations/000061_replicas.down.sql @@ -0,0 +1,2 @@ +DROP TABLE replicas; +ALTER TABLE provisioner_daemons DROP COLUMN replica_id; diff --git a/coderd/database/migrations/000061_replicas.up.sql b/coderd/database/migrations/000061_replicas.up.sql new file mode 100644 index 0000000000..1400662e30 --- /dev/null +++ b/coderd/database/migrations/000061_replicas.up.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS replicas ( + -- A unique identifier for the replica that is stored on disk. + -- For persistent replicas, this will be reused. + -- For ephemeral replicas, this will be a new UUID for each one. + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + -- The time the replica was created. + started_at timestamp with time zone NOT NULL, + -- The time the replica was last seen. + stopped_at timestamp with time zone, + -- Updated periodically to ensure the replica is still alive. + updated_at timestamp with time zone NOT NULL, + -- Hostname is the hostname of the replica. + hostname text NOT NULL, + -- Region is the region the replica is in. + -- We only DERP mesh to the same region ID of a running replica. + region_id integer NOT NULL, + -- An address that should be accessible to other replicas. + relay_address text NOT NULL, + -- The latency of the replica to the database in microseconds. + database_latency int NOT NULL, + -- Version is the Coder version of the replica. + version text NOT NULL, + error text NOT NULL DEFAULT '' +); + +-- Associates a provisioner daemon with a replica. +ALTER TABLE provisioner_daemons ADD COLUMN replica_id uuid; diff --git a/coderd/database/models.go b/coderd/database/models.go index e30615244e..53e074984a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -508,6 +508,7 @@ type ProvisionerDaemon struct { UpdatedAt sql.NullTime `db:"updated_at" json:"updated_at"` Name string `db:"name" json:"name"` Provisioners []ProvisionerType `db:"provisioners" json:"provisioners"` + ReplicaID uuid.NullUUID `db:"replica_id" json:"replica_id"` } type ProvisionerJob struct { @@ -538,6 +539,20 @@ type ProvisionerJobLog struct { Output string `db:"output" json:"output"` } +type Replica struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + StartedAt time.Time `db:"started_at" json:"started_at"` + StoppedAt sql.NullTime `db:"stopped_at" json:"stopped_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Hostname string `db:"hostname" json:"hostname"` + RegionID int32 `db:"region_id" json:"region_id"` + RelayAddress string `db:"relay_address" json:"relay_address"` + DatabaseLatency int32 `db:"database_latency" json:"database_latency"` + Version string `db:"version" json:"version"` + Error string `db:"error" json:"error"` +} + type SiteConfig struct { Key string `db:"key" json:"key"` Value string `db:"value" json:"value"` diff --git a/coderd/database/pubsub_memory.go b/coderd/database/pubsub_memory.go index 148d2f57b1..de5a940414 100644 --- a/coderd/database/pubsub_memory.go +++ b/coderd/database/pubsub_memory.go @@ -47,8 +47,9 @@ func (m *memoryPubsub) Publish(event string, message []byte) error { return nil } for _, listener := range listeners { - listener(context.Background(), message) + go listener(context.Background(), message) } + return nil } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ad26413873..393ab81fdd 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -26,6 +26,7 @@ type sqlcQuerier interface { DeleteLicense(ctx context.Context, id int32) (int32, error) DeleteOldAgentStats(ctx context.Context) error DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error + DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) @@ -38,6 +39,7 @@ type sqlcQuerier interface { // This function returns roles for authorization purposes. Implied member roles // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) + GetDERPMeshKey(ctx context.Context) (string, error) GetDeploymentID(ctx context.Context) (string, error) GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error) GetFileByID(ctx context.Context, id uuid.UUID) (File, error) @@ -67,6 +69,7 @@ type sqlcQuerier interface { GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error) + GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) @@ -123,6 +126,7 @@ type sqlcQuerier interface { // every member of the org. InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) + InsertDERPMeshKey(ctx context.Context, value string) error InsertDeploymentID(ctx context.Context, value string) error InsertFile(ctx context.Context, arg InsertFileParams) (File, error) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) @@ -136,6 +140,7 @@ type sqlcQuerier interface { InsertProvisionerDaemon(ctx context.Context, arg InsertProvisionerDaemonParams) (ProvisionerDaemon, error) InsertProvisionerJob(ctx context.Context, arg InsertProvisionerJobParams) (ProvisionerJob, error) InsertProvisionerJobLogs(ctx context.Context, arg InsertProvisionerJobLogsParams) ([]ProvisionerJobLog, error) + InsertReplica(ctx context.Context, arg InsertReplicaParams) (Replica, error) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) InsertTemplateVersion(ctx context.Context, arg InsertTemplateVersionParams) (TemplateVersion, error) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) @@ -156,6 +161,7 @@ type sqlcQuerier interface { UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error UpdateProvisionerJobWithCompleteByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteByIDParams) error + UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error) UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) (Template, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 41eb029b59..3621050bc0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2031,7 +2031,7 @@ func (q *sqlQuerier) ParameterValues(ctx context.Context, arg ParameterValuesPar const getProvisionerDaemonByID = `-- name: GetProvisionerDaemonByID :one SELECT - id, created_at, updated_at, name, provisioners + id, created_at, updated_at, name, provisioners, replica_id FROM provisioner_daemons WHERE @@ -2047,13 +2047,14 @@ func (q *sqlQuerier) GetProvisionerDaemonByID(ctx context.Context, id uuid.UUID) &i.UpdatedAt, &i.Name, pq.Array(&i.Provisioners), + &i.ReplicaID, ) return i, err } const getProvisionerDaemons = `-- name: GetProvisionerDaemons :many SELECT - id, created_at, updated_at, name, provisioners + id, created_at, updated_at, name, provisioners, replica_id FROM provisioner_daemons ` @@ -2073,6 +2074,7 @@ func (q *sqlQuerier) GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDa &i.UpdatedAt, &i.Name, pq.Array(&i.Provisioners), + &i.ReplicaID, ); err != nil { return nil, err } @@ -2096,7 +2098,7 @@ INSERT INTO provisioners ) VALUES - ($1, $2, $3, $4) RETURNING id, created_at, updated_at, name, provisioners + ($1, $2, $3, $4) RETURNING id, created_at, updated_at, name, provisioners, replica_id ` type InsertProvisionerDaemonParams struct { @@ -2120,6 +2122,7 @@ func (q *sqlQuerier) InsertProvisionerDaemon(ctx context.Context, arg InsertProv &i.UpdatedAt, &i.Name, pq.Array(&i.Provisioners), + &i.ReplicaID, ) return i, err } @@ -2577,6 +2580,177 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a return err } +const deleteReplicasUpdatedBefore = `-- name: DeleteReplicasUpdatedBefore :exec +DELETE FROM replicas WHERE updated_at < $1 +` + +func (q *sqlQuerier) DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error { + _, err := q.db.ExecContext(ctx, deleteReplicasUpdatedBefore, updatedAt) + return err +} + +const getReplicasUpdatedAfter = `-- name: GetReplicasUpdatedAfter :many +SELECT id, created_at, started_at, stopped_at, updated_at, hostname, region_id, relay_address, database_latency, version, error FROM replicas WHERE updated_at > $1 AND stopped_at IS NULL +` + +func (q *sqlQuerier) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) { + rows, err := q.db.QueryContext(ctx, getReplicasUpdatedAfter, updatedAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Replica + for rows.Next() { + var i Replica + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.StartedAt, + &i.StoppedAt, + &i.UpdatedAt, + &i.Hostname, + &i.RegionID, + &i.RelayAddress, + &i.DatabaseLatency, + &i.Version, + &i.Error, + ); 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 insertReplica = `-- name: InsertReplica :one +INSERT INTO replicas ( + id, + created_at, + started_at, + updated_at, + hostname, + region_id, + relay_address, + version, + database_latency +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at, started_at, stopped_at, updated_at, hostname, region_id, relay_address, database_latency, version, error +` + +type InsertReplicaParams struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + StartedAt time.Time `db:"started_at" json:"started_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Hostname string `db:"hostname" json:"hostname"` + RegionID int32 `db:"region_id" json:"region_id"` + RelayAddress string `db:"relay_address" json:"relay_address"` + Version string `db:"version" json:"version"` + DatabaseLatency int32 `db:"database_latency" json:"database_latency"` +} + +func (q *sqlQuerier) InsertReplica(ctx context.Context, arg InsertReplicaParams) (Replica, error) { + row := q.db.QueryRowContext(ctx, insertReplica, + arg.ID, + arg.CreatedAt, + arg.StartedAt, + arg.UpdatedAt, + arg.Hostname, + arg.RegionID, + arg.RelayAddress, + arg.Version, + arg.DatabaseLatency, + ) + var i Replica + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.StartedAt, + &i.StoppedAt, + &i.UpdatedAt, + &i.Hostname, + &i.RegionID, + &i.RelayAddress, + &i.DatabaseLatency, + &i.Version, + &i.Error, + ) + return i, err +} + +const updateReplica = `-- name: UpdateReplica :one +UPDATE replicas SET + updated_at = $2, + started_at = $3, + stopped_at = $4, + relay_address = $5, + region_id = $6, + hostname = $7, + version = $8, + error = $9, + database_latency = $10 +WHERE id = $1 RETURNING id, created_at, started_at, stopped_at, updated_at, hostname, region_id, relay_address, database_latency, version, error +` + +type UpdateReplicaParams struct { + ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + StartedAt time.Time `db:"started_at" json:"started_at"` + StoppedAt sql.NullTime `db:"stopped_at" json:"stopped_at"` + RelayAddress string `db:"relay_address" json:"relay_address"` + RegionID int32 `db:"region_id" json:"region_id"` + Hostname string `db:"hostname" json:"hostname"` + Version string `db:"version" json:"version"` + Error string `db:"error" json:"error"` + DatabaseLatency int32 `db:"database_latency" json:"database_latency"` +} + +func (q *sqlQuerier) UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error) { + row := q.db.QueryRowContext(ctx, updateReplica, + arg.ID, + arg.UpdatedAt, + arg.StartedAt, + arg.StoppedAt, + arg.RelayAddress, + arg.RegionID, + arg.Hostname, + arg.Version, + arg.Error, + arg.DatabaseLatency, + ) + var i Replica + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.StartedAt, + &i.StoppedAt, + &i.UpdatedAt, + &i.Hostname, + &i.RegionID, + &i.RelayAddress, + &i.DatabaseLatency, + &i.Version, + &i.Error, + ) + return i, err +} + +const getDERPMeshKey = `-- name: GetDERPMeshKey :one +SELECT value FROM site_configs WHERE key = 'derp_mesh_key' +` + +func (q *sqlQuerier) GetDERPMeshKey(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getDERPMeshKey) + var value string + err := row.Scan(&value) + return value, err +} + const getDeploymentID = `-- name: GetDeploymentID :one SELECT value FROM site_configs WHERE key = 'deployment_id' ` @@ -2588,6 +2762,15 @@ func (q *sqlQuerier) GetDeploymentID(ctx context.Context) (string, error) { return value, err } +const insertDERPMeshKey = `-- name: InsertDERPMeshKey :exec +INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1) +` + +func (q *sqlQuerier) InsertDERPMeshKey(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, insertDERPMeshKey, value) + return err +} + const insertDeploymentID = `-- name: InsertDeploymentID :exec INSERT INTO site_configs (key, value) VALUES ('deployment_id', $1) ` diff --git a/coderd/database/queries/replicas.sql b/coderd/database/queries/replicas.sql new file mode 100644 index 0000000000..e87c1f4643 --- /dev/null +++ b/coderd/database/queries/replicas.sql @@ -0,0 +1,31 @@ +-- name: GetReplicasUpdatedAfter :many +SELECT * FROM replicas WHERE updated_at > $1 AND stopped_at IS NULL; + +-- name: InsertReplica :one +INSERT INTO replicas ( + id, + created_at, + started_at, + updated_at, + hostname, + region_id, + relay_address, + version, + database_latency +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; + +-- name: UpdateReplica :one +UPDATE replicas SET + updated_at = $2, + started_at = $3, + stopped_at = $4, + relay_address = $5, + region_id = $6, + hostname = $7, + version = $8, + error = $9, + database_latency = $10 +WHERE id = $1 RETURNING *; + +-- name: DeleteReplicasUpdatedBefore :exec +DELETE FROM replicas WHERE updated_at < $1; diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 9d3936e238..b975d2f68c 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -3,3 +3,9 @@ INSERT INTO site_configs (key, value) VALUES ('deployment_id', $1); -- name: GetDeploymentID :one SELECT value FROM site_configs WHERE key = 'deployment_id'; + +-- name: InsertDERPMeshKey :exec +INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1); + +-- name: GetDERPMeshKey :one +SELECT value FROM site_configs WHERE key = 'derp_mesh_key'; diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 294b013e00..04f050f0c5 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -270,7 +270,7 @@ func (api *API) provisionerJobResources(rw http.ResponseWriter, r *http.Request, } } - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, agent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), agent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading job agent.", diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 5492e4397d..1a8861c984 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -146,6 +146,10 @@ var ( ResourceDeploymentFlags = Object{ Type: "deployment_flags", } + + ResourceReplicas = Object{ + Type: "replicas", + } ) // Object is used to create objects for authz checks when you have none in diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 637ced633c..f6aacba8a5 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -627,7 +627,9 @@ func TestTemplateMetrics(t *testing.T) { require.NoError(t, err) assert.Zero(t, workspaces[0].LastUsedAt) - conn, err := client.DialWorkspaceAgentTailnet(ctx, slogtest.Make(t, nil).Named("tailnet"), resources[0].Agents[0].ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: slogtest.Make(t, nil).Named("tailnet"), + }) require.NoError(t, err) defer func() { _ = conn.Close() diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 295beff0d2..fb7f765cc7 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -49,7 +49,7 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) { }) return } - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading workspace agent.", @@ -78,7 +78,7 @@ func (api *API) workspaceAgentApps(rw http.ResponseWriter, r *http.Request) { func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceAgent := httpmw.WorkspaceAgent(r) - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading workspace agent.", @@ -98,7 +98,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) func (api *API) postWorkspaceAgentVersion(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceAgent := httpmw.WorkspaceAgent(r) - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading workspace agent.", @@ -152,7 +152,7 @@ func (api *API) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { httpapi.ResourceNotFound(rw) return } - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading workspace agent.", @@ -229,7 +229,7 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req return } - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error reading workspace agent.", @@ -376,8 +376,9 @@ func (api *API) dialWorkspaceAgentTailnet(r *http.Request, agentID uuid.UUID) (* }) conn.SetNodeCallback(sendNodes) go func() { - err := api.TailnetCoordinator.ServeClient(serverConn, uuid.New(), agentID) + err := (*api.TailnetCoordinator.Load()).ServeClient(serverConn, uuid.New(), agentID) if err != nil { + api.Logger.Warn(r.Context(), "tailnet coordinator client error", slog.Error(err)) _ = conn.Close() } }() @@ -514,8 +515,9 @@ func (api *API) workspaceAgentCoordinate(rw http.ResponseWriter, r *http.Request closeChan := make(chan struct{}) go func() { defer close(closeChan) - err := api.TailnetCoordinator.ServeAgent(wsNetConn, workspaceAgent.ID) + err := (*api.TailnetCoordinator.Load()).ServeAgent(wsNetConn, workspaceAgent.ID) if err != nil { + api.Logger.Warn(ctx, "tailnet coordinator agent error", slog.Error(err)) _ = conn.Close(websocket.StatusInternalError, err.Error()) return } @@ -583,7 +585,7 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R go httpapi.Heartbeat(ctx, conn) defer conn.Close(websocket.StatusNormalClosure, "") - err = api.TailnetCoordinator.ServeClient(websocket.NetConn(ctx, conn, websocket.MessageBinary), uuid.New(), workspaceAgent.ID) + err = (*api.TailnetCoordinator.Load()).ServeClient(websocket.NetConn(ctx, conn, websocket.MessageBinary), uuid.New(), workspaceAgent.ID) if err != nil { _ = conn.Close(websocket.StatusInternalError, err.Error()) return @@ -611,7 +613,7 @@ func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp { return apps } -func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator *tailnet.Coordinator, dbAgent database.WorkspaceAgent, apps []codersdk.WorkspaceApp, agentInactiveDisconnectTimeout time.Duration) (codersdk.WorkspaceAgent, error) { +func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordinator, dbAgent database.WorkspaceAgent, apps []codersdk.WorkspaceApp, agentInactiveDisconnectTimeout time.Duration) (codersdk.WorkspaceAgent, error) { var envs map[string]string if dbAgent.EnvironmentVariables.Valid { err := json.Unmarshal(dbAgent.EnvironmentVariables.RawMessage, &envs) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 6bd569dde9..e8dd772095 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -123,13 +123,13 @@ func TestWorkspaceAgentListen(t *testing.T) { defer cancel() resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, resources[0].Agents[0].ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil) require.NoError(t, err) defer func() { _ = conn.Close() }() require.Eventually(t, func() bool { - _, err := conn.Ping() + _, err := conn.Ping(ctx) return err == nil }, testutil.WaitLong, testutil.IntervalFast) }) @@ -253,7 +253,9 @@ func TestWorkspaceAgentTailnet(t *testing.T) { ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - conn, err := client.DialWorkspaceAgentTailnet(ctx, slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug), resources[0].Agents[0].ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug), + }) require.NoError(t, err) defer conn.Close() sshClient, err := conn.SSHClient() diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index ed136f372b..dc89f576b5 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -861,7 +861,7 @@ func (api *API) convertWorkspaceBuild( apiAgents := make([]codersdk.WorkspaceAgent, 0) for _, agent := range agents { apps := appsByAgentID[agent.ID] - apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, agent, convertApps(apps), api.AgentInactiveDisconnectTimeout) + apiAgent, err := convertWorkspaceAgent(api.DERPMap, *api.TailnetCoordinator.Load(), agent, convertApps(apps), api.AgentInactiveDisconnectTimeout) if err != nil { return codersdk.WorkspaceBuild{}, xerrors.Errorf("converting workspace agent: %w", err) } diff --git a/coderd/wsconncache/wsconncache_test.go b/coderd/wsconncache/wsconncache_test.go index 003d3cddb8..d4345ce9d5 100644 --- a/coderd/wsconncache/wsconncache_test.go +++ b/coderd/wsconncache/wsconncache_test.go @@ -128,7 +128,9 @@ func TestCache(t *testing.T) { return } defer release() - proxy.Transport = conn.HTTPTransport() + transport := conn.HTTPTransport() + defer transport.CloseIdleConnections() + proxy.Transport = transport res := httptest.NewRecorder() proxy.ServeHTTP(res, req) resp := res.Result() diff --git a/codersdk/agentconn.go b/codersdk/agentconn.go index b11c440ce3..ddfb9541a1 100644 --- a/codersdk/agentconn.go +++ b/codersdk/agentconn.go @@ -132,10 +132,10 @@ type AgentConn struct { CloseFunc func() } -func (c *AgentConn) Ping() (time.Duration, error) { +func (c *AgentConn) Ping(ctx context.Context) (time.Duration, error) { errCh := make(chan error, 1) durCh := make(chan time.Duration, 1) - c.Conn.Ping(TailnetIP, tailcfg.PingDisco, func(pr *ipnstate.PingResult) { + go c.Conn.Ping(TailnetIP, tailcfg.PingDisco, func(pr *ipnstate.PingResult) { if pr.Err != "" { errCh <- xerrors.New(pr.Err) return @@ -145,6 +145,8 @@ func (c *AgentConn) Ping() (time.Duration, error) { select { case err := <-errCh: return 0, err + case <-ctx.Done(): + return 0, ctx.Err() case dur := <-durCh: return dur, nil } diff --git a/codersdk/features.go b/codersdk/features.go index 291b5575a7..862411de62 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -15,12 +15,13 @@ const ( ) const ( - FeatureUserLimit = "user_limit" - FeatureAuditLog = "audit_log" - FeatureBrowserOnly = "browser_only" - FeatureSCIM = "scim" - FeatureWorkspaceQuota = "workspace_quota" - FeatureTemplateRBAC = "template_rbac" + FeatureUserLimit = "user_limit" + FeatureAuditLog = "audit_log" + FeatureBrowserOnly = "browser_only" + FeatureSCIM = "scim" + FeatureWorkspaceQuota = "workspace_quota" + FeatureTemplateRBAC = "template_rbac" + FeatureHighAvailability = "high_availability" ) var FeatureNames = []string{ @@ -30,6 +31,7 @@ var FeatureNames = []string{ FeatureSCIM, FeatureWorkspaceQuota, FeatureTemplateRBAC, + FeatureHighAvailability, } type Feature struct { @@ -42,6 +44,7 @@ type Feature struct { type Entitlements struct { Features map[string]Feature `json:"features"` Warnings []string `json:"warnings"` + Errors []string `json:"errors"` HasLicense bool `json:"has_license"` Experimental bool `json:"experimental"` Trial bool `json:"trial"` diff --git a/codersdk/flags.go b/codersdk/flags.go index 92f02941a5..09ca65b1ea 100644 --- a/codersdk/flags.go +++ b/codersdk/flags.go @@ -19,6 +19,7 @@ type DeploymentFlags struct { DerpServerRegionCode *StringFlag `json:"derp_server_region_code" typescript:",notnull"` DerpServerRegionName *StringFlag `json:"derp_server_region_name" typescript:",notnull"` DerpServerSTUNAddresses *StringArrayFlag `json:"derp_server_stun_address" typescript:",notnull"` + DerpServerRelayAddress *StringFlag `json:"derp_server_relay_address" typescript:",notnull"` DerpConfigURL *StringFlag `json:"derp_config_url" typescript:",notnull"` DerpConfigPath *StringFlag `json:"derp_config_path" typescript:",notnull"` PromEnabled *BoolFlag `json:"prom_enabled" typescript:",notnull"` diff --git a/codersdk/replicas.go b/codersdk/replicas.go new file mode 100644 index 0000000000..e74af021ee --- /dev/null +++ b/codersdk/replicas.go @@ -0,0 +1,44 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +type Replica struct { + // ID is the unique identifier for the replica. + ID uuid.UUID `json:"id"` + // Hostname is the hostname of the replica. + Hostname string `json:"hostname"` + // CreatedAt is when the replica was first seen. + CreatedAt time.Time `json:"created_at"` + // RelayAddress is the accessible address to relay DERP connections. + RelayAddress string `json:"relay_address"` + // RegionID is the region of the replica. + RegionID int32 `json:"region_id"` + // Error is the error. + Error string `json:"error"` + // DatabaseLatency is the latency in microseconds to the database. + DatabaseLatency int32 `json:"database_latency"` +} + +// Replicas fetches the list of replicas. +func (c *Client) Replicas(ctx context.Context) ([]Replica, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/replicas", nil) + if err != nil { + return nil, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + + var replicas []Replica + return replicas, json.NewDecoder(res.Body).Decode(&replicas) +} diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 253e8713fd..c86944ae2b 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -21,7 +21,6 @@ import ( "tailscale.com/tailcfg" "cdr.dev/slog" - "github.com/coder/coder/tailnet" "github.com/coder/retry" ) @@ -316,7 +315,8 @@ func (c *Client) ListenWorkspaceAgentTailnet(ctx context.Context) (net.Conn, err Value: c.SessionToken, }}) httpClient := &http.Client{ - Jar: jar, + Jar: jar, + Transport: c.HTTPClient.Transport, } // nolint:bodyclose conn, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{ @@ -332,7 +332,17 @@ func (c *Client) ListenWorkspaceAgentTailnet(ctx context.Context) (net.Conn, err return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil } -func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logger, agentID uuid.UUID) (*AgentConn, error) { +// @typescript-ignore DialWorkspaceAgentOptions +type DialWorkspaceAgentOptions struct { + Logger slog.Logger + // BlockEndpoints forced a direct connection through DERP. + BlockEndpoints bool +} + +func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, options *DialWorkspaceAgentOptions) (*AgentConn, error) { + if options == nil { + options = &DialWorkspaceAgentOptions{} + } res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/connection", agentID), nil) if err != nil { return nil, err @@ -349,9 +359,10 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg ip := tailnet.IP() conn, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)}, - DERPMap: connInfo.DERPMap, - Logger: logger, + Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)}, + DERPMap: connInfo.DERPMap, + Logger: options.Logger, + BlockEndpoints: options.BlockEndpoints, }) if err != nil { return nil, xerrors.Errorf("create tailnet: %w", err) @@ -370,7 +381,8 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg Value: c.SessionToken, }}) httpClient := &http.Client{ - Jar: jar, + Jar: jar, + Transport: c.HTTPClient.Transport, } ctx, cancelFunc := context.WithCancel(ctx) closed := make(chan struct{}) @@ -379,7 +391,7 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg defer close(closed) isFirst := true for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(ctx); { - logger.Debug(ctx, "connecting") + options.Logger.Debug(ctx, "connecting") // nolint:bodyclose ws, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{ HTTPClient: httpClient, @@ -398,21 +410,21 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg if errors.Is(err, context.Canceled) { return } - logger.Debug(ctx, "failed to dial", slog.Error(err)) + options.Logger.Debug(ctx, "failed to dial", slog.Error(err)) continue } sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error { return conn.UpdateNodes(node) }) conn.SetNodeCallback(sendNode) - logger.Debug(ctx, "serving coordinator") + options.Logger.Debug(ctx, "serving coordinator") err = <-errChan if errors.Is(err, context.Canceled) { _ = ws.Close(websocket.StatusGoingAway, "") return } if err != nil { - logger.Debug(ctx, "error serving coordinator", slog.Error(err)) + options.Logger.Debug(ctx, "error serving coordinator", slog.Error(err)) _ = ws.Close(websocket.StatusGoingAway, "") continue } diff --git a/enterprise/cli/features_test.go b/enterprise/cli/features_test.go index 215809c173..78b94a6509 100644 --- a/enterprise/cli/features_test.go +++ b/enterprise/cli/features_test.go @@ -57,7 +57,7 @@ func TestFeaturesList(t *testing.T) { var entitlements codersdk.Entitlements err := json.Unmarshal(buf.Bytes(), &entitlements) require.NoError(t, err, "unmarshal JSON output") - assert.Len(t, entitlements.Features, 6) + assert.Len(t, entitlements.Features, 7) assert.Empty(t, entitlements.Warnings) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureUserLimit].Entitlement) @@ -71,6 +71,8 @@ func TestFeaturesList(t *testing.T) { entitlements.Features[codersdk.FeatureTemplateRBAC].Entitlement) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureSCIM].Entitlement) + assert.Equal(t, codersdk.EntitlementNotEntitled, + entitlements.Features[codersdk.FeatureHighAvailability].Entitlement) assert.False(t, entitlements.HasLicense) assert.False(t, entitlements.Experimental) }) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 62af6f2888..a65b8e8faa 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -2,11 +2,20 @@ package cli import ( "context" + "database/sql" + "errors" + "io" + "net/url" "github.com/spf13/cobra" + "golang.org/x/xerrors" + "tailscale.com/derp" + "tailscale.com/types/key" "github.com/coder/coder/cli/deployment" + "github.com/coder/coder/cryptorand" "github.com/coder/coder/enterprise/coderd" + "github.com/coder/coder/tailnet" agpl "github.com/coder/coder/cli" agplcoderd "github.com/coder/coder/coderd" @@ -14,23 +23,49 @@ import ( func server() *cobra.Command { dflags := deployment.Flags() - cmd := agpl.Server(dflags, func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) { + cmd := agpl.Server(dflags, func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, io.Closer, error) { + if dflags.DerpServerRelayAddress.Value != "" { + _, err := url.Parse(dflags.DerpServerRelayAddress.Value) + if err != nil { + return nil, nil, xerrors.Errorf("derp-server-relay-address must be a valid HTTP URL: %w", err) + } + } + + options.DERPServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger.Named("derp"))) + meshKey, err := options.Database.GetDERPMeshKey(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, nil, xerrors.Errorf("get mesh key: %w", err) + } + meshKey, err = cryptorand.String(32) + if err != nil { + return nil, nil, xerrors.Errorf("generate mesh key: %w", err) + } + err = options.Database.InsertDERPMeshKey(ctx, meshKey) + if err != nil { + return nil, nil, xerrors.Errorf("insert mesh key: %w", err) + } + } + options.DERPServer.SetMeshKey(meshKey) + o := &coderd.Options{ - AuditLogging: dflags.AuditLogging.Value, - BrowserOnly: dflags.BrowserOnly.Value, - SCIMAPIKey: []byte(dflags.SCIMAuthHeader.Value), - UserWorkspaceQuota: dflags.UserWorkspaceQuota.Value, - RBACEnabled: true, - Options: options, + AuditLogging: dflags.AuditLogging.Value, + BrowserOnly: dflags.BrowserOnly.Value, + SCIMAPIKey: []byte(dflags.SCIMAuthHeader.Value), + UserWorkspaceQuota: dflags.UserWorkspaceQuota.Value, + RBAC: true, + DERPServerRelayAddress: dflags.DerpServerRelayAddress.Value, + DERPServerRegionID: dflags.DerpServerRegionID.Value, + + Options: options, } api, err := coderd.New(ctx, o) if err != nil { - return nil, err + return nil, nil, err } - return api.AGPL, nil + return api.AGPL, api, nil }) deployment.AttachFlags(cmd.Flags(), dflags, true) - return cmd } diff --git a/enterprise/coderd/authorize_test.go b/enterprise/coderd/authorize_test.go index 72cc4c5f38..9195387632 100644 --- a/enterprise/coderd/authorize_test.go +++ b/enterprise/coderd/authorize_test.go @@ -28,7 +28,7 @@ func TestCheckACLPermissions(t *testing.T) { // Create adminClient, member, and org adminClient adminUser := coderdtest.CreateFirstUser(t, adminClient) _ = coderdenttest.AddLicense(t, adminClient, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) memberClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 2c341dd13a..1250e6ae12 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -3,6 +3,8 @@ package coderd import ( "context" "crypto/ed25519" + "crypto/tls" + "crypto/x509" "net/http" "sync" "time" @@ -23,6 +25,10 @@ import ( "github.com/coder/coder/enterprise/audit" "github.com/coder/coder/enterprise/audit/backends" "github.com/coder/coder/enterprise/coderd/license" + "github.com/coder/coder/enterprise/derpmesh" + "github.com/coder/coder/enterprise/replicasync" + "github.com/coder/coder/enterprise/tailnet" + agpltailnet "github.com/coder/coder/tailnet" ) // New constructs an Enterprise coderd API instance. @@ -47,6 +53,7 @@ func New(ctx context.Context, options *Options) (*API, error) { Options: options, cancelEntitlementsLoop: cancelFunc, } + oauthConfigs := &httpmw.OAuth2Configs{ Github: options.GithubOAuth2Config, OIDC: options.OIDCConfig, @@ -59,6 +66,10 @@ func New(ctx context.Context, options *Options) (*API, error) { api.AGPL.APIHandler.Group(func(r chi.Router) { r.Get("/entitlements", api.serveEntitlements) + r.Route("/replicas", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/", api.replicas) + }) r.Route("/licenses", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Post("/", api.postLicense) @@ -117,7 +128,40 @@ func New(ctx context.Context, options *Options) (*API, error) { }) } - err := api.updateEntitlements(ctx) + meshRootCA := x509.NewCertPool() + for _, certificate := range options.TLSCertificates { + for _, certificatePart := range certificate.Certificate { + certificate, err := x509.ParseCertificate(certificatePart) + if err != nil { + return nil, xerrors.Errorf("parse certificate %s: %w", certificate.Subject.CommonName, err) + } + meshRootCA.AddCert(certificate) + } + } + // This TLS configuration spoofs access from the access URL hostname + // assuming that the certificates provided will cover that hostname. + // + // Replica sync and DERP meshing require accessing replicas via their + // internal IP addresses, and if TLS is configured we use the same + // certificates. + meshTLSConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: options.TLSCertificates, + RootCAs: meshRootCA, + ServerName: options.AccessURL.Hostname(), + } + var err error + api.replicaManager, err = replicasync.New(ctx, options.Logger, options.Database, options.Pubsub, &replicasync.Options{ + RelayAddress: options.DERPServerRelayAddress, + RegionID: int32(options.DERPServerRegionID), + TLSConfig: meshTLSConfig, + }) + if err != nil { + return nil, xerrors.Errorf("initialize replica: %w", err) + } + api.derpMesh = derpmesh.New(options.Logger.Named("derpmesh"), api.DERPServer, meshTLSConfig) + + err = api.updateEntitlements(ctx) if err != nil { return nil, xerrors.Errorf("update entitlements: %w", err) } @@ -129,13 +173,17 @@ func New(ctx context.Context, options *Options) (*API, error) { type Options struct { *coderd.Options - RBACEnabled bool + RBAC bool AuditLogging bool // Whether to block non-browser connections. BrowserOnly bool SCIMAPIKey []byte UserWorkspaceQuota int + // Used for high availability. + DERPServerRelayAddress string + DERPServerRegionID int + EntitlementsUpdateInterval time.Duration Keys map[string]ed25519.PublicKey } @@ -144,6 +192,11 @@ type API struct { AGPL *coderd.API *Options + // Detects multiple Coder replicas running at the same time. + replicaManager *replicasync.Manager + // Meshes DERP connections from multiple replicas. + derpMesh *derpmesh.Mesh + cancelEntitlementsLoop func() entitlementsMu sync.RWMutex entitlements codersdk.Entitlements @@ -151,6 +204,8 @@ type API struct { func (api *API) Close() error { api.cancelEntitlementsLoop() + _ = api.replicaManager.Close() + _ = api.derpMesh.Close() return api.AGPL.Close() } @@ -158,12 +213,13 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.entitlementsMu.Lock() defer api.entitlementsMu.Unlock() - entitlements, err := license.Entitlements(ctx, api.Database, api.Logger, api.Keys, map[string]bool{ - codersdk.FeatureAuditLog: api.AuditLogging, - codersdk.FeatureBrowserOnly: api.BrowserOnly, - codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, - codersdk.FeatureWorkspaceQuota: api.UserWorkspaceQuota != 0, - codersdk.FeatureTemplateRBAC: api.RBACEnabled, + entitlements, err := license.Entitlements(ctx, api.Database, api.Logger, len(api.replicaManager.All()), api.Keys, map[string]bool{ + codersdk.FeatureAuditLog: api.AuditLogging, + codersdk.FeatureBrowserOnly: api.BrowserOnly, + codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, + codersdk.FeatureWorkspaceQuota: api.UserWorkspaceQuota != 0, + codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "", + codersdk.FeatureTemplateRBAC: api.RBAC, }) if err != nil { return err @@ -209,6 +265,46 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer) } + if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed { + coordinator := agpltailnet.NewCoordinator() + if enabled { + haCoordinator, err := tailnet.NewCoordinator(api.Logger, api.Pubsub) + if err != nil { + api.Logger.Error(ctx, "unable to set up high availability coordinator", slog.Error(err)) + // If we try to setup the HA coordinator and it fails, nothing + // is actually changing. + changed = false + } else { + coordinator = haCoordinator + } + + api.replicaManager.SetCallback(func() { + addresses := make([]string, 0) + for _, replica := range api.replicaManager.Regional() { + addresses = append(addresses, replica.RelayAddress) + } + api.derpMesh.SetAddresses(addresses, false) + _ = api.updateEntitlements(ctx) + }) + } else { + api.derpMesh.SetAddresses([]string{}, false) + api.replicaManager.SetCallback(func() { + // If the amount of replicas change, so should our entitlements. + // This is to display a warning in the UI if the user is unlicensed. + _ = api.updateEntitlements(ctx) + }) + } + + // Recheck changed in case the HA coordinator failed to set up. + if changed { + oldCoordinator := *api.AGPL.TailnetCoordinator.Swap(&coordinator) + err := oldCoordinator.Close() + if err != nil { + api.Logger.Error(ctx, "close old tailnet coordinator", slog.Error(err)) + } + } + } + api.entitlements = entitlements return nil diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 050cad5f9b..7b51845ff3 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -41,9 +41,9 @@ func TestEntitlements(t *testing.T) { }) _ = coderdtest.CreateFirstUser(t, client) coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - UserLimit: 100, - AuditLog: true, - TemplateRBACEnabled: true, + UserLimit: 100, + AuditLog: true, + TemplateRBAC: true, }) res, err := client.Entitlements(context.Background()) require.NoError(t, err) @@ -85,7 +85,7 @@ func TestEntitlements(t *testing.T) { assert.False(t, res.HasLicense) al = res.Features[codersdk.FeatureAuditLog] assert.Equal(t, codersdk.EntitlementNotEntitled, al.Entitlement) - assert.True(t, al.Enabled) + assert.False(t, al.Enabled) }) t.Run("Pubsub", func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 75760b3d4f..a8595b5bc6 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -4,7 +4,9 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "crypto/tls" "io" + "net/http" "testing" "time" @@ -60,19 +62,21 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c if options.Options == nil { options.Options = &coderdtest.Options{} } - srv, cancelFunc, oop := coderdtest.NewOptions(t, options.Options) + setHandler, cancelFunc, oop := coderdtest.NewOptions(t, options.Options) coderAPI, err := coderd.New(context.Background(), &coderd.Options{ - RBACEnabled: true, + RBAC: true, AuditLogging: options.AuditLogging, BrowserOnly: options.BrowserOnly, SCIMAPIKey: options.SCIMAPIKey, + DERPServerRelayAddress: oop.AccessURL.String(), + DERPServerRegionID: oop.DERPMap.RegionIDs()[0], UserWorkspaceQuota: options.UserWorkspaceQuota, Options: oop, EntitlementsUpdateInterval: options.EntitlementsUpdateInterval, Keys: Keys, }) assert.NoError(t, err) - srv.Config.Handler = coderAPI.AGPL.RootHandler + setHandler(coderAPI.AGPL.RootHandler) var provisionerCloser io.Closer = nopcloser{} if options.IncludeProvisionerDaemon { provisionerCloser = coderdtest.NewProvisionerDaemon(t, coderAPI.AGPL) @@ -83,22 +87,32 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c _ = provisionerCloser.Close() _ = coderAPI.Close() }) - return codersdk.New(coderAPI.AccessURL), provisionerCloser, coderAPI + client := codersdk.New(coderAPI.AccessURL) + client.HTTPClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + //nolint:gosec + InsecureSkipVerify: true, + }, + }, + } + return client, provisionerCloser, coderAPI } type LicenseOptions struct { - AccountType string - AccountID string - Trial bool - AllFeatures bool - GraceAt time.Time - ExpiresAt time.Time - UserLimit int64 - AuditLog bool - BrowserOnly bool - SCIM bool - WorkspaceQuota bool - TemplateRBACEnabled bool + AccountType string + AccountID string + Trial bool + AllFeatures bool + GraceAt time.Time + ExpiresAt time.Time + UserLimit int64 + AuditLog bool + BrowserOnly bool + SCIM bool + WorkspaceQuota bool + TemplateRBAC bool + HighAvailability bool } // AddLicense generates a new license with the options provided and inserts it. @@ -134,9 +148,13 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { if options.WorkspaceQuota { workspaceQuota = 1 } + highAvailability := int64(0) + if options.HighAvailability { + highAvailability = 1 + } rbacEnabled := int64(0) - if options.TemplateRBACEnabled { + if options.TemplateRBAC { rbacEnabled = 1 } @@ -154,12 +172,13 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { Version: license.CurrentVersion, AllFeatures: options.AllFeatures, Features: license.Features{ - UserLimit: options.UserLimit, - AuditLog: auditLog, - BrowserOnly: browserOnly, - SCIM: scim, - WorkspaceQuota: workspaceQuota, - TemplateRBAC: rbacEnabled, + UserLimit: options.UserLimit, + AuditLog: auditLog, + BrowserOnly: browserOnly, + SCIM: scim, + WorkspaceQuota: workspaceQuota, + HighAvailability: highAvailability, + TemplateRBAC: rbacEnabled, }, } tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) diff --git a/enterprise/coderd/coderdenttest/coderdenttest_test.go b/enterprise/coderd/coderdenttest/coderdenttest_test.go index d526f6927b..e8ad88cd02 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest_test.go +++ b/enterprise/coderd/coderdenttest/coderdenttest_test.go @@ -33,7 +33,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { ctx, _ := testutil.Context(t) admin := coderdtest.CreateFirstUser(t, client) license := coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ Name: "testgroup", @@ -58,6 +58,10 @@ func TestAuthorizeAllEndpoints(t *testing.T) { AssertAction: rbac.ActionRead, AssertObject: rbac.ResourceLicense, } + assertRoute["GET:/api/v2/replicas"] = coderdtest.RouteCheck{ + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceReplicas, + } assertRoute["DELETE:/api/v2/licenses/{id}"] = coderdtest.RouteCheck{ AssertAction: rbac.ActionDelete, AssertObject: rbac.ResourceLicense, diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 2661da6bcc..eae51b0dfd 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -24,7 +24,7 @@ func TestCreateGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -43,7 +43,7 @@ func TestCreateGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) _, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -67,7 +67,7 @@ func TestCreateGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) _, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -90,7 +90,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -112,7 +112,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -138,7 +138,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -173,7 +173,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -197,7 +197,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -221,7 +221,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) ctx, _ := testutil.Context(t) @@ -247,7 +247,7 @@ func TestPatchGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -276,7 +276,7 @@ func TestGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -296,7 +296,7 @@ func TestGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -326,7 +326,7 @@ func TestGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -347,7 +347,7 @@ func TestGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -380,7 +380,7 @@ func TestGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -421,7 +421,7 @@ func TestGroups(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -467,7 +467,7 @@ func TestDeleteGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -492,7 +492,7 @@ func TestDeleteGroup(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) ctx, _ := testutil.Context(t) err := client.DeleteGroup(ctx, user.OrganizationID) diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index ce9e5d1d59..c5bb689db6 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -17,12 +17,20 @@ import ( ) // Entitlements processes licenses to return whether features are enabled or not. -func Entitlements(ctx context.Context, db database.Store, logger slog.Logger, keys map[string]ed25519.PublicKey, enablements map[string]bool) (codersdk.Entitlements, error) { +func Entitlements( + ctx context.Context, + db database.Store, + logger slog.Logger, + replicaCount int, + keys map[string]ed25519.PublicKey, + enablements map[string]bool, +) (codersdk.Entitlements, error) { now := time.Now() // Default all entitlements to be disabled. entitlements := codersdk.Entitlements{ Features: map[string]codersdk.Feature{}, Warnings: []string{}, + Errors: []string{}, } for _, featureName := range codersdk.FeatureNames { entitlements.Features[featureName] = codersdk.Feature{ @@ -96,6 +104,12 @@ func Entitlements(ctx context.Context, db database.Store, logger slog.Logger, ke Enabled: enablements[codersdk.FeatureWorkspaceQuota], } } + if claims.Features.HighAvailability > 0 { + entitlements.Features[codersdk.FeatureHighAvailability] = codersdk.Feature{ + Entitlement: entitlement, + Enabled: enablements[codersdk.FeatureHighAvailability], + } + } if claims.Features.TemplateRBAC > 0 { entitlements.Features[codersdk.FeatureTemplateRBAC] = codersdk.Feature{ Entitlement: entitlement, @@ -132,6 +146,10 @@ func Entitlements(ctx context.Context, db database.Store, logger slog.Logger, ke if featureName == codersdk.FeatureUserLimit { continue } + // High availability has it's own warnings based on replica count! + if featureName == codersdk.FeatureHighAvailability { + continue + } feature := entitlements.Features[featureName] if !feature.Enabled { continue @@ -141,9 +159,6 @@ func Entitlements(ctx context.Context, db database.Store, logger slog.Logger, ke case codersdk.EntitlementNotEntitled: entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf("%s is enabled but your license is not entitled to this feature.", niceName)) - // Disable the feature and add a warning... - feature.Enabled = false - entitlements.Features[featureName] = feature case codersdk.EntitlementGracePeriod: entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf("%s is enabled but your license for this feature is expired.", niceName)) @@ -152,6 +167,32 @@ func Entitlements(ctx context.Context, db database.Store, logger slog.Logger, ke } } + if replicaCount > 1 { + feature := entitlements.Features[codersdk.FeatureHighAvailability] + + switch feature.Entitlement { + case codersdk.EntitlementNotEntitled: + if entitlements.HasLicense { + entitlements.Errors = append(entitlements.Warnings, + "You have multiple replicas but your license is not entitled to high availability. You will be unable to connect to workspaces.") + } else { + entitlements.Errors = append(entitlements.Warnings, + "You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.") + } + case codersdk.EntitlementGracePeriod: + entitlements.Warnings = append(entitlements.Warnings, + "You have multiple replicas but your license for high availability is expired. Reduce to one replica or workspace connections will stop working.") + } + } + + for _, featureName := range codersdk.FeatureNames { + feature := entitlements.Features[featureName] + if feature.Entitlement == codersdk.EntitlementNotEntitled { + feature.Enabled = false + entitlements.Features[featureName] = feature + } + } + return entitlements, nil } @@ -171,12 +212,13 @@ var ( ) type Features struct { - UserLimit int64 `json:"user_limit"` - AuditLog int64 `json:"audit_log"` - BrowserOnly int64 `json:"browser_only"` - SCIM int64 `json:"scim"` - WorkspaceQuota int64 `json:"workspace_quota"` - TemplateRBAC int64 `json:"template_rbac"` + UserLimit int64 `json:"user_limit"` + AuditLog int64 `json:"audit_log"` + BrowserOnly int64 `json:"browser_only"` + SCIM int64 `json:"scim"` + WorkspaceQuota int64 `json:"workspace_quota"` + TemplateRBAC int64 `json:"template_rbac"` + HighAvailability int64 `json:"high_availability"` } type Claims struct { diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 8f15c5c009..6def291e3e 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -20,17 +20,18 @@ import ( func TestEntitlements(t *testing.T) { t.Parallel() all := map[string]bool{ - codersdk.FeatureAuditLog: true, - codersdk.FeatureBrowserOnly: true, - codersdk.FeatureSCIM: true, - codersdk.FeatureWorkspaceQuota: true, - codersdk.FeatureTemplateRBAC: true, + codersdk.FeatureAuditLog: true, + codersdk.FeatureBrowserOnly: true, + codersdk.FeatureSCIM: true, + codersdk.FeatureWorkspaceQuota: true, + codersdk.FeatureHighAvailability: true, + codersdk.FeatureTemplateRBAC: true, } t.Run("Defaults", func(t *testing.T) { t.Parallel() db := databasefake.New() - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) require.NoError(t, err) require.False(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -46,7 +47,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -60,16 +61,17 @@ func TestEntitlements(t *testing.T) { db := databasefake.New() db.InsertLicense(context.Background(), database.InsertLicenseParams{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ - UserLimit: 100, - AuditLog: true, - BrowserOnly: true, - SCIM: true, - WorkspaceQuota: true, - TemplateRBACEnabled: true, + UserLimit: 100, + AuditLog: true, + BrowserOnly: true, + SCIM: true, + WorkspaceQuota: true, + HighAvailability: true, + TemplateRBAC: true, }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -82,18 +84,19 @@ func TestEntitlements(t *testing.T) { db := databasefake.New() db.InsertLicense(context.Background(), database.InsertLicenseParams{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ - UserLimit: 100, - AuditLog: true, - BrowserOnly: true, - SCIM: true, - WorkspaceQuota: true, - TemplateRBACEnabled: true, - GraceAt: time.Now().Add(-time.Hour), - ExpiresAt: time.Now().Add(time.Hour), + UserLimit: 100, + AuditLog: true, + BrowserOnly: true, + SCIM: true, + WorkspaceQuota: true, + HighAvailability: true, + TemplateRBAC: true, + GraceAt: time.Now().Add(-time.Hour), + ExpiresAt: time.Now().Add(time.Hour), }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -101,6 +104,9 @@ func TestEntitlements(t *testing.T) { if featureName == codersdk.FeatureUserLimit { continue } + if featureName == codersdk.FeatureHighAvailability { + continue + } niceName := strings.Title(strings.ReplaceAll(featureName, "_", " ")) require.Equal(t, codersdk.EntitlementGracePeriod, entitlements.Features[featureName].Entitlement) require.Contains(t, entitlements.Warnings, fmt.Sprintf("%s is enabled but your license for this feature is expired.", niceName)) @@ -113,7 +119,7 @@ func TestEntitlements(t *testing.T) { JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{}), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -121,6 +127,9 @@ func TestEntitlements(t *testing.T) { if featureName == codersdk.FeatureUserLimit { continue } + if featureName == codersdk.FeatureHighAvailability { + continue + } niceName := strings.Title(strings.ReplaceAll(featureName, "_", " ")) // Ensures features that are not entitled are properly disabled. require.False(t, entitlements.Features[featureName].Enabled) @@ -139,7 +148,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Contains(t, entitlements.Warnings, "Your deployment has 2 active users but is only licensed for 1.") @@ -161,7 +170,7 @@ func TestEntitlements(t *testing.T) { }), Exp: time.Now().Add(time.Hour), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.Empty(t, entitlements.Warnings) @@ -184,7 +193,7 @@ func TestEntitlements(t *testing.T) { }), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, map[string]bool{}) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, map[string]bool{}) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -199,7 +208,7 @@ func TestEntitlements(t *testing.T) { AllFeatures: true, }), }) - entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, coderdenttest.Keys, all) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, coderdenttest.Keys, all) require.NoError(t, err) require.True(t, entitlements.HasLicense) require.False(t, entitlements.Trial) @@ -211,4 +220,52 @@ func TestEntitlements(t *testing.T) { require.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[featureName].Entitlement) } }) + + t.Run("MultipleReplicasNoLicense", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, coderdenttest.Keys, all) + require.NoError(t, err) + require.False(t, entitlements.HasLicense) + require.Len(t, entitlements.Errors, 1) + require.Equal(t, "You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.", entitlements.Errors[0]) + }) + + t.Run("MultipleReplicasNotEntitled", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + db.InsertLicense(context.Background(), database.InsertLicenseParams{ + Exp: time.Now().Add(time.Hour), + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + AuditLog: true, + }), + }) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, coderdenttest.Keys, map[string]bool{ + codersdk.FeatureHighAvailability: true, + }) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.Len(t, entitlements.Errors, 1) + require.Equal(t, "You have multiple replicas but your license is not entitled to high availability. You will be unable to connect to workspaces.", entitlements.Errors[0]) + }) + + t.Run("MultipleReplicasGrace", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + db.InsertLicense(context.Background(), database.InsertLicenseParams{ + JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ + HighAvailability: true, + GraceAt: time.Now().Add(-time.Hour), + ExpiresAt: time.Now().Add(time.Hour), + }), + Exp: time.Now().Add(time.Hour), + }) + entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 2, coderdenttest.Keys, map[string]bool{ + codersdk.FeatureHighAvailability: true, + }) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + require.Len(t, entitlements.Warnings, 1) + require.Equal(t, "You have multiple replicas but your license for high availability is expired. Reduce to one replica or workspace connections will stop working.", entitlements.Warnings[0]) + }) } diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index f7c1c63999..aa4dddf1fd 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -78,21 +78,21 @@ func TestGetLicense(t *testing.T) { defer cancel() coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - AccountID: "testing", - AuditLog: true, - SCIM: true, - BrowserOnly: true, - TemplateRBACEnabled: true, + AccountID: "testing", + AuditLog: true, + SCIM: true, + BrowserOnly: true, + TemplateRBAC: true, }) coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - AccountID: "testing2", - AuditLog: true, - SCIM: true, - BrowserOnly: true, - Trial: true, - UserLimit: 200, - TemplateRBACEnabled: false, + AccountID: "testing2", + AuditLog: true, + SCIM: true, + BrowserOnly: true, + Trial: true, + UserLimit: 200, + TemplateRBAC: false, }) licenses, err := client.Licenses(ctx) @@ -101,23 +101,25 @@ func TestGetLicense(t *testing.T) { assert.Equal(t, int32(1), licenses[0].ID) assert.Equal(t, "testing", licenses[0].Claims["account_id"]) assert.Equal(t, map[string]interface{}{ - codersdk.FeatureUserLimit: json.Number("0"), - codersdk.FeatureAuditLog: json.Number("1"), - codersdk.FeatureSCIM: json.Number("1"), - codersdk.FeatureBrowserOnly: json.Number("1"), - codersdk.FeatureWorkspaceQuota: json.Number("0"), - codersdk.FeatureTemplateRBAC: json.Number("1"), + codersdk.FeatureUserLimit: json.Number("0"), + codersdk.FeatureAuditLog: json.Number("1"), + codersdk.FeatureSCIM: json.Number("1"), + codersdk.FeatureBrowserOnly: json.Number("1"), + codersdk.FeatureWorkspaceQuota: json.Number("0"), + codersdk.FeatureHighAvailability: json.Number("0"), + codersdk.FeatureTemplateRBAC: json.Number("1"), }, licenses[0].Claims["features"]) assert.Equal(t, int32(2), licenses[1].ID) assert.Equal(t, "testing2", licenses[1].Claims["account_id"]) assert.Equal(t, true, licenses[1].Claims["trial"]) assert.Equal(t, map[string]interface{}{ - codersdk.FeatureUserLimit: json.Number("200"), - codersdk.FeatureAuditLog: json.Number("1"), - codersdk.FeatureSCIM: json.Number("1"), - codersdk.FeatureBrowserOnly: json.Number("1"), - codersdk.FeatureWorkspaceQuota: json.Number("0"), - codersdk.FeatureTemplateRBAC: json.Number("0"), + codersdk.FeatureUserLimit: json.Number("200"), + codersdk.FeatureAuditLog: json.Number("1"), + codersdk.FeatureSCIM: json.Number("1"), + codersdk.FeatureBrowserOnly: json.Number("1"), + codersdk.FeatureWorkspaceQuota: json.Number("0"), + codersdk.FeatureHighAvailability: json.Number("0"), + codersdk.FeatureTemplateRBAC: json.Number("0"), }, licenses[1].Claims["features"]) }) } diff --git a/enterprise/coderd/replicas.go b/enterprise/coderd/replicas.go new file mode 100644 index 0000000000..906597f257 --- /dev/null +++ b/enterprise/coderd/replicas.go @@ -0,0 +1,37 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" +) + +// replicas returns the number of replicas that are active in Coder. +func (api *API) replicas(rw http.ResponseWriter, r *http.Request) { + if !api.AGPL.Authorize(r, rbac.ActionRead, rbac.ResourceReplicas) { + httpapi.ResourceNotFound(rw) + return + } + + replicas := api.replicaManager.All() + res := make([]codersdk.Replica, 0, len(replicas)) + for _, replica := range replicas { + res = append(res, convertReplica(replica)) + } + httpapi.Write(r.Context(), rw, http.StatusOK, res) +} + +func convertReplica(replica database.Replica) codersdk.Replica { + return codersdk.Replica{ + ID: replica.ID, + Hostname: replica.Hostname, + CreatedAt: replica.CreatedAt, + RelayAddress: replica.RelayAddress, + RegionID: replica.RegionID, + Error: replica.Error, + DatabaseLatency: replica.DatabaseLatency, + } +} diff --git a/enterprise/coderd/replicas_test.go b/enterprise/coderd/replicas_test.go new file mode 100644 index 0000000000..7a3e130cf7 --- /dev/null +++ b/enterprise/coderd/replicas_test.go @@ -0,0 +1,138 @@ +package coderd_test + +import ( + "context" + "crypto/tls" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/testutil" +) + +func TestReplicas(t *testing.T) { + t.Parallel() + t.Run("ErrorWithoutLicense", func(t *testing.T) { + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + firstClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + Database: db, + Pubsub: pubsub, + }, + }) + _ = coderdtest.CreateFirstUser(t, firstClient) + secondClient, _, secondAPI := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }, + }) + secondClient.SessionToken = firstClient.SessionToken + ents, err := secondClient.Entitlements(context.Background()) + require.NoError(t, err) + require.Len(t, ents.Errors, 1) + _ = secondAPI.Close() + + ents, err = firstClient.Entitlements(context.Background()) + require.NoError(t, err) + require.Len(t, ents.Warnings, 0) + }) + t.Run("ConnectAcrossMultiple", func(t *testing.T) { + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + firstClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + Database: db, + Pubsub: pubsub, + }, + }) + firstUser := coderdtest.CreateFirstUser(t, firstClient) + coderdenttest.AddLicense(t, firstClient, coderdenttest.LicenseOptions{ + HighAvailability: true, + }) + + secondClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }, + }) + secondClient.SessionToken = firstClient.SessionToken + replicas, err := secondClient.Replicas(context.Background()) + require.NoError(t, err) + require.Len(t, replicas, 2) + + _, agent := setupWorkspaceAgent(t, firstClient, firstUser, 0) + conn, err := secondClient.DialWorkspaceAgent(context.Background(), agent.ID, &codersdk.DialWorkspaceAgentOptions{ + BlockEndpoints: true, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) + require.NoError(t, err) + require.Eventually(t, func() bool { + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancelFunc() + _, err = conn.Ping(ctx) + return err == nil + }, testutil.WaitLong, testutil.IntervalFast) + _ = conn.Close() + }) + t.Run("ConnectAcrossMultipleTLS", func(t *testing.T) { + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + certificates := []tls.Certificate{testutil.GenerateTLSCertificate(t, "localhost")} + firstClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + Database: db, + Pubsub: pubsub, + TLSCertificates: certificates, + }, + }) + firstUser := coderdtest.CreateFirstUser(t, firstClient) + coderdenttest.AddLicense(t, firstClient, coderdenttest.LicenseOptions{ + HighAvailability: true, + }) + + secondClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + TLSCertificates: certificates, + }, + }) + secondClient.SessionToken = firstClient.SessionToken + replicas, err := secondClient.Replicas(context.Background()) + require.NoError(t, err) + require.Len(t, replicas, 2) + + _, agent := setupWorkspaceAgent(t, firstClient, firstUser, 0) + conn, err := secondClient.DialWorkspaceAgent(context.Background(), agent.ID, &codersdk.DialWorkspaceAgentOptions{ + BlockEndpoints: true, + Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug), + }) + require.NoError(t, err) + require.Eventually(t, func() bool { + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.IntervalSlow) + defer cancelFunc() + _, err = conn.Ping(ctx) + return err == nil + }, testutil.WaitLong, testutil.IntervalFast) + _ = conn.Close() + replicas, err = secondClient.Replicas(context.Background()) + require.NoError(t, err) + require.Len(t, replicas, 2) + for _, replica := range replicas { + require.Empty(t, replica.Error) + } + }) +} diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index fe6dd6f687..87aa5a4ca8 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -23,7 +23,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -64,7 +64,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -88,7 +88,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -138,7 +138,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -176,7 +176,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -214,7 +214,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -262,7 +262,7 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client1, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -318,7 +318,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -361,7 +361,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -422,7 +422,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -447,7 +447,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -472,7 +472,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -498,7 +498,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client2, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -533,7 +533,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client2, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -575,7 +575,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -597,7 +597,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client1, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -662,7 +662,7 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index 9fe3cfeaa3..18285bcb94 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "context" + "crypto/tls" "fmt" "net/http" "testing" @@ -9,7 +10,6 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" @@ -42,7 +42,7 @@ func TestBlockNonBrowser(t *testing.T) { BrowserOnly: true, }) _, agent := setupWorkspaceAgent(t, client, user, 0) - _, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, agent.ID) + _, err := client.DialWorkspaceAgent(context.Background(), agent.ID, nil) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusConflict, apiErr.StatusCode()) @@ -59,7 +59,7 @@ func TestBlockNonBrowser(t *testing.T) { BrowserOnly: false, }) _, agent := setupWorkspaceAgent(t, client, user, 0) - conn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, agent.ID) + conn, err := client.DialWorkspaceAgent(context.Background(), agent.ID, nil) require.NoError(t, err) _ = conn.Close() }) @@ -109,6 +109,14 @@ func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.Cr workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) agentClient := codersdk.New(client.URL) + agentClient.HTTPClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + //nolint:gosec + InsecureSkipVerify: true, + }, + }, + } agentClient.SessionToken = authToken agentCloser := agent.New(agent.Options{ FetchMetadata: agentClient.WorkspaceAgentMetadata, diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 33984e970d..824b3febb1 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -26,7 +26,7 @@ func TestCreateWorkspace(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - TemplateRBACEnabled: true, + TemplateRBAC: true, }) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) diff --git a/enterprise/derpmesh/derpmesh.go b/enterprise/derpmesh/derpmesh.go new file mode 100644 index 0000000000..3982542167 --- /dev/null +++ b/enterprise/derpmesh/derpmesh.go @@ -0,0 +1,165 @@ +package derpmesh + +import ( + "context" + "crypto/tls" + "net" + "net/url" + "sync" + + "golang.org/x/xerrors" + "tailscale.com/derp" + "tailscale.com/derp/derphttp" + "tailscale.com/types/key" + + "github.com/coder/coder/tailnet" + + "cdr.dev/slog" +) + +// New constructs a new mesh for DERP servers. +func New(logger slog.Logger, server *derp.Server, tlsConfig *tls.Config) *Mesh { + return &Mesh{ + logger: logger, + server: server, + tlsConfig: tlsConfig, + ctx: context.Background(), + closed: make(chan struct{}), + active: make(map[string]context.CancelFunc), + } +} + +type Mesh struct { + logger slog.Logger + server *derp.Server + ctx context.Context + tlsConfig *tls.Config + + mutex sync.Mutex + closed chan struct{} + active map[string]context.CancelFunc +} + +// SetAddresses performs a diff of the incoming addresses and adds +// or removes DERP clients from the mesh. +// +// Connect is only used for testing to ensure DERPs are meshed before +// exchanging messages. +// nolint:revive +func (m *Mesh) SetAddresses(addresses []string, connect bool) { + total := make(map[string]struct{}, 0) + for _, address := range addresses { + addressURL, err := url.Parse(address) + if err != nil { + m.logger.Error(m.ctx, "invalid address", slog.F("address", err), slog.Error(err)) + continue + } + derpURL, err := addressURL.Parse("/derp") + if err != nil { + m.logger.Error(m.ctx, "parse derp", slog.F("address", err), slog.Error(err)) + continue + } + address = derpURL.String() + + total[address] = struct{}{} + added, err := m.addAddress(address, connect) + if err != nil { + m.logger.Error(m.ctx, "failed to add address", slog.F("address", address), slog.Error(err)) + continue + } + if added { + m.logger.Debug(m.ctx, "added mesh address", slog.F("address", address)) + } + } + + m.mutex.Lock() + for address := range m.active { + _, found := total[address] + if found { + continue + } + removed := m.removeAddress(address) + if removed { + m.logger.Debug(m.ctx, "removed mesh address", slog.F("address", address)) + } + } + m.mutex.Unlock() +} + +// addAddress begins meshing with a new address. It returns false if the address is already being meshed with. +// It's expected that this is a full HTTP address with a path. +// e.g. http://127.0.0.1:8080/derp +// nolint:revive +func (m *Mesh) addAddress(address string, connect bool) (bool, error) { + m.mutex.Lock() + defer m.mutex.Unlock() + if m.isClosed() { + return false, nil + } + _, isActive := m.active[address] + if isActive { + return false, nil + } + client, err := derphttp.NewClient(m.server.PrivateKey(), address, tailnet.Logger(m.logger.Named("client"))) + if err != nil { + return false, xerrors.Errorf("create derp client: %w", err) + } + client.TLSConfig = m.tlsConfig + client.MeshKey = m.server.MeshKey() + client.SetURLDialer(func(ctx context.Context, network, addr string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, network, addr) + }) + if connect { + _ = client.Connect(m.ctx) + } + ctx, cancelFunc := context.WithCancel(m.ctx) + closed := make(chan struct{}) + closeFunc := func() { + cancelFunc() + _ = client.Close() + <-closed + } + m.active[address] = closeFunc + go func() { + defer close(closed) + client.RunWatchConnectionLoop(ctx, m.server.PublicKey(), tailnet.Logger(m.logger.Named("loop")), func(np key.NodePublic) { + m.server.AddPacketForwarder(np, client) + }, func(np key.NodePublic) { + m.server.RemovePacketForwarder(np, client) + }) + }() + return true, nil +} + +// removeAddress stops meshing with a given address. +func (m *Mesh) removeAddress(address string) bool { + cancelFunc, isActive := m.active[address] + if isActive { + cancelFunc() + } + return isActive +} + +// Close ends all active meshes with the DERP server. +func (m *Mesh) Close() error { + m.mutex.Lock() + defer m.mutex.Unlock() + if m.isClosed() { + return nil + } + close(m.closed) + for _, cancelFunc := range m.active { + cancelFunc() + } + return nil +} + +func (m *Mesh) isClosed() bool { + select { + case <-m.closed: + return true + default: + } + return false +} diff --git a/enterprise/derpmesh/derpmesh_test.go b/enterprise/derpmesh/derpmesh_test.go new file mode 100644 index 0000000000..7fad141238 --- /dev/null +++ b/enterprise/derpmesh/derpmesh_test.go @@ -0,0 +1,219 @@ +package derpmesh_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + "tailscale.com/derp" + "tailscale.com/derp/derphttp" + "tailscale.com/types/key" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/enterprise/derpmesh" + "github.com/coder/coder/tailnet" + "github.com/coder/coder/testutil" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestDERPMesh(t *testing.T) { + t.Parallel() + commonName := "something.org" + rawCert := testutil.GenerateTLSCertificate(t, commonName) + certificate, err := x509.ParseCertificate(rawCert.Certificate[0]) + require.NoError(t, err) + pool := x509.NewCertPool() + pool.AddCert(certificate) + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: commonName, + RootCAs: pool, + Certificates: []tls.Certificate{rawCert}, + } + + t.Run("ExchangeMessages", func(t *testing.T) { + // This tests messages passing through multiple DERP servers. + t.Parallel() + firstServer, firstServerURL := startDERP(t, tlsConfig) + defer firstServer.Close() + secondServer, secondServerURL := startDERP(t, tlsConfig) + firstMesh := derpmesh.New(slogtest.Make(t, nil).Named("first").Leveled(slog.LevelDebug), firstServer, tlsConfig) + firstMesh.SetAddresses([]string{secondServerURL}, true) + secondMesh := derpmesh.New(slogtest.Make(t, nil).Named("second").Leveled(slog.LevelDebug), secondServer, tlsConfig) + secondMesh.SetAddresses([]string{firstServerURL}, true) + defer firstMesh.Close() + defer secondMesh.Close() + + first := key.NewNode() + second := key.NewNode() + firstClient, err := derphttp.NewClient(first, secondServerURL, tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + firstClient.TLSConfig = tlsConfig + secondClient, err := derphttp.NewClient(second, firstServerURL, tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + secondClient.TLSConfig = tlsConfig + err = secondClient.Connect(context.Background()) + require.NoError(t, err) + + closed := make(chan struct{}) + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + sent := []byte("hello world") + go func() { + defer close(closed) + ticker := time.NewTicker(50 * time.Millisecond) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + err = firstClient.Send(second.Public(), sent) + require.NoError(t, err) + } + }() + + got := recvData(t, secondClient) + require.Equal(t, sent, got) + cancelFunc() + <-closed + }) + t.Run("RemoveAddress", func(t *testing.T) { + // This tests messages passing through multiple DERP servers. + t.Parallel() + server, serverURL := startDERP(t, tlsConfig) + mesh := derpmesh.New(slogtest.Make(t, nil).Named("first").Leveled(slog.LevelDebug), server, tlsConfig) + mesh.SetAddresses([]string{"http://fake.com"}, false) + // This should trigger a removal... + mesh.SetAddresses([]string{}, false) + defer mesh.Close() + + first := key.NewNode() + second := key.NewNode() + firstClient, err := derphttp.NewClient(first, serverURL, tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + firstClient.TLSConfig = tlsConfig + secondClient, err := derphttp.NewClient(second, serverURL, tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + secondClient.TLSConfig = tlsConfig + err = secondClient.Connect(context.Background()) + require.NoError(t, err) + + closed := make(chan struct{}) + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + sent := []byte("hello world") + go func() { + defer close(closed) + ticker := time.NewTicker(50 * time.Millisecond) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + err = firstClient.Send(second.Public(), sent) + require.NoError(t, err) + } + }() + got := recvData(t, secondClient) + require.Equal(t, sent, got) + cancelFunc() + <-closed + }) + t.Run("TwentyMeshes", func(t *testing.T) { + t.Parallel() + meshes := make([]*derpmesh.Mesh, 0, 20) + serverURLs := make([]string, 0, 20) + for i := 0; i < 20; i++ { + server, url := startDERP(t, tlsConfig) + mesh := derpmesh.New(slogtest.Make(t, nil).Named("mesh").Leveled(slog.LevelDebug), server, tlsConfig) + t.Cleanup(func() { + _ = server.Close() + _ = mesh.Close() + }) + serverURLs = append(serverURLs, url) + meshes = append(meshes, mesh) + } + for _, mesh := range meshes { + mesh.SetAddresses(serverURLs, true) + } + + first := key.NewNode() + second := key.NewNode() + firstClient, err := derphttp.NewClient(first, serverURLs[9], tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + firstClient.TLSConfig = tlsConfig + secondClient, err := derphttp.NewClient(second, serverURLs[16], tailnet.Logger(slogtest.Make(t, nil))) + require.NoError(t, err) + secondClient.TLSConfig = tlsConfig + err = secondClient.Connect(context.Background()) + require.NoError(t, err) + + closed := make(chan struct{}) + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + sent := []byte("hello world") + go func() { + defer close(closed) + ticker := time.NewTicker(50 * time.Millisecond) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + err = firstClient.Send(second.Public(), sent) + require.NoError(t, err) + } + }() + + got := recvData(t, secondClient) + require.Equal(t, sent, got) + cancelFunc() + <-closed + }) +} + +func recvData(t *testing.T, client *derphttp.Client) []byte { + for { + msg, err := client.Recv() + if errors.Is(err, io.EOF) { + return nil + } + assert.NoError(t, err) + t.Logf("derp: %T", msg) + switch msg := msg.(type) { + case derp.ReceivedPacket: + return msg.Data + default: + // Drop all others! + } + } +} + +func startDERP(t *testing.T, tlsConfig *tls.Config) (*derp.Server, string) { + logf := tailnet.Logger(slogtest.Make(t, nil)) + d := derp.NewServer(key.NewNode(), logf) + d.SetMeshKey("some-key") + server := httptest.NewUnstartedServer(derphttp.Handler(d)) + server.TLS = tlsConfig + server.StartTLS() + t.Cleanup(func() { + _ = d.Close() + }) + t.Cleanup(server.Close) + return d, server.URL +} diff --git a/enterprise/replicasync/replicasync.go b/enterprise/replicasync/replicasync.go new file mode 100644 index 0000000000..0534c55246 --- /dev/null +++ b/enterprise/replicasync/replicasync.go @@ -0,0 +1,391 @@ +package replicasync + +import ( + "context" + "crypto/tls" + "database/sql" + "errors" + "fmt" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/buildinfo" + "github.com/coder/coder/coderd/database" +) + +var ( + PubsubEvent = "replica" +) + +type Options struct { + CleanupInterval time.Duration + UpdateInterval time.Duration + PeerTimeout time.Duration + RelayAddress string + RegionID int32 + TLSConfig *tls.Config +} + +// New registers the replica with the database and periodically updates to ensure +// it's healthy. It contacts all other alive replicas to ensure they are reachable. +func New(ctx context.Context, logger slog.Logger, db database.Store, pubsub database.Pubsub, options *Options) (*Manager, error) { + if options == nil { + options = &Options{} + } + if options.PeerTimeout == 0 { + options.PeerTimeout = 3 * time.Second + } + if options.UpdateInterval == 0 { + options.UpdateInterval = 5 * time.Second + } + if options.CleanupInterval == 0 { + // The cleanup interval can be quite long, because it's + // primary purpose is to clean up dead replicas. + options.CleanupInterval = 30 * time.Minute + } + hostname, err := os.Hostname() + if err != nil { + return nil, xerrors.Errorf("get hostname: %w", err) + } + databaseLatency, err := db.Ping(ctx) + if err != nil { + return nil, xerrors.Errorf("ping database: %w", err) + } + id := uuid.New() + replica, err := db.InsertReplica(ctx, database.InsertReplicaParams{ + ID: id, + CreatedAt: database.Now(), + StartedAt: database.Now(), + UpdatedAt: database.Now(), + Hostname: hostname, + RegionID: options.RegionID, + RelayAddress: options.RelayAddress, + Version: buildinfo.Version(), + DatabaseLatency: int32(databaseLatency.Microseconds()), + }) + if err != nil { + return nil, xerrors.Errorf("insert replica: %w", err) + } + err = pubsub.Publish(PubsubEvent, []byte(id.String())) + if err != nil { + return nil, xerrors.Errorf("publish new replica: %w", err) + } + ctx, cancelFunc := context.WithCancel(ctx) + manager := &Manager{ + id: id, + options: options, + db: db, + pubsub: pubsub, + self: replica, + logger: logger, + closed: make(chan struct{}), + closeCancel: cancelFunc, + } + err = manager.syncReplicas(ctx) + if err != nil { + return nil, xerrors.Errorf("run replica: %w", err) + } + peers := manager.Regional() + if len(peers) > 0 { + self := manager.Self() + if self.RelayAddress == "" { + return nil, xerrors.Errorf("a relay address must be specified when running multiple replicas in the same region") + } + } + + err = manager.subscribe(ctx) + if err != nil { + return nil, xerrors.Errorf("subscribe: %w", err) + } + manager.closeWait.Add(1) + go manager.loop(ctx) + return manager, nil +} + +// Manager keeps the replica up to date and in sync with other replicas. +type Manager struct { + id uuid.UUID + options *Options + db database.Store + pubsub database.Pubsub + logger slog.Logger + + closeWait sync.WaitGroup + closeMutex sync.Mutex + closed chan (struct{}) + closeCancel context.CancelFunc + + self database.Replica + mutex sync.Mutex + peers []database.Replica + callback func() +} + +// updateInterval is used to determine a replicas state. +// If the replica was updated > the time, it's considered healthy. +// If the replica was updated < the time, it's considered stale. +func (m *Manager) updateInterval() time.Time { + return database.Now().Add(-3 * m.options.UpdateInterval) +} + +// loop runs the replica update sequence on an update interval. +func (m *Manager) loop(ctx context.Context) { + defer m.closeWait.Done() + updateTicker := time.NewTicker(m.options.UpdateInterval) + defer updateTicker.Stop() + deleteTicker := time.NewTicker(m.options.CleanupInterval) + defer deleteTicker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-deleteTicker.C: + err := m.db.DeleteReplicasUpdatedBefore(ctx, m.updateInterval()) + if err != nil { + m.logger.Warn(ctx, "delete old replicas", slog.Error(err)) + } + continue + case <-updateTicker.C: + } + err := m.syncReplicas(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + m.logger.Warn(ctx, "run replica update loop", slog.Error(err)) + } + } +} + +// subscribe listens for new replica information! +func (m *Manager) subscribe(ctx context.Context) error { + var ( + needsUpdate = false + updating = false + updateMutex = sync.Mutex{} + ) + + // This loop will continually update nodes as updates are processed. + // The intent is to always be up to date without spamming the run + // function, so if a new update comes in while one is being processed, + // it will reprocess afterwards. + var update func() + update = func() { + err := m.syncReplicas(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + m.logger.Warn(ctx, "run replica from subscribe", slog.Error(err)) + } + updateMutex.Lock() + if needsUpdate { + needsUpdate = false + updateMutex.Unlock() + update() + return + } + updating = false + updateMutex.Unlock() + } + cancelFunc, err := m.pubsub.Subscribe(PubsubEvent, func(ctx context.Context, message []byte) { + updateMutex.Lock() + defer updateMutex.Unlock() + id, err := uuid.Parse(string(message)) + if err != nil { + return + } + // Don't process updates for ourself! + if id == m.id { + return + } + if updating { + needsUpdate = true + return + } + updating = true + go update() + }) + if err != nil { + return err + } + go func() { + <-ctx.Done() + cancelFunc() + }() + return nil +} + +func (m *Manager) syncReplicas(ctx context.Context) error { + m.closeMutex.Lock() + m.closeWait.Add(1) + m.closeMutex.Unlock() + defer m.closeWait.Done() + // Expect replicas to update once every three times the interval... + // If they don't, assume death! + replicas, err := m.db.GetReplicasUpdatedAfter(ctx, m.updateInterval()) + if err != nil { + return xerrors.Errorf("get replicas: %w", err) + } + + m.mutex.Lock() + m.peers = make([]database.Replica, 0, len(replicas)) + for _, replica := range replicas { + if replica.ID == m.id { + continue + } + m.peers = append(m.peers, replica) + } + m.mutex.Unlock() + + client := http.Client{ + Timeout: m.options.PeerTimeout, + Transport: &http.Transport{ + TLSClientConfig: m.options.TLSConfig, + }, + } + defer client.CloseIdleConnections() + var wg sync.WaitGroup + var mu sync.Mutex + failed := make([]string, 0) + for _, peer := range m.Regional() { + wg.Add(1) + go func(peer database.Replica) { + defer wg.Done() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, peer.RelayAddress, nil) + if err != nil { + m.logger.Warn(ctx, "create http request for relay probe", + slog.F("relay_address", peer.RelayAddress), slog.Error(err)) + return + } + res, err := client.Do(req) + if err != nil { + mu.Lock() + failed = append(failed, fmt.Sprintf("relay %s (%s): %s", peer.Hostname, peer.RelayAddress, err)) + mu.Unlock() + return + } + _ = res.Body.Close() + }(peer) + } + wg.Wait() + replicaError := "" + if len(failed) > 0 { + replicaError = fmt.Sprintf("Failed to dial peers: %s", strings.Join(failed, ", ")) + } + + databaseLatency, err := m.db.Ping(ctx) + if err != nil { + return xerrors.Errorf("ping database: %w", err) + } + + replica, err := m.db.UpdateReplica(ctx, database.UpdateReplicaParams{ + ID: m.self.ID, + UpdatedAt: database.Now(), + StartedAt: m.self.StartedAt, + StoppedAt: m.self.StoppedAt, + RelayAddress: m.self.RelayAddress, + RegionID: m.self.RegionID, + Hostname: m.self.Hostname, + Version: m.self.Version, + Error: replicaError, + DatabaseLatency: int32(databaseLatency.Microseconds()), + }) + if err != nil { + return xerrors.Errorf("update replica: %w", err) + } + m.mutex.Lock() + defer m.mutex.Unlock() + if m.self.Error != replica.Error { + // Publish an update occurred! + err = m.pubsub.Publish(PubsubEvent, []byte(m.self.ID.String())) + if err != nil { + return xerrors.Errorf("publish replica update: %w", err) + } + } + m.self = replica + if m.callback != nil { + go m.callback() + } + return nil +} + +// Self represents the current replica. +func (m *Manager) Self() database.Replica { + m.mutex.Lock() + defer m.mutex.Unlock() + return m.self +} + +// All returns every replica, including itself. +func (m *Manager) All() []database.Replica { + m.mutex.Lock() + defer m.mutex.Unlock() + return append(m.peers[:], m.self) +} + +// Regional returns all replicas in the same region excluding itself. +func (m *Manager) Regional() []database.Replica { + m.mutex.Lock() + defer m.mutex.Unlock() + replicas := make([]database.Replica, 0) + for _, replica := range m.peers { + if replica.RegionID != m.self.RegionID { + continue + } + replicas = append(replicas, replica) + } + return replicas +} + +// SetCallback sets a function to execute whenever new peers +// are refreshed or updated. +func (m *Manager) SetCallback(callback func()) { + m.mutex.Lock() + defer m.mutex.Unlock() + m.callback = callback + // Instantly call the callback to inform replicas! + go callback() +} + +func (m *Manager) Close() error { + m.closeMutex.Lock() + select { + case <-m.closed: + m.closeMutex.Unlock() + return nil + default: + } + close(m.closed) + m.closeCancel() + m.closeWait.Wait() + m.closeMutex.Unlock() + m.mutex.Lock() + defer m.mutex.Unlock() + ctx, cancelFunc := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFunc() + _, err := m.db.UpdateReplica(ctx, database.UpdateReplicaParams{ + ID: m.self.ID, + UpdatedAt: database.Now(), + StartedAt: m.self.StartedAt, + StoppedAt: sql.NullTime{ + Time: database.Now(), + Valid: true, + }, + RelayAddress: m.self.RelayAddress, + RegionID: m.self.RegionID, + Hostname: m.self.Hostname, + Version: m.self.Version, + Error: m.self.Error, + }) + if err != nil { + return xerrors.Errorf("update replica: %w", err) + } + err = m.pubsub.Publish(PubsubEvent, []byte(m.self.ID.String())) + if err != nil { + return xerrors.Errorf("publish replica update: %w", err) + } + return nil +} diff --git a/enterprise/replicasync/replicasync_test.go b/enterprise/replicasync/replicasync_test.go new file mode 100644 index 0000000000..b7709c1f6f --- /dev/null +++ b/enterprise/replicasync/replicasync_test.go @@ -0,0 +1,239 @@ +package replicasync_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/enterprise/replicasync" + "github.com/coder/coder/testutil" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestReplica(t *testing.T) { + t.Parallel() + t.Run("CreateOnNew", func(t *testing.T) { + // This ensures that a new replica is created on New. + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + closeChan := make(chan struct{}, 1) + cancel, err := pubsub.Subscribe(replicasync.PubsubEvent, func(ctx context.Context, message []byte) { + closeChan <- struct{}{} + }) + require.NoError(t, err) + defer cancel() + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, nil) + require.NoError(t, err) + <-closeChan + _ = server.Close() + require.NoError(t, err) + }) + t.Run("ErrorsWithoutRelayAddress", func(t *testing.T) { + // Ensures that the replica reports a successful status for + // accessing all of its peers. + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + _, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + StartedAt: database.Now(), + UpdatedAt: database.Now(), + Hostname: "something", + }) + require.NoError(t, err) + _, err = replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, nil) + require.Error(t, err) + require.Equal(t, "a relay address must be specified when running multiple replicas in the same region", err.Error()) + }) + t.Run("ConnectsToPeerReplica", func(t *testing.T) { + // Ensures that the replica reports a successful status for + // accessing all of its peers. + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + db, pubsub := dbtestutil.NewDB(t) + peer, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + StartedAt: database.Now(), + UpdatedAt: database.Now(), + Hostname: "something", + RelayAddress: srv.URL, + }) + require.NoError(t, err) + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, &replicasync.Options{ + RelayAddress: "http://169.254.169.254", + }) + require.NoError(t, err) + require.Len(t, server.Regional(), 1) + require.Equal(t, peer.ID, server.Regional()[0].ID) + require.Empty(t, server.Self().Error) + _ = server.Close() + }) + t.Run("ConnectsToPeerReplicaTLS", func(t *testing.T) { + // Ensures that the replica reports a successful status for + // accessing all of its peers. + t.Parallel() + rawCert := testutil.GenerateTLSCertificate(t, "hello.org") + certificate, err := x509.ParseCertificate(rawCert.Certificate[0]) + require.NoError(t, err) + pool := x509.NewCertPool() + pool.AddCert(certificate) + // nolint:gosec + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{rawCert}, + ServerName: "hello.org", + RootCAs: pool, + } + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + srv.TLS = tlsConfig + srv.StartTLS() + defer srv.Close() + db, pubsub := dbtestutil.NewDB(t) + peer, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + StartedAt: database.Now(), + UpdatedAt: database.Now(), + Hostname: "something", + RelayAddress: srv.URL, + }) + require.NoError(t, err) + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, &replicasync.Options{ + RelayAddress: "http://169.254.169.254", + TLSConfig: tlsConfig, + }) + require.NoError(t, err) + require.Len(t, server.Regional(), 1) + require.Equal(t, peer.ID, server.Regional()[0].ID) + require.Empty(t, server.Self().Error) + _ = server.Close() + }) + t.Run("ConnectsToFakePeerWithError", func(t *testing.T) { + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + peer, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ + ID: uuid.New(), + CreatedAt: database.Now().Add(time.Minute), + StartedAt: database.Now().Add(time.Minute), + UpdatedAt: database.Now().Add(time.Minute), + Hostname: "something", + // Fake address to dial! + RelayAddress: "http://127.0.0.1:1", + }) + require.NoError(t, err) + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, &replicasync.Options{ + PeerTimeout: 1 * time.Millisecond, + RelayAddress: "http://127.0.0.1:1", + }) + require.NoError(t, err) + require.Len(t, server.Regional(), 1) + require.Equal(t, peer.ID, server.Regional()[0].ID) + require.NotEmpty(t, server.Self().Error) + require.Contains(t, server.Self().Error, "Failed to dial peers") + _ = server.Close() + }) + t.Run("RefreshOnPublish", func(t *testing.T) { + // Refresh when a new replica appears! + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, nil) + require.NoError(t, err) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + peer, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ + ID: uuid.New(), + RelayAddress: srv.URL, + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + // Publish multiple times to ensure it can handle that case. + err = pubsub.Publish(replicasync.PubsubEvent, []byte(peer.ID.String())) + require.NoError(t, err) + err = pubsub.Publish(replicasync.PubsubEvent, []byte(peer.ID.String())) + require.NoError(t, err) + require.Eventually(t, func() bool { + return len(server.Regional()) == 1 + }, testutil.WaitShort, testutil.IntervalFast) + _ = server.Close() + }) + t.Run("DeletesOld", func(t *testing.T) { + t.Parallel() + db, pubsub := dbtestutil.NewDB(t) + _, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ + ID: uuid.New(), + UpdatedAt: database.Now().Add(-time.Hour), + }) + require.NoError(t, err) + server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, &replicasync.Options{ + RelayAddress: "google.com", + CleanupInterval: time.Millisecond, + }) + require.NoError(t, err) + defer server.Close() + require.Eventually(t, func() bool { + return len(server.Regional()) == 0 + }, testutil.WaitShort, testutil.IntervalFast) + }) + t.Run("TwentyConcurrent", func(t *testing.T) { + // Ensures that twenty concurrent replicas can spawn and all + // discover each other in parallel! + t.Parallel() + // This doesn't use the database fake because creating + // this many PostgreSQL connections takes some + // configuration tweaking. + db := databasefake.New() + pubsub := database.NewPubsubInMemory() + logger := slogtest.Make(t, nil) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + var wg sync.WaitGroup + count := 20 + wg.Add(count) + for i := 0; i < count; i++ { + server, err := replicasync.New(context.Background(), logger, db, pubsub, &replicasync.Options{ + RelayAddress: srv.URL, + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = server.Close() + }) + done := false + server.SetCallback(func() { + if len(server.All()) != count { + return + } + if done { + return + } + done = true + wg.Done() + }) + } + wg.Wait() + }) +} diff --git a/enterprise/tailnet/coordinator.go b/enterprise/tailnet/coordinator.go new file mode 100644 index 0000000000..5749d9ef47 --- /dev/null +++ b/enterprise/tailnet/coordinator.go @@ -0,0 +1,575 @@ +package tailnet + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "sync" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/coderd/database" + agpl "github.com/coder/coder/tailnet" +) + +// NewCoordinator creates a new high availability coordinator +// that uses PostgreSQL pubsub to exchange handshakes. +func NewCoordinator(logger slog.Logger, pubsub database.Pubsub) (agpl.Coordinator, error) { + ctx, cancelFunc := context.WithCancel(context.Background()) + coord := &haCoordinator{ + id: uuid.New(), + log: logger, + pubsub: pubsub, + closeFunc: cancelFunc, + close: make(chan struct{}), + nodes: map[uuid.UUID]*agpl.Node{}, + agentSockets: map[uuid.UUID]net.Conn{}, + agentToConnectionSockets: map[uuid.UUID]map[uuid.UUID]net.Conn{}, + } + + if err := coord.runPubsub(ctx); err != nil { + return nil, xerrors.Errorf("run coordinator pubsub: %w", err) + } + + return coord, nil +} + +type haCoordinator struct { + id uuid.UUID + log slog.Logger + mutex sync.RWMutex + pubsub database.Pubsub + close chan struct{} + closeFunc context.CancelFunc + + // nodes maps agent and connection IDs their respective node. + nodes map[uuid.UUID]*agpl.Node + // agentSockets maps agent IDs to their open websocket. + agentSockets map[uuid.UUID]net.Conn + // agentToConnectionSockets maps agent IDs to connection IDs of conns that + // are subscribed to updates for that agent. + agentToConnectionSockets map[uuid.UUID]map[uuid.UUID]net.Conn +} + +// Node returns an in-memory node by ID. +func (c *haCoordinator) Node(id uuid.UUID) *agpl.Node { + c.mutex.Lock() + defer c.mutex.Unlock() + node := c.nodes[id] + return node +} + +// ServeClient accepts a WebSocket connection that wants to connect to an agent +// with the specified ID. +func (c *haCoordinator) ServeClient(conn net.Conn, id uuid.UUID, agent uuid.UUID) error { + c.mutex.Lock() + // When a new connection is requested, we update it with the latest + // node of the agent. This allows the connection to establish. + node, ok := c.nodes[agent] + c.mutex.Unlock() + if ok { + data, err := json.Marshal([]*agpl.Node{node}) + if err != nil { + return xerrors.Errorf("marshal node: %w", err) + } + _, err = conn.Write(data) + if err != nil { + return xerrors.Errorf("write nodes: %w", err) + } + } else { + err := c.publishClientHello(agent) + if err != nil { + return xerrors.Errorf("publish client hello: %w", err) + } + } + + c.mutex.Lock() + connectionSockets, ok := c.agentToConnectionSockets[agent] + if !ok { + connectionSockets = map[uuid.UUID]net.Conn{} + c.agentToConnectionSockets[agent] = connectionSockets + } + + // Insert this connection into a map so the agent can publish node updates. + connectionSockets[id] = conn + c.mutex.Unlock() + + defer func() { + c.mutex.Lock() + defer c.mutex.Unlock() + // Clean all traces of this connection from the map. + delete(c.nodes, id) + connectionSockets, ok := c.agentToConnectionSockets[agent] + if !ok { + return + } + delete(connectionSockets, id) + if len(connectionSockets) != 0 { + return + } + delete(c.agentToConnectionSockets, agent) + }() + + decoder := json.NewDecoder(conn) + // Indefinitely handle messages from the client websocket. + for { + err := c.handleNextClientMessage(id, agent, decoder) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) { + return nil + } + return xerrors.Errorf("handle next client message: %w", err) + } + } +} + +func (c *haCoordinator) handleNextClientMessage(id, agent uuid.UUID, decoder *json.Decoder) error { + var node agpl.Node + err := decoder.Decode(&node) + if err != nil { + return xerrors.Errorf("read json: %w", err) + } + + c.mutex.Lock() + // Update the node of this client in our in-memory map. If an agent entirely + // shuts down and reconnects, it needs to be aware of all clients attempting + // to establish connections. + c.nodes[id] = &node + // Write the new node from this client to the actively connected agent. + agentSocket, ok := c.agentSockets[agent] + c.mutex.Unlock() + if !ok { + // If we don't own the agent locally, send it over pubsub to a node that + // owns the agent. + err := c.publishNodesToAgent(agent, []*agpl.Node{&node}) + if err != nil { + return xerrors.Errorf("publish node to agent") + } + return nil + } + + // Write the new node from this client to the actively + // connected agent. + data, err := json.Marshal([]*agpl.Node{&node}) + if err != nil { + return xerrors.Errorf("marshal nodes: %w", err) + } + + _, err = agentSocket.Write(data) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) { + return nil + } + return xerrors.Errorf("write json: %w", err) + } + + return nil +} + +// ServeAgent accepts a WebSocket connection to an agent that listens to +// incoming connections and publishes node updates. +func (c *haCoordinator) ServeAgent(conn net.Conn, id uuid.UUID) error { + // Tell clients on other instances to send a callmemaybe to us. + err := c.publishAgentHello(id) + if err != nil { + return xerrors.Errorf("publish agent hello: %w", err) + } + + // Publish all nodes on this instance that want to connect to this agent. + nodes := c.nodesSubscribedToAgent(id) + if len(nodes) > 0 { + data, err := json.Marshal(nodes) + if err != nil { + return xerrors.Errorf("marshal json: %w", err) + } + _, err = conn.Write(data) + if err != nil { + return xerrors.Errorf("write nodes: %w", err) + } + } + + // If an old agent socket is connected, we close it + // to avoid any leaks. This shouldn't ever occur because + // we expect one agent to be running. + c.mutex.Lock() + oldAgentSocket, ok := c.agentSockets[id] + if ok { + _ = oldAgentSocket.Close() + } + c.agentSockets[id] = conn + c.mutex.Unlock() + defer func() { + c.mutex.Lock() + defer c.mutex.Unlock() + delete(c.agentSockets, id) + delete(c.nodes, id) + }() + + decoder := json.NewDecoder(conn) + for { + node, err := c.handleAgentUpdate(id, decoder) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) { + return nil + } + return xerrors.Errorf("handle next agent message: %w", err) + } + + err = c.publishAgentToNodes(id, node) + if err != nil { + return xerrors.Errorf("publish agent to nodes: %w", err) + } + } +} + +func (c *haCoordinator) nodesSubscribedToAgent(agentID uuid.UUID) []*agpl.Node { + c.mutex.Lock() + defer c.mutex.Unlock() + sockets, ok := c.agentToConnectionSockets[agentID] + if !ok { + return nil + } + + nodes := make([]*agpl.Node, 0, len(sockets)) + for targetID := range sockets { + node, ok := c.nodes[targetID] + if !ok { + continue + } + nodes = append(nodes, node) + } + + return nodes +} + +func (c *haCoordinator) handleClientHello(id uuid.UUID) error { + c.mutex.Lock() + node, ok := c.nodes[id] + c.mutex.Unlock() + if !ok { + return nil + } + return c.publishAgentToNodes(id, node) +} + +func (c *haCoordinator) handleAgentUpdate(id uuid.UUID, decoder *json.Decoder) (*agpl.Node, error) { + var node agpl.Node + err := decoder.Decode(&node) + if err != nil { + return nil, xerrors.Errorf("read json: %w", err) + } + + c.mutex.Lock() + oldNode := c.nodes[id] + if oldNode != nil { + if oldNode.AsOf.After(node.AsOf) { + c.mutex.Unlock() + return oldNode, nil + } + } + c.nodes[id] = &node + connectionSockets, ok := c.agentToConnectionSockets[id] + if !ok { + c.mutex.Unlock() + return &node, nil + } + + data, err := json.Marshal([]*agpl.Node{&node}) + if err != nil { + c.mutex.Unlock() + return nil, xerrors.Errorf("marshal nodes: %w", err) + } + + // Publish the new node to every listening socket. + var wg sync.WaitGroup + wg.Add(len(connectionSockets)) + for _, connectionSocket := range connectionSockets { + connectionSocket := connectionSocket + go func() { + defer wg.Done() + _ = connectionSocket.SetWriteDeadline(time.Now().Add(5 * time.Second)) + _, _ = connectionSocket.Write(data) + }() + } + c.mutex.Unlock() + wg.Wait() + return &node, nil +} + +// Close closes all of the open connections in the coordinator and stops the +// coordinator from accepting new connections. +func (c *haCoordinator) Close() error { + c.mutex.Lock() + defer c.mutex.Unlock() + select { + case <-c.close: + return nil + default: + } + close(c.close) + c.closeFunc() + + wg := sync.WaitGroup{} + + wg.Add(len(c.agentSockets)) + for _, socket := range c.agentSockets { + socket := socket + go func() { + _ = socket.Close() + wg.Done() + }() + } + + for _, connMap := range c.agentToConnectionSockets { + wg.Add(len(connMap)) + for _, socket := range connMap { + socket := socket + go func() { + _ = socket.Close() + wg.Done() + }() + } + } + + wg.Wait() + return nil +} + +func (c *haCoordinator) publishNodesToAgent(recipient uuid.UUID, nodes []*agpl.Node) error { + msg, err := c.formatCallMeMaybe(recipient, nodes) + if err != nil { + return xerrors.Errorf("format publish message: %w", err) + } + + err = c.pubsub.Publish("wireguard_peers", msg) + if err != nil { + return xerrors.Errorf("publish message: %w", err) + } + + return nil +} + +func (c *haCoordinator) publishAgentHello(id uuid.UUID) error { + msg, err := c.formatAgentHello(id) + if err != nil { + return xerrors.Errorf("format publish message: %w", err) + } + + err = c.pubsub.Publish("wireguard_peers", msg) + if err != nil { + return xerrors.Errorf("publish message: %w", err) + } + + return nil +} + +func (c *haCoordinator) publishClientHello(id uuid.UUID) error { + msg, err := c.formatClientHello(id) + if err != nil { + return xerrors.Errorf("format client hello: %w", err) + } + err = c.pubsub.Publish("wireguard_peers", msg) + if err != nil { + return xerrors.Errorf("publish client hello: %w", err) + } + return nil +} + +func (c *haCoordinator) publishAgentToNodes(id uuid.UUID, node *agpl.Node) error { + msg, err := c.formatAgentUpdate(id, node) + if err != nil { + return xerrors.Errorf("format publish message: %w", err) + } + + err = c.pubsub.Publish("wireguard_peers", msg) + if err != nil { + return xerrors.Errorf("publish message: %w", err) + } + + return nil +} + +func (c *haCoordinator) runPubsub(ctx context.Context) error { + messageQueue := make(chan []byte, 64) + cancelSub, err := c.pubsub.Subscribe("wireguard_peers", func(ctx context.Context, message []byte) { + select { + case messageQueue <- message: + case <-ctx.Done(): + return + } + }) + if err != nil { + return xerrors.Errorf("subscribe wireguard peers") + } + go func() { + for { + var message []byte + select { + case <-ctx.Done(): + return + case message = <-messageQueue: + } + c.handlePubsubMessage(ctx, message) + } + }() + + go func() { + defer cancelSub() + <-c.close + }() + + return nil +} + +func (c *haCoordinator) handlePubsubMessage(ctx context.Context, message []byte) { + sp := bytes.Split(message, []byte("|")) + if len(sp) != 4 { + c.log.Error(ctx, "invalid wireguard peer message", slog.F("msg", string(message))) + return + } + + var ( + coordinatorID = sp[0] + eventType = sp[1] + agentID = sp[2] + nodeJSON = sp[3] + ) + + sender, err := uuid.ParseBytes(coordinatorID) + if err != nil { + c.log.Error(ctx, "invalid sender id", slog.F("id", string(coordinatorID)), slog.F("msg", string(message))) + return + } + + // We sent this message! + if sender == c.id { + return + } + + switch string(eventType) { + case "callmemaybe": + agentUUID, err := uuid.ParseBytes(agentID) + if err != nil { + c.log.Error(ctx, "invalid agent id", slog.F("id", string(agentID))) + return + } + + c.mutex.Lock() + agentSocket, ok := c.agentSockets[agentUUID] + if !ok { + c.mutex.Unlock() + return + } + c.mutex.Unlock() + + // We get a single node over pubsub, so turn into an array. + _, err = agentSocket.Write(nodeJSON) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) { + return + } + c.log.Error(ctx, "send callmemaybe to agent", slog.Error(err)) + return + } + case "clienthello": + agentUUID, err := uuid.ParseBytes(agentID) + if err != nil { + c.log.Error(ctx, "invalid agent id", slog.F("id", string(agentID))) + return + } + + err = c.handleClientHello(agentUUID) + if err != nil { + c.log.Error(ctx, "handle agent request node", slog.Error(err)) + return + } + case "agenthello": + agentUUID, err := uuid.ParseBytes(agentID) + if err != nil { + c.log.Error(ctx, "invalid agent id", slog.F("id", string(agentID))) + return + } + + nodes := c.nodesSubscribedToAgent(agentUUID) + if len(nodes) > 0 { + err := c.publishNodesToAgent(agentUUID, nodes) + if err != nil { + c.log.Error(ctx, "publish nodes to agent", slog.Error(err)) + return + } + } + case "agentupdate": + agentUUID, err := uuid.ParseBytes(agentID) + if err != nil { + c.log.Error(ctx, "invalid agent id", slog.F("id", string(agentID))) + return + } + + decoder := json.NewDecoder(bytes.NewReader(nodeJSON)) + _, err = c.handleAgentUpdate(agentUUID, decoder) + if err != nil { + c.log.Error(ctx, "handle agent update", slog.Error(err)) + return + } + default: + c.log.Error(ctx, "unknown peer event", slog.F("name", string(eventType))) + } +} + +// format: |callmemaybe|| +func (c *haCoordinator) formatCallMeMaybe(recipient uuid.UUID, nodes []*agpl.Node) ([]byte, error) { + buf := bytes.Buffer{} + + buf.WriteString(c.id.String() + "|") + buf.WriteString("callmemaybe|") + buf.WriteString(recipient.String() + "|") + err := json.NewEncoder(&buf).Encode(nodes) + if err != nil { + return nil, xerrors.Errorf("encode node: %w", err) + } + + return buf.Bytes(), nil +} + +// format: |agenthello|| +func (c *haCoordinator) formatAgentHello(id uuid.UUID) ([]byte, error) { + buf := bytes.Buffer{} + + buf.WriteString(c.id.String() + "|") + buf.WriteString("agenthello|") + buf.WriteString(id.String() + "|") + + return buf.Bytes(), nil +} + +// format: |clienthello|| +func (c *haCoordinator) formatClientHello(id uuid.UUID) ([]byte, error) { + buf := bytes.Buffer{} + + buf.WriteString(c.id.String() + "|") + buf.WriteString("clienthello|") + buf.WriteString(id.String() + "|") + + return buf.Bytes(), nil +} + +// format: |agentupdate|| +func (c *haCoordinator) formatAgentUpdate(id uuid.UUID, node *agpl.Node) ([]byte, error) { + buf := bytes.Buffer{} + + buf.WriteString(c.id.String() + "|") + buf.WriteString("agentupdate|") + buf.WriteString(id.String() + "|") + err := json.NewEncoder(&buf).Encode(node) + if err != nil { + return nil, xerrors.Errorf("encode node: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/enterprise/tailnet/coordinator_test.go b/enterprise/tailnet/coordinator_test.go new file mode 100644 index 0000000000..86cee94dbd --- /dev/null +++ b/enterprise/tailnet/coordinator_test.go @@ -0,0 +1,261 @@ +package tailnet_test + +import ( + "net" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/enterprise/tailnet" + agpl "github.com/coder/coder/tailnet" + "github.com/coder/coder/testutil" +) + +func TestCoordinatorSingle(t *testing.T) { + t.Parallel() + t.Run("ClientWithoutAgent", func(t *testing.T) { + t.Parallel() + coordinator, err := tailnet.NewCoordinator(slogtest.Make(t, nil), database.NewPubsubInMemory()) + require.NoError(t, err) + defer coordinator.Close() + + client, server := net.Pipe() + sendNode, errChan := agpl.ServeCoordinator(client, func(node []*agpl.Node) error { + return nil + }) + id := uuid.New() + closeChan := make(chan struct{}) + go func() { + err := coordinator.ServeClient(server, id, uuid.New()) + assert.NoError(t, err) + close(closeChan) + }() + sendNode(&agpl.Node{}) + require.Eventually(t, func() bool { + return coordinator.Node(id) != nil + }, testutil.WaitShort, testutil.IntervalFast) + + err = client.Close() + require.NoError(t, err) + <-errChan + <-closeChan + }) + + t.Run("AgentWithoutClients", func(t *testing.T) { + t.Parallel() + coordinator, err := tailnet.NewCoordinator(slogtest.Make(t, nil), database.NewPubsubInMemory()) + require.NoError(t, err) + defer coordinator.Close() + + client, server := net.Pipe() + sendNode, errChan := agpl.ServeCoordinator(client, func(node []*agpl.Node) error { + return nil + }) + id := uuid.New() + closeChan := make(chan struct{}) + go func() { + err := coordinator.ServeAgent(server, id) + assert.NoError(t, err) + close(closeChan) + }() + sendNode(&agpl.Node{}) + require.Eventually(t, func() bool { + return coordinator.Node(id) != nil + }, testutil.WaitShort, testutil.IntervalFast) + err = client.Close() + require.NoError(t, err) + <-errChan + <-closeChan + }) + + t.Run("AgentWithClient", func(t *testing.T) { + t.Parallel() + + coordinator, err := tailnet.NewCoordinator(slogtest.Make(t, nil), database.NewPubsubInMemory()) + require.NoError(t, err) + defer coordinator.Close() + + agentWS, agentServerWS := net.Pipe() + defer agentWS.Close() + agentNodeChan := make(chan []*agpl.Node) + sendAgentNode, agentErrChan := agpl.ServeCoordinator(agentWS, func(nodes []*agpl.Node) error { + agentNodeChan <- nodes + return nil + }) + agentID := uuid.New() + closeAgentChan := make(chan struct{}) + go func() { + err := coordinator.ServeAgent(agentServerWS, agentID) + assert.NoError(t, err) + close(closeAgentChan) + }() + sendAgentNode(&agpl.Node{}) + require.Eventually(t, func() bool { + return coordinator.Node(agentID) != nil + }, testutil.WaitShort, testutil.IntervalFast) + + clientWS, clientServerWS := net.Pipe() + defer clientWS.Close() + defer clientServerWS.Close() + clientNodeChan := make(chan []*agpl.Node) + sendClientNode, clientErrChan := agpl.ServeCoordinator(clientWS, func(nodes []*agpl.Node) error { + clientNodeChan <- nodes + return nil + }) + clientID := uuid.New() + closeClientChan := make(chan struct{}) + go func() { + err := coordinator.ServeClient(clientServerWS, clientID, agentID) + assert.NoError(t, err) + close(closeClientChan) + }() + agentNodes := <-clientNodeChan + require.Len(t, agentNodes, 1) + sendClientNode(&agpl.Node{}) + clientNodes := <-agentNodeChan + require.Len(t, clientNodes, 1) + + // Ensure an update to the agent node reaches the client! + sendAgentNode(&agpl.Node{}) + agentNodes = <-clientNodeChan + require.Len(t, agentNodes, 1) + + // Close the agent WebSocket so a new one can connect. + err = agentWS.Close() + require.NoError(t, err) + <-agentErrChan + <-closeAgentChan + + // Create a new agent connection. This is to simulate a reconnect! + agentWS, agentServerWS = net.Pipe() + defer agentWS.Close() + agentNodeChan = make(chan []*agpl.Node) + _, agentErrChan = agpl.ServeCoordinator(agentWS, func(nodes []*agpl.Node) error { + agentNodeChan <- nodes + return nil + }) + closeAgentChan = make(chan struct{}) + go func() { + err := coordinator.ServeAgent(agentServerWS, agentID) + assert.NoError(t, err) + close(closeAgentChan) + }() + // Ensure the existing listening client sends it's node immediately! + clientNodes = <-agentNodeChan + require.Len(t, clientNodes, 1) + + err = agentWS.Close() + require.NoError(t, err) + <-agentErrChan + <-closeAgentChan + + err = clientWS.Close() + require.NoError(t, err) + <-clientErrChan + <-closeClientChan + }) +} + +func TestCoordinatorHA(t *testing.T) { + t.Parallel() + + t.Run("AgentWithClient", func(t *testing.T) { + t.Parallel() + + _, pubsub := dbtestutil.NewDB(t) + + coordinator1, err := tailnet.NewCoordinator(slogtest.Make(t, nil), pubsub) + require.NoError(t, err) + defer coordinator1.Close() + + agentWS, agentServerWS := net.Pipe() + defer agentWS.Close() + agentNodeChan := make(chan []*agpl.Node) + sendAgentNode, agentErrChan := agpl.ServeCoordinator(agentWS, func(nodes []*agpl.Node) error { + agentNodeChan <- nodes + return nil + }) + agentID := uuid.New() + closeAgentChan := make(chan struct{}) + go func() { + err := coordinator1.ServeAgent(agentServerWS, agentID) + assert.NoError(t, err) + close(closeAgentChan) + }() + sendAgentNode(&agpl.Node{}) + require.Eventually(t, func() bool { + return coordinator1.Node(agentID) != nil + }, testutil.WaitShort, testutil.IntervalFast) + + coordinator2, err := tailnet.NewCoordinator(slogtest.Make(t, nil), pubsub) + require.NoError(t, err) + defer coordinator2.Close() + + clientWS, clientServerWS := net.Pipe() + defer clientWS.Close() + defer clientServerWS.Close() + clientNodeChan := make(chan []*agpl.Node) + sendClientNode, clientErrChan := agpl.ServeCoordinator(clientWS, func(nodes []*agpl.Node) error { + clientNodeChan <- nodes + return nil + }) + clientID := uuid.New() + closeClientChan := make(chan struct{}) + go func() { + err := coordinator2.ServeClient(clientServerWS, clientID, agentID) + assert.NoError(t, err) + close(closeClientChan) + }() + agentNodes := <-clientNodeChan + require.Len(t, agentNodes, 1) + sendClientNode(&agpl.Node{}) + _ = sendClientNode + clientNodes := <-agentNodeChan + require.Len(t, clientNodes, 1) + + // Ensure an update to the agent node reaches the client! + sendAgentNode(&agpl.Node{}) + agentNodes = <-clientNodeChan + require.Len(t, agentNodes, 1) + + // Close the agent WebSocket so a new one can connect. + require.NoError(t, agentWS.Close()) + require.NoError(t, agentServerWS.Close()) + <-agentErrChan + <-closeAgentChan + + // Create a new agent connection. This is to simulate a reconnect! + agentWS, agentServerWS = net.Pipe() + defer agentWS.Close() + agentNodeChan = make(chan []*agpl.Node) + _, agentErrChan = agpl.ServeCoordinator(agentWS, func(nodes []*agpl.Node) error { + agentNodeChan <- nodes + return nil + }) + closeAgentChan = make(chan struct{}) + go func() { + err := coordinator1.ServeAgent(agentServerWS, agentID) + assert.NoError(t, err) + close(closeAgentChan) + }() + // Ensure the existing listening client sends it's node immediately! + clientNodes = <-agentNodeChan + require.Len(t, clientNodes, 1) + + err = agentWS.Close() + require.NoError(t, err) + <-agentErrChan + <-closeAgentChan + + err = clientWS.Close() + require.NoError(t, err) + <-clientErrChan + <-closeClientChan + }) +} diff --git a/go.mod b/go.mod index 9834e27e5f..195a09ae2b 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ replace github.com/tcnksm/go-httpstat => github.com/kylecarbs/go-httpstat v0.0.0 // There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here: // https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main -replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20220926024748-50f068456c6c +replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20221015033036-5861cbbf7bf5 // Switch to our fork that imports fixes from http://github.com/tailscale/ssh. // See: https://github.com/coder/coder/issues/3371 diff --git a/go.sum b/go.sum index 13fdc5724f..b80c0d4173 100644 --- a/go.sum +++ b/go.sum @@ -351,8 +351,8 @@ github.com/coder/retry v1.3.0 h1:5lAAwt/2Cm6lVmnfBY7sOMXcBOwcwJhmV5QGSELIVWY= github.com/coder/retry v1.3.0/go.mod h1:tXuRgZgWjUnU5LZPT4lJh4ew2elUhexhlnXzrJWdyFY= github.com/coder/ssh v0.0.0-20220811105153-fcea99919338 h1:tN5GKFT68YLVzJoA8AHuiMNJ0qlhoD3pGN3JY9gxSko= github.com/coder/ssh v0.0.0-20220811105153-fcea99919338/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914= -github.com/coder/tailscale v1.1.1-0.20220926024748-50f068456c6c h1:xa6lr5Pj87Is26tgpzwBsEGKL7aVz7/fRGgY9QIbf3E= -github.com/coder/tailscale v1.1.1-0.20220926024748-50f068456c6c/go.mod h1:5amxy08qijEa8bcTW2SeIy4MIqcmd7LMsuOxqOlj2Ak= +github.com/coder/tailscale v1.1.1-0.20221015033036-5861cbbf7bf5 h1:WVH6e/qK3Wpl0wbmpORD2oQ1qLJborF3fsFHyO1ps0Y= +github.com/coder/tailscale v1.1.1-0.20221015033036-5861cbbf7bf5/go.mod h1:5amxy08qijEa8bcTW2SeIy4MIqcmd7LMsuOxqOlj2Ak= github.com/containerd/aufs v0.0.0-20200908144142-dab0cbea06f4/go.mod h1:nukgQABAEopAHvB6j7cnP5zJ+/3aVcE7hCYqvIwAHyE= github.com/containerd/aufs v0.0.0-20201003224125-76a6863f2989/go.mod h1:AkGGQs9NM2vtYHaUen+NljV0/baGCAPELGm2q9ZXpWU= github.com/containerd/aufs v0.0.0-20210316121734-20793ff83c97/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= diff --git a/helm/templates/coder.yaml b/helm/templates/coder.yaml index 45f3f6e29a..1165251fc8 100644 --- a/helm/templates/coder.yaml +++ b/helm/templates/coder.yaml @@ -14,10 +14,7 @@ metadata: {{- include "coder.labels" . | nindent 4 }} annotations: {{ toYaml .Values.coder.annotations | nindent 4}} spec: - # NOTE: this is currently not used as coder v2 does not support high - # availability yet. - # replicas: {{ .Values.coder.replicaCount }} - replicas: 1 + replicas: {{ .Values.coder.replicaCount }} selector: matchLabels: {{- include "coder.selectorLabels" . | nindent 6 }} @@ -38,6 +35,13 @@ spec: env: - name: CODER_ADDRESS value: "0.0.0.0:{{ include "coder.port" . }}" + # Used for inter-pod communication with high-availability. + - name: KUBE_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_ADDRESS + value: "{{ include "coder.portName" . }}://$(KUBE_POD_IP):{{ include "coder.port" . }}" {{- include "coder.tlsEnv" . | nindent 12 }} {{- with .Values.coder.env -}} {{ toYaml . | nindent 12 }} diff --git a/helm/templates/service.yaml b/helm/templates/service.yaml index 28fe0e9f9a..b9a7e9a2f0 100644 --- a/helm/templates/service.yaml +++ b/helm/templates/service.yaml @@ -10,6 +10,7 @@ metadata: {{- toYaml .Values.coder.service.annotations | nindent 4 }} spec: type: {{ .Values.coder.service.type }} + sessionAffinity: ClientIP ports: - name: {{ include "coder.portName" . | quote }} port: {{ include "coder.servicePort" . }} diff --git a/helm/values.yaml b/helm/values.yaml index 30a21a8985..392a53c187 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -1,9 +1,9 @@ # coder -- Primary configuration for `coder server`. coder: - # NOTE: this is currently not used as coder v2 does not support high - # availability yet. - # # coder.replicaCount -- The number of Kubernetes deployment replicas. - # replicaCount: 1 + # coder.replicaCount -- The number of Kubernetes deployment replicas. + # This should only be increased if High Availability is enabled. + # This is an Enterprise feature. Contact sales@coder.com. + replicaCount: 1 # coder.image -- The image to use for Coder. image: diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2e60a88b84..fb12571fd9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -28,6 +28,7 @@ export const defaultEntitlements = (): TypesGen.Entitlements => { return { features: features, has_license: false, + errors: [], warnings: [], experimental: false, trial: false, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5347613e77..a4b2cf83a9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -274,6 +274,7 @@ export interface DeploymentFlags { readonly derp_server_region_code: StringFlag readonly derp_server_region_name: StringFlag readonly derp_server_stun_address: StringArrayFlag + readonly derp_server_relay_address: StringFlag readonly derp_config_url: StringFlag readonly derp_config_path: StringFlag readonly prom_enabled: BoolFlag @@ -337,6 +338,7 @@ export interface DurationFlag { export interface Entitlements { readonly features: Record readonly warnings: string[] + readonly errors: string[] readonly has_license: boolean readonly experimental: boolean readonly trial: boolean @@ -528,6 +530,17 @@ export interface PutExtendWorkspaceRequest { readonly deadline: string } +// From codersdk/replicas.go +export interface Replica { + readonly id: string + readonly hostname: string + readonly created_at: string + readonly relay_address: string + readonly region_id: number + readonly error: string + readonly database_latency: number +} + // From codersdk/error.go export interface Response { readonly message: string diff --git a/site/src/components/LicenseBanner/LicenseBanner.tsx b/site/src/components/LicenseBanner/LicenseBanner.tsx index 8532bfca2e..7ecfc2a2a2 100644 --- a/site/src/components/LicenseBanner/LicenseBanner.tsx +++ b/site/src/components/LicenseBanner/LicenseBanner.tsx @@ -8,15 +8,15 @@ export const LicenseBanner: React.FC = () => { const [entitlementsState, entitlementsSend] = useActor( xServices.entitlementsXService, ) - const { warnings } = entitlementsState.context.entitlements + const { errors, warnings } = entitlementsState.context.entitlements /** Gets license data on app mount because LicenseBanner is mounted in App */ useEffect(() => { entitlementsSend("GET_ENTITLEMENTS") }, [entitlementsSend]) - if (warnings.length > 0) { - return + if (errors.length > 0 || warnings.length > 0) { + return } else { return null } diff --git a/site/src/components/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/components/LicenseBanner/LicenseBannerView.stories.tsx index c37653eff7..c7ee69c261 100644 --- a/site/src/components/LicenseBanner/LicenseBannerView.stories.tsx +++ b/site/src/components/LicenseBanner/LicenseBannerView.stories.tsx @@ -12,13 +12,23 @@ const Template: Story = (args) => ( export const OneWarning = Template.bind({}) OneWarning.args = { + errors: [], warnings: ["You have exceeded the number of seats in your license."], } export const TwoWarnings = Template.bind({}) TwoWarnings.args = { + errors: [], warnings: [ "You have exceeded the number of seats in your license.", "You are flying too close to the sun.", ], } + +export const OneError = Template.bind({}) +OneError.args = { + errors: [ + "You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.", + ], + warnings: [], +} diff --git a/site/src/components/LicenseBanner/LicenseBannerView.tsx b/site/src/components/LicenseBanner/LicenseBannerView.tsx index 49276b1f0d..792bc191a0 100644 --- a/site/src/components/LicenseBanner/LicenseBannerView.tsx +++ b/site/src/components/LicenseBanner/LicenseBannerView.tsx @@ -2,47 +2,56 @@ import { makeStyles } from "@material-ui/core/styles" import { Expander } from "components/Expander/Expander" import { Pill } from "components/Pill/Pill" import { useState } from "react" +import { colors } from "theme/colors" export const Language = { licenseIssue: "License Issue", licenseIssues: (num: number): string => `${num} License Issues`, - upgrade: "Contact us to upgrade your license.", + upgrade: "Contact sales@coder.com.", exceeded: "It looks like you've exceeded some limits of your license.", lessDetails: "Less", moreDetails: "More", } export interface LicenseBannerViewProps { + errors: string[] warnings: string[] } export const LicenseBannerView: React.FC = ({ + errors, warnings, }) => { const styles = useStyles() const [showDetails, setShowDetails] = useState(false) - if (warnings.length === 1) { + const isError = errors.length > 0 + const messages = [...errors, ...warnings] + const type = isError ? "error" : "warning" + + if (messages.length === 1) { return ( -
- - {warnings[0]} -   - - {Language.upgrade} - +
+ +
+ {messages[0]} +   + + {Language.upgrade} + +
) } else { return ( -
-
-
- - {Language.exceeded} +
+ +
+
    - {warnings.map((warning) => ( -
  • - {warning} + {messages.map((message) => ( +
  • + {message}
  • ))}
@@ -67,14 +76,18 @@ const useStyles = makeStyles((theme) => ({ container: { padding: theme.spacing(1.5), backgroundColor: theme.palette.warning.main, + display: "flex", + alignItems: "center", + + "&.error": { + backgroundColor: colors.red[12], + }, }, flex: { - display: "flex", + display: "column", }, leftContent: { marginRight: theme.spacing(1), - }, - text: { marginLeft: theme.spacing(1), }, link: { @@ -83,9 +96,10 @@ const useStyles = makeStyles((theme) => ({ fontWeight: "bold", }, list: { - margin: theme.spacing(1.5), + padding: theme.spacing(1), + margin: 0, }, listItem: { - margin: theme.spacing(1), + margin: theme.spacing(0.5), }, })) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 59abb4a913..8d0358bc58 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -821,6 +821,7 @@ export const makeMockApiError = ({ }) export const MockEntitlements: TypesGen.Entitlements = { + errors: [], warnings: [], has_license: false, features: {}, @@ -829,6 +830,7 @@ export const MockEntitlements: TypesGen.Entitlements = { } export const MockEntitlementsWithWarnings: TypesGen.Entitlements = { + errors: [], warnings: ["You are over your active user limit.", "And another thing."], has_license: true, experimental: false, @@ -852,6 +854,7 @@ export const MockEntitlementsWithWarnings: TypesGen.Entitlements = { } export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = { + errors: [], warnings: [], has_license: true, experimental: false, diff --git a/site/src/xServices/entitlements/entitlementsXService.ts b/site/src/xServices/entitlements/entitlementsXService.ts index 83ed44d120..a1e8bb0d9b 100644 --- a/site/src/xServices/entitlements/entitlementsXService.ts +++ b/site/src/xServices/entitlements/entitlementsXService.ts @@ -20,6 +20,7 @@ export type EntitlementsEvent = | { type: "HIDE_MOCK_BANNER" } const emptyEntitlements = { + errors: [], warnings: [], features: {}, has_license: false, diff --git a/tailnet/conn.go b/tailnet/conn.go index 1b454d6346..e3af3786ec 100644 --- a/tailnet/conn.go +++ b/tailnet/conn.go @@ -48,7 +48,10 @@ type Options struct { Addresses []netip.Prefix DERPMap *tailcfg.DERPMap - Logger slog.Logger + // BlockEndpoints specifies whether P2P endpoints are blocked. + // If so, only DERPs can establish connections. + BlockEndpoints bool + Logger slog.Logger } // NewConn constructs a new Wireguard server that will accept connections from the addresses provided. @@ -175,6 +178,7 @@ func NewConn(options *Options) (*Conn, error) { wireguardEngine.SetFilter(filter.New(netMap.PacketFilter, localIPs, logIPs, nil, Logger(options.Logger.Named("packet-filter")))) dialContext, dialCancel := context.WithCancel(context.Background()) server := &Conn{ + blockEndpoints: options.BlockEndpoints, dialContext: dialContext, dialCancel: dialCancel, closed: make(chan struct{}), @@ -240,11 +244,12 @@ func IP() netip.Addr { // Conn is an actively listening Wireguard connection. type Conn struct { - dialContext context.Context - dialCancel context.CancelFunc - mutex sync.Mutex - closed chan struct{} - logger slog.Logger + dialContext context.Context + dialCancel context.CancelFunc + mutex sync.Mutex + closed chan struct{} + logger slog.Logger + blockEndpoints bool dialer *tsdial.Dialer tunDevice *tstun.Wrapper @@ -323,6 +328,8 @@ func (c *Conn) UpdateNodes(nodes []*Node) error { delete(c.peerMap, peer.ID) } for _, node := range nodes { + c.logger.Debug(context.Background(), "adding node", slog.F("node", node)) + peerStatus, ok := status.Peer[node.Key] peerNode := &tailcfg.Node{ ID: node.ID, @@ -339,6 +346,13 @@ func (c *Conn) UpdateNodes(nodes []*Node) error { // reason. TODO: @kylecarbs debug this! KeepAlive: ok && peerStatus.Active, } + // If no preferred DERP is provided, don't set an IP! + if node.PreferredDERP == 0 { + peerNode.DERP = "" + } + if c.blockEndpoints { + peerNode.Endpoints = nil + } c.peerMap[node.ID] = peerNode } c.netMap.Peers = make([]*tailcfg.Node, 0, len(c.peerMap)) @@ -421,6 +435,7 @@ func (c *Conn) sendNode() { } node := &Node{ ID: c.netMap.SelfNode.ID, + AsOf: c.lastStatus, Key: c.netMap.SelfNode.Key, Addresses: c.netMap.SelfNode.Addresses, AllowedIPs: c.netMap.SelfNode.AllowedIPs, @@ -429,6 +444,9 @@ func (c *Conn) sendNode() { PreferredDERP: c.lastPreferredDERP, DERPLatency: c.lastDERPLatency, } + if c.blockEndpoints { + node.Endpoints = nil + } nodeCallback := c.nodeCallback if nodeCallback == nil { return diff --git a/tailnet/coordinator.go b/tailnet/coordinator.go index ee696b0925..4216bbc624 100644 --- a/tailnet/coordinator.go +++ b/tailnet/coordinator.go @@ -7,6 +7,7 @@ import ( "net" "net/netip" "sync" + "time" "github.com/google/uuid" "golang.org/x/xerrors" @@ -14,10 +15,30 @@ import ( "tailscale.com/types/key" ) +// Coordinator exchanges nodes with agents to establish connections. +// ┌──────────────────┐ ┌────────────────────┐ ┌───────────────────┐ ┌──────────────────┐ +// │tailnet.Coordinate├──►│tailnet.AcceptClient│◄─►│tailnet.AcceptAgent│◄──┤tailnet.Coordinate│ +// └──────────────────┘ └────────────────────┘ └───────────────────┘ └──────────────────┘ +// Coordinators have different guarantees for HA support. +type Coordinator interface { + // Node returns an in-memory node by ID. + Node(id uuid.UUID) *Node + // ServeClient accepts a WebSocket connection that wants to connect to an agent + // with the specified ID. + ServeClient(conn net.Conn, id uuid.UUID, agent uuid.UUID) error + // ServeAgent accepts a WebSocket connection to an agent that listens to + // incoming connections and publishes node updates. + ServeAgent(conn net.Conn, id uuid.UUID) error + // Close closes the coordinator. + Close() error +} + // Node represents a node in the network. type Node struct { // ID is used to identify the connection. ID tailcfg.NodeID `json:"id"` + // AsOf is the time the node was created. + AsOf time.Time `json:"as_of"` // Key is the Wireguard public key of the node. Key key.NodePublic `json:"key"` // DiscoKey is used for discovery messages over DERP to establish peer-to-peer connections. @@ -75,48 +96,59 @@ func ServeCoordinator(conn net.Conn, updateNodes func(node []*Node) error) (func }, errChan } -// NewCoordinator constructs a new in-memory connection coordinator. -func NewCoordinator() *Coordinator { - return &Coordinator{ +// NewCoordinator constructs a new in-memory connection coordinator. This +// coordinator is incompatible with multiple Coder replicas as all node data is +// in-memory. +func NewCoordinator() Coordinator { + return &coordinator{ + closed: false, nodes: map[uuid.UUID]*Node{}, agentSockets: map[uuid.UUID]net.Conn{}, agentToConnectionSockets: map[uuid.UUID]map[uuid.UUID]net.Conn{}, } } -// Coordinator exchanges nodes with agents to establish connections. +// coordinator exchanges nodes with agents to establish connections entirely in-memory. +// The Enterprise implementation provides this for high-availability. // ┌──────────────────┐ ┌────────────────────┐ ┌───────────────────┐ ┌──────────────────┐ // │tailnet.Coordinate├──►│tailnet.AcceptClient│◄─►│tailnet.AcceptAgent│◄──┤tailnet.Coordinate│ // └──────────────────┘ └────────────────────┘ └───────────────────┘ └──────────────────┘ // This coordinator is incompatible with multiple Coder // replicas as all node data is in-memory. -type Coordinator struct { - mutex sync.Mutex +type coordinator struct { + mutex sync.Mutex + closed bool - // Maps agent and connection IDs to a node. + // nodes maps agent and connection IDs their respective node. nodes map[uuid.UUID]*Node - // Maps agent ID to an open socket. + // agentSockets maps agent IDs to their open websocket. agentSockets map[uuid.UUID]net.Conn - // Maps agent ID to connection ID for sending - // new node data as it comes in! + // agentToConnectionSockets maps agent IDs to connection IDs of conns that + // are subscribed to updates for that agent. agentToConnectionSockets map[uuid.UUID]map[uuid.UUID]net.Conn } // Node returns an in-memory node by ID. -func (c *Coordinator) Node(id uuid.UUID) *Node { +// If the node does not exist, nil is returned. +func (c *coordinator) Node(id uuid.UUID) *Node { c.mutex.Lock() defer c.mutex.Unlock() - node := c.nodes[id] - return node + return c.nodes[id] } -// ServeClient accepts a WebSocket connection that wants to -// connect to an agent with the specified ID. -func (c *Coordinator) ServeClient(conn net.Conn, id uuid.UUID, agent uuid.UUID) error { +// ServeClient accepts a WebSocket connection that wants to connect to an agent +// with the specified ID. +func (c *coordinator) ServeClient(conn net.Conn, id uuid.UUID, agent uuid.UUID) error { c.mutex.Lock() + if c.closed { + c.mutex.Unlock() + return xerrors.New("coordinator is closed") + } + // When a new connection is requested, we update it with the latest // node of the agent. This allows the connection to establish. node, ok := c.nodes[agent] + c.mutex.Unlock() if ok { data, err := json.Marshal([]*Node{node}) if err != nil { @@ -129,6 +161,7 @@ func (c *Coordinator) ServeClient(conn net.Conn, id uuid.UUID, agent uuid.UUID) return xerrors.Errorf("write nodes: %w", err) } } + c.mutex.Lock() connectionSockets, ok := c.agentToConnectionSockets[agent] if !ok { connectionSockets = map[uuid.UUID]net.Conn{} @@ -156,47 +189,62 @@ func (c *Coordinator) ServeClient(conn net.Conn, id uuid.UUID, agent uuid.UUID) decoder := json.NewDecoder(conn) for { - var node Node - err := decoder.Decode(&node) - if errors.Is(err, io.EOF) { - return nil - } + err := c.handleNextClientMessage(id, agent, decoder) if err != nil { - return xerrors.Errorf("read json: %w", err) - } - c.mutex.Lock() - // Update the node of this client in our in-memory map. - // If an agent entirely shuts down and reconnects, it - // needs to be aware of all clients attempting to - // establish connections. - c.nodes[id] = &node - agentSocket, ok := c.agentSockets[agent] - if !ok { - c.mutex.Unlock() - continue - } - c.mutex.Unlock() - // Write the new node from this client to the actively - // connected agent. - data, err := json.Marshal([]*Node{&node}) - if err != nil { - c.mutex.Unlock() - return xerrors.Errorf("marshal nodes: %w", err) - } - _, err = agentSocket.Write(data) - if errors.Is(err, io.EOF) { - return nil - } - if err != nil { - return xerrors.Errorf("write json: %w", err) + if errors.Is(err, io.EOF) { + return nil + } + return xerrors.Errorf("handle next client message: %w", err) } } } +func (c *coordinator) handleNextClientMessage(id, agent uuid.UUID, decoder *json.Decoder) error { + var node Node + err := decoder.Decode(&node) + if err != nil { + return xerrors.Errorf("read json: %w", err) + } + + c.mutex.Lock() + // Update the node of this client in our in-memory map. If an agent entirely + // shuts down and reconnects, it needs to be aware of all clients attempting + // to establish connections. + c.nodes[id] = &node + + agentSocket, ok := c.agentSockets[agent] + if !ok { + c.mutex.Unlock() + return nil + } + c.mutex.Unlock() + + // Write the new node from this client to the actively connected agent. + data, err := json.Marshal([]*Node{&node}) + if err != nil { + return xerrors.Errorf("marshal nodes: %w", err) + } + + _, err = agentSocket.Write(data) + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return xerrors.Errorf("write json: %w", err) + } + + return nil +} + // ServeAgent accepts a WebSocket connection to an agent that // listens to incoming connections and publishes node updates. -func (c *Coordinator) ServeAgent(conn net.Conn, id uuid.UUID) error { +func (c *coordinator) ServeAgent(conn net.Conn, id uuid.UUID) error { c.mutex.Lock() + if c.closed { + c.mutex.Unlock() + return xerrors.New("coordinator is closed") + } + sockets, ok := c.agentToConnectionSockets[id] if ok { // Publish all nodes that want to connect to the @@ -209,16 +257,16 @@ func (c *Coordinator) ServeAgent(conn net.Conn, id uuid.UUID) error { } nodes = append(nodes, node) } + c.mutex.Unlock() data, err := json.Marshal(nodes) if err != nil { - c.mutex.Unlock() return xerrors.Errorf("marshal json: %w", err) } _, err = conn.Write(data) if err != nil { - c.mutex.Unlock() return xerrors.Errorf("write nodes: %w", err) } + c.mutex.Lock() } // If an old agent socket is connected, we close it @@ -239,36 +287,84 @@ func (c *Coordinator) ServeAgent(conn net.Conn, id uuid.UUID) error { decoder := json.NewDecoder(conn) for { - var node Node - err := decoder.Decode(&node) - if errors.Is(err, io.EOF) { - return nil - } + err := c.handleNextAgentMessage(id, decoder) if err != nil { - return xerrors.Errorf("read json: %w", err) + if errors.Is(err, io.EOF) { + return nil + } + return xerrors.Errorf("handle next agent message: %w", err) } - c.mutex.Lock() - c.nodes[id] = &node - connectionSockets, ok := c.agentToConnectionSockets[id] - if !ok { - c.mutex.Unlock() - continue - } - data, err := json.Marshal([]*Node{&node}) - if err != nil { - return xerrors.Errorf("marshal nodes: %w", err) - } - // Publish the new node to every listening socket. - var wg sync.WaitGroup - wg.Add(len(connectionSockets)) - for _, connectionSocket := range connectionSockets { - connectionSocket := connectionSocket + } +} + +func (c *coordinator) handleNextAgentMessage(id uuid.UUID, decoder *json.Decoder) error { + var node Node + err := decoder.Decode(&node) + if err != nil { + return xerrors.Errorf("read json: %w", err) + } + + c.mutex.Lock() + c.nodes[id] = &node + connectionSockets, ok := c.agentToConnectionSockets[id] + if !ok { + c.mutex.Unlock() + return nil + } + data, err := json.Marshal([]*Node{&node}) + if err != nil { + return xerrors.Errorf("marshal nodes: %w", err) + } + + // Publish the new node to every listening socket. + var wg sync.WaitGroup + wg.Add(len(connectionSockets)) + for _, connectionSocket := range connectionSockets { + connectionSocket := connectionSocket + go func() { + _ = connectionSocket.SetWriteDeadline(time.Now().Add(5 * time.Second)) + _, _ = connectionSocket.Write(data) + wg.Done() + }() + } + + c.mutex.Unlock() + wg.Wait() + return nil +} + +// Close closes all of the open connections in the coordinator and stops the +// coordinator from accepting new connections. +func (c *coordinator) Close() error { + c.mutex.Lock() + if c.closed { + return nil + } + c.closed = true + c.mutex.Unlock() + + wg := sync.WaitGroup{} + + wg.Add(len(c.agentSockets)) + for _, socket := range c.agentSockets { + socket := socket + go func() { + _ = socket.Close() + wg.Done() + }() + } + + for _, connMap := range c.agentToConnectionSockets { + wg.Add(len(connMap)) + for _, socket := range connMap { + socket := socket go func() { - _, _ = connectionSocket.Write(data) + _ = socket.Close() wg.Done() }() } - c.mutex.Unlock() - wg.Wait() } + + wg.Wait() + return nil } diff --git a/tailnet/coordinator_test.go b/tailnet/coordinator_test.go index f3fdab88d5..a4a020dead 100644 --- a/tailnet/coordinator_test.go +++ b/tailnet/coordinator_test.go @@ -32,8 +32,8 @@ func TestCoordinator(t *testing.T) { require.Eventually(t, func() bool { return coordinator.Node(id) != nil }, testutil.WaitShort, testutil.IntervalFast) - err := client.Close() - require.NoError(t, err) + require.NoError(t, client.Close()) + require.NoError(t, server.Close()) <-errChan <-closeChan }) diff --git a/testutil/certificate.go b/testutil/certificate.go new file mode 100644 index 0000000000..1edc975746 --- /dev/null +++ b/testutil/certificate.go @@ -0,0 +1,53 @@ +package testutil + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func GenerateTLSCertificate(t testing.TB, commonName string) tls.Certificate { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + CommonName: commonName, + }, + DNSNames: []string{commonName}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 180), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + require.NoError(t, err) + var certFile bytes.Buffer + require.NoError(t, err) + _, err = certFile.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})) + require.NoError(t, err) + privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + require.NoError(t, err) + var keyFile bytes.Buffer + err = pem.Encode(&keyFile, &pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyBytes}) + require.NoError(t, err) + cert, err := tls.X509KeyPair(certFile.Bytes(), keyFile.Bytes()) + require.NoError(t, err) + return cert +} From d2ee18c14f5bf49466eea956326af7aeb7e31908 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 10:27:28 -0500 Subject: [PATCH 21/51] fix: Don't check for existing replicas when starting up (#4587) This was blocking startup, creating a chicken and egg problem where if a replica died, a single one couldn't be bootstrapped again. --- enterprise/replicasync/replicasync.go | 8 -------- enterprise/replicasync/replicasync_test.go | 17 ----------------- 2 files changed, 25 deletions(-) diff --git a/enterprise/replicasync/replicasync.go b/enterprise/replicasync/replicasync.go index 0534c55246..daac634efe 100644 --- a/enterprise/replicasync/replicasync.go +++ b/enterprise/replicasync/replicasync.go @@ -93,14 +93,6 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, pubsub data if err != nil { return nil, xerrors.Errorf("run replica: %w", err) } - peers := manager.Regional() - if len(peers) > 0 { - self := manager.Self() - if self.RelayAddress == "" { - return nil, xerrors.Errorf("a relay address must be specified when running multiple replicas in the same region") - } - } - err = manager.subscribe(ctx) if err != nil { return nil, xerrors.Errorf("subscribe: %w", err) diff --git a/enterprise/replicasync/replicasync_test.go b/enterprise/replicasync/replicasync_test.go index b7709c1f6f..7538b48a38 100644 --- a/enterprise/replicasync/replicasync_test.go +++ b/enterprise/replicasync/replicasync_test.go @@ -44,23 +44,6 @@ func TestReplica(t *testing.T) { _ = server.Close() require.NoError(t, err) }) - t.Run("ErrorsWithoutRelayAddress", func(t *testing.T) { - // Ensures that the replica reports a successful status for - // accessing all of its peers. - t.Parallel() - db, pubsub := dbtestutil.NewDB(t) - _, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{ - ID: uuid.New(), - CreatedAt: database.Now(), - StartedAt: database.Now(), - UpdatedAt: database.Now(), - Hostname: "something", - }) - require.NoError(t, err) - _, err = replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, nil) - require.Error(t, err) - require.Equal(t, "a relay address must be specified when running multiple replicas in the same region", err.Error()) - }) t.Run("ConnectsToPeerReplica", func(t *testing.T) { // Ensures that the replica reports a successful status for // accessing all of its peers. From 29acd25b4ec20f35ed4c10b30b14b33ff1ca5204 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 17 Oct 2022 11:45:29 -0500 Subject: [PATCH 22/51] fix: chrome requests hanging over port-forward (#4588) --- agent/agent.go | 16 +++++++++++++--- cli/portforward.go | 9 ++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index f7c5598b7b..ffaf2ed454 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -879,12 +879,22 @@ func (r *reconnectingPTY) Close() { // after one or both of them are done writing. If the context is canceled, both // of the connections will be closed. func Bicopy(ctx context.Context, c1, c2 io.ReadWriteCloser) { - defer c1.Close() - defer c2.Close() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + defer func() { + _ = c1.Close() + _ = c2.Close() + }() var wg sync.WaitGroup copyFunc := func(dst io.WriteCloser, src io.Reader) { - defer wg.Done() + defer func() { + wg.Done() + // If one side of the copy fails, ensure the other one exits as + // well. + cancel() + }() _, _ = io.Copy(dst, src) } diff --git a/cli/portforward.go b/cli/portforward.go index 5a6f4391dd..911e8fb520 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -138,8 +138,7 @@ func portForward() *cobra.Command { case <-ctx.Done(): closeErr = ctx.Err() case <-sigs: - _, _ = fmt.Fprintln(cmd.OutOrStderr(), "Received signal, closing all listeners and active connections") - closeErr = xerrors.New("signal received") + _, _ = fmt.Fprintln(cmd.OutOrStderr(), "\nReceived signal, closing all listeners and active connections") } cancel() @@ -213,7 +212,11 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersd for { netConn, err := l.Accept() if err != nil { - _, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error accepting connection from '%v://%v': %+v\n", spec.listenNetwork, spec.listenAddress, err) + // Silently ignore net.ErrClosed errors. + if xerrors.Is(err, net.ErrClosed) { + return + } + _, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error accepting connection from '%v://%v': %v\n", spec.listenNetwork, spec.listenAddress, err) _, _ = fmt.Fprintln(cmd.OutOrStderr(), "Killing listener") return } From 9b5d627a5567c25d8c1bf308881ca3a0c46009a8 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 17 Oct 2022 11:47:45 -0500 Subject: [PATCH 23/51] fix(tailnet): data race in `coordinator.Close()` (#4589) --- tailnet/coordinator.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailnet/coordinator.go b/tailnet/coordinator.go index 4216bbc624..ded78e64aa 100644 --- a/tailnet/coordinator.go +++ b/tailnet/coordinator.go @@ -338,10 +338,10 @@ func (c *coordinator) handleNextAgentMessage(id uuid.UUID, decoder *json.Decoder func (c *coordinator) Close() error { c.mutex.Lock() if c.closed { + c.mutex.Unlock() return nil } c.closed = true - c.mutex.Unlock() wg := sync.WaitGroup{} @@ -365,6 +365,8 @@ func (c *coordinator) Close() error { } } + c.mutex.Unlock() + wg.Wait() return nil } From 6b1b3a2037ae99beeca12d8cad1476aa8ce89bc3 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 12:22:59 -0500 Subject: [PATCH 24/51] feat: Add deployment settings page (#4590) * Add base components for the Settings Page * WIP OIDC page * Imrove layout * Add table * Abstract option * Refactor badges * Load settings from the API * Update deployment page * feat: Add deployment settings page This allows deployment admins to view options set on their deployments. * Format * Remove replicas table since it's not used * Remove references to HA table * Fix tests * Improve language Co-authored-by: Bruno Quaresma --- codersdk/flags.go | 4 +- site/src/AppRouter.tsx | 72 ++++ site/src/api/api.ts | 11 + site/src/api/typesGenerated.ts | 4 +- .../DeploySettingsLayout/Badges.tsx | 79 +++++ .../DeploySettingsLayout.tsx | 73 ++++ .../DeploySettingsLayout/Header.tsx | 67 ++++ .../DeploySettingsLayout/Option.tsx | 40 +++ .../DeploySettingsLayout/Sidebar.tsx | 114 +++++++ site/src/components/Navbar/Navbar.tsx | 2 + .../components/NavbarView/NavbarView.test.tsx | 89 ++++- site/src/components/NavbarView/NavbarView.tsx | 26 +- .../DeploySettingsPage/AuthSettingsPage.tsx | 314 ++++++++++++++++++ .../GeneralSettingsPage.tsx | 85 +++++ .../NetworkSettingsPage.tsx | 121 +++++++ .../SecuritySettingsPage.tsx | 231 +++++++++++++ site/src/xServices/StateContext.tsx | 4 + site/src/xServices/auth/authXService.ts | 38 +++ .../deploymentFlags/deploymentFlagsMachine.ts | 61 ++++ 19 files changed, 1420 insertions(+), 15 deletions(-) create mode 100644 site/src/components/DeploySettingsLayout/Badges.tsx create mode 100644 site/src/components/DeploySettingsLayout/DeploySettingsLayout.tsx create mode 100644 site/src/components/DeploySettingsLayout/Header.tsx create mode 100644 site/src/components/DeploySettingsLayout/Option.tsx create mode 100644 site/src/components/DeploySettingsLayout/Sidebar.tsx create mode 100644 site/src/pages/DeploySettingsPage/AuthSettingsPage.tsx create mode 100644 site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx create mode 100644 site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx create mode 100644 site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx create mode 100644 site/src/xServices/deploymentFlags/deploymentFlagsMachine.ts diff --git a/codersdk/flags.go b/codersdk/flags.go index 09ca65b1ea..bf407760bb 100644 --- a/codersdk/flags.go +++ b/codersdk/flags.go @@ -38,7 +38,7 @@ type DeploymentFlags struct { OAuth2GithubEnterpriseBaseURL *StringFlag `json:"oauth2_github_enterprise_base_url" typescript:",notnull"` OIDCAllowSignups *BoolFlag `json:"oidc_allow_signups" typescript:",notnull"` OIDCClientID *StringFlag `json:"oidc_client_id" typescript:",notnull"` - OIDCClientSecret *StringFlag `json:"oidc_cliet_secret" typescript:",notnull"` + OIDCClientSecret *StringFlag `json:"oidc_client_secret" typescript:",notnull"` OIDCEmailDomain *StringFlag `json:"oidc_email_domain" typescript:",notnull"` OIDCIssuerURL *StringFlag `json:"oidc_issuer_url" typescript:",notnull"` OIDCScopes *StringArrayFlag `json:"oidc_scopes" typescript:",notnull"` @@ -49,7 +49,7 @@ type DeploymentFlags struct { TLSCertFiles *StringArrayFlag `json:"tls_cert_files" typescript:",notnull"` TLSClientCAFile *StringFlag `json:"tls_client_ca_file" typescript:",notnull"` TLSClientAuth *StringFlag `json:"tls_client_auth" typescript:",notnull"` - TLSKeyFiles *StringArrayFlag `json:"tls_key_tiles" typescript:",notnull"` + TLSKeyFiles *StringArrayFlag `json:"tls_key_files" typescript:",notnull"` TLSMinVersion *StringFlag `json:"tls_min_version" typescript:",notnull"` TraceEnable *BoolFlag `json:"trace_enable" typescript:",notnull"` SecureAuthCookie *BoolFlag `json:"secure_auth_cookie" typescript:",notnull"` diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index ea078c17c6..c0759aecaf 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -21,6 +21,7 @@ import { XServiceContext } from "xServices/StateContext" import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame" import { RequireAuth } from "./components/RequireAuth/RequireAuth" import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout" +import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout" // Lazy load pages // - Pages that are secondary, not in the main navigation or not usually accessed @@ -67,6 +68,18 @@ const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage")) const SettingsGroupPage = lazy( () => import("./pages/GroupsPage/SettingsGroupPage"), ) +const GeneralSettingsPage = lazy( + () => import("./pages/DeploySettingsPage/GeneralSettingsPage"), +) +const SecuritySettingsPage = lazy( + () => import("./pages/DeploySettingsPage/SecuritySettingsPage"), +) +const AuthSettingsPage = lazy( + () => import("./pages/DeploySettingsPage/AuthSettingsPage"), +) +const NetworkSettingsPage = lazy( + () => import("./pages/DeploySettingsPage/NetworkSettingsPage"), +) export const AppRouter: FC = () => { const xServices = useContext(XServiceContext) @@ -237,6 +250,65 @@ export const AppRouter: FC = () => { /> + + + + + + + + + } + /> + + + + + + + + } + /> + + + + + + + + } + /> + + + + + + + + } + /> + + }> } /> } /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index fb12571fd9..e9a0cd7e44 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -641,3 +641,14 @@ export const getAgentListeningPorts = async ( ) return response.data } + +export const getDeploymentFlags = + async (): Promise => { + const response = await axios.get(`/api/v2/flags/deployment`) + return response.data + } + +export const getReplicas = async (): Promise => { + const response = await axios.get(`/api/v2/replicas`) + return response.data +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a4b2cf83a9..d6305b3dbc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -293,7 +293,7 @@ export interface DeploymentFlags { readonly oauth2_github_enterprise_base_url: StringFlag readonly oidc_allow_signups: BoolFlag readonly oidc_client_id: StringFlag - readonly oidc_cliet_secret: StringFlag + readonly oidc_client_secret: StringFlag readonly oidc_email_domain: StringFlag readonly oidc_issuer_url: StringFlag readonly oidc_scopes: StringArrayFlag @@ -304,7 +304,7 @@ export interface DeploymentFlags { readonly tls_cert_files: StringArrayFlag readonly tls_client_ca_file: StringFlag readonly tls_client_auth: StringFlag - readonly tls_key_tiles: StringArrayFlag + readonly tls_key_files: StringArrayFlag readonly tls_min_version: StringFlag readonly trace_enable: BoolFlag readonly secure_auth_cookie: BoolFlag diff --git a/site/src/components/DeploySettingsLayout/Badges.tsx b/site/src/components/DeploySettingsLayout/Badges.tsx new file mode 100644 index 0000000000..7dd2600901 --- /dev/null +++ b/site/src/components/DeploySettingsLayout/Badges.tsx @@ -0,0 +1,79 @@ +import { makeStyles } from "@material-ui/core/styles" +import { Stack } from "components/Stack/Stack" +import React, { PropsWithChildren } from "react" +import { combineClasses } from "util/combineClasses" + +export const EnabledBadge: React.FC = () => { + const styles = useStyles() + return ( + + Enabled + + ) +} + +export const DisabledBadge: React.FC = () => { + const styles = useStyles() + return ( + + Disabled + + ) +} + +export const EnterpriseBadge: React.FC = () => { + const styles = useStyles() + return ( + + Enterprise + + ) +} + +export const Badges: React.FC = ({ children }) => { + const styles = useStyles() + return ( + + {children} + + ) +} + +const useStyles = makeStyles((theme) => ({ + badges: { + margin: theme.spacing(0, 0, 2), + }, + + badge: { + fontSize: 10, + height: 24, + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.085em", + padding: theme.spacing(0, 1.5), + borderRadius: 9999, + display: "flex", + alignItems: "center", + width: "fit-content", + }, + + enterpriseBadge: { + backgroundColor: theme.palette.info.dark, + border: `1px solid ${theme.palette.info.light}`, + }, + + enabledBadge: { + border: `1px solid ${theme.palette.success.light}`, + backgroundColor: theme.palette.success.dark, + }, + + disabledBadge: { + border: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.paper, + }, +})) diff --git a/site/src/components/DeploySettingsLayout/DeploySettingsLayout.tsx b/site/src/components/DeploySettingsLayout/DeploySettingsLayout.tsx new file mode 100644 index 0000000000..b6abb27841 --- /dev/null +++ b/site/src/components/DeploySettingsLayout/DeploySettingsLayout.tsx @@ -0,0 +1,73 @@ +import { makeStyles } from "@material-ui/core/styles" +import { Margins } from "components/Margins/Margins" +import { Stack } from "components/Stack/Stack" +import { Sidebar } from "./Sidebar" +import React, { + createContext, + PropsWithChildren, + useContext, + useEffect, +} from "react" +import { useActor } from "@xstate/react" +import { XServiceContext } from "xServices/StateContext" +import { Loader } from "components/Loader/Loader" +import { DeploymentFlags } from "api/typesGenerated" + +type DeploySettingsContextValue = { deploymentFlags: DeploymentFlags } + +const DeploySettingsContext = createContext< + DeploySettingsContextValue | undefined +>(undefined) + +export const useDeploySettings = (): DeploySettingsContextValue => { + const context = useContext(DeploySettingsContext) + if (!context) { + throw new Error( + "useDeploySettings should be used inside of DeploySettingsLayout", + ) + } + return context +} + +export const DeploySettingsLayout: React.FC = ({ + children, +}) => { + const xServices = useContext(XServiceContext) + const [state, send] = useActor(xServices.deploymentFlagsXService) + const styles = useStyles() + const { deploymentFlags } = state.context + + useEffect(() => { + if (state.matches("idle")) { + send("LOAD") + } + }, [send, state]) + + return ( + + + +
+ {deploymentFlags ? ( + + {children} + + ) : ( + + )} +
+
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + wrapper: { + padding: theme.spacing(6, 0), + }, + + content: { + maxWidth: 800, + width: "100%", + }, +})) diff --git a/site/src/components/DeploySettingsLayout/Header.tsx b/site/src/components/DeploySettingsLayout/Header.tsx new file mode 100644 index 0000000000..ace8b79bf0 --- /dev/null +++ b/site/src/components/DeploySettingsLayout/Header.tsx @@ -0,0 +1,67 @@ +import Button from "@material-ui/core/Button" +import { makeStyles } from "@material-ui/core/styles" +import LaunchOutlined from "@material-ui/icons/LaunchOutlined" +import { Stack } from "components/Stack/Stack" +import React from "react" + +export const Header: React.FC<{ + title: string | JSX.Element + description: string | JSX.Element + secondary?: boolean + docsHref?: string +}> = ({ title, description, docsHref, secondary }) => { + const styles = useStyles() + + return ( + +
+

+ {title} +

+ {description} +
+ + {docsHref && ( + + )} +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + headingGroup: { + maxWidth: 420, + marginBottom: theme.spacing(3), + }, + + title: { + fontSize: 32, + fontWeight: 700, + display: "flex", + alignItems: "center", + lineHeight: "initial", + margin: 0, + marginBottom: theme.spacing(0.5), + gap: theme.spacing(1), + + "&.secondary": { + fontSize: 24, + fontWeight: 500, + }, + }, + + description: { + fontSize: 14, + color: theme.palette.text.secondary, + lineHeight: "160%", + }, +})) diff --git a/site/src/components/DeploySettingsLayout/Option.tsx b/site/src/components/DeploySettingsLayout/Option.tsx new file mode 100644 index 0000000000..3d59f0e056 --- /dev/null +++ b/site/src/components/DeploySettingsLayout/Option.tsx @@ -0,0 +1,40 @@ +import { makeStyles } from "@material-ui/core/styles" +import React, { PropsWithChildren } from "react" +import { MONOSPACE_FONT_FAMILY } from "theme/constants" + +export const OptionName: React.FC = ({ children }) => { + const styles = useStyles() + return {children} +} + +export const OptionDescription: React.FC = ({ + children, +}) => { + const styles = useStyles() + return {children} +} + +export const OptionValue: React.FC = ({ children }) => { + const styles = useStyles() + return {children} +} + +const useStyles = makeStyles((theme) => ({ + optionName: { + display: "block", + }, + optionDescription: { + display: "block", + color: theme.palette.text.secondary, + fontSize: 14, + marginTop: theme.spacing(0.5), + }, + optionValue: { + fontSize: 14, + fontFamily: MONOSPACE_FONT_FAMILY, + + "& ul": { + padding: theme.spacing(2), + }, + }, +})) diff --git a/site/src/components/DeploySettingsLayout/Sidebar.tsx b/site/src/components/DeploySettingsLayout/Sidebar.tsx new file mode 100644 index 0000000000..ad195acbdc --- /dev/null +++ b/site/src/components/DeploySettingsLayout/Sidebar.tsx @@ -0,0 +1,114 @@ +import { makeStyles } from "@material-ui/core/styles" +import LaunchOutlined from "@material-ui/icons/LaunchOutlined" +import LockRounded from "@material-ui/icons/LockRounded" +import Globe from "@material-ui/icons/Public" +import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined" +import { Stack } from "components/Stack/Stack" +import React, { ElementType, PropsWithChildren, ReactNode } from "react" +import { NavLink } from "react-router-dom" +import { combineClasses } from "util/combineClasses" + +const SidebarNavItem: React.FC< + PropsWithChildren<{ href: string; icon: ReactNode }> +> = ({ children, href, icon }) => { + const styles = useStyles() + return ( + + combineClasses([ + styles.sidebarNavItem, + isActive ? styles.sidebarNavItemActive : undefined, + ]) + } + > + + {icon} + {children} + + + ) +} + +const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({ + icon: Icon, +}) => { + const styles = useStyles() + return +} + +export const Sidebar: React.FC = () => { + const styles = useStyles() + + return ( + + ) +} + +const useStyles = makeStyles((theme) => ({ + sidebar: { + width: 245, + }, + + sidebarNavItem: { + color: "inherit", + display: "block", + fontSize: 16, + textDecoration: "none", + padding: theme.spacing(1.5, 1.5, 1.5, 3), + borderRadius: theme.shape.borderRadius / 2, + transition: "background-color 0.15s ease-in-out", + marginBottom: 1, + position: "relative", + + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, + }, + + sidebarNavItemActive: { + backgroundColor: theme.palette.action.hover, + + "&:before": { + content: '""', + display: "block", + width: 3, + height: "100%", + position: "absolute", + left: 0, + top: 0, + backgroundColor: theme.palette.secondary.dark, + borderRadius: theme.shape.borderRadius, + }, + }, + + sidebarNavItemIcon: { + width: theme.spacing(2), + height: theme.spacing(2), + }, +})) diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index c9c51b6a7a..f9ffe05b54 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -17,6 +17,7 @@ export const Navbar: React.FC = () => { const canViewAuditLog = featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) + const canViewDeployment = Boolean(permissions?.viewDeploymentFlags) const onSignOut = () => authSend("SIGN_OUT") return ( @@ -24,6 +25,7 @@ export const Navbar: React.FC = () => { user={me} onSignOut={onSignOut} canViewAuditLog={canViewAuditLog} + canViewDeployment={canViewDeployment} /> ) } diff --git a/site/src/components/NavbarView/NavbarView.test.tsx b/site/src/components/NavbarView/NavbarView.test.tsx index 58ffd82766..2b65a81b00 100644 --- a/site/src/components/NavbarView/NavbarView.test.tsx +++ b/site/src/components/NavbarView/NavbarView.test.tsx @@ -22,26 +22,54 @@ describe("NavbarView", () => { it("renders content", async () => { // When - render() + render( + , + ) // Then await screen.findAllByText("Coder", { exact: false }) }) it("workspaces nav link has the correct href", async () => { - render() + render( + , + ) const workspacesLink = await screen.findByText(navLanguage.workspaces) expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces") }) it("templates nav link has the correct href", async () => { - render() + render( + , + ) const templatesLink = await screen.findByText(navLanguage.templates) expect((templatesLink as HTMLAnchorElement).href).toContain("/templates") }) it("users nav link has the correct href", async () => { - render() + render( + , + ) const userLink = await screen.findByText(navLanguage.users) expect((userLink as HTMLAnchorElement).href).toContain("/users") }) @@ -55,7 +83,14 @@ describe("NavbarView", () => { } // When - render() + render( + , + ) // Then // There should be a 'B' avatar! @@ -64,16 +99,56 @@ describe("NavbarView", () => { }) it("audit nav link has the correct href", async () => { - render() + render( + , + ) const auditLink = await screen.findByText(navLanguage.audit) expect((auditLink as HTMLAnchorElement).href).toContain("/audit") }) it("audit nav link is hidden for members", async () => { render( - , + , ) const auditLink = screen.queryByText(navLanguage.audit) expect(auditLink).not.toBeInTheDocument() }) + + it("deployment nav link has the correct href", async () => { + render( + , + ) + const auditLink = await screen.findByText(navLanguage.deployment) + expect((auditLink as HTMLAnchorElement).href).toContain( + "/settings/deployment/general", + ) + }) + + it("deployment nav link is hidden for members", async () => { + render( + , + ) + const auditLink = screen.queryByText(navLanguage.deployment) + expect(auditLink).not.toBeInTheDocument() + }) }) diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 03a4451314..c7d2260344 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -17,6 +17,7 @@ export interface NavbarViewProps { user?: TypesGen.User onSignOut: () => void canViewAuditLog: boolean + canViewDeployment: boolean } export const Language = { @@ -24,11 +25,16 @@ export const Language = { templates: "Templates", users: "Users", audit: "Audit", + deployment: "Deployment", } const NavItems: React.FC< - React.PropsWithChildren<{ className?: string; canViewAuditLog: boolean }> -> = ({ className, canViewAuditLog }) => { + React.PropsWithChildren<{ + className?: string + canViewAuditLog: boolean + canViewDeployment: boolean + }> +> = ({ className, canViewAuditLog, canViewDeployment }) => { const styles = useStyles() const location = useLocation() @@ -65,6 +71,13 @@ const NavItems: React.FC< )} + {canViewDeployment && ( + + + {Language.deployment} + + + )} ) } @@ -72,6 +85,7 @@ export const NavbarView: React.FC> = ({ user, onSignOut, canViewAuditLog, + canViewDeployment, }) => { const styles = useStyles() const [isDrawerOpen, setIsDrawerOpen] = useState(false) @@ -98,7 +112,10 @@ export const NavbarView: React.FC> = ({
- +
@@ -109,6 +126,7 @@ export const NavbarView: React.FC> = ({
@@ -192,7 +210,7 @@ const useStyles = makeStyles((theme) => ({ fontSize: 16, padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, textDecoration: "none", - transition: "background-color 0.3s ease", + transition: "background-color 0.15s ease-in-out", "&:hover": { backgroundColor: theme.palette.action.hover, diff --git a/site/src/pages/DeploySettingsPage/AuthSettingsPage.tsx b/site/src/pages/DeploySettingsPage/AuthSettingsPage.tsx new file mode 100644 index 0000000000..066ccfe447 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/AuthSettingsPage.tsx @@ -0,0 +1,314 @@ +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableContainer from "@material-ui/core/TableContainer" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import { + Badges, + DisabledBadge, + EnabledBadge, +} from "components/DeploySettingsLayout/Badges" +import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout" +import { Header } from "components/DeploySettingsLayout/Header" +import { + OptionDescription, + OptionName, + OptionValue, +} from "components/DeploySettingsLayout/Option" +import { Stack } from "components/Stack/Stack" +import React from "react" + +const AuthSettingsPage: React.FC = () => { + const { deploymentFlags } = useDeploySettings() + + return ( + <> + +
+
+ + + {deploymentFlags.oidc_client_id.value ? ( + + ) : ( + + )} + + + +
+ + + Option + Value + + + + + + + {deploymentFlags.oidc_client_id.name} + + + {deploymentFlags.oidc_client_id.description} + + + + + + {deploymentFlags.oidc_client_id.value} + + + + + + + + {deploymentFlags.oidc_client_secret.name} + + + {deploymentFlags.oidc_client_secret.description} + + + + + + {deploymentFlags.oidc_client_secret.value} + + + + + + + + {deploymentFlags.oidc_allow_signups.name} + + + {deploymentFlags.oidc_allow_signups.description} + + + + + + {deploymentFlags.oidc_allow_signups.value.toString()} + + + + + + + + {deploymentFlags.oidc_email_domain.name} + + + {deploymentFlags.oidc_email_domain.description} + + + + + + {deploymentFlags.oidc_email_domain.value} + + + + + + + + {deploymentFlags.oidc_issuer_url.name} + + + {deploymentFlags.oidc_issuer_url.description} + + + + + + {deploymentFlags.oidc_issuer_url.value} + + + + + + + {deploymentFlags.oidc_scopes.name} + + {deploymentFlags.oidc_scopes.description} + + + + + +
    + {deploymentFlags.oidc_scopes.value.map((scope) => ( +
  • {scope}
  • + ))} +
+
+
+
+
+
+ +
+ +
+
+ + + {deploymentFlags.oauth2_github_client_id.value ? ( + + ) : ( + + )} + + + + + + + Option + Value + + + + + + + {deploymentFlags.oauth2_github_client_id.name} + + + {deploymentFlags.oauth2_github_client_id.description} + + + + + + {deploymentFlags.oauth2_github_client_id.value} + + + + + + + + {deploymentFlags.oauth2_github_client_secret.name} + + + {deploymentFlags.oauth2_github_client_secret.description} + + + + + + {deploymentFlags.oauth2_github_client_secret.value} + + + + + + + + {deploymentFlags.oauth2_github_allow_signups.name} + + + {deploymentFlags.oauth2_github_allow_signups.description} + + + + + + {deploymentFlags.oauth2_github_allow_signups.value.toString()} + + + + + + + + {deploymentFlags.oauth2_github_allowed_organizations.name} + + + { + deploymentFlags.oauth2_github_allowed_organizations + .description + } + + + + + +
    + {deploymentFlags.oauth2_github_allowed_organizations.value.map( + (org) => ( +
  • {org}
  • + ), + )} +
+
+
+
+ + + + + {deploymentFlags.oauth2_github_allowed_teams.name} + + + {deploymentFlags.oauth2_github_allowed_teams.description} + + + + + +
    + {deploymentFlags.oauth2_github_allowed_teams.value.map( + (team) => ( +
  • {team}
  • + ), + )} +
+
+
+
+ + + + + {deploymentFlags.oauth2_github_enterprise_base_url.name} + + + { + deploymentFlags.oauth2_github_enterprise_base_url + .description + } + + + + + + {deploymentFlags.oauth2_github_enterprise_base_url.value} + + + +
+
+
+
+ + + ) +} + +export default AuthSettingsPage diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx new file mode 100644 index 0000000000..becc21b5ee --- /dev/null +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx @@ -0,0 +1,85 @@ +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableContainer from "@material-ui/core/TableContainer" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout" +import { Header } from "components/DeploySettingsLayout/Header" +import { + OptionDescription, + OptionName, + OptionValue, +} from "components/DeploySettingsLayout/Option" +import React from "react" + +const GeneralSettingsPage: React.FC = () => { + const { deploymentFlags } = useDeploySettings() + + return ( + <> +
+ + + + + + Option + Value + + + + + + {deploymentFlags.access_url.name} + + {deploymentFlags.access_url.description} + + + + + {deploymentFlags.access_url.value} + + + + + + {deploymentFlags.address.name} + + {deploymentFlags.address.description} + + + + + {deploymentFlags.address.value} + + + + + + + {deploymentFlags.wildcard_access_url.name} + + + {deploymentFlags.wildcard_access_url.description} + + + + + + {deploymentFlags.wildcard_access_url.value} + + + + +
+
+ + ) +} + +export default GeneralSettingsPage diff --git a/site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx b/site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx new file mode 100644 index 0000000000..ccdfbc605c --- /dev/null +++ b/site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx @@ -0,0 +1,121 @@ +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableContainer from "@material-ui/core/TableContainer" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import { + DisabledBadge, + EnabledBadge, +} from "components/DeploySettingsLayout/Badges" +import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout" +import { Header } from "components/DeploySettingsLayout/Header" +import { + OptionDescription, + OptionName, + OptionValue, +} from "components/DeploySettingsLayout/Option" +import { Stack } from "components/Stack/Stack" +import React from "react" + +const NetworkSettingsPage: React.FC = () => { + const { deploymentFlags } = useDeploySettings() + + return ( + +
+
+ + + + + + Option + Value + + + + + + + {deploymentFlags.derp_server_enabled.name} + + + {deploymentFlags.derp_server_enabled.description} + + + + + + {deploymentFlags.derp_server_enabled.value ? ( + + ) : ( + + )} + + + + + + + + {deploymentFlags.derp_server_region_name.name} + + + {deploymentFlags.derp_server_region_name.description} + + + + + + {deploymentFlags.derp_server_region_name.value} + + + + + + + + {deploymentFlags.derp_server_stun_address.name} + + + {deploymentFlags.derp_server_stun_address.description} + + + + + + {deploymentFlags.derp_server_stun_address.value} + + + + + + + + {deploymentFlags.derp_config_url.name} + + + {deploymentFlags.derp_config_url.description} + + + + + + {deploymentFlags.derp_config_url.value} + + + + +
+
+
+
+ ) +} + +export default NetworkSettingsPage diff --git a/site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx b/site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx new file mode 100644 index 0000000000..6dfa81a882 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx @@ -0,0 +1,231 @@ +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableContainer from "@material-ui/core/TableContainer" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import { useActor } from "@xstate/react" +import { FeatureNames } from "api/types" +import { + Badges, + DisabledBadge, + EnabledBadge, + EnterpriseBadge, +} from "components/DeploySettingsLayout/Badges" +import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout" +import { Header } from "components/DeploySettingsLayout/Header" +import { + OptionDescription, + OptionName, + OptionValue, +} from "components/DeploySettingsLayout/Option" +import { Stack } from "components/Stack/Stack" +import React, { useContext } from "react" +import { XServiceContext } from "xServices/StateContext" + +const SecuritySettingsPage: React.FC = () => { + const { deploymentFlags } = useDeploySettings() + const xServices = useContext(XServiceContext) + const [entitlementsState] = useActor(xServices.entitlementsXService) + + return ( + +
+
+ + + + + + Option + Value + + + + + + + {deploymentFlags.ssh_keygen_algorithm.name} + + + {deploymentFlags.ssh_keygen_algorithm.description} + + + + + + {deploymentFlags.ssh_keygen_algorithm.value} + + + + + + + {deploymentFlags.secure_auth_cookie.name} + + + {deploymentFlags.secure_auth_cookie.description} + + + + + + {deploymentFlags.secure_auth_cookie.value ? ( + + ) : ( + + )} + + + + +
+
+
+ +
+
+ + + {entitlementsState.context.entitlements.features[ + FeatureNames.AuditLog + ].enabled ? ( + + ) : ( + + )} + + +
+ +
+
+ + + {entitlementsState.context.entitlements.features[ + FeatureNames.BrowserOnly + ].enabled ? ( + + ) : ( + + )} + + +
+ +
+
+ + + + + + Option + Value + + + + + + {deploymentFlags.tls_enable.name} + + {deploymentFlags.tls_enable.description} + + + + + + {deploymentFlags.tls_enable.value ? ( + + ) : ( + + )} + + + + + + + {deploymentFlags.tls_cert_files.name} + + {deploymentFlags.tls_cert_files.description} + + + + + +
    + {deploymentFlags.tls_cert_files.value.map( + (file, index) => ( +
  • {file}
  • + ), + )} +
+
+
+
+ + + + {deploymentFlags.tls_key_files.name} + + {deploymentFlags.tls_key_files.description} + + + + + +
    + {deploymentFlags.tls_key_files.value.map( + (file, index) => ( +
  • {file}
  • + ), + )} +
+
+
+
+ + + + + {deploymentFlags.tls_min_version.name} + + + {deploymentFlags.tls_min_version.description} + + + + + + {deploymentFlags.tls_min_version.value} + + + +
+
+
+
+
+ ) +} + +export default SecuritySettingsPage diff --git a/site/src/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index 78ac6fe452..565ca3023a 100644 --- a/site/src/xServices/StateContext.tsx +++ b/site/src/xServices/StateContext.tsx @@ -3,6 +3,7 @@ import { createContext, FC, ReactNode } from "react" import { ActorRefFrom } from "xstate" import { authMachine } from "./auth/authXService" import { buildInfoMachine } from "./buildInfo/buildInfoXService" +import { deploymentFlagsMachine } from "./deploymentFlags/deploymentFlagsMachine" import { entitlementsMachine } from "./entitlements/entitlementsXService" import { siteRolesMachine } from "./roles/siteRolesXService" @@ -11,6 +12,8 @@ interface XServiceContextType { buildInfoXService: ActorRefFrom entitlementsXService: ActorRefFrom siteRolesXService: ActorRefFrom + // Since the info here is used by multiple deployment settings page and we don't want to refetch them every time + deploymentFlagsXService: ActorRefFrom } /** @@ -31,6 +34,7 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => { buildInfoXService: useInterpret(buildInfoMachine), entitlementsXService: useInterpret(entitlementsMachine), siteRolesXService: useInterpret(siteRolesMachine), + deploymentFlagsXService: useInterpret(deploymentFlagsMachine), }} > {children} diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index d2cba57637..af25518f8b 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -16,6 +16,7 @@ export const checks = { createTemplates: "createTemplates", deleteTemplates: "deleteTemplates", viewAuditLog: "viewAuditLog", + viewDeploymentFlags: "viewDeploymentFlags", createGroup: "createGroup", } as const @@ -56,6 +57,12 @@ export const permissionsToCheck = { }, action: "read", }, + [checks.viewDeploymentFlags]: { + object: { + resource_type: "deployment_flags", + }, + action: "read", + }, [checks.createGroup]: { object: { resource_type: "group", @@ -93,6 +100,7 @@ export type AuthEvent = | { type: "REGENERATE_SSH_KEY" } | { type: "CONFIRM_REGENERATE_SSH_KEY" } | { type: "CANCEL_REGENERATE_SSH_KEY" } + | { type: "GET_AUTH_METHODS" } export const authMachine = /** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogBsATimEAzDIAcAFgUB2VTJ26ANCACeiAIwaADKsLKFtu-dsBfRwbRZc+IqQolyUBuS0ECJEvgBufADWXmTkAWL8gsKiSBKICubKhKpSyhoArAbGCGaqGoRSlVXVlfnOrhg4eATEsb7+gWAATl18XYQ8ADb4AGZ9ALatFPGpiSRCImKSCLLySmqa2ro6RaYFAEzWDscK9SBuTZ6EMOhCfgCqsN1BIYThUUQ3ALJgCQLzySW6Uy2VyBV2JXyUhMFRqcLqLnOjQ8LRudygj2e3V6-SGowm1zA6B+fySi1Sy32MnyhHyyksYK2ug0EJM1IURxO9jOFxRnyJ6IACt1xiRYKQRLAXpQ3uQItFCABjTBgRWRYVdUXi5LwWb-BYpUDLZRScygvKFIyIGQaQ4FHnI5r827tDVaiXkKXYvoDYboMaapUqtVusUe3W8fWAimIE1mnIW1l0rImOE1BENdxOwkuvw-LB8CBS4Iy94K75EzCFiMgOYGoEIZRUwgyVT5BQmfaW4omGxZGxcuwOrNXNHtfNVou0b24v0ByYVgtF0kA8lG2PN1vtzvd0zszmD06I3nZ7yUCABAa9EYkQahCB32j3QUAEQAggAVACibEFACUqAAMQYAAZL8V3rGMSjbGQKihLsISkOlaWHS4WjPSBLx4a9byIVAeAgfBXRwx8S1COUPkIE8rgwi9yCvPgbzvQh8MIoUSLABB3kVIiRAAbXMABdCDo3XaD8lgpCpAQq0EA0eTUL5KZzywjiWIIoi-EFDjpx6H08X9AlqPQ2JMPo7DGNw9S2OIyy7y4iieINAThL1MlDTScTJPg3dGxkfZFNPUy6OIWBMDeB8wFoJgvw-NhsGwAAJNgAGkvwATREtdPM7VQrE0XyTF7coB0PQKaOCy9xXCsc-ASxKUrAQxpXI+UiGMmIKDM0KaoFdp6sawwHIiJzkhcrKPOWIqkOscFZNTfYOXtY9HQqrqQuqnN0QGprdJxX18UDDrlO6zbaqgHahu43jyHGtzV0m0x9jyxQ5p7ExzBhUrB3Kkz1qqsLCEGPhkAgSAIsfP8vxilgvz-T8f3q1KMomht9g+8ocnMGQCtZDRZAPLkMyREc-pU+jNuB0HwcVEQb01S6-zAGBKC6TxaAAYTfFgOa-EC2ChmG4YR+KkuRzL7sgsT0bMQhzCUcxpMK20vsPBRieO2iAfCqmwYgJU6ZIBmksGpmWe6dmOaoFhgL-L4Behr9Yfh79ReStKJcjdy0Yx0Fsdx+b23MX7OvJnqgZBvXCC6ZmwFZzSLpN3ayNlNqqNWsnTsB3XwZj822e2pOrscm67q9h6GzMBQrDy7cZJ7DR1ZQlbSdDrOdcj3PY-jwuGt2mcDsMo6M7bjbs87-W87ji3e8G4a+FG-ihNRqCq5rtsO3r0wpG0ZvMzQ0eqtVVAunmQwIai5931d7Avw5+4-wYD9PdrKNsqmsoOQyBM3sQZ6pBDidDax9T7oHPqxBO2AQFnxaqnSimtKoU2gWA6ykDkHFxGqXZektRI5U-ooBkiZZIKFyHvEmB8gFH0VCfM+qDtroL2vpOcRkR6UKQdQ0B4CNL0I4Wfeei9brYPLlLPBjcCE-18m2KwGtWFa0CIwVgbAqD3A-CvaWWgYSEN-iUDIZpUxpiqBoQBZ52g0HQLAssoczFqM8k2WCW5N6+X2OYeShMTgyNbspUxdAB4GXnMpaxOD35-w0XLCRrJ0ZWH0QYqQRiW4UOVKqSI7RAJG1gOgTEXQLEUQVMdRJaoUlpIyU8Lo-CsGuWEbg5YmhCD7B3nU-YA5lA6HVhCZxYjoRshyDvJQ+NVCAIAO7IABH4QCfQPwqlSV0dJmT6DMHYJwGx1Sm7Yx0I3SwziTBOLqWaLQdIpC2HyKoLZ5gESInIIWOAYgEHrUCZU4JJRsY0iIT2akZoPEUJMX4GY9zHoIDbIcdGLzt4qFhDEpCgDzqZKWYgVQ+xWSyCyOC2okK+paRFGGHUML-mmnNNorZbIwUxI+Upc6E5qzYq2LUuQwKSitnymrI8+8lJyIYkxe8zELlfj0l0bFVcaRlCklvRspzjGILZVZEgkVCAzj5Y3AV+MfIQjyEy8hLLxUWXZRfOVRVsiKqVhCRuhxtgaBVY3A5MgxX-XMmpCB7E7K-CCX8oq+RnnaIKJa+J6rrUSrvHykwHZZq+X2bScwYbzUSTUBCr1QUfWbSlX6p1lctlusKp2Q4JLY1hzOmixOfdii-MrlIiotKPpyCtdm8e1N9YJsdYWqCmyshyH9vigoVhkWxIre3CO1aDbkHpuMRm3cZ51tft7BtqhzAZoVga+aGhexuOOJmtalaO69qnj3fqRc+UKHpIQIq87S25GXZnMea69Y7qhPuswxVCptnKNsOQ7Z2xUhPYfCmYV-WBtLVOjkJqzUkP6TGldp10EX0IFynlcroRywsI4iEu6ArAdPVQmhKDa0yqg0m1e+NNFwZ3BCNswdkPvuIGB2tcqakuPlgR4h2MWzMgAxartwDeEoLtf1dB-rXVBoQyQljqHOFfq+q2v9jHG7mqUKqm55N-UuN4-NT6DGdD5C0Gp-Ypq31eL8HcsdFcG0yA+rB5QtHijOKOYoNWgD8nJNGUU6F2GxK9LlvSTIcLGn5C2ZUNpLj5CxIUFSD6XmuyDOGeiMZXQJlgCmTMkp2LGm1OUFCHQORGkyEVqoZQbTNly2-poaERy6kh0pYl5LrZpLNIy1l2Sm5dAkPRs4wz+NlDOGcEAA */ @@ -340,6 +348,36 @@ export const authMachine = }, }, }, + methods: { + initial: "idle", + states: { + idle: { + on: { + GET_AUTH_METHODS: { + target: "gettingMethods", + }, + }, + }, + gettingMethods: { + entry: "clearGetMethodsError", + invoke: { + src: "getMethods", + onDone: [ + { + actions: ["assignMethods", "clearGetMethodsError"], + target: "idle", + }, + ], + onError: [ + { + actions: "assignGetMethodsError", + target: "idle", + }, + ], + }, + }, + }, + }, security: { initial: "idle", states: { diff --git a/site/src/xServices/deploymentFlags/deploymentFlagsMachine.ts b/site/src/xServices/deploymentFlags/deploymentFlagsMachine.ts new file mode 100644 index 0000000000..249c49e2b1 --- /dev/null +++ b/site/src/xServices/deploymentFlags/deploymentFlagsMachine.ts @@ -0,0 +1,61 @@ +import { getDeploymentFlags } from "api/api" +import { DeploymentFlags } from "api/typesGenerated" +import { createMachine, assign } from "xstate" + +export const deploymentFlagsMachine = createMachine( + { + id: "deploymentFlagsMachine", + initial: "idle", + schema: { + context: {} as { + deploymentFlags?: DeploymentFlags + getDeploymentFlagsError?: unknown + }, + events: {} as { type: "LOAD" }, + services: {} as { + getDeploymentFlags: { + data: DeploymentFlags + } + }, + }, + tsTypes: {} as import("./deploymentFlagsMachine.typegen").Typegen0, + states: { + idle: { + on: { + LOAD: { + target: "loading", + }, + }, + }, + loading: { + invoke: { + src: "getDeploymentFlags", + onDone: { + target: "loaded", + actions: ["assignDeploymentFlags"], + }, + onError: { + target: "idle", + actions: ["assignGetDeploymentFlagsError"], + }, + }, + }, + loaded: { + type: "final", + }, + }, + }, + { + services: { + getDeploymentFlags, + }, + actions: { + assignDeploymentFlags: assign({ + deploymentFlags: (_, { data }) => data, + }), + assignGetDeploymentFlagsError: assign({ + getDeploymentFlagsError: (_, { data }) => data, + }), + }, + }, +) From 4895e011df863094b89028d9da79fdf585ec1612 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:23:26 -0500 Subject: [PATCH 25/51] chore: bump go.opentelemetry.io/otel from 1.10.0 to 1.11.0 (#4582) Bumps [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) from 1.10.0 to 1.11.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.10.0...v1.11.0) --- updated-dependencies: - dependency-name: go.opentelemetry.io/otel dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 195a09ae2b..8fec641bc9 100644 --- a/go.mod +++ b/go.mod @@ -125,12 +125,12 @@ require ( github.com/u-root/u-root v0.9.0 github.com/unrolled/secure v1.13.0 go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 - go.opentelemetry.io/otel v1.10.0 + go.opentelemetry.io/otel v1.11.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.10.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.10.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.10.0 go.opentelemetry.io/otel/sdk v1.10.0 - go.opentelemetry.io/otel/trace v1.10.0 + go.opentelemetry.io/otel/trace v1.11.0 go.uber.org/atomic v1.10.0 go.uber.org/goleak v1.2.0 golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e diff --git a/go.sum b/go.sum index b80c0d4173..099a4d96cb 100644 --- a/go.sum +++ b/go.sum @@ -1859,8 +1859,8 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.2 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.20.0/go.mod h1:2AboqHi0CiIZU0qwhtUfCYD1GeUzvvIXWNkhDt7ZMG4= go.opentelemetry.io/otel v0.20.0/go.mod h1:Y3ugLH2oa81t5QO+Lty+zXf8zC9L26ax4Nzoxm/dooo= go.opentelemetry.io/otel v1.3.0/go.mod h1:PWIKzi6JCp7sM0k9yZ43VX+T345uNbAkDKwHVjb2PTs= -go.opentelemetry.io/otel v1.10.0 h1:Y7DTJMR6zs1xkS/upamJYk0SxxN4C9AqRd77jmZnyY4= -go.opentelemetry.io/otel v1.10.0/go.mod h1:NbvWjCthWHKBEUMpf0/v8ZRZlni86PpGFEMA9pnQSnQ= +go.opentelemetry.io/otel v1.11.0 h1:kfToEGMDq6TrVrJ9Vht84Y8y9enykSZzDDZglV0kIEk= +go.opentelemetry.io/otel v1.11.0/go.mod h1:H2KtuEphyMvlhZ+F7tg9GRhAOe60moNx61Ex+WmiKkk= go.opentelemetry.io/otel/exporters/otlp v0.20.0/go.mod h1:YIieizyaN77rtLJra0buKiNBOm9XQfkPEKBeuhoMwAM= go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.3.0/go.mod h1:VpP4/RMn8bv8gNo9uK7/IMY4mtWLELsS+JIP0inH0h4= go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0 h1:TaB+1rQhddO1sF71MpZOZAuSPW1klK2M8XxfrBMfK7Y= @@ -1889,8 +1889,8 @@ go.opentelemetry.io/otel/sdk/metric v0.20.0/go.mod h1:knxiS8Xd4E/N+ZqKmUPf3gTTZ4 go.opentelemetry.io/otel/sdk/metric v0.31.0 h1:2sZx4R43ZMhJdteKAlKoHvRgrMp53V1aRxvEf5lCq8Q= go.opentelemetry.io/otel/trace v0.20.0/go.mod h1:6GjCW8zgDjwGHGa6GkyeB8+/5vjT16gUEi0Nf1iBdgw= go.opentelemetry.io/otel/trace v1.3.0/go.mod h1:c/VDhno8888bvQYmbYLqe41/Ldmr/KKunbvWM4/fEjk= -go.opentelemetry.io/otel/trace v1.10.0 h1:npQMbR8o7mum8uF95yFbOEJffhs1sbCOfDh8zAJiH5E= -go.opentelemetry.io/otel/trace v1.10.0/go.mod h1:Sij3YYczqAdz+EhmGhE6TpTxUO5/F/AzrK+kxfGqySM= +go.opentelemetry.io/otel/trace v1.11.0 h1:20U/Vj42SX+mASlXLmSGBg6jpI1jQtv682lZtTAOVFI= +go.opentelemetry.io/otel/trace v1.11.0/go.mod h1:nyYjis9jy0gytE9LXGU+/m1sHTKbRY0fX0hulNNDP1U= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.11.0/go.mod h1:QpEjXPrNQzrFDZgoTo49dgHR9RYRSrg3NAKnUGl9YpQ= go.opentelemetry.io/proto/otlp v0.19.0 h1:IVN6GR+mhC4s5yfcTbmzHYODqvWAp3ZedA2SJPI1Nnw= From 5325bec26ce7b3a4e30213d558a801cc2d956619 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 13:03:17 -0500 Subject: [PATCH 26/51] fix: Add timeout to selecting a dev tunnel (#4592) For some reason this timed out for a prospect. Even if this doesn't fix it, the problem will be revealed. --- coderd/devtunnel/servers.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/devtunnel/servers.go b/coderd/devtunnel/servers.go index 092638fc36..7025fb9c71 100644 --- a/coderd/devtunnel/servers.go +++ b/coderd/devtunnel/servers.go @@ -73,6 +73,7 @@ func FindClosestNode() (Node, error) { } pinger.Count = 5 + pinger.Timeout = 5 * time.Second err = pinger.Run() if err != nil { return err From 18c4368571c4a8ddcfc3d8e90746cee17f118d37 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 13:29:40 -0500 Subject: [PATCH 27/51] chore: Move deployment UI and HA into experimental (#4595) These are new, so they deserve a bit of gel-time! --- coderd/coderdtest/coderdtest.go | 2 ++ enterprise/coderd/coderd.go | 2 +- enterprise/coderd/replicas_test.go | 9 +++++++-- site/src/components/Navbar/Navbar.tsx | 7 ++++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 5cf307d842..d9ddc0bae2 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -72,6 +72,7 @@ type Options struct { AppHostname string AWSCertificates awsidentity.Certificates Authorizer rbac.Authorizer + Experimental bool AzureCertificates x509.VerifyOptions GithubOAuth2Config *coderd.GithubOAuth2Config OIDCConfig *coderd.OIDCConfig @@ -229,6 +230,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can CacheDir: t.TempDir(), Database: options.Database, Pubsub: options.Pubsub, + Experimental: options.Experimental, Auditor: options.Auditor, AWSCertificates: options.AWSCertificates, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 1250e6ae12..4cdea7ddb5 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -267,7 +267,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed { coordinator := agpltailnet.NewCoordinator() - if enabled { + if api.Experimental && enabled { haCoordinator, err := tailnet.NewCoordinator(api.Logger, api.Pubsub) if err != nil { api.Logger.Error(ctx, "unable to set up high availability coordinator", slog.Error(err)) diff --git a/enterprise/coderd/replicas_test.go b/enterprise/coderd/replicas_test.go index 7a3e130cf7..b14e52dd58 100644 --- a/enterprise/coderd/replicas_test.go +++ b/enterprise/coderd/replicas_test.go @@ -24,6 +24,7 @@ func TestReplicas(t *testing.T) { db, pubsub := dbtestutil.NewDB(t) firstClient := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ + Experimental: true, IncludeProvisionerDaemon: true, Database: db, Pubsub: pubsub, @@ -54,6 +55,7 @@ func TestReplicas(t *testing.T) { IncludeProvisionerDaemon: true, Database: db, Pubsub: pubsub, + Experimental: true, }, }) firstUser := coderdtest.CreateFirstUser(t, firstClient) @@ -63,8 +65,9 @@ func TestReplicas(t *testing.T) { secondClient := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ - Database: db, - Pubsub: pubsub, + Experimental: true, + Database: db, + Pubsub: pubsub, }, }) secondClient.SessionToken = firstClient.SessionToken @@ -92,6 +95,7 @@ func TestReplicas(t *testing.T) { certificates := []tls.Certificate{testutil.GenerateTLSCertificate(t, "localhost")} firstClient := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ + Experimental: true, IncludeProvisionerDaemon: true, Database: db, Pubsub: pubsub, @@ -105,6 +109,7 @@ func TestReplicas(t *testing.T) { secondClient := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ + Experimental: true, Database: db, Pubsub: pubsub, TLSCertificates: certificates, diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index f9ffe05b54..95355e5a1d 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -14,10 +14,15 @@ export const Navbar: React.FC = () => { selectFeatureVisibility, shallowEqual, ) + const experimental = useSelector( + xServices.entitlementsXService, + (state) => state.context.entitlements.experimental, + ) const canViewAuditLog = featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) - const canViewDeployment = Boolean(permissions?.viewDeploymentFlags) + const canViewDeployment = + experimental && Boolean(permissions?.viewDeploymentFlags) const onSignOut = () => authSend("SIGN_OUT") return ( From ae9d7f6b4c129b42a323192f3268a7eac0c471f2 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Mon, 17 Oct 2022 19:12:21 +0000 Subject: [PATCH 28/51] coderd: fix race condition in template test --- coderd/templates_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/coderd/templates_test.go b/coderd/templates_test.go index f6aacba8a5..7fb1f0c025 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -663,8 +663,16 @@ func TestTemplateMetrics(t *testing.T) { template, err = client.Template(ctx, template.ID) require.NoError(t, err) require.Equal(t, 1, template.ActiveUserCount) - require.NotNil(t, template.BuildTimeStats.StartMillis, template.BuildTimeStats) - require.Greater(t, *template.BuildTimeStats.StartMillis, int64(1)) + + require.Eventuallyf(t, func() bool { + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + startMs := template.BuildTimeStats.StartMillis + return startMs != nil && *startMs > 1 + }, + testutil.WaitShort, testutil.IntervalFast, + "BuildTimeStats never loaded", + ) workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) From 618c6dcaa49735ec9b513a46788ee277ae6fd958 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 14:14:49 -0500 Subject: [PATCH 29/51] fix: Allow OIDC with the username as email (#4594) Fixes #4472. --- coderd/userauth.go | 27 ++++++++++++++++++--------- coderd/userauth_test.go | 9 +++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/coderd/userauth.go b/coderd/userauth.go index 4dd67844cf..7a18b9790d 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "net/mail" "strconv" "strings" @@ -219,12 +220,25 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { }) return } + usernameRaw, ok := claims["preferred_username"] + var username string + if ok { + username, _ = usernameRaw.(string) + } emailRaw, ok := claims["email"] if !ok { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "No email found in OIDC payload!", - }) - return + // Email is an optional claim in OIDC and + // instead the email is frequently sent in + // "preferred_username". See: + // https://github.com/coder/coder/issues/4472 + _, err = mail.ParseAddress(username) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "No email found in OIDC payload!", + }) + return + } + emailRaw = username } email, ok := emailRaw.(string) if !ok { @@ -243,11 +257,6 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { return } } - usernameRaw, ok := claims["preferred_username"] - var username string - if ok { - username, _ = usernameRaw.(string) - } // The username is a required property in Coder. We make a best-effort // attempt at using what the claims provide, but if that fails we will // generate a random username. diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 42ac974d4e..9643351032 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -425,6 +425,15 @@ func TestUserOIDC(t *testing.T) { Username: "kyle", AllowSignups: true, StatusCode: http.StatusTemporaryRedirect, + }, { + // See: https://github.com/coder/coder/issues/4472 + Name: "UsernameIsEmail", + Claims: jwt.MapClaims{ + "preferred_username": "kyle@kwc.io", + }, + Username: "kyle", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, }, { Name: "WithPicture", Claims: jwt.MapClaims{ From fda71dadcba9fe1bf1e92e4f34cacf00412a384b Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 14:22:54 -0500 Subject: [PATCH 30/51] fix: Copy replicas to prevent race (#4596) This was seen in https://github.com/coder/coder/actions/runs/3267638198/jobs/5373066836 --- enterprise/replicasync/replicasync.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/enterprise/replicasync/replicasync.go b/enterprise/replicasync/replicasync.go index daac634efe..68344c91f7 100644 --- a/enterprise/replicasync/replicasync.go +++ b/enterprise/replicasync/replicasync.go @@ -315,7 +315,14 @@ func (m *Manager) Self() database.Replica { func (m *Manager) All() []database.Replica { m.mutex.Lock() defer m.mutex.Unlock() - return append(m.peers[:], m.self) + replicas := make([]database.Replica, 0, len(m.peers)) + for _, replica := range append(m.peers, m.self) { + // When we assign the non-pointer to a + // variable it loses the reference. + replica := replica + replicas = append(replicas, replica) + } + return replicas } // Regional returns all replicas in the same region excluding itself. From 4e44716b0c10c60a98cffd4c37c1c98768117cc0 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 14:27:38 -0500 Subject: [PATCH 31/51] docs: add high availability (#4583) - Rename `CODER_DERP_SERVER_RELAY_ADDRESS` Co-authored-by: Ammar Bandukwala --- cli/deployment/flags.go | 2 +- docs/admin/enterprise.md | 22 +++++++----- docs/admin/high-availability.md | 63 +++++++++++++++++++++++++++++++++ docs/images/icons/hydra.svg | 5 +++ docs/manifest.json | 31 +++++++++------- helm/templates/coder.yaml | 2 +- 6 files changed, 102 insertions(+), 23 deletions(-) create mode 100644 docs/admin/high-availability.md create mode 100644 docs/images/icons/hydra.svg diff --git a/cli/deployment/flags.go b/cli/deployment/flags.go index 714365cc8e..792051f805 100644 --- a/cli/deployment/flags.go +++ b/cli/deployment/flags.go @@ -88,7 +88,7 @@ func Flags() *codersdk.DeploymentFlags { DerpServerRelayAddress: &codersdk.StringFlag{ Name: "DERP Server Relay Address", Flag: "derp-server-relay-address", - EnvVar: "CODER_DERP_SERVER_RELAY_ADDRESS", + EnvVar: "CODER_DERP_SERVER_RELAY_URL", Description: "An HTTP address that is accessible by other replicas to relay DERP traffic. Required for high availability.", Enterprise: true, }, diff --git a/docs/admin/enterprise.md b/docs/admin/enterprise.md index 5524660bc8..45ecf3dfe4 100644 --- a/docs/admin/enterprise.md +++ b/docs/admin/enterprise.md @@ -3,28 +3,32 @@ Coder is free to use and includes some features that are only accessible with a paid license. Contact sales@coder.com to obtain a license. -These features are available in the enterprise edition: - -- [Audit Logging](./audit-logs.md) -- [Browser Only Connections](../networking.md#browser-only-connections) +### User Management - [Groups](./groups.md) - [Template RBAC](./rbac.md) -- [Quotas](./quotas.md) - [SCIM](./auth.md#scim) -And we're releasing these imminently: +### Networking & Deployment +- [High Availability](./high-availability.md) +- [Browser Only Connections](../networking.md#browser-only-connections) + +### Other +- [Audit Logging](./audit-logs.md) +- [Quotas](./quotas.md) + +### Coming soon -- High Availability - Multiple Git Provider Authentication +- Max Workspace Auto-Stop ## Adding your license key -### You will need: +### Requirements - Your license key (contact sales@coder.com if you don't have yours) - Coder CLI installed -### Steps: +### Instructions 1. Save your license key to disk and make note of the path 2. Open a terminal diff --git a/docs/admin/high-availability.md b/docs/admin/high-availability.md new file mode 100644 index 0000000000..7d418ea5ba --- /dev/null +++ b/docs/admin/high-availability.md @@ -0,0 +1,63 @@ +# High Availability + +High Availability (HA) mode solves for horizontal scalability and automatic failover +within a single region. When in HA mode, Coder continues using a single Postgres +endpoint. [GCP](https://cloud.google.com/sql/docs/postgres/high-availability), [AWS](https://docs.aws.amazon.com/prescriptive-guidance/latest/saas-multitenant-managed-postgresql/availability.html), +and other cloud vendors offer fully-managed HA Postgres services that pair +nicely with Coder. + +For Coder to operate correctly, every node must be within 10ms of each other +and Postgres. We make a best-effort attempt to warn the user when inter-coder +latency is too high, but if requests start dropping, this is one metric to investigate. +Note that this latency requirement applies _only_ to coder services. Coder will +operate correctly even with few seconds of latency on +workspace <-> coder and user <-> coder connections. + +## Setup + +Coder automatically enters HA mode when multiple instances simultaneously connect +to the same Postgres endpoint. + +HA brings one configuration variable to set in each Coder +node: `CODER_DERP_SERVER_RELAY_URL`. The HA nodes use these URLs to communicate +with each other. Inter-node communication is only required while using the +embedded relay (default). If you're using [custom relays](../networking.md#custom-relays), Coder ignores `CODER_DERP_SERVER_RELAY_URL` since Postgres is the sole rendezvous for the Coder nodes. + +`CODER_DERP_SERVER_RELAY_URL` will never be `CODER_ACCESS_URL` because +`CODER_ACCESS_URL` is a load balancer to all Coder nodes. + +Here's an example 3-node network configuration setup: + +| Name | `CODER_ADDRESS` | `CODER_DERP_SERVER_RELAY_URL` | `CODER_ACCESS_URL` | +| ------- | --------------- | ----------------------------- | ----------------------- | +| `coder-1` | `*:80` | `http://10.0.0.1:80` | `https://coder.big.corp` | +| `coder-2` | `*:80` | `http://10.0.0.2:80` | `https://coder.big.corp` | +| `coder-3` | `*:80` | `http://10.0.0.3:80` | `https://coder.big.corp` | + + +## Kubernetes + +If you installed Coder via +[our Helm Chart](../install/kubernetes.md#install-coder-with-helm), just +increase `coder.replicaCount` in `values.yaml`. + + +If you installed Coder into Kubernetes by some other means, insert the relay URL +via the environment like so: + +```yaml + env: + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: http://$(POD_IP) +``` +Then, increase the number of pods. + +## Up next + +- [Networking](../networking.md) +- [Kubernetes](../install/kubernetes.md) +- [Enterprise](./enterprise.md) diff --git a/docs/images/icons/hydra.svg b/docs/images/icons/hydra.svg new file mode 100644 index 0000000000..4b6d0938ed --- /dev/null +++ b/docs/images/icons/hydra.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/manifest.json b/docs/manifest.json index 1cf16bd323..4ff1536a6b 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -27,17 +27,17 @@ "children": [ { "title": "Install script", - "description": "One-line install script for macOS and Linux.", + "description": "One-line install script for macOS and Linux", "path": "./install/install.sh.md" }, { "title": "System packages", - "description": "System packages for Debian, Ubuntu, Fedora, CentOS, RHEL, SUSE, and Alpine.", + "description": "System packages for Debian, Ubuntu, Fedora, CentOS, RHEL, SUSE, and Alpine", "path": "./install/packages.md" }, { "title": "Kubernetes", - "description": "Install Coder with Kubernetes via Helm.", + "description": "Install Coder with Kubernetes via Helm", "path": "./install/kubernetes.md" }, { @@ -47,12 +47,12 @@ }, { "title": "Standalone binaries", - "description": "Download binaries for macOS, Windows, and Linux.", + "description": "Download binaries for macOS, Windows, and Linux", "path": "./install/binary.md" }, { "title": "Uninstall", - "description": "Learn how to uninstall Coder.", + "description": "Learn how to uninstall Coder", "path": "./install/uninstall.md" } ] @@ -182,7 +182,7 @@ "children": [ { "title": "Authentication", - "description": "Learn how to set up authentication using GitHub or OpenID Connect.", + "description": "Learn how to set up authentication using GitHub or OpenID Connect", "icon_path": "./images/icons/key.svg", "path": "./admin/auth.md" }, @@ -208,13 +208,13 @@ }, { "title": "Configuration", - "description": "Learn how to configure Coder.", + "description": "Learn how to configure Coder", "path": "./admin/configure.md", "icon_path": "./images/icons/toggle_on.svg" }, { "title": "Upgrading", - "description": "Learn how to upgrade Coder.", + "description": "Learn how to upgrade Coder", "icon_path": "./images/icons/upgrade.svg", "path": "./admin/upgrade.md" }, @@ -226,27 +226,34 @@ }, { "title": "Audit Logs", - "description": "Learn how to use Audit Logs in your Coder deployment.", + "description": "Learn how to use Audit Logs in your Coder deployment", "icon_path": "./images/icons/radar.svg", "path": "./admin/audit-logs.md", "state": "enterprise" }, { "title": "Quotas", - "description": "Learn how to use Workspace Quotas in Coder.", + "description": "Learn how to use Workspace Quotas in Coder", "icon_path": "./images/icons/dollar.svg", "path": "./admin/quotas.md", "state": "enterprise" }, + { + "title": "High Availability", + "description": "Learn how to configure Coder for High Availability", + "icon_path": "./images/icons/hydra.svg", + "path": "./admin/high-availability.md", + "state": "enterprise" + }, { "title": "Enterprise", - "description": "Learn how to enable Enterprise features.", + "description": "Learn how to enable Enterprise features", "icon_path": "./images/icons/group.svg", "path": "./admin/enterprise.md" }, { "title": "Telemetry", - "description": "Learn what usage telemetry Coder collects.", + "description": "Learn what usage telemetry Coder collects", "icon_path": "./images/icons/science.svg", "path": "./admin/telemetry.md" } diff --git a/helm/templates/coder.yaml b/helm/templates/coder.yaml index 1165251fc8..02badc277d 100644 --- a/helm/templates/coder.yaml +++ b/helm/templates/coder.yaml @@ -40,7 +40,7 @@ spec: valueFrom: fieldRef: fieldPath: status.podIP - - name: CODER_DERP_SERVER_RELAY_ADDRESS + - name: CODER_DERP_SERVER_RELAY_URL value: "{{ include "coder.portName" . }}://$(KUBE_POD_IP):{{ include "coder.port" . }}" {{- include "coder.tlsEnv" . | nindent 12 }} {{- with .Values.coder.env -}} From 6f5544e0e4199782e5fef3f7f46c653ef83385ec Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 14:44:04 -0500 Subject: [PATCH 32/51] fix: Pass `experimental` to the FE through the API (#4597) --- enterprise/coderd/coderd.go | 1 + 1 file changed, 1 insertion(+) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 4cdea7ddb5..a9c8c3b590 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -224,6 +224,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { if err != nil { return err } + entitlements.Experimental = api.Experimental featureChanged := func(featureName string) (changed bool, enabled bool) { if api.entitlements.Features == nil { From 8acba0ccffc9a823deb047f5cb3bb65e6a8e56c9 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Mon, 17 Oct 2022 14:45:06 -0500 Subject: [PATCH 33/51] site: cleanup code in WorkspaceBuildProgress (#4584) --- .../WorkspaceBuildProgress.tsx | 75 ++++++++++--------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx index f9685b8951..f1d61ee26e 100644 --- a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx +++ b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx @@ -11,23 +11,19 @@ dayjs.extend(duration) const estimateFinish = ( startedAt: Dayjs, - templateAverage?: number, + buildEstimate: number, ): [number, string] => { - if (templateAverage === undefined) { - return [0, "Unknown"] - } - const realPercentage = dayjs().diff(startedAt) / templateAverage + const realPercentage = dayjs().diff(startedAt) / buildEstimate - // Showing a full bar is frustrating. - const maxPercentage = 0.99 + const maxPercentage = 1 if (realPercentage > maxPercentage) { - return [maxPercentage, "Any moment now..."] + return [maxPercentage * 100, "Any moment now..."] } return [ - realPercentage, + realPercentage * 100, `~${Math.ceil( - dayjs.duration((1 - realPercentage) * templateAverage).asSeconds(), + dayjs.duration((1 - realPercentage) * buildEstimate).asSeconds(), )} seconds remaining...`, ] } @@ -62,49 +58,55 @@ export const WorkspaceBuildProgress: FC = ({ }) => { const styles = useStyles() const job = workspace.latest_build.job - const [progressValue, setProgressValue] = useState(0) + const [progressValue, setProgressValue] = useState(0) // By default workspace is updated every second, which can cause visual stutter // when the build estimate is a few seconds. The timer ensures no observable // stutter in all cases. useEffect(() => { const updateProgress = () => { - if (job.status !== "running") { - setProgressValue(0) + if (job.status !== "running" || buildEstimate === undefined) { + setProgressValue(undefined) return } - setProgressValue( - estimateFinish(dayjs(job.started_at), buildEstimate)[0] * 100, - ) + const est = estimateFinish(dayjs(job.started_at), buildEstimate)[0] + setProgressValue(est) } - setTimeout(updateProgress, 100) + setTimeout(updateProgress, 5) }, [progressValue, job, buildEstimate]) - // buildEstimate may be undefined if the template is new or coderd hasn't - // finished initial metrics collection. - if (buildEstimate === undefined) { - return ( -
- -
-
{`Build ${job.status}`}
-
Unknown ETA
-
-
- ) - } - return (
{`Build ${job.status}`}
- {job.status === "running" && - estimateFinish(dayjs(job.started_at), buildEstimate)[1]} + {(() => { + if (job.status !== "running") { + return "" + } else if (buildEstimate !== undefined) { + return estimateFinish(dayjs(job.started_at), buildEstimate)[1] + } else { + return "Unknown ETA" + } + })()}
@@ -116,6 +118,9 @@ const useStyles = makeStyles((theme) => ({ paddingLeft: theme.spacing(0.2), paddingRight: theme.spacing(0.2), }, + noTransition: { + transition: "none", + }, barHelpers: { display: "flex", justifyContent: "space-between", From 8ab4d264746527789603377358e20dabf4645fca Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Mon, 17 Oct 2022 15:14:27 -0500 Subject: [PATCH 34/51] chore: add note that HA is experimental (#4601) --- docs/admin/high-availability.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/admin/high-availability.md b/docs/admin/high-availability.md index 7d418ea5ba..4c56d67752 100644 --- a/docs/admin/high-availability.md +++ b/docs/admin/high-availability.md @@ -15,6 +15,8 @@ workspace <-> coder and user <-> coder connections. ## Setup +> We're stress testing High Availability this week before we enable it by default. To try HA beforehand, set `CODER_EXPERIMENTAL=true` for the Coder server as well as the additional configuration options below. + Coder automatically enters HA mode when multiple instances simultaneously connect to the same Postgres endpoint. @@ -28,32 +30,31 @@ embedded relay (default). If you're using [custom relays](../networking.md#custo Here's an example 3-node network configuration setup: -| Name | `CODER_ADDRESS` | `CODER_DERP_SERVER_RELAY_URL` | `CODER_ACCESS_URL` | -| ------- | --------------- | ----------------------------- | ----------------------- | +| Name | `CODER_ADDRESS` | `CODER_DERP_SERVER_RELAY_URL` | `CODER_ACCESS_URL` | +| --------- | --------------- | ----------------------------- | ------------------------ | | `coder-1` | `*:80` | `http://10.0.0.1:80` | `https://coder.big.corp` | | `coder-2` | `*:80` | `http://10.0.0.2:80` | `https://coder.big.corp` | | `coder-3` | `*:80` | `http://10.0.0.3:80` | `https://coder.big.corp` | - ## Kubernetes If you installed Coder via [our Helm Chart](../install/kubernetes.md#install-coder-with-helm), just increase `coder.replicaCount` in `values.yaml`. - If you installed Coder into Kubernetes by some other means, insert the relay URL via the environment like so: ```yaml - env: - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: CODER_DERP_SERVER_RELAY_URL - value: http://$(POD_IP) +env: + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CODER_DERP_SERVER_RELAY_URL + value: http://$(POD_IP) ``` + Then, increase the number of pods. ## Up next From 9b4ab82044c67409fbd972d65d5b99e574b2748e Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 17 Oct 2022 15:46:19 -0500 Subject: [PATCH 35/51] fix: potential deadlock in coordinator (#4598) --- tailnet/coordinator.go | 1 + 1 file changed, 1 insertion(+) diff --git a/tailnet/coordinator.go b/tailnet/coordinator.go index ded78e64aa..fc48974ca7 100644 --- a/tailnet/coordinator.go +++ b/tailnet/coordinator.go @@ -313,6 +313,7 @@ func (c *coordinator) handleNextAgentMessage(id uuid.UUID, decoder *json.Decoder } data, err := json.Marshal([]*Node{&node}) if err != nil { + c.mutex.Unlock() return xerrors.Errorf("marshal nodes: %w", err) } From e0a14f68fa4003ee85c3e83d5ce9541651aebc8e Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 17 Oct 2022 17:46:01 -0500 Subject: [PATCH 36/51] feat: add avatar urls to groups (#4525) --- coderd/database/databasefake/databasefake.go | 2 + coderd/database/dump.sql | 3 +- .../migrations/000062_group_avatars.down.sql | 5 ++ .../migrations/000062_group_avatars.up.sql | 5 ++ coderd/database/models.go | 1 + coderd/database/queries.sql.go | 86 ++++++++++++++----- coderd/database/queries/groups.sql | 10 ++- codersdk/groups.go | 5 +- enterprise/coderd/groups.go | 41 ++++++--- enterprise/coderd/groups_test.go | 70 ++++++++++++++- site/src/api/typesGenerated.ts | 3 + .../GroupAvatar/GroupAvatar.stories.tsx | 1 + .../components/GroupAvatar/GroupAvatar.tsx | 5 +- .../pages/GroupsPage/CreateGroupPageView.tsx | 9 ++ site/src/pages/GroupsPage/GroupsPageView.tsx | 7 +- .../GroupsPage/SettingsGroupPageView.tsx | 10 +++ .../TemplatePermissionsPageView.tsx | 7 +- site/src/testHelpers/entities.ts | 1 + site/src/util/groups.ts | 1 + .../src/xServices/groups/editGroupXService.ts | 2 +- 20 files changed, 228 insertions(+), 46 deletions(-) create mode 100644 coderd/database/migrations/000062_group_avatars.down.sql create mode 100644 coderd/database/migrations/000062_group_avatars.up.sql diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 65043d2412..757616774c 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2784,6 +2784,7 @@ func (q *fakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGrou for i, group := range q.groups { if group.ID == arg.ID { group.Name = arg.Name + group.AvatarURL = arg.AvatarURL q.groups[i] = group return group, nil } @@ -3135,6 +3136,7 @@ func (q *fakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupPar ID: arg.ID, Name: arg.Name, OrganizationID: arg.OrganizationID, + AvatarURL: arg.AvatarURL, } q.groups = append(q.groups, group) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b946a1130e..36e1bd583a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -181,7 +181,8 @@ CREATE TABLE group_members ( CREATE TABLE groups ( id uuid NOT NULL, name text NOT NULL, - organization_id uuid NOT NULL + organization_id uuid NOT NULL, + avatar_url text DEFAULT ''::text NOT NULL ); CREATE TABLE licenses ( diff --git a/coderd/database/migrations/000062_group_avatars.down.sql b/coderd/database/migrations/000062_group_avatars.down.sql new file mode 100644 index 0000000000..eb15f35438 --- /dev/null +++ b/coderd/database/migrations/000062_group_avatars.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE groups DROP COLUMN avatar_url; + +COMMIT; diff --git a/coderd/database/migrations/000062_group_avatars.up.sql b/coderd/database/migrations/000062_group_avatars.up.sql new file mode 100644 index 0000000000..b7f033874b --- /dev/null +++ b/coderd/database/migrations/000062_group_avatars.up.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE groups ADD COLUMN avatar_url text NOT NULL DEFAULT ''; + +COMMIT; diff --git a/coderd/database/models.go b/coderd/database/models.go index 53e074984a..7e398552de 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -439,6 +439,7 @@ type Group struct { ID uuid.UUID `db:"id" json:"id"` Name string `db:"name" json:"name"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + AvatarURL string `db:"avatar_url" json:"avatar_url"` } type GroupMember struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3621050bc0..cb4b43591a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -928,7 +928,7 @@ func (q *sqlQuerier) GetAllOrganizationMembers(ctx context.Context, organization const getGroupByID = `-- name: GetGroupByID :one SELECT - id, name, organization_id + id, name, organization_id, avatar_url FROM groups WHERE @@ -940,13 +940,18 @@ LIMIT func (q *sqlQuerier) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) { row := q.db.QueryRowContext(ctx, getGroupByID, id) var i Group - err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + err := row.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ) return i, err } const getGroupByOrgAndName = `-- name: GetGroupByOrgAndName :one SELECT - id, name, organization_id + id, name, organization_id, avatar_url FROM groups WHERE @@ -965,7 +970,12 @@ type GetGroupByOrgAndNameParams struct { func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) { row := q.db.QueryRowContext(ctx, getGroupByOrgAndName, arg.OrganizationID, arg.Name) var i Group - err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + err := row.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ) return i, err } @@ -1024,7 +1034,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([] const getGroupsByOrganizationID = `-- name: GetGroupsByOrganizationID :many SELECT - id, name, organization_id + id, name, organization_id, avatar_url FROM groups WHERE @@ -1042,7 +1052,12 @@ func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organization var items []Group for rows.Next() { var i Group - if err := rows.Scan(&i.ID, &i.Name, &i.OrganizationID); err != nil { + if err := rows.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ); err != nil { return nil, err } items = append(items, i) @@ -1058,7 +1073,7 @@ func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organization const getUserGroups = `-- name: GetUserGroups :many SELECT - groups.id, groups.name, groups.organization_id + groups.id, groups.name, groups.organization_id, groups.avatar_url FROM groups JOIN @@ -1078,7 +1093,12 @@ func (q *sqlQuerier) GetUserGroups(ctx context.Context, userID uuid.UUID) ([]Gro var items []Group for rows.Next() { var i Group - if err := rows.Scan(&i.ID, &i.Name, &i.OrganizationID); err != nil { + if err := rows.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ); err != nil { return nil, err } items = append(items, i) @@ -1099,7 +1119,7 @@ INSERT INTO groups ( organization_id ) VALUES - ( $1, 'Everyone', $1) RETURNING id, name, organization_id + ( $1, 'Everyone', $1) RETURNING id, name, organization_id, avatar_url ` // We use the organization_id as the id @@ -1108,7 +1128,12 @@ VALUES func (q *sqlQuerier) InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) { row := q.db.QueryRowContext(ctx, insertAllUsersGroup, organizationID) var i Group - err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + err := row.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ) return i, err } @@ -1116,22 +1141,34 @@ const insertGroup = `-- name: InsertGroup :one INSERT INTO groups ( id, name, - organization_id + organization_id, + avatar_url ) VALUES - ( $1, $2, $3) RETURNING id, name, organization_id + ( $1, $2, $3, $4) RETURNING id, name, organization_id, avatar_url ` type InsertGroupParams struct { ID uuid.UUID `db:"id" json:"id"` Name string `db:"name" json:"name"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + AvatarURL string `db:"avatar_url" json:"avatar_url"` } func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) { - row := q.db.QueryRowContext(ctx, insertGroup, arg.ID, arg.Name, arg.OrganizationID) + row := q.db.QueryRowContext(ctx, insertGroup, + arg.ID, + arg.Name, + arg.OrganizationID, + arg.AvatarURL, + ) var i Group - err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + err := row.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ) return i, err } @@ -1157,21 +1194,28 @@ const updateGroupByID = `-- name: UpdateGroupByID :one UPDATE groups SET - name = $1 + name = $1, + avatar_url = $2 WHERE - id = $2 -RETURNING id, name, organization_id + id = $3 +RETURNING id, name, organization_id, avatar_url ` type UpdateGroupByIDParams struct { - Name string `db:"name" json:"name"` - ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + AvatarURL string `db:"avatar_url" json:"avatar_url"` + ID uuid.UUID `db:"id" json:"id"` } func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) { - row := q.db.QueryRowContext(ctx, updateGroupByID, arg.Name, arg.ID) + row := q.db.QueryRowContext(ctx, updateGroupByID, arg.Name, arg.AvatarURL, arg.ID) var i Group - err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + err := row.Scan( + &i.ID, + &i.Name, + &i.OrganizationID, + &i.AvatarURL, + ) return i, err } diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index 137bf3040a..45c1b8d03c 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -74,10 +74,11 @@ AND INSERT INTO groups ( id, name, - organization_id + organization_id, + avatar_url ) VALUES - ( $1, $2, $3) RETURNING *; + ( $1, $2, $3, $4) RETURNING *; -- We use the organization_id as the id -- for simplicity since all users is @@ -95,9 +96,10 @@ VALUES UPDATE groups SET - name = $1 + name = $1, + avatar_url = $2 WHERE - id = $2 + id = $3 RETURNING *; -- name: InsertGroupMember :exec diff --git a/codersdk/groups.go b/codersdk/groups.go index b4b9759a02..a84f8560b2 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -11,7 +11,8 @@ import ( ) type CreateGroupRequest struct { - Name string `json:"name"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` } type Group struct { @@ -19,6 +20,7 @@ type Group struct { Name string `json:"name"` OrganizationID uuid.UUID `json:"organization_id"` Members []User `json:"members"` + AvatarURL string `json:"avatar_url"` } func (c *Client) CreateGroup(ctx context.Context, orgID uuid.UUID, req CreateGroupRequest) (Group, error) { @@ -77,6 +79,7 @@ type PatchGroupRequest struct { AddUsers []string `json:"add_users"` RemoveUsers []string `json:"remove_users"` Name string `json:"name"` + AvatarURL *string `json:"avatar_url"` } func (c *Client) PatchGroup(ctx context.Context, group uuid.UUID, req PatchGroupRequest) (Group, error) { diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 4c81c4a5ef..7c1cb90706 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -43,6 +43,7 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) ID: uuid.New(), Name: req.Name, OrganizationID: org.ID, + AvatarURL: req.AvatarURL, }) if database.IsUniqueViolation(err) { httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ @@ -81,6 +82,12 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { return } + // If the name matches the existing group name pretend we aren't + // updating the name at all. + if req.Name == group.Name { + req.Name = "" + } + users := make([]string, 0, len(req.AddUsers)+len(req.RemoveUsers)) users = append(users, req.AddUsers...) users = append(users, req.RemoveUsers...) @@ -109,7 +116,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { return } } - if req.Name != "" { + if req.Name != "" && req.Name != group.Name { _, err := api.Database.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{ OrganizationID: group.OrganizationID, Name: req.Name, @@ -123,16 +130,29 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { } err := api.Database.InTx(func(tx database.Store) error { - if req.Name != "" { - var err error - group, err = tx.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{ - ID: group.ID, - Name: req.Name, - }) - if err != nil { - return xerrors.Errorf("update group by ID: %w", err) - } + var err error + group, err = tx.GetGroupByID(ctx, group.ID) + if err != nil { + return xerrors.Errorf("get group by ID: %w", err) } + + // TODO: Do we care about validating this? + if req.AvatarURL != nil { + group.AvatarURL = *req.AvatarURL + } + if req.Name != "" { + group.Name = req.Name + } + + group, err = tx.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{ + ID: group.ID, + Name: group.Name, + AvatarURL: group.AvatarURL, + }) + if err != nil { + return xerrors.Errorf("update group by ID: %w", err) + } + for _, id := range req.AddUsers { err := tx.InsertGroupMember(ctx, database.InsertGroupMemberParams{ GroupID: group.ID, @@ -276,6 +296,7 @@ func convertGroup(g database.Group, users []database.User) codersdk.Group { ID: g.ID, Name: g.Name, OrganizationID: g.OrganizationID, + AvatarURL: g.AvatarURL, Members: convertUsers(users, orgs), } } diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index eae51b0dfd..8019b956d4 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "k8s.io/utils/pointer" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" @@ -28,10 +29,12 @@ func TestCreateGroup(t *testing.T) { }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ - Name: "hi", + Name: "hi", + AvatarURL: "https://example.com", }) require.NoError(t, err) require.Equal(t, "hi", group.Name) + require.Equal(t, "https://example.com", group.AvatarURL) require.Empty(t, group.Members) require.NotEqual(t, uuid.Nil.String(), group.ID.String()) }) @@ -83,7 +86,35 @@ func TestCreateGroup(t *testing.T) { func TestPatchGroup(t *testing.T) { t.Parallel() - t.Run("Name", func(t *testing.T) { + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + AvatarURL: "https://example.com", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + Name: "bye", + AvatarURL: pointer.String("https://google.com"), + }) + require.NoError(t, err) + require.Equal(t, "bye", group.Name) + require.Equal(t, "https://google.com", group.AvatarURL) + }) + + // The FE sends a request from the edit page where the old name == new name. + // This should pass since it's not really an error to update a group name + // to itself. + t.Run("SameNameOK", func(t *testing.T) { t.Parallel() client := coderdenttest.New(t, nil) @@ -99,10 +130,10 @@ func TestPatchGroup(t *testing.T) { require.NoError(t, err) group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ - Name: "bye", + Name: "hi", }) require.NoError(t, err) - require.Equal(t, "bye", group.Name) + require.Equal(t, "hi", group.Name) }) t.Run("AddUsers", func(t *testing.T) { @@ -166,6 +197,37 @@ func TestPatchGroup(t *testing.T) { require.Contains(t, group.Members, user4) }) + t.Run("NameConflict", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + TemplateRBAC: true, + }) + ctx, _ := testutil.Context(t) + group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + AvatarURL: "https://example.com", + }) + require.NoError(t, err) + + group2, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "bye", + }) + require.NoError(t, err) + + group1, err = client.PatchGroup(ctx, group1.ID, codersdk.PatchGroupRequest{ + Name: group2.Name, + AvatarURL: pointer.String("https://google.com"), + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusConflict, cerr.StatusCode()) + }) + t.Run("UserNotExist", func(t *testing.T) { t.Parallel() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d6305b3dbc..0a709f8927 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -169,6 +169,7 @@ export interface CreateFirstUserResponse { // From codersdk/groups.go export interface CreateGroupRequest { readonly name: string + readonly avatar_url: string } // From codersdk/users.go @@ -376,6 +377,7 @@ export interface Group { readonly name: string readonly organization_id: string readonly members: User[] + readonly avatar_url: string } // From codersdk/workspaceapps.go @@ -491,6 +493,7 @@ export interface PatchGroupRequest { readonly add_users: string[] readonly remove_users: string[] readonly name: string + readonly avatar_url?: string } // From codersdk/provisionerdaemons.go diff --git a/site/src/components/GroupAvatar/GroupAvatar.stories.tsx b/site/src/components/GroupAvatar/GroupAvatar.stories.tsx index 315a179c3c..8fda557362 100644 --- a/site/src/components/GroupAvatar/GroupAvatar.stories.tsx +++ b/site/src/components/GroupAvatar/GroupAvatar.stories.tsx @@ -11,4 +11,5 @@ const Template: Story = (args) => export const Example = Template.bind({}) Example.args = { name: "My Group", + avatarURL: "", } diff --git a/site/src/components/GroupAvatar/GroupAvatar.tsx b/site/src/components/GroupAvatar/GroupAvatar.tsx index 6f3b043db0..ab9762050a 100644 --- a/site/src/components/GroupAvatar/GroupAvatar.tsx +++ b/site/src/components/GroupAvatar/GroupAvatar.tsx @@ -25,9 +25,10 @@ const StyledBadge = withStyles((theme) => ({ export type GroupAvatarProps = { name: string + avatarURL?: string } -export const GroupAvatar: FC = ({ name }) => { +export const GroupAvatar: FC = ({ name, avatarURL }) => { return ( = ({ name }) => { }} badgeContent={} > - {firstLetter(name)} + {firstLetter(name)} ) } diff --git a/site/src/pages/GroupsPage/CreateGroupPageView.tsx b/site/src/pages/GroupsPage/CreateGroupPageView.tsx index 8fd863815c..44e87e3d53 100644 --- a/site/src/pages/GroupsPage/CreateGroupPageView.tsx +++ b/site/src/pages/GroupsPage/CreateGroupPageView.tsx @@ -28,6 +28,7 @@ export const CreateGroupPageView: React.FC = ({ const form = useFormik({ initialValues: { name: "", + avatar_url: "", }, validationSchema, onSubmit, @@ -48,6 +49,14 @@ export const CreateGroupPageView: React.FC = ({ label="Name" variant="outlined" /> + diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 83c090fda9..19bc2a8851 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -136,7 +136,12 @@ export const GroupsPageView: React.FC = ({ > } + avatar={ + + } title={group.name} subtitle={`${group.members.length} members`} highlightTitle diff --git a/site/src/pages/GroupsPage/SettingsGroupPageView.tsx b/site/src/pages/GroupsPage/SettingsGroupPageView.tsx index c1b6391534..55c59ccd93 100644 --- a/site/src/pages/GroupsPage/SettingsGroupPageView.tsx +++ b/site/src/pages/GroupsPage/SettingsGroupPageView.tsx @@ -12,6 +12,7 @@ import * as Yup from "yup" type FormData = { name: string + avatar_url: string } const validationSchema = Yup.object({ @@ -28,6 +29,7 @@ const UpdateGroupForm: React.FC<{ const form = useFormik({ initialValues: { name: group.name, + avatar_url: group.avatar_url, }, validationSchema, onSubmit, @@ -46,6 +48,14 @@ const UpdateGroupForm: React.FC<{ label="Name" variant="outlined" /> + diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx index 0188f0de11..d9c4c74001 100644 --- a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx +++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx @@ -241,7 +241,12 @@ export const TemplatePermissionsPageView: FC< } + avatar={ + + } title={group.name} subtitle={getGroupSubtitle(group)} highlightTitle diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8d0358bc58..f2c9836990 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -929,6 +929,7 @@ export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { export const MockGroup: TypesGen.Group = { id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", name: "Front-End", + avatar_url: "https://example.com", organization_id: MockOrganization.id, members: [MockUser, MockUser2], } diff --git a/site/src/util/groups.ts b/site/src/util/groups.ts index 6f6f211a41..a5140bb7b4 100644 --- a/site/src/util/groups.ts +++ b/site/src/util/groups.ts @@ -5,6 +5,7 @@ export const everyOneGroup = (organizationId: string): Group => ({ name: "Everyone", organization_id: organizationId, members: [], + avatar_url: "", }) export const getGroupSubtitle = (group: Group): string => { diff --git a/site/src/xServices/groups/editGroupXService.ts b/site/src/xServices/groups/editGroupXService.ts index 78d4b1a143..ae06e93799 100644 --- a/site/src/xServices/groups/editGroupXService.ts +++ b/site/src/xServices/groups/editGroupXService.ts @@ -29,7 +29,7 @@ export const editGroupMachine = createMachine( }, events: {} as { type: "UPDATE" - data: { name: string } + data: { name: string; avatar_url: string } }, }, tsTypes: {} as import("./editGroupXService.typegen").Typegen0, From c5afaffa7e4c18e66291725b9d75af45a7f6e4ed Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 18:36:23 -0500 Subject: [PATCH 37/51] fix: Tidy up closes for nicer output (#4605) * fix: Tidy up closes for nicer output There was a context canceled message that would appear because of traces, and this was using the wrong close. I don't think it was causing any specific problems, but it could make a replica warning appear on restart. * Fix migration and experimental --- cli/root.go | 3 ++- cli/server.go | 7 +++---- coderd/database/dump.sql | 4 ---- coderd/database/migrations/000059_file_id.up.sql | 3 --- coderd/tracing/exporter.go | 4 ++++ enterprise/tailnet/coordinator.go | 2 +- tailnet/coordinator.go | 3 ++- 7 files changed, 12 insertions(+), 14 deletions(-) diff --git a/cli/root.go b/cli/root.go index 91d4551916..ea803cdfa5 100644 --- a/cli/root.go +++ b/cli/root.go @@ -609,7 +609,8 @@ func (h *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { // ExperimentalEnabled returns if the experimental feature flag is enabled. func ExperimentalEnabled(cmd *cobra.Command) bool { - return cliflag.IsSetBool(cmd, varExperimental) + enabled, _ := cmd.Flags().GetBool(varExperimental) + return enabled } // EnsureExperimental will ensure that the experimental feature flag is set if the given flag is set. diff --git a/cli/server.go b/cli/server.go index c2dbeac07e..b9861bf141 100644 --- a/cli/server.go +++ b/cli/server.go @@ -476,14 +476,13 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code ), dflags.PromAddress.Value, "prometheus")() } - // We use a separate closer so the Enterprise API + // We use a separate coderAPICloser so the Enterprise API // can have it's own close functions. This is cleaner // than abstracting the Coder API itself. - coderAPI, closer, err := newAPI(ctx, options) + coderAPI, coderAPICloser, err := newAPI(ctx, options) if err != nil { return err } - defer closer.Close() client := codersdk.New(localURL) if dflags.TLSEnable.Value { @@ -663,7 +662,7 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code wg.Wait() cmd.Println("Waiting for WebSocket connections to close...") - _ = coderAPI.Close() + _ = coderAPICloser.Close() cmd.Println("Done waiting for WebSocket connections") // Close tunnel after we no longer have in-flight connections. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 36e1bd583a..f68f8194f6 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1,9 +1,5 @@ -- Code generated by 'make coderd/database/generate'. DO NOT EDIT. -CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; - -COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; - CREATE TYPE api_key_scope AS ENUM ( 'all', 'application_connect' diff --git a/coderd/database/migrations/000059_file_id.up.sql b/coderd/database/migrations/000059_file_id.up.sql index 640876e7b7..f1b6f96edd 100644 --- a/coderd/database/migrations/000059_file_id.up.sql +++ b/coderd/database/migrations/000059_file_id.up.sql @@ -14,9 +14,6 @@ BEGIN; -- Drop the primary key on hash. ALTER TABLE files DROP CONSTRAINT files_pkey; --- This extension is required by gen_random_uuid -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -- Add an 'id' column and designate it the primary key. ALTER TABLE files ADD COLUMN id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid (); diff --git a/coderd/tracing/exporter.go b/coderd/tracing/exporter.go index b516e196d7..56a8d7dcc6 100644 --- a/coderd/tracing/exporter.go +++ b/coderd/tracing/exporter.go @@ -3,6 +3,7 @@ package tracing import ( "context" + "github.com/go-logr/logr" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" @@ -59,12 +60,15 @@ func TracerProvider(ctx context.Context, service string, opts TracerOpts) (*sdkt tracerProvider := sdktrace.NewTracerProvider(tracerOpts...) otel.SetTracerProvider(tracerProvider) + // Ignore otel errors! + otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) {})) otel.SetTextMapPropagator( propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, ), ) + otel.SetLogger(logr.Discard()) return tracerProvider, func(ctx context.Context) error { for _, close := range closers { diff --git a/enterprise/tailnet/coordinator.go b/enterprise/tailnet/coordinator.go index 5749d9ef47..c24107fe2a 100644 --- a/enterprise/tailnet/coordinator.go +++ b/enterprise/tailnet/coordinator.go @@ -215,7 +215,7 @@ func (c *haCoordinator) ServeAgent(conn net.Conn, id uuid.UUID) error { for { node, err := c.handleAgentUpdate(id, decoder) if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, context.Canceled) { return nil } return xerrors.Errorf("handle next agent message: %w", err) diff --git a/tailnet/coordinator.go b/tailnet/coordinator.go index fc48974ca7..9203bbebff 100644 --- a/tailnet/coordinator.go +++ b/tailnet/coordinator.go @@ -1,6 +1,7 @@ package tailnet import ( + "context" "encoding/json" "errors" "io" @@ -289,7 +290,7 @@ func (c *coordinator) ServeAgent(conn net.Conn, id uuid.UUID) error { for { err := c.handleNextAgentMessage(id, decoder) if err != nil { - if errors.Is(err, io.EOF) { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { return nil } return xerrors.Errorf("handle next agent message: %w", err) From 211ffabe3938b6c5c839c005436eab97448c3f3f Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 23:50:29 +0000 Subject: [PATCH 38/51] Fix kill mode stopping the built-in PostgreSQL This was preventing final cleanups from occurring! --- coder.service | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coder.service b/coder.service index 697250bf8f..4ff2cc260a 100644 --- a/coder.service +++ b/coder.service @@ -20,11 +20,12 @@ AmbientCapabilities=CAP_IPC_LOCK CAP_NET_BIND_SERVICE CacheDirectory=coder CapabilityBoundingSet=CAP_SYSLOG CAP_IPC_LOCK CAP_NET_BIND_SERVICE KillSignal=SIGINT +KillMode=mixed NoNewPrivileges=yes ExecStart=/usr/bin/coder server Restart=on-failure RestartSec=5 -TimeoutStopSec=30 +TimeoutStopSec=90 [Install] WantedBy=multi-user.target From 7851fb1c99ea446c1340b90f8547ebbbd09cd16d Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 23:51:41 +0000 Subject: [PATCH 39/51] Fix unlock of unlocked mutex in tailnet coordinator --- tailnet/coordinator.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/tailnet/coordinator.go b/tailnet/coordinator.go index 9203bbebff..dbd70ead1a 100644 --- a/tailnet/coordinator.go +++ b/tailnet/coordinator.go @@ -153,12 +153,10 @@ func (c *coordinator) ServeClient(conn net.Conn, id uuid.UUID, agent uuid.UUID) if ok { data, err := json.Marshal([]*Node{node}) if err != nil { - c.mutex.Unlock() return xerrors.Errorf("marshal node: %w", err) } _, err = conn.Write(data) if err != nil { - c.mutex.Unlock() return xerrors.Errorf("write nodes: %w", err) } } From e95239cfcd1f7937c9952dcf5ce2a3c86a6aa838 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Mon, 17 Oct 2022 19:15:07 -0500 Subject: [PATCH 40/51] fix: agent stats websocket blocking until next interval (#4609) --- coderd/workspaceagents.go | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index fb7f765cc7..250b4c73ae 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -757,18 +757,30 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques // Allow overriding the stat interval for debugging and testing purposes. timer := time.NewTicker(api.AgentStatsRefreshInterval) - for { - err := wsjson.Write(ctx, conn, codersdk.AgentStatsReportRequest{}) - if err != nil { - api.Logger.Debug(ctx, "write report request", slog.Error(err)) - conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("write report request: %s", err)) - return - } - var rep codersdk.AgentStatsReportResponse + defer timer.Stop() + go func() { + for { + err := wsjson.Write(ctx, conn, codersdk.AgentStatsReportRequest{}) + if err != nil { + conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("write report request: %s", err)) + return + } + + select { + case <-timer.C: + continue + case <-ctx.Done(): + conn.Close(websocket.StatusNormalClosure, "") + return + } + } + }() + + for { + var rep codersdk.AgentStatsReportResponse err = wsjson.Read(ctx, conn, &rep) if err != nil { - api.Logger.Debug(ctx, "read report response", slog.Error(err)) conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("read report response: %s", err)) return } @@ -827,14 +839,6 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques return } } - - select { - case <-timer.C: - continue - case <-ctx.Done(): - conn.Close(websocket.StatusNormalClosure, "") - return - } } } From d1c537407d439d851fa87db229d1324683a61cc2 Mon Sep 17 00:00:00 2001 From: Eric Paulsen Date: Mon, 17 Oct 2022 19:22:56 -0500 Subject: [PATCH 41/51] fix: install index 404 (#4610) --- docs/install/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install/index.md b/docs/install/index.md index 973936080a..b08bfdaab7 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -1,5 +1,5 @@ There are a number of different methods to install and run Coder: - This page is rendered on https://coder.com/docs/coder-oss/install. Refer to the other documents in the `install/` directory for per-platform instructions. + This page is rendered on https://coder.com/docs/coder-oss/latest/install. Refer to the other documents in the `install/` directory for per-platform instructions. From 614e40c0f53f89a582b6aecb25fe19526450d4b8 Mon Sep 17 00:00:00 2001 From: Eric Paulsen Date: Mon, 17 Oct 2022 19:24:49 -0500 Subject: [PATCH 42/51] add: postgres version requirement (#4611) --- README.md | 4 ++-- docs/admin/configure.md | 2 ++ docs/install/binary.md | 2 +- docs/install/docker.md | 6 +++--- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 157510b19f..ccc01b3fc9 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Once installed, you can start a production deployment1 with a single # Automatically sets up an external access URL on *.try.coder.app coder server -# Requires a PostgreSQL instance and external access URL +# Requires a PostgreSQL instance (version 13 or higher) and external access URL coder server --postgres-url --access-url ``` @@ -95,7 +95,7 @@ Join our community on [Discord](https://coder.com/chat?utm_source=github.com/cod ## Contributing -If you're using Coder in your organization, please try to add your company name to the [ADOPTERS.md](./ADOPTERS.md). It really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact. +If you're using Coder in your organization, please try to add your company name to the [ADOPTERS.md](./ADOPTERS.md). It really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact. Read the [contributing docs](https://coder.com/docs/coder-oss/latest/CONTRIBUTING). diff --git a/docs/admin/configure.md b/docs/admin/configure.md index 8e70475fe2..e1607d9672 100644 --- a/docs/admin/configure.md +++ b/docs/admin/configure.md @@ -29,6 +29,8 @@ Coder uses a PostgreSQL database to store users, workspace metadata, and other d Use `CODER_PG_CONNECTION_URL` to set the database that Coder connects to. If unset, PostgreSQL binaries will be downloaded from Maven (https://repo1.maven.org/maven2) and store all data in the config root. +> Postgres 13 is the minimum supported version. + ## System packages If you've installed Coder via a [system package](../install/packages.md) Coder, you can diff --git a/docs/install/binary.md b/docs/install/binary.md index bde79b0422..20cd3f1643 100644 --- a/docs/install/binary.md +++ b/docs/install/binary.md @@ -17,7 +17,7 @@ Coder publishes self-contained .zip and .tar.gz archives in [GitHub releases](ht # Automatically sets up an external access URL on *.try.coder.app coder server - # Requires a PostgreSQL instance and external access URL + # Requires a PostgreSQL instance (version 13 or higher) and external access URL coder server --postgres-url --access-url ``` diff --git a/docs/install/docker.md b/docs/install/docker.md index 565bdd2f59..73124a13fc 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -30,9 +30,9 @@ Learn more about Coder's [configuration options](../admin/configure.md). ## Run Coder with access URL and external PostgreSQL (recommended) -For production deployments, we recommend using an external PostgreSQL database. -Set `ACCESS_URL` to the external URL that users and workspaces will use to -connect to Coder. +For production deployments, we recommend using an external PostgreSQL database +(version 13 or higher). Set `ACCESS_URL` to the external URL that users and +workspaces will use to connect to Coder. ```sh docker run --rm -it \ From 3c406980336303f32685915a9bd3e27ace81c91d Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 17 Oct 2022 20:02:25 -0500 Subject: [PATCH 43/51] chore: Enforce PostgreSQL >=13 (#4612) * chore: Enforce PostgreSQL >=13 Fixes #4608. * Fix version string parsing --- cli/server.go | 18 ++++++++++++++++++ cli/server_test.go | 3 +++ 2 files changed, 21 insertions(+) diff --git a/cli/server.go b/cli/server.go index b9861bf141..25b435ed6c 100644 --- a/cli/server.go +++ b/cli/server.go @@ -33,6 +33,7 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" "go.opentelemetry.io/otel/trace" + "golang.org/x/mod/semver" "golang.org/x/oauth2" xgithub "golang.org/x/oauth2/github" "golang.org/x/sync/errgroup" @@ -389,6 +390,23 @@ func Server(dflags *codersdk.DeploymentFlags, newAPI func(context.Context, *code return xerrors.Errorf("dial postgres: %w", err) } defer sqlDB.Close() + // Ensure the PostgreSQL version is >=13.0.0! + version, err := sqlDB.QueryContext(ctx, "SHOW server_version;") + if err != nil { + return xerrors.Errorf("get postgres version: %w", err) + } + if !version.Next() { + return xerrors.Errorf("no rows returned for version select") + } + var versionStr string + err = version.Scan(&versionStr) + if err != nil { + return xerrors.Errorf("scan version: %w", err) + } + versionStr = strings.Split(versionStr, " ")[0] + if semver.Compare("v"+versionStr, "v13") < 0 { + return xerrors.New("PostgreSQL version must be v13.0.0 or higher!") + } err = sqlDB.Ping() if err != nil { diff --git a/cli/server_test.go b/cli/server_test.go index fe71da9309..d91e2e8889 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -60,6 +60,9 @@ func TestServer(t *testing.T) { "--postgres-url", connectionURL, "--cache-dir", t.TempDir(), ) + pty := ptytest.New(t) + root.SetOutput(pty.Output()) + root.SetErr(pty.Output()) errC := make(chan error, 1) go func() { errC <- root.ExecuteContext(ctx) From 61683f1961ae23c0741b2a3b0487f8a226b7f822 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 17 Oct 2022 22:07:11 -0500 Subject: [PATCH 44/51] fix: allow for alternate usernames on conflict (#4614) --- coderd/coderdtest/coderdtest.go | 92 +++++++++++++++++ coderd/database/databasefake/databasefake.go | 6 ++ coderd/userauth.go | 33 ++++++ coderd/userauth_test.go | 102 ++++++++++--------- enterprise/coderd/license/license_test.go | 8 +- 5 files changed, 192 insertions(+), 49 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index d9ddc0bae2..05e3d6a27d 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -29,6 +29,7 @@ import ( "time" "cloud.google.com/go/compute/metadata" + "github.com/coreos/go-oidc/v3/oidc" "github.com/fullsailor/pkcs7" "github.com/golang-jwt/jwt" "github.com/google/uuid" @@ -36,6 +37,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "golang.org/x/xerrors" "google.golang.org/api/idtoken" "google.golang.org/api/option" @@ -725,6 +727,80 @@ func NewAWSInstanceIdentity(t *testing.T, instanceID string) (awsidentity.Certif } } +type OIDCConfig struct { + key *rsa.PrivateKey + issuer string +} + +func NewOIDCConfig(t *testing.T, issuer string) *OIDCConfig { + t.Helper() + + block, _ := pem.Decode([]byte(testRSAPrivateKey)) + pkey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + require.NoError(t, err) + + if issuer == "" { + issuer = "https://coder.com" + } + + return &OIDCConfig{ + key: pkey, + issuer: issuer, + } +} + +func (*OIDCConfig) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string { + return "/?state=" + url.QueryEscape(state) +} + +func (*OIDCConfig) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { + return nil +} + +func (*OIDCConfig) Exchange(_ context.Context, code string, _ ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + token, err := base64.StdEncoding.DecodeString(code) + if err != nil { + return nil, xerrors.Errorf("decode code: %w", err) + } + return (&oauth2.Token{ + AccessToken: "token", + }).WithExtra(map[string]interface{}{ + "id_token": string(token), + }), nil +} + +func (o *OIDCConfig) EncodeClaims(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + + if _, ok := claims["exp"]; !ok { + claims["exp"] = time.Now().Add(time.Hour).UnixMilli() + } + + if _, ok := claims["iss"]; !ok { + claims["iss"] = o.issuer + } + + if _, ok := claims["sub"]; !ok { + claims["sub"] = "testme" + } + + signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(o.key) + require.NoError(t, err) + + return base64.StdEncoding.EncodeToString([]byte(signed)) +} + +func (o *OIDCConfig) OIDCConfig() *coderd.OIDCConfig { + return &coderd.OIDCConfig{ + OAuth2Config: o, + Verifier: oidc.NewVerifier(o.issuer, &oidc.StaticKeySet{ + PublicKeys: []crypto.PublicKey{o.key.Public()}, + }, &oidc.Config{ + SkipClientIDCheck: true, + }), + } +} + // NewAzureInstanceIdentity returns a metadata client and ID token validator for faking // instance authentication for Azure. func NewAzureInstanceIdentity(t *testing.T, instanceID string) (x509.VerifyOptions, *http.Client) { @@ -805,3 +881,19 @@ func SDKError(t *testing.T, err error) *codersdk.Error { require.True(t, errors.As(err, &cerr)) return cerr } + +const testRSAPrivateKey = `-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDLets8+7M+iAQAqN/5BVyCIjhTQ4cmXulL+gm3v0oGMWzLupUS +v8KPA+Tp7dgC/DZPfMLaNH1obBBhJ9DhS6RdS3AS3kzeFrdu8zFHLWF53DUBhS92 +5dCAEuJpDnNizdEhxTfoHrhuCmz8l2nt1pe5eUK2XWgd08Uc93h5ij098wIDAQAB +AoGAHLaZeWGLSaen6O/rqxg2laZ+jEFbMO7zvOTruiIkL/uJfrY1kw+8RLIn+1q0 +wLcWcuEIHgKKL9IP/aXAtAoYh1FBvRPLkovF1NZB0Je/+CSGka6wvc3TGdvppZJe +rKNcUvuOYLxkmLy4g9zuY5qrxFyhtIn2qZzXEtLaVOHzPQECQQDvN0mSajpU7dTB +w4jwx7IRXGSSx65c+AsHSc1Rj++9qtPC6WsFgAfFN2CEmqhMbEUVGPv/aPjdyWk9 +pyLE9xR/AkEA2cGwyIunijE5v2rlZAD7C4vRgdcMyCf3uuPcgzFtsR6ZhyQSgLZ8 +YRPuvwm4cdPJMmO3YwBfxT6XGuSc2k8MjQJBAI0+b8prvpV2+DCQa8L/pjxp+VhR +Xrq2GozrHrgR7NRokTB88hwFRJFF6U9iogy9wOx8HA7qxEbwLZuhm/4AhbECQC2a +d8h4Ht09E+f3nhTEc87mODkl7WJZpHL6V2sORfeq/eIkds+H6CJ4hy5w/bSw8tjf +sz9Di8sGIaUbLZI2rd0CQQCzlVwEtRtoNCyMJTTrkgUuNufLP19RZ5FpyXxBO5/u +QastnN77KfUwdj3SJt44U/uh1jAIv4oSLBr8HYUkbnI8 +-----END RSA PRIVATE KEY-----` diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 757616774c..6f809ad117 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2221,6 +2221,12 @@ func (q *fakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam q.mutex.Lock() defer q.mutex.Unlock() + for _, user := range q.users { + if user.Username == arg.Username && !user.Deleted { + return database.User{}, errDuplicateKey + } + } + user := database.User{ ID: arg.ID, Email: arg.Email, diff --git a/coderd/userauth.go b/coderd/userauth.go index 7a18b9790d..30c66a0c58 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -13,6 +13,7 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/google/go-github/v43/github" "github.com/google/uuid" + "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/oauth2" "golang.org/x/xerrors" @@ -390,6 +391,38 @@ func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cook organizationID = organizations[0].ID } + _, err := tx.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{ + Username: params.Username, + }) + if err == nil { + var ( + original = params.Username + validUsername bool + ) + for i := 0; i < 10; i++ { + alternate := fmt.Sprintf("%s-%s", original, namesgenerator.GetRandomName(1)) + + params.Username = httpapi.UsernameFrom(alternate) + + _, err := tx.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{ + Username: params.Username, + }) + if xerrors.Is(err, sql.ErrNoRows) { + validUsername = true + break + } + if err != nil { + return xerrors.Errorf("get user by email/username: %w", err) + } + } + if !validUsername { + return httpError{ + code: http.StatusConflict, + msg: fmt.Sprintf("exhausted alternatives for taken username %q", original), + } + } + } + user, _, err = api.CreateUser(ctx, tx, CreateUserRequest{ CreateUserRequest: codersdk.CreateUserRequest{ Email: params.Email, diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 9643351032..ba4b437bdb 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -3,13 +3,12 @@ package coderd_test import ( "context" "crypto" - "crypto/rand" - "crypto/rsa" + "fmt" "io" "net/http" "net/url" + "strings" "testing" - "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/golang-jwt/jwt" @@ -450,17 +449,19 @@ func TestUserOIDC(t *testing.T) { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() - config := createOIDCConfig(t, tc.Claims) + conf := coderdtest.NewOIDCConfig(t, "") + + config := conf.OIDCConfig() config.AllowSignups = tc.AllowSignups config.EmailDomain = tc.EmailDomain + client := coderdtest.New(t, &coderdtest.Options{ OIDCConfig: config, }) - resp := oidcCallback(t, client) + resp := oidcCallback(t, client, conf.EncodeClaims(t, tc.Claims)) assert.Equal(t, tc.StatusCode, resp.StatusCode) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx, _ := testutil.Context(t) if tc.Username != "" { client.SessionToken = authCookieValue(resp.Cookies()) @@ -478,10 +479,50 @@ func TestUserOIDC(t *testing.T) { }) } + t.Run("AlternateUsername", func(t *testing.T) { + t.Parallel() + + conf := coderdtest.NewOIDCConfig(t, "") + + config := conf.OIDCConfig() + config.AllowSignups = true + + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: config, + }) + + code := conf.EncodeClaims(t, jwt.MapClaims{ + "email": "jon@coder.com", + }) + resp := oidcCallback(t, client, code) + assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + ctx, _ := testutil.Context(t) + + client.SessionToken = authCookieValue(resp.Cookies()) + user, err := client.User(ctx, "me") + require.NoError(t, err) + require.Equal(t, "jon", user.Username) + + // Pass a different subject field so that we prompt creating a + // new user. + code = conf.EncodeClaims(t, jwt.MapClaims{ + "email": "jon@example2.com", + "sub": "diff", + }) + resp = oidcCallback(t, client, code) + assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + + client.SessionToken = authCookieValue(resp.Cookies()) + user, err = client.User(ctx, "me") + require.NoError(t, err) + require.True(t, strings.HasPrefix(user.Username, "jon-"), "username %q should have prefix %q", user.Username, "jon-") + }) + t.Run("Disabled", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) - resp := oidcCallback(t, client) + resp := oidcCallback(t, client, "asdf") require.Equal(t, http.StatusPreconditionRequired, resp.StatusCode) }) @@ -492,7 +533,7 @@ func TestUserOIDC(t *testing.T) { OAuth2Config: &oauth2Config{}, }, }) - resp := oidcCallback(t, client) + resp := oidcCallback(t, client, "asdf") require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) @@ -514,48 +555,16 @@ func TestUserOIDC(t *testing.T) { Verifier: verifier, }, }) - resp := oidcCallback(t, client) + resp := oidcCallback(t, client, "asdf") require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) } -// createOIDCConfig generates a new OIDCConfig that returns a static token -// with the claims provided. -func createOIDCConfig(t *testing.T, claims jwt.MapClaims) *coderd.OIDCConfig { - t.Helper() - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - - // https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 - claims["exp"] = time.Now().Add(time.Hour).UnixMilli() - claims["iss"] = "https://coder.com" - claims["sub"] = "hello" - - signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(key) - require.NoError(t, err) - - verifier := oidc.NewVerifier("https://coder.com", &oidc.StaticKeySet{ - PublicKeys: []crypto.PublicKey{key.Public()}, - }, &oidc.Config{ - SkipClientIDCheck: true, - }) - - return &coderd.OIDCConfig{ - OAuth2Config: &oauth2Config{ - token: (&oauth2.Token{ - AccessToken: "token", - }).WithExtra(map[string]interface{}{ - "id_token": signed, - }), - }, - Verifier: verifier, - } -} - func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } + state := "somestate" oauthURL, err := client.URL.Parse("/api/v2/users/oauth2/github/callback?code=asd&state=" + state) require.NoError(t, err) @@ -573,19 +582,18 @@ func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { return res } -func oidcCallback(t *testing.T, client *codersdk.Client) *http.Response { +func oidcCallback(t *testing.T, client *codersdk.Client, code string) *http.Response { t.Helper() client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } - state := "somestate" - oauthURL, err := client.URL.Parse("/api/v2/users/oidc/callback?code=asd&state=" + state) + oauthURL, err := client.URL.Parse(fmt.Sprintf("/api/v2/users/oidc/callback?code=%s&state=somestate", code)) require.NoError(t, err) req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil) require.NoError(t, err) req.AddCookie(&http.Cookie{ Name: codersdk.OAuth2StateKey, - Value: state, + Value: "somestate", }) res, err := client.HTTPClient.Do(req) require.NoError(t, err) diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 6def291e3e..a47dd83c98 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -140,8 +140,12 @@ func TestEntitlements(t *testing.T) { t.Run("TooManyUsers", func(t *testing.T) { t.Parallel() db := databasefake.New() - db.InsertUser(context.Background(), database.InsertUserParams{}) - db.InsertUser(context.Background(), database.InsertUserParams{}) + db.InsertUser(context.Background(), database.InsertUserParams{ + Username: "test1", + }) + db.InsertUser(context.Background(), database.InsertUserParams{ + Username: "test2", + }) db.InsertLicense(context.Background(), database.InsertLicenseParams{ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ UserLimit: 1, From 616fe7a3b14a6495f3ac7009b76844758dc54ef4 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 18 Oct 2022 10:44:58 -0300 Subject: [PATCH 45/51] feat: Redesign resources table (#4600) --- site/src/components/CopyButton/CopyButton.tsx | 36 +-- .../CopyableValue/CopyableValue.tsx | 39 +++ site/src/components/PageHeader/PageHeader.tsx | 7 +- .../src/components/Resources/AgentLatency.tsx | 114 +++++++ site/src/components/Resources/AgentStatus.tsx | 100 ++++++ .../src/components/Resources/AgentVersion.tsx | 61 ++++ .../Resources/ResourceCard.stories.tsx | 81 +++++ .../src/components/Resources/ResourceCard.tsx | 273 +++++++++++++++++ site/src/components/Resources/Resources.tsx | 288 ++---------------- .../components/Resources/SensitiveValue.tsx | 69 +++++ site/src/components/Stack/Stack.tsx | 4 + .../Tooltips/HelpTooltip/HelpTooltip.tsx | 56 ++-- site/src/hooks/useClickable.ts | 21 ++ site/src/hooks/useClipboard.ts | 44 +++ site/src/i18n/en/workspacePage.json | 5 + .../WorkspacePage/WorkspacePage.test.tsx | 27 +- site/src/util/workspace.test.ts | 6 +- site/src/util/workspace.ts | 46 +-- .../portForward/portForwardXService.ts | 1 + 19 files changed, 904 insertions(+), 374 deletions(-) create mode 100644 site/src/components/CopyableValue/CopyableValue.tsx create mode 100644 site/src/components/Resources/AgentLatency.tsx create mode 100644 site/src/components/Resources/AgentStatus.tsx create mode 100644 site/src/components/Resources/AgentVersion.tsx create mode 100644 site/src/components/Resources/ResourceCard.stories.tsx create mode 100644 site/src/components/Resources/ResourceCard.tsx create mode 100644 site/src/components/Resources/SensitiveValue.tsx create mode 100644 site/src/hooks/useClickable.ts create mode 100644 site/src/hooks/useClipboard.ts diff --git a/site/src/components/CopyButton/CopyButton.tsx b/site/src/components/CopyButton/CopyButton.tsx index 107ccc862d..ac8bc3cb87 100644 --- a/site/src/components/CopyButton/CopyButton.tsx +++ b/site/src/components/CopyButton/CopyButton.tsx @@ -2,7 +2,7 @@ import IconButton from "@material-ui/core/Button" import { makeStyles } from "@material-ui/core/styles" import Tooltip from "@material-ui/core/Tooltip" import Check from "@material-ui/icons/Check" -import React, { useState } from "react" +import { useClipboard } from "hooks/useClipboard" import { combineClasses } from "../../util/combineClasses" import { FileCopyIcon } from "../Icons/FileCopyIcon" @@ -30,39 +30,7 @@ export const CopyButton: React.FC> = ({ tooltipTitle = Language.tooltipTitle, }) => { const styles = useStyles() - const [isCopied, setIsCopied] = useState(false) - - const copyToClipboard = async (): Promise => { - try { - await window.navigator.clipboard.writeText(text) - setIsCopied(true) - window.setTimeout(() => { - setIsCopied(false) - }, 1000) - } catch (err) { - const input = document.createElement("input") - input.value = text - document.body.appendChild(input) - input.focus() - input.select() - const result = document.execCommand("copy") - document.body.removeChild(input) - if (result) { - setIsCopied(true) - window.setTimeout(() => { - setIsCopied(false) - }, 1000) - } else { - const wrappedErr = new Error( - "copyToClipboard: failed to copy text to clipboard", - ) - if (err instanceof Error) { - wrappedErr.stack = err.stack - } - console.error(wrappedErr) - } - } - } + const { isCopied, copy: copyToClipboard } = useClipboard(text) return ( diff --git a/site/src/components/CopyableValue/CopyableValue.tsx b/site/src/components/CopyableValue/CopyableValue.tsx new file mode 100644 index 0000000000..15bd72d84c --- /dev/null +++ b/site/src/components/CopyableValue/CopyableValue.tsx @@ -0,0 +1,39 @@ +import { makeStyles } from "@material-ui/core/styles" +import Tooltip from "@material-ui/core/Tooltip" +import { useClickable } from "hooks/useClickable" +import { useClipboard } from "hooks/useClipboard" +import React, { HTMLProps } from "react" +import { combineClasses } from "util/combineClasses" + +interface CopyableValueProps extends HTMLProps { + value: string +} + +export const CopyableValue: React.FC = ({ + value, + className, + ...props +}) => { + const { isCopied, copy } = useClipboard(value) + const clickableProps = useClickable(copy) + const styles = useStyles() + + return ( + + + + ) +} + +const useStyles = makeStyles(() => ({ + value: { + cursor: "pointer", + }, +})) diff --git a/site/src/components/PageHeader/PageHeader.tsx b/site/src/components/PageHeader/PageHeader.tsx index c7f0e49bba..3944f34050 100644 --- a/site/src/components/PageHeader/PageHeader.tsx +++ b/site/src/components/PageHeader/PageHeader.tsx @@ -15,14 +15,17 @@ export const PageHeader: React.FC> = ({ const styles = useStyles({}) return ( -
+
{children}
{actions && ( {actions} )} -
+
) } diff --git a/site/src/components/Resources/AgentLatency.tsx b/site/src/components/Resources/AgentLatency.tsx new file mode 100644 index 0000000000..ab74f8271e --- /dev/null +++ b/site/src/components/Resources/AgentLatency.tsx @@ -0,0 +1,114 @@ +import { useRef, useState, FC } from "react" +import { makeStyles, Theme, useTheme } from "@material-ui/core/styles" +import { + HelpTooltipText, + HelpPopover, + HelpTooltipTitle, +} from "components/Tooltips/HelpTooltip" +import { Stack } from "components/Stack/Stack" +import { WorkspaceAgent, DERPRegion } from "api/typesGenerated" + +const getDisplayLatency = (theme: Theme, agent: WorkspaceAgent) => { + // Find the right latency to display + const latencyValues = Object.values(agent.latency ?? {}) + const latency = + latencyValues.find((derp) => derp.preferred) ?? + // Accessing an array index can return undefined as well + // for some reason TS does not handle that + (latencyValues[0] as DERPRegion | undefined) + + if (!latency) { + return undefined + } + + // Get the color + let color = theme.palette.success.light + if (latency.latency_ms >= 150 && latency.latency_ms < 300) { + color = theme.palette.warning.light + } else if (latency.latency_ms >= 300) { + color = theme.palette.error.light + } + + return { + ...latency, + color, + } +} + +export const AgentLatency: FC<{ agent: WorkspaceAgent }> = ({ agent }) => { + const theme: Theme = useTheme() + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const id = isOpen ? "latency-popover" : undefined + const latency = getDisplayLatency(theme, agent) + const styles = useStyles() + + if (!latency || !agent.latency) { + return null + } + + return ( + <> + setIsOpen(true)} + className={styles.trigger} + style={{ color: latency.color }} + > + {Math.round(Math.round(latency.latency_ms))}ms + + setIsOpen(true)} + onClose={() => setIsOpen(false)} + > + Latency + + Latency from relay servers, used when connections cannot connect + peer-to-peer. Star indicates the preferred relay. + + + + + {Object.keys(agent.latency).map((regionName) => { + if (!agent.latency) { + throw new Error("No latency found on agent") + } + + const region = agent.latency[regionName] + + return ( + + {regionName} + {Math.round(region.latency_ms)}ms + + ) + })} + + + + + ) +} + +const useStyles = makeStyles((theme) => ({ + trigger: { + cursor: "pointer", + }, + regions: { + marginTop: theme.spacing(2), + }, + preferred: { + color: theme.palette.text.primary, + }, +})) diff --git a/site/src/components/Resources/AgentStatus.tsx b/site/src/components/Resources/AgentStatus.tsx new file mode 100644 index 0000000000..1a55f2255b --- /dev/null +++ b/site/src/components/Resources/AgentStatus.tsx @@ -0,0 +1,100 @@ +import Tooltip from "@material-ui/core/Tooltip" +import { makeStyles } from "@material-ui/core/styles" +import { combineClasses } from "util/combineClasses" +import { WorkspaceAgent } from "api/typesGenerated" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { useTranslation } from "react-i18next" + +const ConnectedStatus: React.FC = () => { + const styles = useStyles() + const { t } = useTranslation("workspacePage") + + return ( + +
+ + ) +} + +const DisconnectedStatus: React.FC = () => { + const styles = useStyles() + const { t } = useTranslation("workspacePage") + + return ( + +
+ + ) +} + +const ConnectingStatus: React.FC = () => { + const styles = useStyles() + const { t } = useTranslation("workspacePage") + + return ( + +
+ + ) +} + +export const AgentStatus: React.FC<{ agent: WorkspaceAgent }> = ({ agent }) => { + return ( + + + + + + + + + + + + ) +} + +const useStyles = makeStyles((theme) => ({ + status: { + width: theme.spacing(1), + height: theme.spacing(1), + borderRadius: "100%", + }, + + connected: { + backgroundColor: theme.palette.success.light, + }, + + disconnected: { + backgroundColor: theme.palette.text.secondary, + }, + + "@keyframes pulse": { + "0%": { + opacity: 0.25, + }, + "50%": { + opacity: 1, + }, + "100%": { + opacity: 0.25, + }, + }, + + connecting: { + backgroundColor: theme.palette.info.light, + animation: "$pulse 1s ease-in-out forwards infinite", + }, +})) diff --git a/site/src/components/Resources/AgentVersion.tsx b/site/src/components/Resources/AgentVersion.tsx new file mode 100644 index 0000000000..aab5fa73e7 --- /dev/null +++ b/site/src/components/Resources/AgentVersion.tsx @@ -0,0 +1,61 @@ +import { useRef, useState, FC } from "react" +import { makeStyles } from "@material-ui/core/styles" +import { + HelpTooltipText, + HelpPopover, + HelpTooltipTitle, +} from "components/Tooltips/HelpTooltip" +import { WorkspaceAgent } from "api/typesGenerated" +import { getDisplayVersionStatus } from "util/workspace" + +export const AgentVersion: FC<{ + agent: WorkspaceAgent + serverVersion: string +}> = ({ agent, serverVersion }) => { + const styles = useStyles() + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const id = isOpen ? "version-outdated-popover" : undefined + const { displayVersion, outdated } = getDisplayVersionStatus( + agent.version, + serverVersion, + ) + + if (!outdated) { + return {displayVersion} + } + + return ( + <> + setIsOpen(true)} + className={styles.trigger} + > + Agent Outdated + + setIsOpen(true)} + onClose={() => setIsOpen(false)} + > + Agent Outdated + + This agent is an older version than the Coder server. This can happen + after you update Coder with running workspaces. To fix this, you can + stop and start the workspace. + + + + ) +} + +const useStyles = makeStyles(() => ({ + trigger: { + cursor: "pointer", + }, +})) diff --git a/site/src/components/Resources/ResourceCard.stories.tsx b/site/src/components/Resources/ResourceCard.stories.tsx new file mode 100644 index 0000000000..11684a3ec4 --- /dev/null +++ b/site/src/components/Resources/ResourceCard.stories.tsx @@ -0,0 +1,81 @@ +import { Story } from "@storybook/react" +import { + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceResource, +} from "testHelpers/entities" +import { ResourceCard, ResourceCardProps } from "./ResourceCard" + +export default { + title: "components/ResourceCard", + component: ResourceCard, +} + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + resource: MockWorkspaceResource, + workspace: MockWorkspace, + applicationsHost: "https://dev.coder.com", + hideSSHButton: false, + showApps: true, + serverVersion: MockWorkspaceAgent.version, +} + +export const NotShowingApps = Template.bind({}) +NotShowingApps.args = { + ...Example.args, + showApps: false, +} + +export const HideSSHButton = Template.bind({}) +HideSSHButton.args = { + ...Example.args, + hideSSHButton: true, +} + +export const BunchOfMetadata = Template.bind({}) +BunchOfMetadata.args = { + ...Example.args, + resource: { + ...MockWorkspaceResource, + metadata: [ + { key: "type", value: "kubernetes_pod", sensitive: false }, + { + key: "CPU(limits, requests)", + value: "2 cores, 500m", + sensitive: false, + }, + { key: "container image pull policy", value: "Always", sensitive: false }, + { key: "Disk", value: "10GiB", sensitive: false }, + { + key: "image", + value: "docker.io/markmilligan/pycharm-community:latest", + sensitive: false, + }, + { key: "kubernetes namespace", value: "oss", sensitive: false }, + { + key: "memory(limits, requests)", + value: "4GB, 500mi", + sensitive: false, + }, + { + key: "security context - container", + value: "run_as_user 1000", + sensitive: false, + }, + { + key: "security context - pod", + value: "run_as_user 1000 fs_group 1000", + sensitive: false, + }, + { key: "volume", value: "/home/coder", sensitive: false }, + { + key: "secret", + value: "3XqfNW0b1bvsGsqud8O6OW6VabH3fwzI", + sensitive: true, + }, + ], + }, +} diff --git a/site/src/components/Resources/ResourceCard.tsx b/site/src/components/Resources/ResourceCard.tsx new file mode 100644 index 0000000000..c1513d158f --- /dev/null +++ b/site/src/components/Resources/ResourceCard.tsx @@ -0,0 +1,273 @@ +import { makeStyles } from "@material-ui/core/styles" +import { Skeleton } from "@material-ui/lab" +import { PortForwardButton } from "components/PortForwardButton/PortForwardButton" +import { FC, useState } from "react" +import { Workspace, WorkspaceResource } from "../../api/typesGenerated" +import { AppLink } from "../AppLink/AppLink" +import { SSHButton } from "../SSHButton/SSHButton" +import { Stack } from "../Stack/Stack" +import { TerminalLink } from "../TerminalLink/TerminalLink" +import { ResourceAvatar } from "./ResourceAvatar" +import { SensitiveValue } from "./SensitiveValue" +import { AgentLatency } from "./AgentLatency" +import { AgentVersion } from "./AgentVersion" +import { + OpenDropdown, + CloseDropdown, +} from "components/DropdownArrows/DropdownArrows" +import IconButton from "@material-ui/core/IconButton" +import Tooltip from "@material-ui/core/Tooltip" +import { Maybe } from "components/Conditionals/Maybe" +import { CopyableValue } from "components/CopyableValue/CopyableValue" +import { AgentStatus } from "./AgentStatus" + +export interface ResourceCardProps { + resource: WorkspaceResource + workspace: Workspace + applicationsHost: string | undefined + showApps: boolean + hideSSHButton?: boolean + serverVersion: string +} + +export const ResourceCard: FC = ({ + resource, + workspace, + applicationsHost, + showApps, + hideSSHButton, + serverVersion, +}) => { + const [shouldDisplayAllMetadata, setShouldDisplayAllMetadata] = + useState(false) + const styles = useStyles() + const metadataToDisplay = + // Type is already displayed in the header + resource.metadata?.filter((data) => data.key !== "type") ?? [] + const visibleMetadata = shouldDisplayAllMetadata + ? metadataToDisplay + : metadataToDisplay.slice(0, 4) + + return ( +
+ + +
+ +
+
+
{resource.type}
+
{resource.name}
+
+
+ + +
+ {visibleMetadata.map((meta) => { + return ( +
+
{meta.key}
+
+ {meta.sensitive ? ( + + ) : ( + + {meta.value} + + )} +
+
+ ) + })} +
+ + 4}> + + { + setShouldDisplayAllMetadata((value) => !value) + }} + > + {shouldDisplayAllMetadata ? ( + + ) : ( + + )} + + + +
+
+ + {resource.agents && resource.agents.length > 0 && ( +
+ {resource.agents.map((agent) => { + return ( + + + +
+
{agent.name}
+ + + {agent.operating_system} + + + + +
+
+ + + {showApps && agent.status === "connected" && ( + <> + {applicationsHost !== undefined && ( + + )} + {!hideSSHButton && ( + + )} + + {agent.apps.map((app) => ( + + ))} + + )} + {showApps && agent.status === "connecting" && ( + <> + + + + )} + +
+ ) + })} +
+ )} +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + resourceCard: { + background: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + }, + + resourceCardProfile: { + flexShrink: 0, + width: "fit-content", + }, + + resourceCardHeader: { + padding: theme.spacing(3, 4), + borderBottom: `1px solid ${theme.palette.divider}`, + + "&:last-child": { + borderBottom: 0, + }, + }, + + metadataHeader: { + display: "grid", + gridTemplateColumns: "repeat(4, minmax(0, 1fr))", + gap: theme.spacing(5), + rowGap: theme.spacing(3), + }, + + metadata: { + fontSize: 16, + }, + + metadataLabel: { + fontSize: 12, + color: theme.palette.text.secondary, + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + }, + + metadataValue: { + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + }, + + agentRow: { + padding: theme.spacing(3, 4), + backgroundColor: theme.palette.background.paperLight, + fontSize: 16, + + "&:not(:last-child)": { + borderBottom: `1px solid ${theme.palette.divider}`, + }, + }, + + agentName: { + fontWeight: 600, + }, + + agentOS: { + textTransform: "capitalize", + }, + + agentData: { + fontSize: 14, + color: theme.palette.text.secondary, + marginTop: theme.spacing(0.5), + }, +})) diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index 9d2e63ac9e..d15bcf5441 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -1,46 +1,21 @@ import Button from "@material-ui/core/Button" -import { makeStyles, Theme } from "@material-ui/core/styles" -import Table from "@material-ui/core/Table" -import TableBody from "@material-ui/core/TableBody" -import TableCell from "@material-ui/core/TableCell" -import TableContainer from "@material-ui/core/TableContainer" -import TableHead from "@material-ui/core/TableHead" -import TableRow from "@material-ui/core/TableRow" -import { Skeleton } from "@material-ui/lab" -import useTheme from "@material-ui/styles/useTheme" +import { makeStyles } from "@material-ui/core/styles" import { CloseDropdown, OpenDropdown, } from "components/DropdownArrows/DropdownArrows" -import { PortForwardButton } from "components/PortForwardButton/PortForwardButton" -import { TableCellDataPrimary } from "components/TableCellData/TableCellData" import { FC, useState } from "react" -import { getDisplayAgentStatus, getDisplayVersionStatus } from "util/workspace" import { BuildInfoResponse, Workspace, WorkspaceResource, } from "../../api/typesGenerated" -import { AppLink } from "../AppLink/AppLink" -import { SSHButton } from "../SSHButton/SSHButton" import { Stack } from "../Stack/Stack" -import { TableHeaderRow } from "../TableHeaders/TableHeaders" -import { TerminalLink } from "../TerminalLink/TerminalLink" -import { AgentHelpTooltip } from "../Tooltips/AgentHelpTooltip" -import { AgentOutdatedTooltip } from "../Tooltips/AgentOutdatedTooltip" -import { ResourcesHelpTooltip } from "../Tooltips/ResourcesHelpTooltip" -import { ResourceAgentLatency } from "./ResourceAgentLatency" -import { ResourceAvatarData } from "./ResourceAvatarData" import { AlertBanner } from "components/AlertBanner/AlertBanner" +import { ResourceCard } from "./ResourceCard" -const Language = { - resources: "Resources", - resourceLabel: "Resource", - agentsLabel: "Agents", - agentLabel: "Agent", - statusLabel: "status: ", - versionLabel: "version: ", - osLabel: "os: ", +const countAgents = (resource: WorkspaceResource) => { + return resource.agents ? resource.agents.length : 0 } interface ResourcesProps { @@ -58,178 +33,41 @@ export const Resources: FC> = ({ getResourcesError, workspace, canUpdateWorkspace, - buildInfo, hideSSHButton, applicationsHost, + buildInfo, }) => { - const styles = useStyles() - const theme: Theme = useTheme() const serverVersion = buildInfo?.version || "" + const styles = useStyles() const [shouldDisplayHideResources, setShouldDisplayHideResources] = useState(false) const displayResources = shouldDisplayHideResources ? resources - : resources.filter((resource) => !resource.hide) + : resources + .filter((resource) => !resource.hide) + // Display the resources with agents first + .sort((a, b) => countAgents(b) - countAgents(a)) const hasHideResources = resources.some((r) => r.hide) + if (getResourcesError) { + return + } + return ( -
- {getResourcesError ? ( - - ) : ( - - - - - - - {Language.resourceLabel} - - - - - - {Language.agentLabel} - - - - {canUpdateWorkspace && } - - - - {displayResources.map((resource) => { - { - /* We need to initialize the agents to display the resource */ - } - const agents = resource.agents ?? [null] - const resourceName = ( - - ) - - return agents.map((agent, agentIndex) => { - { - /* If there is no agent, just display the resource name */ - } - if ( - !agent || - workspace.latest_build.transition === "stop" - ) { - return ( - - {resourceName} - - - ) - } - const { displayVersion, outdated } = - getDisplayVersionStatus(agent.version, serverVersion) - const agentStatus = getDisplayAgentStatus(theme, agent) - return ( - - {/* We only want to display the name in the first row because we are using rowSpan */} - {/* The rowspan should be the same than the number of agents */} - {agentIndex === 0 && ( - - {resourceName} - - )} - - - - {agent.name} - -
-
- {Language.statusLabel} - - {agentStatus.status} - -
-
- {Language.osLabel} - - {agent.operating_system} - -
-
- {Language.versionLabel} - - {displayVersion} - - -
-
- -
-
-
- -
- {canUpdateWorkspace && - agent.status === "connected" && ( - <> - {applicationsHost !== undefined && ( - - )} - {!hideSSHButton && ( - - )} - - {agent.apps.map((app) => ( - - ))} - - )} - {canUpdateWorkspace && - agent.status === "connecting" && ( - <> - - - - )} -
-
-
- ) - }) - })} -
-
-
- )} -
+ {displayResources.map((resource) => { + return ( + + ) + })} {hasHideResources && (
@@ -255,77 +93,7 @@ export const Resources: FC> = ({ ) } -const useStyles = makeStyles((theme) => ({ - wrapper: { - borderRadius: theme.shape.borderRadius, - border: `1px solid ${theme.palette.divider}`, - }, - - tableContainer: { - border: 0, - }, - - resourceAvatar: { - color: "#FFF", - backgroundColor: "#3B73D8", - }, - - resourceNameCell: { - borderRight: `1px solid ${theme.palette.divider}`, - }, - - resourceType: { - fontSize: 14, - color: theme.palette.text.secondary, - marginTop: theme.spacing(0.5), - display: "block", - }, - - // Adds some left spacing - agentColumn: { - paddingLeft: `${theme.spacing(4)}px !important`, - }, - - operatingSystem: { - display: "block", - textTransform: "capitalize", - }, - - agentVersion: { - display: "block", - }, - - accessLinks: { - display: "flex", - gap: theme.spacing(0.5), - flexWrap: "wrap", - justifyContent: "flex-end", - }, - - status: { - whiteSpace: "nowrap", - }, - - data: { - color: theme.palette.text.secondary, - fontSize: 14, - marginTop: theme.spacing(0.75), - display: "grid", - gridAutoFlow: "row", - whiteSpace: "nowrap", - gap: theme.spacing(0.75), - height: "fit-content", - }, - - dataRow: { - display: "flex", - alignItems: "center", - - "& strong": { - marginRight: theme.spacing(1), - }, - }, - +const useStyles = makeStyles(() => ({ buttonWrapper: { display: "flex", alignItems: "center", diff --git a/site/src/components/Resources/SensitiveValue.tsx b/site/src/components/Resources/SensitiveValue.tsx new file mode 100644 index 0000000000..b8e7f42d76 --- /dev/null +++ b/site/src/components/Resources/SensitiveValue.tsx @@ -0,0 +1,69 @@ +import IconButton from "@material-ui/core/IconButton" +import { makeStyles } from "@material-ui/core/styles" +import Tooltip from "@material-ui/core/Tooltip" +import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined" +import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined" +import { CopyableValue } from "components/CopyableValue/CopyableValue" +import { useState } from "react" + +const Language = { + showLabel: "Show value", + hideLabel: "Hide value", +} + +export const SensitiveValue: React.FC<{ value: string }> = ({ value }) => { + const [shouldDisplay, setShouldDisplay] = useState(false) + const styles = useStyles() + const displayValue = shouldDisplay ? value : "••••••••" + const buttonLabel = shouldDisplay ? Language.hideLabel : Language.showLabel + const icon = shouldDisplay ? ( + + ) : ( + + ) + + return ( +
+ + {displayValue} + + + { + setShouldDisplay((value) => !value) + }} + size="small" + aria-label={buttonLabel} + > + {icon} + + +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + value: { + // 22px is the button width + width: "calc(100% - 22px)", + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + }, + + sensitiveValue: { + display: "flex", + alignItems: "center", + gap: theme.spacing(0.5), + }, + + button: { + color: "inherit", + + "& .MuiSvgIcon-root": { + width: 16, + height: 16, + }, + }, +})) diff --git a/site/src/components/Stack/Stack.tsx b/site/src/components/Stack/Stack.tsx index d12f4e5821..1f181816c6 100644 --- a/site/src/components/Stack/Stack.tsx +++ b/site/src/components/Stack/Stack.tsx @@ -12,6 +12,7 @@ export type StackProps = { spacing?: number alignItems?: CSSProperties["alignItems"] justifyContent?: CSSProperties["justifyContent"] + wrap?: CSSProperties["flexWrap"] } & React.HTMLProps type StyleProps = Omit @@ -23,6 +24,7 @@ const useStyles = makeStyles((theme) => ({ gap: ({ spacing }: StyleProps) => spacing && theme.spacing(spacing), alignItems: ({ alignItems }: StyleProps) => alignItems, justifyContent: ({ justifyContent }: StyleProps) => justifyContent, + flexWrap: ({ wrap }: StyleProps) => wrap, [theme.breakpoints.down("sm")]: { width: "100%", @@ -37,6 +39,7 @@ export const Stack: FC = ({ spacing = 2, alignItems, justifyContent, + wrap, ...divProps }) => { const styles = useStyles({ @@ -44,6 +47,7 @@ export const Stack: FC = ({ direction, alignItems, justifyContent, + wrap, }) return ( diff --git a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx index 281c140b00..9b992e6bca 100644 --- a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx +++ b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx @@ -1,5 +1,5 @@ import Link from "@material-ui/core/Link" -import Popover from "@material-ui/core/Popover" +import Popover, { PopoverProps } from "@material-ui/core/Popover" import { makeStyles } from "@material-ui/core/styles" import HelpIcon from "@material-ui/icons/HelpOutline" import OpenInNewIcon from "@material-ui/icons/OpenInNew" @@ -35,6 +35,35 @@ const useHelpTooltip = () => { return helpTooltipContext } +export const HelpPopover: React.FC< + PopoverProps & { onOpen: () => void; onClose: () => void } +> = ({ onOpen, onClose, children, ...props }) => { + const styles = useStyles({ size: "small" }) + + return ( + + {children} + + ) +} + export const HelpTooltip: React.FC< React.PropsWithChildren > = ({ children, open, size = "medium" }) => { @@ -67,34 +96,17 @@ export const HelpTooltip: React.FC< > - { - setIsOpen(true) - }, - onMouseLeave: () => { - setIsOpen(false) - }, - }} + onOpen={() => setIsOpen(true)} + onClose={() => setIsOpen(false)} > {children} - + ) } diff --git a/site/src/hooks/useClickable.ts b/site/src/hooks/useClickable.ts new file mode 100644 index 0000000000..a694a5f682 --- /dev/null +++ b/site/src/hooks/useClickable.ts @@ -0,0 +1,21 @@ +import { KeyboardEvent } from "react" + +interface UseClickableResult { + tabIndex: 0 + role: "button" + onClick: () => void + onKeyDown: (event: KeyboardEvent) => void +} + +export const useClickable = (onClick: () => void): UseClickableResult => { + return { + tabIndex: 0, + role: "button", + onClick, + onKeyDown: (event: KeyboardEvent) => { + if (event.key === "Enter") { + onClick() + } + }, + } +} diff --git a/site/src/hooks/useClipboard.ts b/site/src/hooks/useClipboard.ts new file mode 100644 index 0000000000..3737ac64ec --- /dev/null +++ b/site/src/hooks/useClipboard.ts @@ -0,0 +1,44 @@ +import { useState } from "react" + +export const useClipboard = ( + text: string, +): { isCopied: boolean; copy: () => Promise } => { + const [isCopied, setIsCopied] = useState(false) + + const copy = async (): Promise => { + try { + await window.navigator.clipboard.writeText(text) + setIsCopied(true) + window.setTimeout(() => { + setIsCopied(false) + }, 1000) + } catch (err) { + const input = document.createElement("input") + input.value = text + document.body.appendChild(input) + input.focus() + input.select() + const result = document.execCommand("copy") + document.body.removeChild(input) + if (result) { + setIsCopied(true) + window.setTimeout(() => { + setIsCopied(false) + }, 1000) + } else { + const wrappedErr = new Error( + "copyToClipboard: failed to copy text to clipboard", + ) + if (err instanceof Error) { + wrappedErr.stack = err.stack + } + console.error(wrappedErr) + } + } + } + + return { + isCopied, + copy, + } +} diff --git a/site/src/i18n/en/workspacePage.json b/site/src/i18n/en/workspacePage.json index 8f36e9b3d4..a0901d88bf 100644 --- a/site/src/i18n/en/workspacePage.json +++ b/site/src/i18n/en/workspacePage.json @@ -33,5 +33,10 @@ "canceling": "Canceling", "deleted": "Deleted", "pending": "Pending" + }, + "agentStatus": { + "connected": "Connected", + "connecting": "Connecting...", + "disconnected": "Disconnected" } } diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 3f2df80c35..3986d9930f 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ -import { fireEvent, screen, waitFor } from "@testing-library/react" +import { fireEvent, screen, waitFor, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import EventSourceMock from "eventsourcemock" import i18next from "i18next" @@ -27,7 +27,6 @@ import { renderWithAuth, } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" -import { DisplayAgentStatusLanguage } from "../../util/workspace" import { WorkspacePage } from "./WorkspacePage" const { t } = i18next @@ -71,7 +70,8 @@ const testStatus = async (ws: Workspace, label: string) => { ), ) await renderWorkspacePage() - const status = await screen.findByRole("status") + const header = screen.getByTestId("header") + const status = await within(header).findByRole("status") expect(status).toHaveTextContent(label) } @@ -96,7 +96,8 @@ describe("WorkspacePage", () => { await renderWorkspacePage() const workspaceName = await screen.findByText(MockWorkspace.name) expect(workspaceName).toBeDefined() - const status = await screen.findByRole("status") + const header = screen.getByTestId("header") + const status = await within(header).findByRole("status") expect(status).toHaveTextContent("Running") // wait for workspace page to finish loading await screen.findByText("stop") @@ -335,16 +336,22 @@ describe("WorkspacePage", () => { MockWorkspaceAgentDisconnected.name, ) expect(agent2Names.length).toEqual(2) - const agent1Status = await screen.findAllByText( - DisplayAgentStatusLanguage[MockWorkspaceAgent.status], + const agent1Status = await screen.findAllByLabelText( + t(`agentStatus.${MockWorkspaceAgent.status}`, { + ns: "workspacePage", + }), ) expect(agent1Status.length).toEqual(1) - const agentDisconnected = await screen.findAllByText( - DisplayAgentStatusLanguage[MockWorkspaceAgentDisconnected.status], + const agentDisconnected = await screen.findAllByLabelText( + t(`agentStatus.${MockWorkspaceAgentDisconnected.status}`, { + ns: "workspacePage", + }), ) expect(agentDisconnected.length).toEqual(1) - const agentConnecting = await screen.findAllByText( - DisplayAgentStatusLanguage[MockWorkspaceAgentConnecting.status], + const agentConnecting = await screen.findAllByLabelText( + t(`agentStatus.${MockWorkspaceAgentConnecting.status}`, { + ns: "workspacePage", + }), ) expect(agentConnecting.length).toEqual(1) expect(getTemplateMock).toBeCalled() diff --git a/site/src/util/workspace.test.ts b/site/src/util/workspace.test.ts index 6650a9090c..8f2a6893d1 100644 --- a/site/src/util/workspace.test.ts +++ b/site/src/util/workspace.test.ts @@ -101,11 +101,11 @@ describe("util > workspace", () => { describe("getDisplayVersionStatus", () => { it.each<[string, string, string, boolean]>([ - ["", "", "(unknown)", false], - ["", "v1.2.3", "(unknown)", false], + ["", "", "Unknown", false], + ["", "v1.2.3", "Unknown", false], ["v1.2.3", "", "v1.2.3", false], ["v1.2.3", "v1.2.3", "v1.2.3", false], - ["v1.2.3", "v1.2.4", "v1.2.3 (outdated)", true], + ["v1.2.3", "v1.2.4", "v1.2.3", true], ["v1.2.4", "v1.2.3", "v1.2.4", false], ["foo", "bar", "foo", false], ])( diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index 49574e4abe..1e23a1cfe3 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -20,8 +20,7 @@ export const DisplayWorkspaceBuildStatusLanguage = { } export const DisplayAgentVersionLanguage = { - unknown: "unknown", - outdated: "outdated", + unknown: "Unknown", } export const getDisplayWorkspaceBuildStatus = ( @@ -105,57 +104,18 @@ export const displayWorkspaceBuildDuration = ( return duration ? `${duration} seconds` : inProgressLabel } -export const DisplayAgentStatusLanguage = { - loading: "Loading...", - connected: "⦿ Connected", - connecting: "⦿ Connecting", - disconnected: "◍ Disconnected", -} - -export const getDisplayAgentStatus = ( - theme: Theme, - agent: TypesGen.WorkspaceAgent, -): { - color: string - status: string -} => { - switch (agent.status) { - case undefined: - return { - color: theme.palette.text.secondary, - status: DisplayAgentStatusLanguage.loading, - } - case "connected": - return { - color: theme.palette.success.main, - status: DisplayAgentStatusLanguage["connected"], - } - case "connecting": - return { - color: theme.palette.primary.main, - status: DisplayAgentStatusLanguage["connecting"], - } - case "disconnected": - return { - color: theme.palette.text.secondary, - status: DisplayAgentStatusLanguage["disconnected"], - } - } -} - export const getDisplayVersionStatus = ( agentVersion: string, serverVersion: string, ): { displayVersion: string; outdated: boolean } => { if (!semver.valid(serverVersion) || !semver.valid(agentVersion)) { return { - displayVersion: - `${agentVersion}` || `(${DisplayAgentVersionLanguage.unknown})`, + displayVersion: agentVersion || DisplayAgentVersionLanguage.unknown, outdated: false, } } else if (semver.lt(agentVersion, serverVersion)) { return { - displayVersion: `${agentVersion} (${DisplayAgentVersionLanguage.outdated})`, + displayVersion: agentVersion, outdated: true, } } else { diff --git a/site/src/xServices/portForward/portForwardXService.ts b/site/src/xServices/portForward/portForwardXService.ts index 27fa2bfd4c..8c2494083c 100644 --- a/site/src/xServices/portForward/portForwardXService.ts +++ b/site/src/xServices/portForward/portForwardXService.ts @@ -4,6 +4,7 @@ import { createMachine, assign } from "xstate" export const portForwardMachine = createMachine( { + predictableActionArguments: true, id: "portForwardMachine", schema: { context: {} as { From 39498b65316f7641c3eb25e4ed470da0b5480383 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Tue, 18 Oct 2022 12:12:06 -0500 Subject: [PATCH 46/51] fix: invalid docs links in "deployment settings" (#4617) --- site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx | 4 ++-- site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx | 2 +- site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx index becc21b5ee..f98822c3a6 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx @@ -20,8 +20,8 @@ const GeneralSettingsPage: React.FC = () => { <>
diff --git a/site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx b/site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx index ccdfbc605c..d18d6391ec 100644 --- a/site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx @@ -27,7 +27,7 @@ const NetworkSettingsPage: React.FC = () => {
diff --git a/site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx b/site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx index 6dfa81a882..f0d36c098e 100644 --- a/site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx @@ -34,7 +34,6 @@ const SecuritySettingsPage: React.FC = () => {
From 1984932dc908279cd6d9fc70520e01c94fa81ac0 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Tue, 18 Oct 2022 12:28:40 -0500 Subject: [PATCH 47/51] coderd/metricscache: attempt to fix macOS race (#4622) --- coderd/metricscache/metricscache_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index 98e43c067f..8e2c1dba06 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -331,7 +331,7 @@ func TestCache_BuildTime(t *testing.T) { require.Eventuallyf(t, func() bool { stats := cache.TemplateBuildTimeStats(template.ID) return assert.NotEmpty(t, stats) - }, testutil.WaitShort, testutil.IntervalMedium, + }, testutil.WaitLong, testutil.IntervalMedium, "BuildTime never populated", ) From 0d67dfc21511cea4b57f36c1b387c62c6036a923 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 18 Oct 2022 15:19:25 -0300 Subject: [PATCH 48/51] fix: Agent version is only visible when connected (#4619) --- site/src/components/Resources/ResourceCard.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/site/src/components/Resources/ResourceCard.tsx b/site/src/components/Resources/ResourceCard.tsx index c1513d158f..6899428eb4 100644 --- a/site/src/components/Resources/ResourceCard.tsx +++ b/site/src/components/Resources/ResourceCard.tsx @@ -136,10 +136,14 @@ export const ResourceCard: FC = ({ {agent.operating_system} - + + + + +
From 906046c1cc1f5679c99a4085ec6de999d8ac7fa0 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 18 Oct 2022 16:25:52 -0300 Subject: [PATCH 49/51] feat: Add minor settings improvements (#4626) --- .../DeploySettingsLayout/Header.tsx | 6 +- .../DeploySettingsLayout/Option.tsx | 45 +++ .../DeploySettingsLayout/OptionsTable.tsx | 64 ++++ .../DeploySettingsPage/AuthSettingsPage.tsx | 291 ++---------------- .../GeneralSettingsPage.tsx | 79 +---- .../NetworkSettingsPage.tsx | 128 ++------ .../SecuritySettingsPage.tsx | 272 +++++----------- 7 files changed, 255 insertions(+), 630 deletions(-) create mode 100644 site/src/components/DeploySettingsLayout/OptionsTable.tsx diff --git a/site/src/components/DeploySettingsLayout/Header.tsx b/site/src/components/DeploySettingsLayout/Header.tsx index ace8b79bf0..638725fbd1 100644 --- a/site/src/components/DeploySettingsLayout/Header.tsx +++ b/site/src/components/DeploySettingsLayout/Header.tsx @@ -6,7 +6,7 @@ import React from "react" export const Header: React.FC<{ title: string | JSX.Element - description: string | JSX.Element + description?: string | JSX.Element secondary?: boolean docsHref?: string }> = ({ title, description, docsHref, secondary }) => { @@ -18,7 +18,9 @@ export const Header: React.FC<{

{title}

- {description} + {description && ( + {description} + )}
{docsHref && ( diff --git a/site/src/components/DeploySettingsLayout/Option.tsx b/site/src/components/DeploySettingsLayout/Option.tsx index 3d59f0e056..d82aeff1d4 100644 --- a/site/src/components/DeploySettingsLayout/Option.tsx +++ b/site/src/components/DeploySettingsLayout/Option.tsx @@ -1,6 +1,7 @@ import { makeStyles } from "@material-ui/core/styles" import React, { PropsWithChildren } from "react" import { MONOSPACE_FONT_FAMILY } from "theme/constants" +import { DisabledBadge, EnabledBadge } from "./Badges" export const OptionName: React.FC = ({ children }) => { const styles = useStyles() @@ -14,8 +15,39 @@ export const OptionDescription: React.FC = ({ return {children} } +const NotSet: React.FC = () => { + const styles = useStyles() + + return Not set +} + export const OptionValue: React.FC = ({ children }) => { const styles = useStyles() + + if (typeof children === "boolean") { + return children ? : + } + + if (Array.isArray(children)) { + if (children.length === 0) { + return + } + + return ( +
    + {children.map((item) => ( +
  • + {item} +
  • + ))} +
+ ) + } + + if (children === "") { + return + } + return {children} } @@ -23,18 +55,31 @@ const useStyles = makeStyles((theme) => ({ optionName: { display: "block", }, + optionDescription: { display: "block", color: theme.palette.text.secondary, fontSize: 14, marginTop: theme.spacing(0.5), }, + optionValue: { fontSize: 14, fontFamily: MONOSPACE_FONT_FAMILY, + overflowWrap: "anywhere", + userSelect: "all", "& ul": { padding: theme.spacing(2), }, }, + + optionValueList: { + margin: 0, + padding: 0, + listStylePosition: "inside", + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5), + }, })) diff --git a/site/src/components/DeploySettingsLayout/OptionsTable.tsx b/site/src/components/DeploySettingsLayout/OptionsTable.tsx new file mode 100644 index 0000000000..d54c614b48 --- /dev/null +++ b/site/src/components/DeploySettingsLayout/OptionsTable.tsx @@ -0,0 +1,64 @@ +import { makeStyles } from "@material-ui/core/styles" +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableContainer from "@material-ui/core/TableContainer" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import { DeploymentFlags } from "api/typesGenerated" +import { + OptionDescription, + OptionName, + OptionValue, +} from "components/DeploySettingsLayout/Option" +import React from "react" + +const OptionsTable: React.FC<{ options: Partial }> = ({ + options, +}) => { + const styles = useStyles() + + return ( + + + + + Option + Value + + + + {Object.values(options).map((option) => { + return ( + + + {option.name} + {option.description} + + + + {option.value} + + + ) + })} + +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + table: { + "& td": { + paddingTop: theme.spacing(3), + paddingBottom: theme.spacing(3), + }, + + "& td:last-child, & th:last-child": { + paddingLeft: theme.spacing(4), + }, + }, +})) + +export default OptionsTable diff --git a/site/src/pages/DeploySettingsPage/AuthSettingsPage.tsx b/site/src/pages/DeploySettingsPage/AuthSettingsPage.tsx index 066ccfe447..008f9f25cb 100644 --- a/site/src/pages/DeploySettingsPage/AuthSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/AuthSettingsPage.tsx @@ -1,9 +1,3 @@ -import Table from "@material-ui/core/Table" -import TableBody from "@material-ui/core/TableBody" -import TableCell from "@material-ui/core/TableCell" -import TableContainer from "@material-ui/core/TableContainer" -import TableHead from "@material-ui/core/TableHead" -import TableRow from "@material-ui/core/TableRow" import { Badges, DisabledBadge, @@ -11,21 +5,25 @@ import { } from "components/DeploySettingsLayout/Badges" import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout" import { Header } from "components/DeploySettingsLayout/Header" -import { - OptionDescription, - OptionName, - OptionValue, -} from "components/DeploySettingsLayout/Option" +import OptionsTable from "components/DeploySettingsLayout/OptionsTable" import { Stack } from "components/Stack/Stack" import React from "react" +import { Helmet } from "react-helmet-async" +import { pageTitle } from "util/page" const AuthSettingsPage: React.FC = () => { const { deploymentFlags } = useDeploySettings() return ( <> + + {pageTitle("Authentication Settings")} + +
+
+
{ )} - - - - - Option - Value - - - - - - - {deploymentFlags.oidc_client_id.name} - - - {deploymentFlags.oidc_client_id.description} - - - - - - {deploymentFlags.oidc_client_id.value} - - - - - - - - {deploymentFlags.oidc_client_secret.name} - - - {deploymentFlags.oidc_client_secret.description} - - - - - - {deploymentFlags.oidc_client_secret.value} - - - - - - - - {deploymentFlags.oidc_allow_signups.name} - - - {deploymentFlags.oidc_allow_signups.description} - - - - - - {deploymentFlags.oidc_allow_signups.value.toString()} - - - - - - - - {deploymentFlags.oidc_email_domain.name} - - - {deploymentFlags.oidc_email_domain.description} - - - - - - {deploymentFlags.oidc_email_domain.value} - - - - - - - - {deploymentFlags.oidc_issuer_url.name} - - - {deploymentFlags.oidc_issuer_url.description} - - - - - - {deploymentFlags.oidc_issuer_url.value} - - - - - - - {deploymentFlags.oidc_scopes.name} - - {deploymentFlags.oidc_scopes.description} - - - - - -
    - {deploymentFlags.oidc_scopes.value.map((scope) => ( -
  • {scope}
  • - ))} -
-
-
-
-
-
-
+
@@ -174,137 +67,21 @@ const AuthSettingsPage: React.FC = () => { )} - - - - - Option - Value - - - - - - - {deploymentFlags.oauth2_github_client_id.name} - - - {deploymentFlags.oauth2_github_client_id.description} - - - - - - {deploymentFlags.oauth2_github_client_id.value} - - - - - - - - {deploymentFlags.oauth2_github_client_secret.name} - - - {deploymentFlags.oauth2_github_client_secret.description} - - - - - - {deploymentFlags.oauth2_github_client_secret.value} - - - - - - - - {deploymentFlags.oauth2_github_allow_signups.name} - - - {deploymentFlags.oauth2_github_allow_signups.description} - - - - - - {deploymentFlags.oauth2_github_allow_signups.value.toString()} - - - - - - - - {deploymentFlags.oauth2_github_allowed_organizations.name} - - - { - deploymentFlags.oauth2_github_allowed_organizations - .description - } - - - - - -
    - {deploymentFlags.oauth2_github_allowed_organizations.value.map( - (org) => ( -
  • {org}
  • - ), - )} -
-
-
-
- - - - - {deploymentFlags.oauth2_github_allowed_teams.name} - - - {deploymentFlags.oauth2_github_allowed_teams.description} - - - - - -
    - {deploymentFlags.oauth2_github_allowed_teams.value.map( - (team) => ( -
  • {team}
  • - ), - )} -
-
-
-
- - - - - {deploymentFlags.oauth2_github_enterprise_base_url.name} - - - { - deploymentFlags.oauth2_github_enterprise_base_url - .description - } - - - - - - {deploymentFlags.oauth2_github_enterprise_base_url.value} - - - -
-
-
+
diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx index f98822c3a6..ebf523fb0e 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage.tsx @@ -1,83 +1,32 @@ -import Table from "@material-ui/core/Table" -import TableBody from "@material-ui/core/TableBody" -import TableCell from "@material-ui/core/TableCell" -import TableContainer from "@material-ui/core/TableContainer" -import TableHead from "@material-ui/core/TableHead" -import TableRow from "@material-ui/core/TableRow" import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout" import { Header } from "components/DeploySettingsLayout/Header" -import { - OptionDescription, - OptionName, - OptionValue, -} from "components/DeploySettingsLayout/Option" +import OptionsTable from "components/DeploySettingsLayout/OptionsTable" import React from "react" +import { Helmet } from "react-helmet-async" +import { pageTitle } from "util/page" const GeneralSettingsPage: React.FC = () => { const { deploymentFlags } = useDeploySettings() return ( <> + + {pageTitle("General Settings")} + +
- - - - - Option - Value - - - - - - {deploymentFlags.access_url.name} - - {deploymentFlags.access_url.description} - - - - - {deploymentFlags.access_url.value} - - - - - - {deploymentFlags.address.name} - - {deploymentFlags.address.description} - - - - - {deploymentFlags.address.value} - - - - - - - {deploymentFlags.wildcard_access_url.name} - - - {deploymentFlags.wildcard_access_url.description} - - - - - - {deploymentFlags.wildcard_access_url.value} - - - - -
-
+ ) } diff --git a/site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx b/site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx index d18d6391ec..7bcf9cdede 100644 --- a/site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/NetworkSettingsPage.tsx @@ -1,120 +1,34 @@ -import Table from "@material-ui/core/Table" -import TableBody from "@material-ui/core/TableBody" -import TableCell from "@material-ui/core/TableCell" -import TableContainer from "@material-ui/core/TableContainer" -import TableHead from "@material-ui/core/TableHead" -import TableRow from "@material-ui/core/TableRow" -import { - DisabledBadge, - EnabledBadge, -} from "components/DeploySettingsLayout/Badges" import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout" import { Header } from "components/DeploySettingsLayout/Header" -import { - OptionDescription, - OptionName, - OptionValue, -} from "components/DeploySettingsLayout/Option" -import { Stack } from "components/Stack/Stack" +import OptionsTable from "components/DeploySettingsLayout/OptionsTable" import React from "react" +import { Helmet } from "react-helmet-async" +import { pageTitle } from "util/page" const NetworkSettingsPage: React.FC = () => { const { deploymentFlags } = useDeploySettings() return ( - -
-
+ <> + + {pageTitle("Network Settings")} + - - - - - Option - Value - - - - - - - {deploymentFlags.derp_server_enabled.name} - - - {deploymentFlags.derp_server_enabled.description} - - +
- - - {deploymentFlags.derp_server_enabled.value ? ( - - ) : ( - - )} - - - - - - - - {deploymentFlags.derp_server_region_name.name} - - - {deploymentFlags.derp_server_region_name.description} - - - - - - {deploymentFlags.derp_server_region_name.value} - - - - - - - - {deploymentFlags.derp_server_stun_address.name} - - - {deploymentFlags.derp_server_stun_address.description} - - - - - - {deploymentFlags.derp_server_stun_address.value} - - - - - - - - {deploymentFlags.derp_config_url.name} - - - {deploymentFlags.derp_config_url.description} - - - - - - {deploymentFlags.derp_config_url.value} - - - - -
-
-
-
+ + ) } diff --git a/site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx b/site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx index f0d36c098e..987c61b93c 100644 --- a/site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/SecuritySettingsPage.tsx @@ -1,9 +1,3 @@ -import Table from "@material-ui/core/Table" -import TableBody from "@material-ui/core/TableBody" -import TableCell from "@material-ui/core/TableCell" -import TableContainer from "@material-ui/core/TableContainer" -import TableHead from "@material-ui/core/TableHead" -import TableRow from "@material-ui/core/TableRow" import { useActor } from "@xstate/react" import { FeatureNames } from "api/types" import { @@ -14,13 +8,11 @@ import { } from "components/DeploySettingsLayout/Badges" import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout" import { Header } from "components/DeploySettingsLayout/Header" -import { - OptionDescription, - OptionName, - OptionValue, -} from "components/DeploySettingsLayout/Option" +import OptionsTable from "components/DeploySettingsLayout/OptionsTable" import { Stack } from "components/Stack/Stack" import React, { useContext } from "react" +import { Helmet } from "react-helmet-async" +import { pageTitle } from "util/page" import { XServiceContext } from "xServices/StateContext" const SecuritySettingsPage: React.FC = () => { @@ -29,201 +21,83 @@ const SecuritySettingsPage: React.FC = () => { const [entitlementsState] = useActor(xServices.entitlementsXService) return ( - -
-
+ <> + + {pageTitle("Security Settings")} + + +
+
- - - - - Option - Value - - - - - - - {deploymentFlags.ssh_keygen_algorithm.name} - - - {deploymentFlags.ssh_keygen_algorithm.description} - - + + - - - {deploymentFlags.ssh_keygen_algorithm.value} - - - - - - - {deploymentFlags.secure_auth_cookie.name} - - - {deploymentFlags.secure_auth_cookie.description} - - +
+
- - - {deploymentFlags.secure_auth_cookie.value ? ( - - ) : ( - - )} - - - - -
-
-
+ + {entitlementsState.context.entitlements.features[ + FeatureNames.AuditLog + ].enabled ? ( + + ) : ( + + )} + + +
-
-
+
+
- - {entitlementsState.context.entitlements.features[ - FeatureNames.AuditLog - ].enabled ? ( - - ) : ( - - )} - - -
+ + {entitlementsState.context.entitlements.features[ + FeatureNames.BrowserOnly + ].enabled ? ( + + ) : ( + + )} + + +
-
-
+
+
- - {entitlementsState.context.entitlements.features[ - FeatureNames.BrowserOnly - ].enabled ? ( - - ) : ( - - )} - - -
- -
-
- - - - - - Option - Value - - - - - - {deploymentFlags.tls_enable.name} - - {deploymentFlags.tls_enable.description} - - - - - - {deploymentFlags.tls_enable.value ? ( - - ) : ( - - )} - - - - - - - {deploymentFlags.tls_cert_files.name} - - {deploymentFlags.tls_cert_files.description} - - - - - -
    - {deploymentFlags.tls_cert_files.value.map( - (file, index) => ( -
  • {file}
  • - ), - )} -
-
-
-
- - - - {deploymentFlags.tls_key_files.name} - - {deploymentFlags.tls_key_files.description} - - - - - -
    - {deploymentFlags.tls_key_files.value.map( - (file, index) => ( -
  • {file}
  • - ), - )} -
-
-
-
- - - - - {deploymentFlags.tls_min_version.name} - - - {deploymentFlags.tls_min_version.description} - - - - - - {deploymentFlags.tls_min_version.value} - - - -
-
-
-
- + +
+
+ ) } From 5d7d8c3a9a33869407c18f6fe9a1a0b82fa3d130 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 18 Oct 2022 16:52:47 -0300 Subject: [PATCH 50/51] refactor: Update resources to look as a stack (#4631) --- site/src/components/Resources/ResourceCard.tsx | 11 +++++++++++ site/src/components/Resources/Resources.tsx | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/site/src/components/Resources/ResourceCard.tsx b/site/src/components/Resources/ResourceCard.tsx index 6899428eb4..6e4d766260 100644 --- a/site/src/components/Resources/ResourceCard.tsx +++ b/site/src/components/Resources/ResourceCard.tsx @@ -210,6 +210,17 @@ const useStyles = makeStyles((theme) => ({ background: theme.palette.background.paper, borderRadius: theme.shape.borderRadius, border: `1px solid ${theme.palette.divider}`, + + "&:not(:first-child)": { + borderTop: 0, + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + }, + + "&:not(:last-child)": { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + }, }, resourceCardProfile: { diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index d15bcf5441..2351f6ccba 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -54,7 +54,7 @@ export const Resources: FC> = ({ } return ( - + {displayResources.map((resource) => { return ( Date: Tue, 18 Oct 2022 15:26:21 -0500 Subject: [PATCH 51/51] fix: `time.NewTicker` leaks (#4630) --- agent/apphealth.go | 5 ++++- enterprise/derpmesh/derpmesh_test.go | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/agent/apphealth.go b/agent/apphealth.go index d53c76e57a..88e107160d 100644 --- a/agent/apphealth.go +++ b/agent/apphealth.go @@ -60,8 +60,10 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, workspaceAgentApps Worksp continue } app := nextApp - t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second) go func() { + t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second) + defer t.Stop() + for { select { case <-ctx.Done(): @@ -118,6 +120,7 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, workspaceAgentApps Worksp lastHealth := copyHealth(health) mu.Unlock() reportTicker := time.NewTicker(time.Second) + defer reportTicker.Stop() // every second we check if the health values of the apps have changed // and if there is a change we will report the new values. for { diff --git a/enterprise/derpmesh/derpmesh_test.go b/enterprise/derpmesh/derpmesh_test.go index 7fad141238..7c7331d21c 100644 --- a/enterprise/derpmesh/derpmesh_test.go +++ b/enterprise/derpmesh/derpmesh_test.go @@ -74,6 +74,7 @@ func TestDERPMesh(t *testing.T) { go func() { defer close(closed) ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() for { select { case <-ctx.Done(): @@ -118,6 +119,7 @@ func TestDERPMesh(t *testing.T) { go func() { defer close(closed) ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() for { select { case <-ctx.Done(): @@ -169,6 +171,7 @@ func TestDERPMesh(t *testing.T) { go func() { defer close(closed) ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() for { select { case <-ctx.Done():