diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index 58c9179da5e4b..c8e8880b79fed 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -3330,13 +3330,6 @@ func (q *querier) RegisterWorkspaceProxy(ctx context.Context, arg database.Regis
return updateWithReturn(q.log, q.auth, fetch, q.db.RegisterWorkspaceProxy)(ctx, arg)
}
-func (q *querier) RemoveRefreshToken(ctx context.Context, arg database.RemoveRefreshTokenParams) error {
- fetch := func(ctx context.Context, arg database.RemoveRefreshTokenParams) (database.ExternalAuthLink, error) {
- return q.db.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{UserID: arg.UserID, ProviderID: arg.ProviderID})
- }
- return fetchAndExec(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.RemoveRefreshToken)(ctx, arg)
-}
-
func (q *querier) RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error {
// This is a system function to clear user groups in group sync.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
@@ -3435,6 +3428,13 @@ func (q *querier) UpdateExternalAuthLink(ctx context.Context, arg database.Updat
return fetchAndQuery(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.UpdateExternalAuthLink)(ctx, arg)
}
+func (q *querier) UpdateExternalAuthLinkRefreshToken(ctx context.Context, arg database.UpdateExternalAuthLinkRefreshTokenParams) error {
+ fetch := func(ctx context.Context, arg database.UpdateExternalAuthLinkRefreshTokenParams) (database.ExternalAuthLink, error) {
+ return q.db.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{UserID: arg.UserID, ProviderID: arg.ProviderID})
+ }
+ return fetchAndExec(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.UpdateExternalAuthLinkRefreshToken)(ctx, arg)
+}
+
func (q *querier) UpdateGitSSHKey(ctx context.Context, arg database.UpdateGitSSHKeyParams) (database.GitSSHKey, error) {
fetch := func(ctx context.Context, arg database.UpdateGitSSHKeyParams) (database.GitSSHKey, error) {
return q.db.GetGitSSHKey(ctx, arg.UserID)
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index 638829ae24ae5..1c60018e87062 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -1282,12 +1282,14 @@ func (s *MethodTestSuite) TestUser() {
UserID: u.ID,
}).Asserts(u, policy.ActionUpdatePersonal)
}))
- s.Run("RemoveRefreshToken", s.Subtest(func(db database.Store, check *expects) {
+ s.Run("UpdateExternalAuthLinkRefreshToken", s.Subtest(func(db database.Store, check *expects) {
link := dbgen.ExternalAuthLink(s.T(), db, database.ExternalAuthLink{})
- check.Args(database.RemoveRefreshTokenParams{
- ProviderID: link.ProviderID,
- UserID: link.UserID,
- UpdatedAt: link.UpdatedAt,
+ check.Args(database.UpdateExternalAuthLinkRefreshTokenParams{
+ OAuthRefreshToken: "",
+ OAuthRefreshTokenKeyID: "",
+ ProviderID: link.ProviderID,
+ UserID: link.UserID,
+ UpdatedAt: link.UpdatedAt,
}).Asserts(rbac.ResourceUserObject(link.UserID), policy.ActionUpdatePersonal)
}))
s.Run("UpdateExternalAuthLink", s.Subtest(func(db database.Store, check *expects) {
diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go
index ae898d4f1fdc3..9c8696112dea8 100644
--- a/coderd/database/dbgen/dbgen.go
+++ b/coderd/database/dbgen/dbgen.go
@@ -788,16 +788,17 @@ func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVers
err := db.InTx(func(db database.Store) error {
versionID := takeFirst(orig.ID, uuid.New())
err := db.InsertTemplateVersion(genCtx, database.InsertTemplateVersionParams{
- ID: versionID,
- TemplateID: takeFirst(orig.TemplateID, uuid.NullUUID{}),
- OrganizationID: takeFirst(orig.OrganizationID, uuid.New()),
- CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
- UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()),
- Name: takeFirst(orig.Name, testutil.GetRandomName(t)),
- Message: orig.Message,
- Readme: takeFirst(orig.Readme, testutil.GetRandomName(t)),
- JobID: takeFirst(orig.JobID, uuid.New()),
- CreatedBy: takeFirst(orig.CreatedBy, uuid.New()),
+ ID: versionID,
+ TemplateID: takeFirst(orig.TemplateID, uuid.NullUUID{}),
+ OrganizationID: takeFirst(orig.OrganizationID, uuid.New()),
+ CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
+ UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()),
+ Name: takeFirst(orig.Name, testutil.GetRandomName(t)),
+ Message: orig.Message,
+ Readme: takeFirst(orig.Readme, testutil.GetRandomName(t)),
+ JobID: takeFirst(orig.JobID, uuid.New()),
+ CreatedBy: takeFirst(orig.CreatedBy, uuid.New()),
+ SourceExampleID: takeFirst(orig.SourceExampleID, sql.NullString{}),
})
if err != nil {
return err
diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go
index 5583fff111648..385cdcfde5709 100644
--- a/coderd/database/dbmem/dbmem.go
+++ b/coderd/database/dbmem/dbmem.go
@@ -7699,16 +7699,17 @@ func (q *FakeQuerier) InsertTemplateVersion(_ context.Context, arg database.Inse
//nolint:gosimple
version := database.TemplateVersionTable{
- ID: arg.ID,
- TemplateID: arg.TemplateID,
- OrganizationID: arg.OrganizationID,
- CreatedAt: arg.CreatedAt,
- UpdatedAt: arg.UpdatedAt,
- Name: arg.Name,
- Message: arg.Message,
- Readme: arg.Readme,
- JobID: arg.JobID,
- CreatedBy: arg.CreatedBy,
+ ID: arg.ID,
+ TemplateID: arg.TemplateID,
+ OrganizationID: arg.OrganizationID,
+ CreatedAt: arg.CreatedAt,
+ UpdatedAt: arg.UpdatedAt,
+ Name: arg.Name,
+ Message: arg.Message,
+ Readme: arg.Readme,
+ JobID: arg.JobID,
+ CreatedBy: arg.CreatedBy,
+ SourceExampleID: arg.SourceExampleID,
}
q.templateVersions = append(q.templateVersions, version)
return nil
@@ -8555,29 +8556,6 @@ func (q *FakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.Reg
return database.WorkspaceProxy{}, sql.ErrNoRows
}
-func (q *FakeQuerier) RemoveRefreshToken(_ context.Context, arg database.RemoveRefreshTokenParams) error {
- if err := validateDatabaseType(arg); err != nil {
- return err
- }
-
- q.mutex.Lock()
- defer q.mutex.Unlock()
- for index, gitAuthLink := range q.externalAuthLinks {
- if gitAuthLink.ProviderID != arg.ProviderID {
- continue
- }
- if gitAuthLink.UserID != arg.UserID {
- continue
- }
- gitAuthLink.UpdatedAt = arg.UpdatedAt
- gitAuthLink.OAuthRefreshToken = ""
- q.externalAuthLinks[index] = gitAuthLink
-
- return nil
- }
- return sql.ErrNoRows
-}
-
func (q *FakeQuerier) RemoveUserFromAllGroups(_ context.Context, userID uuid.UUID) error {
q.mutex.Lock()
defer q.mutex.Unlock()
@@ -8797,6 +8775,29 @@ func (q *FakeQuerier) UpdateExternalAuthLink(_ context.Context, arg database.Upd
return database.ExternalAuthLink{}, sql.ErrNoRows
}
+func (q *FakeQuerier) UpdateExternalAuthLinkRefreshToken(_ context.Context, arg database.UpdateExternalAuthLinkRefreshTokenParams) error {
+ if err := validateDatabaseType(arg); err != nil {
+ return err
+ }
+
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+ for index, gitAuthLink := range q.externalAuthLinks {
+ if gitAuthLink.ProviderID != arg.ProviderID {
+ continue
+ }
+ if gitAuthLink.UserID != arg.UserID {
+ continue
+ }
+ gitAuthLink.UpdatedAt = arg.UpdatedAt
+ gitAuthLink.OAuthRefreshToken = arg.OAuthRefreshToken
+ q.externalAuthLinks[index] = gitAuthLink
+
+ return nil
+ }
+ return sql.ErrNoRows
+}
+
func (q *FakeQuerier) UpdateGitSSHKey(_ context.Context, arg database.UpdateGitSSHKeyParams) (database.GitSSHKey, error) {
if err := validateDatabaseType(arg); err != nil {
return database.GitSSHKey{}, err
diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go
index efde94488828f..54dd723ae1395 100644
--- a/coderd/database/dbmetrics/querymetrics.go
+++ b/coderd/database/dbmetrics/querymetrics.go
@@ -2093,13 +2093,6 @@ func (m queryMetricsStore) RegisterWorkspaceProxy(ctx context.Context, arg datab
return proxy, err
}
-func (m queryMetricsStore) RemoveRefreshToken(ctx context.Context, arg database.RemoveRefreshTokenParams) error {
- start := time.Now()
- r0 := m.s.RemoveRefreshToken(ctx, arg)
- m.queryLatencies.WithLabelValues("RemoveRefreshToken").Observe(time.Since(start).Seconds())
- return r0
-}
-
func (m queryMetricsStore) RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error {
start := time.Now()
r0 := m.s.RemoveUserFromAllGroups(ctx, userID)
@@ -2170,6 +2163,13 @@ func (m queryMetricsStore) UpdateExternalAuthLink(ctx context.Context, arg datab
return link, err
}
+func (m queryMetricsStore) UpdateExternalAuthLinkRefreshToken(ctx context.Context, arg database.UpdateExternalAuthLinkRefreshTokenParams) error {
+ start := time.Now()
+ r0 := m.s.UpdateExternalAuthLinkRefreshToken(ctx, arg)
+ m.queryLatencies.WithLabelValues("UpdateExternalAuthLinkRefreshToken").Observe(time.Since(start).Seconds())
+ return r0
+}
+
func (m queryMetricsStore) UpdateGitSSHKey(ctx context.Context, arg database.UpdateGitSSHKeyParams) (database.GitSSHKey, error) {
start := time.Now()
key, err := m.s.UpdateGitSSHKey(ctx, arg)
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index eefa89c86b57f..064d0dfd926c8 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -4463,20 +4463,6 @@ func (mr *MockStoreMockRecorder) RegisterWorkspaceProxy(arg0, arg1 any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterWorkspaceProxy", reflect.TypeOf((*MockStore)(nil).RegisterWorkspaceProxy), arg0, arg1)
}
-// RemoveRefreshToken mocks base method.
-func (m *MockStore) RemoveRefreshToken(arg0 context.Context, arg1 database.RemoveRefreshTokenParams) error {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "RemoveRefreshToken", arg0, arg1)
- ret0, _ := ret[0].(error)
- return ret0
-}
-
-// RemoveRefreshToken indicates an expected call of RemoveRefreshToken.
-func (mr *MockStoreMockRecorder) RemoveRefreshToken(arg0, arg1 any) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveRefreshToken", reflect.TypeOf((*MockStore)(nil).RemoveRefreshToken), arg0, arg1)
-}
-
// RemoveUserFromAllGroups mocks base method.
func (m *MockStore) RemoveUserFromAllGroups(arg0 context.Context, arg1 uuid.UUID) error {
m.ctrl.T.Helper()
@@ -4622,6 +4608,20 @@ func (mr *MockStoreMockRecorder) UpdateExternalAuthLink(arg0, arg1 any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateExternalAuthLink", reflect.TypeOf((*MockStore)(nil).UpdateExternalAuthLink), arg0, arg1)
}
+// UpdateExternalAuthLinkRefreshToken mocks base method.
+func (m *MockStore) UpdateExternalAuthLinkRefreshToken(arg0 context.Context, arg1 database.UpdateExternalAuthLinkRefreshTokenParams) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateExternalAuthLinkRefreshToken", arg0, arg1)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateExternalAuthLinkRefreshToken indicates an expected call of UpdateExternalAuthLinkRefreshToken.
+func (mr *MockStoreMockRecorder) UpdateExternalAuthLinkRefreshToken(arg0, arg1 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateExternalAuthLinkRefreshToken", reflect.TypeOf((*MockStore)(nil).UpdateExternalAuthLinkRefreshToken), arg0, arg1)
+}
+
// UpdateGitSSHKey mocks base method.
func (m *MockStore) UpdateGitSSHKey(arg0 context.Context, arg1 database.UpdateGitSSHKeyParams) (database.GitSSHKey, error) {
m.ctrl.T.Helper()
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 9919011579bde..eba9b7cf106d3 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -1217,7 +1217,8 @@ CREATE TABLE template_versions (
created_by uuid NOT NULL,
external_auth_providers jsonb DEFAULT '[]'::jsonb NOT NULL,
message character varying(1048576) DEFAULT ''::character varying NOT NULL,
- archived boolean DEFAULT false NOT NULL
+ archived boolean DEFAULT false NOT NULL,
+ source_example_id text
);
COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of External auth providers for a specific template version';
@@ -1245,6 +1246,7 @@ CREATE VIEW template_version_with_user AS
template_versions.external_auth_providers,
template_versions.message,
template_versions.archived,
+ template_versions.source_example_id,
COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url,
COALESCE(visible_users.username, ''::text) AS created_by_username
FROM (template_versions
diff --git a/coderd/database/migrations/000277_template_version_example_ids.down.sql b/coderd/database/migrations/000277_template_version_example_ids.down.sql
new file mode 100644
index 0000000000000..ad961e9f635c7
--- /dev/null
+++ b/coderd/database/migrations/000277_template_version_example_ids.down.sql
@@ -0,0 +1,28 @@
+-- We cannot alter the column type while a view depends on it, so we drop it and recreate it.
+DROP VIEW template_version_with_user;
+
+ALTER TABLE
+ template_versions
+DROP COLUMN source_example_id;
+
+-- Recreate `template_version_with_user` as described in dump.sql
+CREATE VIEW template_version_with_user AS
+SELECT
+ template_versions.id,
+ template_versions.template_id,
+ template_versions.organization_id,
+ template_versions.created_at,
+ template_versions.updated_at,
+ template_versions.name,
+ template_versions.readme,
+ template_versions.job_id,
+ template_versions.created_by,
+ template_versions.external_auth_providers,
+ template_versions.message,
+ template_versions.archived,
+ COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url,
+ COALESCE(visible_users.username, ''::text) AS created_by_username
+FROM (template_versions
+ LEFT JOIN visible_users ON (template_versions.created_by = visible_users.id));
+
+COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.';
diff --git a/coderd/database/migrations/000277_template_version_example_ids.up.sql b/coderd/database/migrations/000277_template_version_example_ids.up.sql
new file mode 100644
index 0000000000000..aca34b31de5dc
--- /dev/null
+++ b/coderd/database/migrations/000277_template_version_example_ids.up.sql
@@ -0,0 +1,30 @@
+-- We cannot alter the column type while a view depends on it, so we drop it and recreate it.
+DROP VIEW template_version_with_user;
+
+ALTER TABLE
+ template_versions
+ADD
+ COLUMN source_example_id TEXT;
+
+-- Recreate `template_version_with_user` as described in dump.sql
+CREATE VIEW template_version_with_user AS
+SELECT
+ template_versions.id,
+ template_versions.template_id,
+ template_versions.organization_id,
+ template_versions.created_at,
+ template_versions.updated_at,
+ template_versions.name,
+ template_versions.readme,
+ template_versions.job_id,
+ template_versions.created_by,
+ template_versions.external_auth_providers,
+ template_versions.message,
+ template_versions.archived,
+ template_versions.source_example_id,
+ COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url,
+ COALESCE(visible_users.username, ''::text) AS created_by_username
+FROM (template_versions
+ LEFT JOIN visible_users ON (template_versions.created_by = visible_users.id));
+
+COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.';
diff --git a/coderd/database/models.go b/coderd/database/models.go
index af0a3122f7964..6b99245079950 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -2773,6 +2773,7 @@ type TemplateVersion struct {
ExternalAuthProviders json.RawMessage `db:"external_auth_providers" json:"external_auth_providers"`
Message string `db:"message" json:"message"`
Archived bool `db:"archived" json:"archived"`
+ SourceExampleID sql.NullString `db:"source_example_id" json:"source_example_id"`
CreatedByAvatarURL string `db:"created_by_avatar_url" json:"created_by_avatar_url"`
CreatedByUsername string `db:"created_by_username" json:"created_by_username"`
}
@@ -2826,8 +2827,9 @@ type TemplateVersionTable struct {
// IDs of External auth providers for a specific template version
ExternalAuthProviders json.RawMessage `db:"external_auth_providers" json:"external_auth_providers"`
// Message describing the changes in this version of the template, similar to a Git commit message. Like a commit message, this should be a short, high-level description of the changes in this version of the template. This message is immutable and should not be updated after the fact.
- Message string `db:"message" json:"message"`
- Archived bool `db:"archived" json:"archived"`
+ Message string `db:"message" json:"message"`
+ Archived bool `db:"archived" json:"archived"`
+ SourceExampleID sql.NullString `db:"source_example_id" json:"source_example_id"`
}
type TemplateVersionVariable struct {
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index d75b051cac330..07b8056e1a5c4 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -424,10 +424,6 @@ type sqlcQuerier interface {
OrganizationMembers(ctx context.Context, arg OrganizationMembersParams) ([]OrganizationMembersRow, error)
ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error
RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error)
- // Removing the refresh token disables the refresh behavior for a given
- // auth token. If a refresh token is marked invalid, it is better to remove it
- // then continually attempt to refresh the token.
- RemoveRefreshToken(ctx context.Context, arg RemoveRefreshTokenParams) error
RemoveUserFromAllGroups(ctx context.Context, userID uuid.UUID) error
RemoveUserFromGroups(ctx context.Context, arg RemoveUserFromGroupsParams) ([]uuid.UUID, error)
RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error
@@ -443,6 +439,7 @@ type sqlcQuerier interface {
UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error)
UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error)
UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error)
+ UpdateExternalAuthLinkRefreshToken(ctx context.Context, arg UpdateExternalAuthLinkRefreshTokenParams) error
UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error)
UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error)
UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error)
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 4eec78cf97fba..e9fe766f31e53 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -1194,29 +1194,6 @@ func (q *sqlQuerier) InsertExternalAuthLink(ctx context.Context, arg InsertExter
return i, err
}
-const removeRefreshToken = `-- name: RemoveRefreshToken :exec
-UPDATE
- external_auth_links
-SET
- oauth_refresh_token = '',
- updated_at = $1
-WHERE provider_id = $2 AND user_id = $3
-`
-
-type RemoveRefreshTokenParams struct {
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
- ProviderID string `db:"provider_id" json:"provider_id"`
- UserID uuid.UUID `db:"user_id" json:"user_id"`
-}
-
-// Removing the refresh token disables the refresh behavior for a given
-// auth token. If a refresh token is marked invalid, it is better to remove it
-// then continually attempt to refresh the token.
-func (q *sqlQuerier) RemoveRefreshToken(ctx context.Context, arg RemoveRefreshTokenParams) error {
- _, err := q.db.ExecContext(ctx, removeRefreshToken, arg.UpdatedAt, arg.ProviderID, arg.UserID)
- return err
-}
-
const updateExternalAuthLink = `-- name: UpdateExternalAuthLink :one
UPDATE external_auth_links SET
updated_at = $3,
@@ -1269,6 +1246,40 @@ func (q *sqlQuerier) UpdateExternalAuthLink(ctx context.Context, arg UpdateExter
return i, err
}
+const updateExternalAuthLinkRefreshToken = `-- name: UpdateExternalAuthLinkRefreshToken :exec
+UPDATE
+ external_auth_links
+SET
+ oauth_refresh_token = $1,
+ updated_at = $2
+WHERE
+ provider_id = $3
+AND
+ user_id = $4
+AND
+ -- Required for sqlc to generate a parameter for the oauth_refresh_token_key_id
+ $5 :: text = $5 :: text
+`
+
+type UpdateExternalAuthLinkRefreshTokenParams struct {
+ OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ ProviderID string `db:"provider_id" json:"provider_id"`
+ UserID uuid.UUID `db:"user_id" json:"user_id"`
+ OAuthRefreshTokenKeyID string `db:"oauth_refresh_token_key_id" json:"oauth_refresh_token_key_id"`
+}
+
+func (q *sqlQuerier) UpdateExternalAuthLinkRefreshToken(ctx context.Context, arg UpdateExternalAuthLinkRefreshTokenParams) error {
+ _, err := q.db.ExecContext(ctx, updateExternalAuthLinkRefreshToken,
+ arg.OAuthRefreshToken,
+ arg.UpdatedAt,
+ arg.ProviderID,
+ arg.UserID,
+ arg.OAuthRefreshTokenKeyID,
+ )
+ return err
+}
+
const getFileByHashAndCreator = `-- name: GetFileByHashAndCreator :one
SELECT
hash, created_at, created_by, mimetype, data, id
@@ -8996,7 +9007,7 @@ FROM
-- Scope an archive to a single template and ignore already archived template versions
(
SELECT
- id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived
+ id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id
FROM
template_versions
WHERE
@@ -9097,7 +9108,7 @@ func (q *sqlQuerier) ArchiveUnusedTemplateVersions(ctx context.Context, arg Arch
const getPreviousTemplateVersion = `-- name: GetPreviousTemplateVersion :one
SELECT
- id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username
+ id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, created_by_avatar_url, created_by_username
FROM
template_version_with_user AS template_versions
WHERE
@@ -9134,6 +9145,7 @@ func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPrev
&i.ExternalAuthProviders,
&i.Message,
&i.Archived,
+ &i.SourceExampleID,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
)
@@ -9142,7 +9154,7 @@ func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPrev
const getTemplateVersionByID = `-- name: GetTemplateVersionByID :one
SELECT
- id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username
+ id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, created_by_avatar_url, created_by_username
FROM
template_version_with_user AS template_versions
WHERE
@@ -9165,6 +9177,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (
&i.ExternalAuthProviders,
&i.Message,
&i.Archived,
+ &i.SourceExampleID,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
)
@@ -9173,7 +9186,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (
const getTemplateVersionByJobID = `-- name: GetTemplateVersionByJobID :one
SELECT
- id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username
+ id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, created_by_avatar_url, created_by_username
FROM
template_version_with_user AS template_versions
WHERE
@@ -9196,6 +9209,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U
&i.ExternalAuthProviders,
&i.Message,
&i.Archived,
+ &i.SourceExampleID,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
)
@@ -9204,7 +9218,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U
const getTemplateVersionByTemplateIDAndName = `-- name: GetTemplateVersionByTemplateIDAndName :one
SELECT
- id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username
+ id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, created_by_avatar_url, created_by_username
FROM
template_version_with_user AS template_versions
WHERE
@@ -9233,6 +9247,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context,
&i.ExternalAuthProviders,
&i.Message,
&i.Archived,
+ &i.SourceExampleID,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
)
@@ -9241,7 +9256,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context,
const getTemplateVersionsByIDs = `-- name: GetTemplateVersionsByIDs :many
SELECT
- id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username
+ id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, created_by_avatar_url, created_by_username
FROM
template_version_with_user AS template_versions
WHERE
@@ -9270,6 +9285,7 @@ func (q *sqlQuerier) GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UU
&i.ExternalAuthProviders,
&i.Message,
&i.Archived,
+ &i.SourceExampleID,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
); err != nil {
@@ -9288,7 +9304,7 @@ func (q *sqlQuerier) GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UU
const getTemplateVersionsByTemplateID = `-- name: GetTemplateVersionsByTemplateID :many
SELECT
- id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username
+ id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, created_by_avatar_url, created_by_username
FROM
template_version_with_user AS template_versions
WHERE
@@ -9364,6 +9380,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge
&i.ExternalAuthProviders,
&i.Message,
&i.Archived,
+ &i.SourceExampleID,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
); err != nil {
@@ -9381,7 +9398,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge
}
const getTemplateVersionsCreatedAfter = `-- name: GetTemplateVersionsCreatedAfter :many
-SELECT id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username FROM template_version_with_user AS template_versions WHERE created_at > $1
+SELECT id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, created_by_avatar_url, created_by_username FROM template_version_with_user AS template_versions WHERE created_at > $1
`
func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error) {
@@ -9406,6 +9423,7 @@ func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, create
&i.ExternalAuthProviders,
&i.Message,
&i.Archived,
+ &i.SourceExampleID,
&i.CreatedByAvatarURL,
&i.CreatedByUsername,
); err != nil {
@@ -9434,23 +9452,25 @@ INSERT INTO
message,
readme,
job_id,
- created_by
+ created_by,
+ source_example_id
)
VALUES
- ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
type InsertTemplateVersionParams struct {
- ID uuid.UUID `db:"id" json:"id"`
- TemplateID uuid.NullUUID `db:"template_id" json:"template_id"`
- OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
- Name string `db:"name" json:"name"`
- Message string `db:"message" json:"message"`
- Readme string `db:"readme" json:"readme"`
- JobID uuid.UUID `db:"job_id" json:"job_id"`
- CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
+ ID uuid.UUID `db:"id" json:"id"`
+ TemplateID uuid.NullUUID `db:"template_id" json:"template_id"`
+ OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ Name string `db:"name" json:"name"`
+ Message string `db:"message" json:"message"`
+ Readme string `db:"readme" json:"readme"`
+ JobID uuid.UUID `db:"job_id" json:"job_id"`
+ CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
+ SourceExampleID sql.NullString `db:"source_example_id" json:"source_example_id"`
}
func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTemplateVersionParams) error {
@@ -9465,6 +9485,7 @@ func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTempla
arg.Readme,
arg.JobID,
arg.CreatedBy,
+ arg.SourceExampleID,
)
return err
}
diff --git a/coderd/database/queries/externalauth.sql b/coderd/database/queries/externalauth.sql
index cd223bd792a2a..4368ce56589f0 100644
--- a/coderd/database/queries/externalauth.sql
+++ b/coderd/database/queries/externalauth.sql
@@ -43,13 +43,16 @@ UPDATE external_auth_links SET
oauth_extra = $9
WHERE provider_id = $1 AND user_id = $2 RETURNING *;
--- name: RemoveRefreshToken :exec
--- Removing the refresh token disables the refresh behavior for a given
--- auth token. If a refresh token is marked invalid, it is better to remove it
--- then continually attempt to refresh the token.
+-- name: UpdateExternalAuthLinkRefreshToken :exec
UPDATE
external_auth_links
SET
- oauth_refresh_token = '',
+ oauth_refresh_token = @oauth_refresh_token,
updated_at = @updated_at
-WHERE provider_id = @provider_id AND user_id = @user_id;
+WHERE
+ provider_id = @provider_id
+AND
+ user_id = @user_id
+AND
+ -- Required for sqlc to generate a parameter for the oauth_refresh_token_key_id
+ @oauth_refresh_token_key_id :: text = @oauth_refresh_token_key_id :: text;
diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql
index 094c1b6014de7..0436a7f9ba3b9 100644
--- a/coderd/database/queries/templateversions.sql
+++ b/coderd/database/queries/templateversions.sql
@@ -87,10 +87,11 @@ INSERT INTO
message,
readme,
job_id,
- created_by
+ created_by,
+ source_example_id
)
VALUES
- ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
+ ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
-- name: UpdateTemplateVersionByID :exec
UPDATE
diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go
index 1ce850c9cec03..95ee751ca674e 100644
--- a/coderd/externalauth/externalauth.go
+++ b/coderd/externalauth/externalauth.go
@@ -143,10 +143,12 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu
// get rid of it. Keeping it around will cause additional refresh
// attempts that will fail and cost us api rate limits.
if isFailedRefresh(existingToken, err) {
- dbExecErr := db.RemoveRefreshToken(ctx, database.RemoveRefreshTokenParams{
- UpdatedAt: dbtime.Now(),
- ProviderID: externalAuthLink.ProviderID,
- UserID: externalAuthLink.UserID,
+ dbExecErr := db.UpdateExternalAuthLinkRefreshToken(ctx, database.UpdateExternalAuthLinkRefreshTokenParams{
+ OAuthRefreshToken: "", // It is better to clear the refresh token than to keep retrying.
+ OAuthRefreshTokenKeyID: externalAuthLink.OAuthRefreshTokenKeyID.String,
+ UpdatedAt: dbtime.Now(),
+ ProviderID: externalAuthLink.ProviderID,
+ UserID: externalAuthLink.UserID,
})
if dbExecErr != nil {
// This error should be rare.
diff --git a/coderd/externalauth/externalauth_test.go b/coderd/externalauth/externalauth_test.go
index 84bded9856572..d3ba2262962b6 100644
--- a/coderd/externalauth/externalauth_test.go
+++ b/coderd/externalauth/externalauth_test.go
@@ -190,7 +190,7 @@ func TestRefreshToken(t *testing.T) {
// Try again with a bad refresh token error
// Expect DB call to remove the refresh token
- mDB.EXPECT().RemoveRefreshToken(gomock.Any(), gomock.Any()).Return(nil).Times(1)
+ mDB.EXPECT().UpdateExternalAuthLinkRefreshToken(gomock.Any(), gomock.Any()).Return(nil).Times(1)
refreshErr = &oauth2.RetrieveError{ // github error
Response: &http.Response{
StatusCode: http.StatusOK,
diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go
index df832b810e696..3db5d7c20a4bf 100644
--- a/coderd/provisionerjobs.go
+++ b/coderd/provisionerjobs.go
@@ -15,6 +15,7 @@ import (
"nhooyr.io/websocket"
"cdr.dev/slog"
+ "github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
@@ -312,6 +313,7 @@ type logFollower struct {
r *http.Request
rw http.ResponseWriter
conn *websocket.Conn
+ enc *wsjson.Encoder[codersdk.ProvisionerJobLog]
jobID uuid.UUID
after int64
@@ -391,6 +393,7 @@ func (f *logFollower) follow() {
}
defer f.conn.Close(websocket.StatusNormalClosure, "done")
go httpapi.Heartbeat(f.ctx, f.conn)
+ f.enc = wsjson.NewEncoder[codersdk.ProvisionerJobLog](f.conn, websocket.MessageText)
// query for logs once right away, so we can get historical data from before
// subscription
@@ -488,11 +491,7 @@ func (f *logFollower) query() error {
return xerrors.Errorf("error fetching logs: %w", err)
}
for _, log := range logs {
- logB, err := json.Marshal(convertProvisionerJobLog(log))
- if err != nil {
- return xerrors.Errorf("error marshaling log: %w", err)
- }
- err = f.conn.Write(f.ctx, websocket.MessageText, logB)
+ err := f.enc.Encode(convertProvisionerJobLog(log))
if err != nil {
return xerrors.Errorf("error writing to websocket: %w", err)
}
diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go
index 8ad85b0b39982..233450c43d943 100644
--- a/coderd/telemetry/telemetry.go
+++ b/coderd/telemetry/telemetry.go
@@ -868,6 +868,9 @@ func ConvertTemplateVersion(version database.TemplateVersion) TemplateVersion {
if version.TemplateID.Valid {
snapVersion.TemplateID = &version.TemplateID.UUID
}
+ if version.SourceExampleID.Valid {
+ snapVersion.SourceExampleID = &version.SourceExampleID.String
+ }
return snapVersion
}
@@ -1116,11 +1119,12 @@ type Template struct {
}
type TemplateVersion struct {
- ID uuid.UUID `json:"id"`
- CreatedAt time.Time `json:"created_at"`
- TemplateID *uuid.UUID `json:"template_id,omitempty"`
- OrganizationID uuid.UUID `json:"organization_id"`
- JobID uuid.UUID `json:"job_id"`
+ ID uuid.UUID `json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ TemplateID *uuid.UUID `json:"template_id,omitempty"`
+ OrganizationID uuid.UUID `json:"organization_id"`
+ JobID uuid.UUID `json:"job_id"`
+ SourceExampleID *string `json:"source_example_id,omitempty"`
}
type ProvisionerJob struct {
diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go
index 214d111a170a1..2b70cd2a6d2c3 100644
--- a/coderd/telemetry/telemetry_test.go
+++ b/coderd/telemetry/telemetry_test.go
@@ -1,6 +1,7 @@
package telemetry_test
import (
+ "database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -48,6 +49,10 @@ func TestTelemetry(t *testing.T) {
_ = dbgen.Template(t, db, database.Template{
Provisioner: database.ProvisionerTypeTerraform,
})
+ sourceExampleID := uuid.NewString()
+ _ = dbgen.TemplateVersion(t, db, database.TemplateVersion{
+ SourceExampleID: sql.NullString{String: sourceExampleID, Valid: true},
+ })
_ = dbgen.TemplateVersion(t, db, database.TemplateVersion{})
user := dbgen.User(t, db, database.User{})
_ = dbgen.Workspace(t, db, database.WorkspaceTable{})
@@ -93,7 +98,7 @@ func TestTelemetry(t *testing.T) {
require.Len(t, snapshot.ProvisionerJobs, 1)
require.Len(t, snapshot.Licenses, 1)
require.Len(t, snapshot.Templates, 1)
- require.Len(t, snapshot.TemplateVersions, 1)
+ require.Len(t, snapshot.TemplateVersions, 2)
require.Len(t, snapshot.Users, 1)
require.Len(t, snapshot.Groups, 2)
// 1 member in the everyone group + 1 member in the custom group
@@ -111,6 +116,17 @@ func TestTelemetry(t *testing.T) {
require.Len(t, wsa.Subsystems, 2)
require.Equal(t, string(database.WorkspaceAgentSubsystemEnvbox), wsa.Subsystems[0])
require.Equal(t, string(database.WorkspaceAgentSubsystemExectrace), wsa.Subsystems[1])
+
+ tvs := snapshot.TemplateVersions
+ sort.Slice(tvs, func(i, j int) bool {
+ // Sort by SourceExampleID presence (non-nil comes before nil)
+ if (tvs[i].SourceExampleID != nil) != (tvs[j].SourceExampleID != nil) {
+ return tvs[i].SourceExampleID != nil
+ }
+ return false
+ })
+ require.Equal(t, tvs[0].SourceExampleID, &sourceExampleID)
+ require.Nil(t, tvs[1].SourceExampleID)
})
t.Run("HashedEmail", func(t *testing.T) {
t.Parallel()
diff --git a/coderd/templateversions.go b/coderd/templateversions.go
index a0609c42c33f9..12def3e5d681b 100644
--- a/coderd/templateversions.go
+++ b/coderd/templateversions.go
@@ -1582,6 +1582,10 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
Readme: "",
JobID: provisionerJob.ID,
CreatedBy: apiKey.UserID,
+ SourceExampleID: sql.NullString{
+ String: req.ExampleID,
+ Valid: req.ExampleID != "",
+ },
})
if err != nil {
if database.IsUniqueViolation(err, database.UniqueTemplateVersionsTemplateIDNameKey) {
diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go
index 5ebbd0f41804f..5e96de10d5058 100644
--- a/coderd/templateversions_test.go
+++ b/coderd/templateversions_test.go
@@ -16,6 +16,7 @@ import (
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/rbac"
@@ -134,7 +135,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
t.Run("WithParameters", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
- client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor})
+ client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor})
user := coderdtest.CreateFirstUser(t, client)
data, err := echo.Tar(&echo.Responses{
Parse: echo.ParseComplete,
@@ -160,11 +161,17 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
require.Len(t, auditor.AuditLogs(), 2)
assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs()[1].Action)
+
+ admin, err := client.User(ctx, user.UserID.String())
+ require.NoError(t, err)
+ tvDB, err := db.GetTemplateVersionByID(dbauthz.As(ctx, coderdtest.AuthzUserSubject(admin, user.OrganizationID)), version.ID)
+ require.NoError(t, err)
+ require.False(t, tvDB.SourceExampleID.Valid)
})
t.Run("Example", func(t *testing.T) {
t.Parallel()
- client := coderdtest.New(t, nil)
+ client, db := coderdtest.NewWithDatabase(t, nil)
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
@@ -205,6 +212,12 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "my-example", tv.Name)
+ admin, err := client.User(ctx, user.UserID.String())
+ require.NoError(t, err)
+ tvDB, err := db.GetTemplateVersionByID(dbauthz.As(ctx, coderdtest.AuthzUserSubject(admin, user.OrganizationID)), tv.ID)
+ require.NoError(t, err)
+ require.Equal(t, ls[0].ID, tvDB.SourceExampleID.String)
+
// ensure the template tar was uploaded correctly
fl, ct, err := client.Download(ctx, tv.Job.FileID)
require.NoError(t, err)
diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go
index 922d80f0e8085..6bc09e0e770f6 100644
--- a/coderd/workspaceagents.go
+++ b/coderd/workspaceagents.go
@@ -39,6 +39,7 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
+ "github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/tailnet/proto"
)
@@ -396,11 +397,9 @@ func (api *API) workspaceAgentLogs(rw http.ResponseWriter, r *http.Request) {
}
go httpapi.Heartbeat(ctx, conn)
- ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText)
- defer wsNetConn.Close() // Also closes conn.
+ encoder := wsjson.NewEncoder[[]codersdk.WorkspaceAgentLog](conn, websocket.MessageText)
+ defer encoder.Close(websocket.StatusNormalClosure)
- // The Go stdlib JSON encoder appends a newline character after message write.
- encoder := json.NewEncoder(wsNetConn)
err = encoder.Encode(convertWorkspaceAgentLogs(logs))
if err != nil {
return
@@ -740,16 +739,8 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) {
})
return
}
- ctx, nconn := codersdk.WebsocketNetConn(ctx, ws, websocket.MessageBinary)
- defer nconn.Close()
-
- // Slurp all packets from the connection into io.Discard so pongs get sent
- // by the websocket package. We don't do any reads ourselves so this is
- // necessary.
- go func() {
- _, _ = io.Copy(io.Discard, nconn)
- _ = nconn.Close()
- }()
+ encoder := wsjson.NewEncoder[*tailcfg.DERPMap](ws, websocket.MessageBinary)
+ defer encoder.Close(websocket.StatusGoingAway)
go func(ctx context.Context) {
// TODO(mafredri): Is this too frequent? Use separate ping disconnect timeout?
@@ -767,7 +758,7 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) {
err := ws.Ping(ctx)
cancel()
if err != nil {
- _ = nconn.Close()
+ _ = ws.Close(websocket.StatusGoingAway, "ping failed")
return
}
}
@@ -780,9 +771,8 @@ func (api *API) derpMapUpdates(rw http.ResponseWriter, r *http.Request) {
for {
derpMap := api.DERPMap()
if lastDERPMap == nil || !tailnet.CompareDERPMaps(lastDERPMap, derpMap) {
- err := json.NewEncoder(nconn).Encode(derpMap)
+ err := encoder.Encode(derpMap)
if err != nil {
- _ = nconn.Close()
return
}
lastDERPMap = derpMap
diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go
index acd0c6955ab7f..c8bd4354df153 100644
--- a/codersdk/provisionerdaemons.go
+++ b/codersdk/provisionerdaemons.go
@@ -19,6 +19,7 @@ import (
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/codersdk/drpc"
+ "github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/coder/v2/provisionerd/proto"
"github.com/coder/coder/v2/provisionerd/runner"
)
@@ -161,36 +162,8 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after
}
return nil, nil, ReadBodyAsError(res)
}
- logs := make(chan ProvisionerJobLog)
- closed := make(chan struct{})
- go func() {
- defer close(closed)
- defer close(logs)
- defer conn.Close(websocket.StatusGoingAway, "")
- var log ProvisionerJobLog
- for {
- msgType, msg, err := conn.Read(ctx)
- if err != nil {
- return
- }
- if msgType != websocket.MessageText {
- return
- }
- err = json.Unmarshal(msg, &log)
- if err != nil {
- return
- }
- select {
- case <-ctx.Done():
- return
- case logs <- log:
- }
- }
- }()
- return logs, closeFunc(func() error {
- <-closed
- return nil
- }), nil
+ d := wsjson.NewDecoder[ProvisionerJobLog](conn, websocket.MessageText, c.logger)
+ return d.Chan(), d, nil
}
// ServeProvisionerDaemonRequest are the parameters to call ServeProvisionerDaemon with
diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go
index eeb335b130cdd..b4aec16a83190 100644
--- a/codersdk/workspaceagents.go
+++ b/codersdk/workspaceagents.go
@@ -15,6 +15,7 @@ import (
"nhooyr.io/websocket"
"github.com/coder/coder/v2/coderd/tracing"
+ "github.com/coder/coder/v2/codersdk/wsjson"
)
type WorkspaceAgentStatus string
@@ -454,30 +455,6 @@ func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID,
}
return nil, nil, ReadBodyAsError(res)
}
- logChunks := make(chan []WorkspaceAgentLog, 1)
- closed := make(chan struct{})
- ctx, wsNetConn := WebsocketNetConn(ctx, conn, websocket.MessageText)
- decoder := json.NewDecoder(wsNetConn)
- go func() {
- defer close(closed)
- defer close(logChunks)
- defer conn.Close(websocket.StatusGoingAway, "")
- for {
- var logs []WorkspaceAgentLog
- err = decoder.Decode(&logs)
- if err != nil {
- return
- }
- select {
- case <-ctx.Done():
- return
- case logChunks <- logs:
- }
- }
- }()
- return logChunks, closeFunc(func() error {
- _ = wsNetConn.Close()
- <-closed
- return nil
- }), nil
+ d := wsjson.NewDecoder[[]WorkspaceAgentLog](conn, websocket.MessageText, c.logger)
+ return d.Chan(), d, nil
}
diff --git a/codersdk/wsjson/decoder.go b/codersdk/wsjson/decoder.go
new file mode 100644
index 0000000000000..4cc7ff380a73a
--- /dev/null
+++ b/codersdk/wsjson/decoder.go
@@ -0,0 +1,75 @@
+package wsjson
+
+import (
+ "context"
+ "encoding/json"
+ "sync/atomic"
+
+ "nhooyr.io/websocket"
+
+ "cdr.dev/slog"
+)
+
+type Decoder[T any] struct {
+ conn *websocket.Conn
+ typ websocket.MessageType
+ ctx context.Context
+ cancel context.CancelFunc
+ chanCalled atomic.Bool
+ logger slog.Logger
+}
+
+// Chan starts the decoder reading from the websocket and returns a channel for reading the
+// resulting values. The chan T is closed if the underlying websocket is closed, or we encounter an
+// error. We also close the underlying websocket if we encounter an error reading or decoding.
+func (d *Decoder[T]) Chan() <-chan T {
+ if !d.chanCalled.CompareAndSwap(false, true) {
+ panic("chan called more than once")
+ }
+ values := make(chan T, 1)
+ go func() {
+ defer close(values)
+ defer d.conn.Close(websocket.StatusGoingAway, "")
+ for {
+ // we don't use d.ctx here because it only gets canceled after closing the connection
+ // and a "connection closed" type error is more clear than context canceled.
+ typ, b, err := d.conn.Read(context.Background())
+ if err != nil {
+ // might be benign like EOF, so just log at debug
+ d.logger.Debug(d.ctx, "error reading from websocket", slog.Error(err))
+ return
+ }
+ if typ != d.typ {
+ d.logger.Error(d.ctx, "websocket type mismatch while decoding")
+ return
+ }
+ var value T
+ err = json.Unmarshal(b, &value)
+ if err != nil {
+ d.logger.Error(d.ctx, "error unmarshalling", slog.Error(err))
+ return
+ }
+ select {
+ case values <- value:
+ // OK
+ case <-d.ctx.Done():
+ return
+ }
+ }
+ }()
+ return values
+}
+
+// nolint: revive // complains that Encoder has the same function name
+func (d *Decoder[T]) Close() error {
+ err := d.conn.Close(websocket.StatusNormalClosure, "")
+ d.cancel()
+ return err
+}
+
+// NewDecoder creates a JSON-over-websocket decoder for type T, which must be deserializable from
+// JSON.
+func NewDecoder[T any](conn *websocket.Conn, typ websocket.MessageType, logger slog.Logger) *Decoder[T] {
+ ctx, cancel := context.WithCancel(context.Background())
+ return &Decoder[T]{conn: conn, ctx: ctx, cancel: cancel, typ: typ, logger: logger}
+}
diff --git a/codersdk/wsjson/encoder.go b/codersdk/wsjson/encoder.go
new file mode 100644
index 0000000000000..4cde05984e690
--- /dev/null
+++ b/codersdk/wsjson/encoder.go
@@ -0,0 +1,42 @@
+package wsjson
+
+import (
+ "context"
+ "encoding/json"
+
+ "golang.org/x/xerrors"
+ "nhooyr.io/websocket"
+)
+
+type Encoder[T any] struct {
+ conn *websocket.Conn
+ typ websocket.MessageType
+}
+
+func (e *Encoder[T]) Encode(v T) error {
+ w, err := e.conn.Writer(context.Background(), e.typ)
+ if err != nil {
+ return xerrors.Errorf("get websocket writer: %w", err)
+ }
+ defer w.Close()
+ j := json.NewEncoder(w)
+ err = j.Encode(v)
+ if err != nil {
+ return xerrors.Errorf("encode json: %w", err)
+ }
+ return nil
+}
+
+func (e *Encoder[T]) Close(c websocket.StatusCode) error {
+ return e.conn.Close(c, "")
+}
+
+// NewEncoder creates a JSON-over websocket encoder for the type T, which must be JSON-serializable.
+// You may then call Encode() to send objects over the websocket. Creating an Encoder closes the
+// websocket for reading, turning it into a unidirectional write stream of JSON-encoded objects.
+func NewEncoder[T any](conn *websocket.Conn, typ websocket.MessageType) *Encoder[T] {
+ // Here we close the websocket for reading, so that the websocket library will handle pings and
+ // close frames.
+ _ = conn.CloseRead(context.Background())
+ return &Encoder[T]{conn: conn, typ: typ}
+}
diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md
index 3ea4e145d13eb..db214b0e1443e 100644
--- a/docs/admin/security/audit-logs.md
+++ b/docs/admin/security/audit-logs.md
@@ -24,7 +24,7 @@ We track the following resources:
| OAuth2ProviderAppSecret
|
Field | Tracked |
---|
app_id | false |
created_at | false |
display_secret | false |
hashed_secret | false |
id | false |
last_used_at | false |
secret_prefix | false |
|
| Organization
| Field | Tracked |
---|
created_at | false |
description | true |
display_name | true |
icon | true |
id | false |
is_default | true |
name | true |
updated_at | true |
|
| Template
write, delete | Field | Tracked |
---|
active_version_id | true |
activity_bump | true |
allow_user_autostart | true |
allow_user_autostop | true |
allow_user_cancel_workspace_jobs | true |
autostart_block_days_of_week | true |
autostop_requirement_days_of_week | true |
autostop_requirement_weeks | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_username | false |
default_ttl | true |
deleted | false |
deprecated | true |
description | true |
display_name | true |
failure_ttl | true |
group_acl | true |
icon | true |
id | true |
max_port_sharing_level | true |
name | true |
organization_display_name | false |
organization_icon | false |
organization_id | false |
organization_name | false |
provisioner | true |
require_active_version | true |
time_til_dormant | true |
time_til_dormant_autodelete | true |
updated_at | false |
user_acl | true |
|
-| TemplateVersion
create, write | Field | Tracked |
---|
archived | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_username | false |
external_auth_providers | false |
id | true |
job_id | false |
message | false |
name | true |
organization_id | false |
readme | true |
template_id | true |
updated_at | false |
|
+| TemplateVersion
create, write | Field | Tracked |
---|
archived | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_username | false |
external_auth_providers | false |
id | true |
job_id | false |
message | false |
name | true |
organization_id | false |
readme | true |
source_example_id | false |
template_id | true |
updated_at | false |
|
| User
create, write, delete | Field | Tracked |
---|
avatar_url | false |
created_at | false |
deleted | true |
email | true |
github_com_user_id | false |
hashed_one_time_passcode | false |
hashed_password | true |
id | true |
last_seen_at | false |
login_type | true |
name | true |
one_time_passcode_expires_at | true |
quiet_hours_schedule | true |
rbac_roles | true |
status | true |
theme_preference | false |
updated_at | false |
username | true |
|
| WorkspaceBuild
start, stop | Field | Tracked |
---|
build_number | false |
created_at | false |
daily_cost | false |
deadline | false |
id | false |
initiator_by_avatar_url | false |
initiator_by_username | false |
initiator_id | false |
job_id | false |
max_deadline | false |
provisioner_state | false |
reason | false |
template_version_id | true |
transition | false |
updated_at | false |
workspace_id | false |
|
| WorkspaceProxy
| Field | Tracked |
---|
created_at | true |
deleted | false |
derp_enabled | true |
derp_only | true |
display_name | true |
icon | true |
id | true |
name | true |
region_id | true |
token_hashed_secret | true |
updated_at | false |
url | true |
version | true |
wildcard_hostname | true |
|
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index f9e74959f2a28..24f7dfa4b4fe0 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -127,6 +127,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"created_by_avatar_url": ActionIgnore,
"created_by_username": ActionIgnore,
"archived": ActionTrack,
+ "source_example_id": ActionIgnore, // Never changes.
},
&database.User{}: {
"id": ActionTrack,
diff --git a/enterprise/dbcrypt/cipher_internal_test.go b/enterprise/dbcrypt/cipher_internal_test.go
index b6740de17eec6..c70796ba27e97 100644
--- a/enterprise/dbcrypt/cipher_internal_test.go
+++ b/enterprise/dbcrypt/cipher_internal_test.go
@@ -3,6 +3,8 @@ package dbcrypt
import (
"bytes"
"encoding/base64"
+ "os"
+ "strings"
"testing"
"github.com/stretchr/testify/require"
@@ -89,3 +91,35 @@ func TestCiphersBackwardCompatibility(t *testing.T) {
require.NoError(t, err, "decryption should succeed")
require.Equal(t, msg, string(decrypted), "decrypted message should match original message")
}
+
+// If you're looking here, you're probably in trouble.
+// Here's what you need to do:
+// 1. Get the current CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS environment variable.
+// 2. Run the following command:
+// ENCRYPT_ME="" CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS="" go test -v -count=1 ./enterprise/dbcrypt -test.run='^TestHelpMeEncryptSomeValue$'
+// 3. Copy the value from the test output and do what you need with it.
+func TestHelpMeEncryptSomeValue(t *testing.T) {
+ t.Parallel()
+ t.Skip("this only exists if you need to encrypt a value with dbcrypt, it does not actually test anything")
+
+ valueToEncrypt := os.Getenv("ENCRYPT_ME")
+ t.Logf("valueToEncrypt: %q", valueToEncrypt)
+ keys := os.Getenv("CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS")
+ require.NotEmpty(t, keys, "Set the CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS environment variable to use this")
+
+ base64Keys := strings.Split(keys, ",")
+ activeKey := base64Keys[0]
+
+ decodedKey, err := base64.StdEncoding.DecodeString(activeKey)
+ require.NoError(t, err, "the active key should be valid base64")
+
+ cipher, err := cipherAES256(decodedKey)
+ require.NoError(t, err)
+
+ t.Logf("cipher digest: %+v", cipher.HexDigest())
+
+ encryptedEmptyString, err := cipher.Encrypt([]byte(valueToEncrypt))
+ require.NoError(t, err)
+
+ t.Logf("encrypted and base64-encoded: %q", base64.StdEncoding.EncodeToString(encryptedEmptyString))
+}
diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go
index 77a7d5cb78738..e0ca58cc5231a 100644
--- a/enterprise/dbcrypt/dbcrypt.go
+++ b/enterprise/dbcrypt/dbcrypt.go
@@ -261,6 +261,21 @@ func (db *dbCrypt) UpdateExternalAuthLink(ctx context.Context, params database.U
return link, nil
}
+func (db *dbCrypt) UpdateExternalAuthLinkRefreshToken(ctx context.Context, params database.UpdateExternalAuthLinkRefreshTokenParams) error {
+ // We would normally use a sql.NullString here, but sqlc does not want to make
+ // a params struct with a nullable string.
+ var digest sql.NullString
+ if params.OAuthRefreshTokenKeyID != "" {
+ digest.String = params.OAuthRefreshTokenKeyID
+ digest.Valid = true
+ }
+ if err := db.encryptField(¶ms.OAuthRefreshToken, &digest); err != nil {
+ return err
+ }
+
+ return db.Store.UpdateExternalAuthLinkRefreshToken(ctx, params)
+}
+
func (db *dbCrypt) GetCryptoKeys(ctx context.Context) ([]database.CryptoKey, error) {
keys, err := db.Store.GetCryptoKeys(ctx)
if err != nil {
diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go
index 3e252496d6a69..e73c3eee85c16 100644
--- a/enterprise/dbcrypt/dbcrypt_internal_test.go
+++ b/enterprise/dbcrypt/dbcrypt_internal_test.go
@@ -17,6 +17,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
+ "github.com/coder/coder/v2/coderd/database/dbtime"
)
func TestUserLinks(t *testing.T) {
@@ -96,6 +97,31 @@ func TestUserLinks(t *testing.T) {
require.EqualValues(t, expectedClaims, rawLink.Claims)
})
+ t.Run("UpdateExternalAuthLinkRefreshToken", func(t *testing.T) {
+ t.Parallel()
+ db, crypt, ciphers := setup(t)
+ user := dbgen.User(t, crypt, database.User{})
+ link := dbgen.ExternalAuthLink(t, crypt, database.ExternalAuthLink{
+ UserID: user.ID,
+ })
+
+ err := crypt.UpdateExternalAuthLinkRefreshToken(ctx, database.UpdateExternalAuthLinkRefreshTokenParams{
+ OAuthRefreshToken: "",
+ OAuthRefreshTokenKeyID: link.OAuthRefreshTokenKeyID.String,
+ UpdatedAt: dbtime.Now(),
+ ProviderID: link.ProviderID,
+ UserID: link.UserID,
+ })
+ require.NoError(t, err)
+
+ rawLink, err := db.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
+ ProviderID: link.ProviderID,
+ UserID: link.UserID,
+ })
+ require.NoError(t, err)
+ requireEncryptedEquals(t, ciphers[0], rawLink.OAuthRefreshToken, "")
+ })
+
t.Run("GetUserLinkByLinkedID", func(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
diff --git a/site/src/api/api.ts b/site/src/api/api.ts
index 3ad195f2bd9e4..cfba27408e9c6 100644
--- a/site/src/api/api.ts
+++ b/site/src/api/api.ts
@@ -682,12 +682,20 @@ class ApiMethods {
/**
* @param organization Can be the organization's ID or name
+ * @param tags to filter provisioner daemons by.
*/
getProvisionerDaemonsByOrganization = async (
organization: string,
+ tags?: Record,
): Promise => {
+ const params = new URLSearchParams();
+
+ if (tags) {
+ params.append("tags", JSON.stringify(tags));
+ }
+
const response = await this.axios.get(
- `/api/v2/organizations/${organization}/provisionerdaemons`,
+ `/api/v2/organizations/${organization}/provisionerdaemons?${params.toString()}`,
);
return response.data;
};
diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts
index d1df8f409dcdf..c3f5a4ebd3ced 100644
--- a/site/src/api/queries/organizations.ts
+++ b/site/src/api/queries/organizations.ts
@@ -115,16 +115,18 @@ export const organizations = () => {
};
};
-export const getProvisionerDaemonsKey = (organization: string) => [
- "organization",
- organization,
- "provisionerDaemons",
-];
+export const getProvisionerDaemonsKey = (
+ organization: string,
+ tags?: Record,
+) => ["organization", organization, tags, "provisionerDaemons"];
-export const provisionerDaemons = (organization: string) => {
+export const provisionerDaemons = (
+ organization: string,
+ tags?: Record,
+) => {
return {
- queryKey: getProvisionerDaemonsKey(organization),
- queryFn: () => API.getProvisionerDaemonsByOrganization(organization),
+ queryKey: getProvisionerDaemonsKey(organization, tags),
+ queryFn: () => API.getProvisionerDaemonsByOrganization(organization, tags),
};
};
diff --git a/site/src/components/Alert/Alert.tsx b/site/src/components/Alert/Alert.tsx
index df741a1924fa9..7750a6bc7d1e8 100644
--- a/site/src/components/Alert/Alert.tsx
+++ b/site/src/components/Alert/Alert.tsx
@@ -1,4 +1,5 @@
import MuiAlert, {
+ type AlertColor as MuiAlertColor,
type AlertProps as MuiAlertProps,
// biome-ignore lint/nursery/noRestrictedImports: Used as base component
} from "@mui/material/Alert";
@@ -11,6 +12,8 @@ import {
useState,
} from "react";
+export type AlertColor = MuiAlertColor;
+
export type AlertProps = MuiAlertProps & {
actions?: ReactNode;
dismissible?: boolean;
diff --git a/site/src/modules/provisioners/ProvisionerAlert.stories.tsx b/site/src/modules/provisioners/ProvisionerAlert.stories.tsx
new file mode 100644
index 0000000000000..d9ca1501d6611
--- /dev/null
+++ b/site/src/modules/provisioners/ProvisionerAlert.stories.tsx
@@ -0,0 +1,28 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { chromatic } from "testHelpers/chromatic";
+import { ProvisionerAlert } from "./ProvisionerAlert";
+
+const meta: Meta = {
+ title: "modules/provisioners/ProvisionerAlert",
+ parameters: {
+ chromatic,
+ layout: "centered",
+ },
+ component: ProvisionerAlert,
+ args: {
+ title: "Title",
+ detail: "Detail",
+ severity: "info",
+ tags: { tag: "tagValue" },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Info: Story = {};
+export const NullTags: Story = {
+ args: {
+ tags: undefined,
+ },
+};
diff --git a/site/src/modules/provisioners/ProvisionerAlert.tsx b/site/src/modules/provisioners/ProvisionerAlert.tsx
new file mode 100644
index 0000000000000..54d9ab8473e87
--- /dev/null
+++ b/site/src/modules/provisioners/ProvisionerAlert.tsx
@@ -0,0 +1,45 @@
+import AlertTitle from "@mui/material/AlertTitle";
+import { Alert, type AlertColor } from "components/Alert/Alert";
+import { AlertDetail } from "components/Alert/Alert";
+import { Stack } from "components/Stack/Stack";
+import { ProvisionerTag } from "modules/provisioners/ProvisionerTag";
+import type { FC } from "react";
+interface ProvisionerAlertProps {
+ title: string;
+ detail: string;
+ severity: AlertColor;
+ tags: Record;
+}
+
+export const ProvisionerAlert: FC = ({
+ title,
+ detail,
+ severity,
+ tags,
+}) => {
+ return (
+ {
+ return {
+ borderRadius: 0,
+ border: 0,
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ borderLeft: `2px solid ${theme.palette[severity].main}`,
+ };
+ }}
+ >
+ {title}
+
+ {detail}
+
+ {Object.entries(tags ?? {})
+ .filter(([key]) => key !== "owner")
+ .map(([key, value]) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx b/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx
new file mode 100644
index 0000000000000..d4f746e99c417
--- /dev/null
+++ b/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { chromatic } from "testHelpers/chromatic";
+import { MockTemplateVersion } from "testHelpers/entities";
+import { ProvisionerStatusAlert } from "./ProvisionerStatusAlert";
+
+const meta: Meta = {
+ title: "modules/provisioners/ProvisionerStatusAlert",
+ parameters: {
+ chromatic,
+ layout: "centered",
+ },
+ component: ProvisionerStatusAlert,
+ args: {
+ matchingProvisioners: 0,
+ availableProvisioners: 0,
+ tags: MockTemplateVersion.job.tags,
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const HealthyProvisioners: Story = {
+ args: {
+ matchingProvisioners: 1,
+ availableProvisioners: 1,
+ },
+};
+
+export const UndefinedMatchingProvisioners: Story = {
+ args: {
+ matchingProvisioners: undefined,
+ availableProvisioners: undefined,
+ },
+};
+
+export const UndefinedAvailableProvisioners: Story = {
+ args: {
+ matchingProvisioners: 1,
+ availableProvisioners: undefined,
+ },
+};
+
+export const NoMatchingProvisioners: Story = {
+ args: {
+ matchingProvisioners: 0,
+ },
+};
+
+export const NoAvailableProvisioners: Story = {
+ args: {
+ matchingProvisioners: 1,
+ availableProvisioners: 0,
+ },
+};
diff --git a/site/src/modules/provisioners/ProvisionerStatusAlert.tsx b/site/src/modules/provisioners/ProvisionerStatusAlert.tsx
new file mode 100644
index 0000000000000..54a2b56704877
--- /dev/null
+++ b/site/src/modules/provisioners/ProvisionerStatusAlert.tsx
@@ -0,0 +1,47 @@
+import type { AlertColor } from "components/Alert/Alert";
+import type { FC } from "react";
+import { ProvisionerAlert } from "./ProvisionerAlert";
+
+interface ProvisionerStatusAlertProps {
+ matchingProvisioners: number | undefined;
+ availableProvisioners: number | undefined;
+ tags: Record;
+}
+
+export const ProvisionerStatusAlert: FC = ({
+ matchingProvisioners,
+ availableProvisioners,
+ tags,
+}) => {
+ let title: string;
+ let detail: string;
+ let severity: AlertColor;
+ switch (true) {
+ case matchingProvisioners === 0:
+ title = "Build pending provisioner deployment";
+ detail =
+ "Your build has been enqueued, but there are no provisioners that accept the required tags. Once a compatible provisioner becomes available, your build will continue. Please contact your administrator.";
+ severity = "warning";
+ break;
+ case availableProvisioners === 0:
+ title = "Build delayed";
+ detail =
+ "Provisioners that accept the required tags have not responded for longer than expected. This may delay your build. Please contact your administrator if your build does not complete.";
+ severity = "warning";
+ break;
+ default:
+ title = "Build enqueued";
+ detail =
+ "Your build has been enqueued and will begin once a provisioner becomes available to process it.";
+ severity = "info";
+ }
+
+ return (
+
+ );
+};
diff --git a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx
index afc3c1321a6b4..29229fadfd0ad 100644
--- a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx
+++ b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx
@@ -34,6 +34,42 @@ export const MissingVariables: Story = {
},
};
+export const NoProvisioners: Story = {
+ args: {
+ templateVersion: {
+ ...MockTemplateVersion,
+ matched_provisioners: {
+ count: 0,
+ available: 0,
+ },
+ },
+ },
+};
+
+export const ProvisionersUnhealthy: Story = {
+ args: {
+ templateVersion: {
+ ...MockTemplateVersion,
+ matched_provisioners: {
+ count: 1,
+ available: 0,
+ },
+ },
+ },
+};
+
+export const ProvisionersHealthy: Story = {
+ args: {
+ templateVersion: {
+ ...MockTemplateVersion,
+ matched_provisioners: {
+ count: 1,
+ available: 1,
+ },
+ },
+ },
+};
+
export const Logs: Story = {
args: {
templateVersion: {
diff --git a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx
index 5af38b649c695..4eb1805b60e36 100644
--- a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx
+++ b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx
@@ -8,6 +8,7 @@ import { visuallyHidden } from "@mui/utils";
import { JobError } from "api/queries/templates";
import type { TemplateVersion } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
+import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert";
import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs";
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
import { type FC, useLayoutEffect, useRef } from "react";
@@ -27,6 +28,10 @@ export const BuildLogsDrawer: FC = ({
variablesSectionRef,
...drawerProps
}) => {
+ const matchingProvisioners = templateVersion?.matched_provisioners?.count;
+ const availableProvisioners =
+ templateVersion?.matched_provisioners?.available;
+
const logs = useWatchVersionLogs(templateVersion);
const logsContainer = useRef(null);
@@ -65,6 +70,8 @@ export const BuildLogsDrawer: FC = ({
+ {}
+
{isMissingVariables ? (
{
@@ -82,7 +89,14 @@ export const BuildLogsDrawer: FC = ({
) : (
-
+ <>
+
+
+ >
)}
diff --git a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx
index 9147a1a5befff..05ed426d5dcc9 100644
--- a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx
+++ b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx
@@ -42,6 +42,7 @@ const meta: Meta = {
deploymentDAUs: MockDeploymentDAUResponse,
invalidExperiments: [],
safeExperiments: [],
+ entitlements: undefined,
},
};
@@ -136,3 +137,74 @@ export const invalidExperimentsEnabled: Story = {
invalidExperiments: ["invalid"],
},
};
+
+export const WithLicenseUtilization: Story = {
+ args: {
+ entitlements: {
+ ...MockEntitlementsWithUserLimit,
+ features: {
+ ...MockEntitlementsWithUserLimit.features,
+ user_limit: {
+ ...MockEntitlementsWithUserLimit.features.user_limit,
+ enabled: true,
+ actual: 75,
+ limit: 100,
+ entitlement: "entitled",
+ },
+ },
+ },
+ },
+};
+
+export const HighLicenseUtilization: Story = {
+ args: {
+ entitlements: {
+ ...MockEntitlementsWithUserLimit,
+ features: {
+ ...MockEntitlementsWithUserLimit.features,
+ user_limit: {
+ ...MockEntitlementsWithUserLimit.features.user_limit,
+ enabled: true,
+ actual: 95,
+ limit: 100,
+ entitlement: "entitled",
+ },
+ },
+ },
+ },
+};
+
+export const ExceedsLicenseUtilization: Story = {
+ args: {
+ entitlements: {
+ ...MockEntitlementsWithUserLimit,
+ features: {
+ ...MockEntitlementsWithUserLimit.features,
+ user_limit: {
+ ...MockEntitlementsWithUserLimit.features.user_limit,
+ enabled: true,
+ actual: 100,
+ limit: 95,
+ entitlement: "entitled",
+ },
+ },
+ },
+ },
+};
+export const NoLicenseLimit: Story = {
+ args: {
+ entitlements: {
+ ...MockEntitlementsWithUserLimit,
+ features: {
+ ...MockEntitlementsWithUserLimit.features,
+ user_limit: {
+ ...MockEntitlementsWithUserLimit.features.user_limit,
+ enabled: false,
+ actual: 0,
+ limit: 0,
+ entitlement: "entitled",
+ },
+ },
+ },
+ },
+};
diff --git a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx
index 29edacd08d9e7..df5550d70e965 100644
--- a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx
+++ b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx
@@ -1,4 +1,5 @@
import AlertTitle from "@mui/material/AlertTitle";
+import LinearProgress from "@mui/material/LinearProgress";
import type {
DAUsResponse,
Entitlements,
@@ -36,6 +37,12 @@ export const GeneralSettingsPageView: FC = ({
safeExperiments,
invalidExperiments,
}) => {
+ const licenseUtilizationPercentage =
+ entitlements?.features?.user_limit?.actual &&
+ entitlements?.features?.user_limit?.limit
+ ? entitlements.features.user_limit.actual /
+ entitlements.features.user_limit.limit
+ : undefined;
return (
<>
= ({
)}
+ {licenseUtilizationPercentage && (
+
+
+
+ {Math.round(licenseUtilizationPercentage * 100)}% used (
+ {entitlements!.features.user_limit.actual}/
+ {entitlements!.features.user_limit.limit} users)
+
+
+ )}
{invalidExperiments.length > 0 && (
Invalid experiments in use:
diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx
index 1382aa100a1dc..4b8413215c9e8 100644
--- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx
+++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx
@@ -49,6 +49,73 @@ type Story = StoryObj;
export const Example: Story = {};
+export const UndefinedLogs: Story = {
+ args: {
+ defaultTab: "logs",
+ buildLogs: undefined,
+ templateVersion: {
+ ...MockTemplateVersion,
+ job: MockRunningProvisionerJob,
+ },
+ },
+};
+
+export const EmptyLogs: Story = {
+ args: {
+ defaultTab: "logs",
+ buildLogs: [],
+ templateVersion: {
+ ...MockTemplateVersion,
+ job: MockRunningProvisionerJob,
+ },
+ },
+};
+
+export const NoProvisioners: Story = {
+ args: {
+ defaultTab: "logs",
+ buildLogs: [],
+ templateVersion: {
+ ...MockTemplateVersion,
+ job: MockRunningProvisionerJob,
+ matched_provisioners: {
+ count: 0,
+ available: 0,
+ },
+ },
+ },
+};
+
+export const UnavailableProvisioners: Story = {
+ args: {
+ defaultTab: "logs",
+ buildLogs: [],
+ templateVersion: {
+ ...MockTemplateVersion,
+ job: MockRunningProvisionerJob,
+ matched_provisioners: {
+ count: 1,
+ available: 0,
+ },
+ },
+ },
+};
+
+export const HealthyProvisioners: Story = {
+ args: {
+ defaultTab: "logs",
+ buildLogs: [],
+ templateVersion: {
+ ...MockTemplateVersion,
+ job: MockRunningProvisionerJob,
+ matched_provisioners: {
+ count: 1,
+ available: 1,
+ },
+ },
+ },
+};
+
export const Logs: Story = {
args: {
defaultTab: "logs",
diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx
index 943370f89e2a4..858f57dd59493 100644
--- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx
+++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx
@@ -4,7 +4,6 @@ import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
import CloseOutlined from "@mui/icons-material/CloseOutlined";
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
import WarningOutlined from "@mui/icons-material/WarningOutlined";
-import AlertTitle from "@mui/material/AlertTitle";
import Button from "@mui/material/Button";
import ButtonGroup from "@mui/material/ButtonGroup";
import IconButton from "@mui/material/IconButton";
@@ -17,7 +16,7 @@ import type {
VariableValue,
WorkspaceResource,
} from "api/typesGenerated";
-import { Alert, AlertDetail } from "components/Alert/Alert";
+import { Alert } from "components/Alert/Alert";
import { Sidebar } from "components/FullPageLayout/Sidebar";
import {
Topbar,
@@ -29,6 +28,8 @@ import {
} from "components/FullPageLayout/Topbar";
import { Loader } from "components/Loader/Loader";
import { linkToTemplate, useLinks } from "modules/navigation";
+import { ProvisionerAlert } from "modules/provisioners/ProvisionerAlert";
+import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert";
import { TemplateFileTree } from "modules/templates/TemplateFiles/TemplateFileTree";
import { isBinaryData } from "modules/templates/TemplateFiles/isBinaryData";
import { TemplateResourcesTable } from "modules/templates/TemplateResourcesTable/TemplateResourcesTable";
@@ -126,6 +127,8 @@ export const TemplateVersionEditor: FC = ({
const [deleteFileOpen, setDeleteFileOpen] = useState();
const [renameFileOpen, setRenameFileOpen] = useState();
const [dirty, setDirty] = useState(false);
+ const matchingProvisioners = templateVersion.matched_provisioners?.count;
+ const availableProvisioners = templateVersion.matched_provisioners?.available;
const triggerPreview = useCallback(async () => {
await onPreview(fileTree);
@@ -192,6 +195,8 @@ export const TemplateVersionEditor: FC = ({
linkToTemplate(template.organization_name, template.name),
);
+ const gotBuildLogs = buildLogs && buildLogs.length > 0;
+
return (
<>
@@ -581,31 +586,34 @@ export const TemplateVersionEditor: FC
= ({
css={[styles.logs, styles.tabContent]}
ref={logsContentRef}
>
- {templateVersion.job.error && (
+ {templateVersion.job.error ? (
-
- Error during the build
- {templateVersion.job.error}
-
+ tags={templateVersion.job.tags}
+ />
+ ) : (
+ !gotBuildLogs && (
+ <>
+
+
+ >
+ )
)}
- {buildLogs && buildLogs.length > 0 ? (
+ {gotBuildLogs && (
- ) : (
-
)}
)}
diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx
index e905a9b412c2c..514d34e0265e8 100644
--- a/site/src/testHelpers/storybook.tsx
+++ b/site/src/testHelpers/storybook.tsx
@@ -1,6 +1,7 @@
import type { StoryContext } from "@storybook/react";
import { withDefaultFeatures } from "api/api";
import { getAuthorizationKey } from "api/queries/authCheck";
+import { getProvisionerDaemonsKey } from "api/queries/organizations";
import { hasFirstUserKey, meKey } from "api/queries/users";
import type { Entitlements } from "api/typesGenerated";
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
@@ -121,6 +122,30 @@ export const withAuthProvider = (Story: FC, { parameters }: StoryContext) => {
);
};
+export const withProvisioners = (Story: FC, { parameters }: StoryContext) => {
+ if (!parameters.organization_id) {
+ throw new Error(
+ "You forgot to add `parameters.organization_id` to your story",
+ );
+ }
+ if (!parameters.provisioners) {
+ throw new Error(
+ "You forgot to add `parameters.provisioners` to your story",
+ );
+ }
+ if (!parameters.tags) {
+ throw new Error("You forgot to add `parameters.tags` to your story");
+ }
+
+ const queryClient = useQueryClient();
+ queryClient.setQueryData(
+ getProvisionerDaemonsKey(parameters.organization_id, parameters.tags),
+ parameters.provisioners,
+ );
+
+ return ;
+};
+
export const withGlobalSnackbar = (Story: FC) => (
<>
pFad - Phonifier reborn
Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy