mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
chore: add cherry-picks for release 2.18 (#15735)
Co-authored-by: Hugo Dutka <hugo@coder.com> Co-authored-by: Sas Swart <sas.swart.cdk@gmail.com> Co-authored-by: Spike Curtis <spike@coder.com> Co-authored-by: Cian Johnston <cian@coder.com>
This commit is contained in:
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
4
coderd/database/dump.sql
generated
4
coderd/database/dump.sql
generated
@ -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
|
||||
|
@ -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.';
|
@ -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.';
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
75
codersdk/wsjson/decoder.go
Normal file
75
codersdk/wsjson/decoder.go
Normal file
@ -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}
|
||||
}
|
42
codersdk/wsjson/encoder.go
Normal file
42
codersdk/wsjson/encoder.go
Normal file
@ -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}
|
||||
}
|
@ -24,7 +24,7 @@ We track the following resources:
|
||||
| OAuth2ProviderAppSecret<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>app_id</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>display_secret</td><td>false</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>secret_prefix</td><td>false</td></tr></tbody></table> |
|
||||
| Organization<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_default</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr></tbody></table> |
|
||||
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>activity_bump</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_port_sharing_level</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_display_name</td><td>false</td></tr><tr><td>organization_icon</td><td>false</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>organization_name</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
|
||||
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>theme_preference</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
|
||||
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
|
||||
|
@ -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,
|
||||
|
@ -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="<value to encrypt>" CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS="<secret keys here>" 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))
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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<string, string>,
|
||||
): Promise<TypesGen.ProvisionerDaemon[]> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (tags) {
|
||||
params.append("tags", JSON.stringify(tags));
|
||||
}
|
||||
|
||||
const response = await this.axios.get<TypesGen.ProvisionerDaemon[]>(
|
||||
`/api/v2/organizations/${organization}/provisionerdaemons`,
|
||||
`/api/v2/organizations/${organization}/provisionerdaemons?${params.toString()}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
@ -115,16 +115,18 @@ export const organizations = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getProvisionerDaemonsKey = (organization: string) => [
|
||||
"organization",
|
||||
organization,
|
||||
"provisionerDaemons",
|
||||
];
|
||||
export const getProvisionerDaemonsKey = (
|
||||
organization: string,
|
||||
tags?: Record<string, string>,
|
||||
) => ["organization", organization, tags, "provisionerDaemons"];
|
||||
|
||||
export const provisionerDaemons = (organization: string) => {
|
||||
export const provisionerDaemons = (
|
||||
organization: string,
|
||||
tags?: Record<string, string>,
|
||||
) => {
|
||||
return {
|
||||
queryKey: getProvisionerDaemonsKey(organization),
|
||||
queryFn: () => API.getProvisionerDaemonsByOrganization(organization),
|
||||
queryKey: getProvisionerDaemonsKey(organization, tags),
|
||||
queryFn: () => API.getProvisionerDaemonsByOrganization(organization, tags),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
28
site/src/modules/provisioners/ProvisionerAlert.stories.tsx
Normal file
28
site/src/modules/provisioners/ProvisionerAlert.stories.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import { ProvisionerAlert } from "./ProvisionerAlert";
|
||||
|
||||
const meta: Meta<typeof ProvisionerAlert> = {
|
||||
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<typeof ProvisionerAlert>;
|
||||
|
||||
export const Info: Story = {};
|
||||
export const NullTags: Story = {
|
||||
args: {
|
||||
tags: undefined,
|
||||
},
|
||||
};
|
45
site/src/modules/provisioners/ProvisionerAlert.tsx
Normal file
45
site/src/modules/provisioners/ProvisionerAlert.tsx
Normal file
@ -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<string, string>;
|
||||
}
|
||||
|
||||
export const ProvisionerAlert: FC<ProvisionerAlertProps> = ({
|
||||
title,
|
||||
detail,
|
||||
severity,
|
||||
tags,
|
||||
}) => {
|
||||
return (
|
||||
<Alert
|
||||
severity={severity}
|
||||
css={(theme) => {
|
||||
return {
|
||||
borderRadius: 0,
|
||||
border: 0,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
borderLeft: `2px solid ${theme.palette[severity].main}`,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDetail>
|
||||
<div>{detail}</div>
|
||||
<Stack direction="row" spacing={1} wrap="wrap">
|
||||
{Object.entries(tags ?? {})
|
||||
.filter(([key]) => key !== "owner")
|
||||
.map(([key, value]) => (
|
||||
<ProvisionerTag key={key} tagName={key} tagValue={value} />
|
||||
))}
|
||||
</Stack>
|
||||
</AlertDetail>
|
||||
</Alert>
|
||||
);
|
||||
};
|
@ -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<typeof ProvisionerStatusAlert> = {
|
||||
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<typeof ProvisionerStatusAlert>;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
47
site/src/modules/provisioners/ProvisionerStatusAlert.tsx
Normal file
47
site/src/modules/provisioners/ProvisionerStatusAlert.tsx
Normal file
@ -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<string, string>;
|
||||
}
|
||||
|
||||
export const ProvisionerStatusAlert: FC<ProvisionerStatusAlertProps> = ({
|
||||
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 (
|
||||
<ProvisionerAlert
|
||||
title={title}
|
||||
detail={detail}
|
||||
severity={severity}
|
||||
tags={tags}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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: {
|
||||
|
@ -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<BuildLogsDrawerProps> = ({
|
||||
variablesSectionRef,
|
||||
...drawerProps
|
||||
}) => {
|
||||
const matchingProvisioners = templateVersion?.matched_provisioners?.count;
|
||||
const availableProvisioners =
|
||||
templateVersion?.matched_provisioners?.available;
|
||||
|
||||
const logs = useWatchVersionLogs(templateVersion);
|
||||
const logsContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -65,6 +70,8 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
{}
|
||||
|
||||
{isMissingVariables ? (
|
||||
<MissingVariablesBanner
|
||||
onFillVariables={() => {
|
||||
@ -82,7 +89,14 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
|
||||
<WorkspaceBuildLogs logs={logs} css={{ border: 0 }} />
|
||||
</section>
|
||||
) : (
|
||||
<Loader />
|
||||
<>
|
||||
<ProvisionerStatusAlert
|
||||
matchingProvisioners={matchingProvisioners}
|
||||
availableProvisioners={availableProvisioners}
|
||||
tags={templateVersion?.job.tags ?? {}}
|
||||
/>
|
||||
<Loader />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
|
@ -42,6 +42,7 @@ const meta: Meta<typeof GeneralSettingsPageView> = {
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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<GeneralSettingsPageViewProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<SettingsHeader
|
||||
@ -54,6 +61,37 @@ export const GeneralSettingsPageView: FC<GeneralSettingsPageViewProps> = ({
|
||||
</ChartSection>
|
||||
</div>
|
||||
)}
|
||||
{licenseUtilizationPercentage && (
|
||||
<ChartSection title="License Utilization">
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(licenseUtilizationPercentage * 100, 100)}
|
||||
color={
|
||||
licenseUtilizationPercentage < 0.9
|
||||
? "primary"
|
||||
: licenseUtilizationPercentage < 1
|
||||
? "warning"
|
||||
: "error"
|
||||
}
|
||||
css={{
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
css={{
|
||||
fontSize: "0.75rem",
|
||||
display: "block",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{Math.round(licenseUtilizationPercentage * 100)}% used (
|
||||
{entitlements!.features.user_limit.actual}/
|
||||
{entitlements!.features.user_limit.limit} users)
|
||||
</span>
|
||||
</ChartSection>
|
||||
)}
|
||||
{invalidExperiments.length > 0 && (
|
||||
<Alert severity="warning">
|
||||
<AlertTitle>Invalid experiments in use:</AlertTitle>
|
||||
|
@ -49,6 +49,73 @@ type Story = StoryObj<typeof TemplateVersionEditor>;
|
||||
|
||||
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",
|
||||
|
@ -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<TemplateVersionEditorProps> = ({
|
||||
const [deleteFileOpen, setDeleteFileOpen] = useState<string>();
|
||||
const [renameFileOpen, setRenameFileOpen] = useState<string>();
|
||||
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<TemplateVersionEditorProps> = ({
|
||||
linkToTemplate(template.organization_name, template.name),
|
||||
);
|
||||
|
||||
const gotBuildLogs = buildLogs && buildLogs.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div css={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
@ -581,31 +586,34 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
css={[styles.logs, styles.tabContent]}
|
||||
ref={logsContentRef}
|
||||
>
|
||||
{templateVersion.job.error && (
|
||||
{templateVersion.job.error ? (
|
||||
<div>
|
||||
<Alert
|
||||
<ProvisionerAlert
|
||||
title="Error during the build"
|
||||
detail={templateVersion.job.error}
|
||||
severity="error"
|
||||
css={{
|
||||
borderRadius: 0,
|
||||
border: 0,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
borderLeft: `2px solid ${theme.palette.error.main}`,
|
||||
}}
|
||||
>
|
||||
<AlertTitle>Error during the build</AlertTitle>
|
||||
<AlertDetail>{templateVersion.job.error}</AlertDetail>
|
||||
</Alert>
|
||||
tags={templateVersion.job.tags}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
!gotBuildLogs && (
|
||||
<>
|
||||
<ProvisionerStatusAlert
|
||||
matchingProvisioners={matchingProvisioners}
|
||||
availableProvisioners={availableProvisioners}
|
||||
tags={templateVersion.job.tags}
|
||||
/>
|
||||
<Loader css={{ height: "100%" }} />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{buildLogs && buildLogs.length > 0 ? (
|
||||
{gotBuildLogs && (
|
||||
<WorkspaceBuildLogs
|
||||
css={styles.buildLogs}
|
||||
hideTimestamps
|
||||
logs={buildLogs}
|
||||
/>
|
||||
) : (
|
||||
<Loader css={{ height: "100%" }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
@ -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 <Story />;
|
||||
};
|
||||
|
||||
export const withGlobalSnackbar = (Story: FC) => (
|
||||
<>
|
||||
<Story />
|
||||
|
Reference in New Issue
Block a user