From 637ef37d874ce1996e4a8b9b28d93191efcbdadd Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 10 Mar 2023 10:51:08 -0600 Subject: [PATCH 1/4] Revert "Revert "chore: Implement joins with golang templates (#6429)" (#6560)" This reverts commit 7eb2c2ff6d229619f88f41e9b2b185714a967619. --- coderd/audit/diff.go | 2 +- coderd/audit/request.go | 6 +- .../autobuild/executor/lifecycle_executor.go | 4 +- .../executor/lifecycle_executor_test.go | 7 +- coderd/database/db.go | 12 + coderd/database/dbauthz/querier.go | 86 ++-- coderd/database/dbauthz/querier_test.go | 50 +-- coderd/database/dbauthz/system.go | 4 +- coderd/database/dbauthz/system_test.go | 6 +- coderd/database/dbfake/databasefake.go | 78 ++-- coderd/database/dbgen/generator.go | 42 +- coderd/database/dbgen/generator_test.go | 4 +- coderd/database/dbgen/take.go | 7 +- coderd/database/dbtestutil/db.go | 6 +- coderd/database/modelmethods.go | 20 + coderd/database/modelqueries.go | 91 +++++ coderd/database/modelqueries_test.go | 189 +++++++++ coderd/database/querier.go | 8 - coderd/database/queries.sql.go | 374 ------------------ coderd/database/queries/workspacebuilds.sql | 107 ----- coderd/database/sqlxqueries/README.md | 35 ++ coderd/database/sqlxqueries/bindvars.go | 95 +++++ .../sqlxqueries/imgs/goland-gosql.png | Bin 0 -> 32805 bytes .../sqlxqueries/imgs/goland-user-params.png | Bin 0 -> 25338 bytes coderd/database/sqlxqueries/sqlx.go | 72 ++++ coderd/database/sqlxqueries/sqlxqueries.go | 55 +++ .../database/sqlxqueries/sqlxqueries_test.go | 15 + coderd/database/sqlxqueries/workspace.gosql | 128 ++++++ coderd/httpmw/workspacebuildparam.go | 4 +- .../provisionerdserver/provisionerdserver.go | 14 +- .../provisionerdserver_test.go | 3 +- coderd/telemetry/telemetry.go | 2 +- coderd/workspacebuilds.go | 19 +- coderd/workspaces.go | 5 +- docs/admin/audit-logs.md | 2 +- enterprise/audit/table.go | 2 + 36 files changed, 894 insertions(+), 660 deletions(-) create mode 100644 coderd/database/modelqueries_test.go create mode 100644 coderd/database/sqlxqueries/README.md create mode 100644 coderd/database/sqlxqueries/bindvars.go create mode 100644 coderd/database/sqlxqueries/imgs/goland-gosql.png create mode 100644 coderd/database/sqlxqueries/imgs/goland-user-params.png create mode 100644 coderd/database/sqlxqueries/sqlx.go create mode 100644 coderd/database/sqlxqueries/sqlxqueries.go create mode 100644 coderd/database/sqlxqueries/sqlxqueries_test.go create mode 100644 coderd/database/sqlxqueries/workspace.gosql diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 1cc6702d1b06c..26134576cdf1f 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -14,7 +14,7 @@ type Auditable interface { database.User | database.Workspace | database.GitSSHKey | - database.WorkspaceBuild | + database.WorkspaceBuildRBAC | database.AuditableGroup | database.License } diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 8aba0ffc30adf..7e7690d44b5d7 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -62,7 +62,7 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.Username case database.Workspace: return typed.Name - case database.WorkspaceBuild: + case database.WorkspaceBuildRBAC: // this isn't used return "" case database.GitSSHKey: @@ -89,7 +89,7 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.ID case database.Workspace: return typed.ID - case database.WorkspaceBuild: + case database.WorkspaceBuildRBAC: return typed.ID case database.GitSSHKey: return typed.UserID @@ -114,7 +114,7 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeUser case database.Workspace: return database.ResourceTypeWorkspace - case database.WorkspaceBuild: + case database.WorkspaceBuildRBAC: return database.ResourceTypeWorkspaceBuild case database.GitSSHKey: return database.ResourceTypeGitSshKey diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index f6b4d0db12d87..c818dcf6d4dd3 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -204,7 +204,7 @@ func isEligibleForAutoStartStop(ws database.Workspace) bool { func getNextTransition( ws database.Workspace, - priorHistory database.WorkspaceBuild, + priorHistory database.WorkspaceBuildRBAC, priorJob database.ProvisionerJob, ) ( validTransition database.WorkspaceTransition, @@ -239,7 +239,7 @@ func getNextTransition( // TODO(cian): this function duplicates most of api.postWorkspaceBuilds. Refactor. // See: https://github.com/coder/coder/issues/1401 -func build(ctx context.Context, store database.Store, workspace database.Workspace, trans database.WorkspaceTransition, priorHistory database.WorkspaceBuild, priorJob database.ProvisionerJob) error { +func build(ctx context.Context, store database.Store, workspace database.Workspace, trans database.WorkspaceTransition, priorHistory database.WorkspaceBuildRBAC, priorJob database.ProvisionerJob) error { template, err := store.GetTemplateByID(ctx, workspace.TemplateID) if err != nil { return xerrors.Errorf("get workspace template: %w", err) diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index 2548e317b69ff..7ffbecbd4c23c 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -2,17 +2,16 @@ package executor_test import ( "context" - "os" "testing" "time" - "go.uber.org/goleak" - "github.com/google/uuid" + "go.uber.org/goleak" "github.com/coder/coder/coderd/autobuild/executor" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbtestutil" "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" @@ -493,7 +492,7 @@ func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) { } func TestExecutorAutostartMultipleOK(t *testing.T) { - if os.Getenv("DB") == "" { + if !dbtestutil.UsingRealDatabase() { t.Skip(`This test only really works when using a "real" database, similar to a HA setup`) } diff --git a/coderd/database/db.go b/coderd/database/db.go index f8de976e92f72..6af2c459a9b7c 100644 --- a/coderd/database/db.go +++ b/coderd/database/db.go @@ -16,6 +16,8 @@ import ( "github.com/jmoiron/sqlx" "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database/sqlxqueries" ) // Store contains all queryable database functions. @@ -37,11 +39,21 @@ type DBTX interface { QueryRowContext(context.Context, string, ...interface{}) *sql.Row SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error + + // Extends the sqlx interface + sqlx.QueryerContext } // New creates a new database store using a SQL database connection. func New(sdb *sql.DB) Store { dbx := sqlx.NewDb(sdb, "postgres") + // Load the embedded queries. If this fails, some of our queries + // will never work. This is a fatal developer error that should never + // happen. + _, err := sqlxqueries.LoadQueries() + if err != nil { + panic(xerrors.Errorf("load queries: %w", err)) + } return &sqlQuerier{ db: dbx, sdb: dbx, diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 052863e2edc74..f0616a2810265 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -1167,25 +1167,12 @@ func (q *querier) GetWorkspaces(ctx context.Context, arg database.GetWorkspacesP return q.db.GetAuthorizedWorkspaces(ctx, arg, prep) } -func (q *querier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { - if _, err := q.GetWorkspaceByID(ctx, workspaceID); err != nil { - return database.WorkspaceBuild{}, err - } - return q.db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspaceID) +func (q *querier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuildRBAC, error) { + return fetch(q.log, q.auth, q.db.GetLatestWorkspaceBuildByWorkspaceID)(ctx, workspaceID) } -func (q *querier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceBuild, error) { - // This is not ideal as not all builds will be returned if the workspace cannot be read. - // This should probably be handled differently? Maybe join workspace builds with workspace - // ownership properties and filter on that. - for _, id := range ids { - _, err := q.GetWorkspaceByID(ctx, id) - if err != nil { - return nil, err - } - } - - return q.db.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, ids) +func (q *querier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceBuildRBAC, error) { + return fetchWithPostFilter(q.auth, q.db.GetLatestWorkspaceBuildsByWorkspaceIDs)(ctx, ids) } func (q *querier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (database.WorkspaceAgent, error) { @@ -1263,35 +1250,16 @@ func (q *querier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UU return q.db.GetWorkspaceAppsByAgentID(ctx, agentID) } -func (q *querier) GetWorkspaceBuildByID(ctx context.Context, buildID uuid.UUID) (database.WorkspaceBuild, error) { - build, err := q.db.GetWorkspaceBuildByID(ctx, buildID) - if err != nil { - return database.WorkspaceBuild{}, err - } - if _, err := q.GetWorkspaceByID(ctx, build.WorkspaceID); err != nil { - return database.WorkspaceBuild{}, err - } - return build, nil +func (q *querier) GetWorkspaceBuildByID(ctx context.Context, buildID uuid.UUID) (database.WorkspaceBuildRBAC, error) { + return fetch(q.log, q.auth, q.db.GetWorkspaceBuildByID)(ctx, buildID) } -func (q *querier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (database.WorkspaceBuild, error) { - build, err := q.db.GetWorkspaceBuildByJobID(ctx, jobID) - if err != nil { - return database.WorkspaceBuild{}, err - } - // Authorized fetch - _, err = q.GetWorkspaceByID(ctx, build.WorkspaceID) - if err != nil { - return database.WorkspaceBuild{}, err - } - return build, nil +func (q *querier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (database.WorkspaceBuildRBAC, error) { + return fetch(q.log, q.auth, q.db.GetWorkspaceBuildByJobID)(ctx, jobID) } -func (q *querier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (database.WorkspaceBuild, error) { - if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { - return database.WorkspaceBuild{}, err - } - return q.db.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, arg) +func (q *querier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (database.WorkspaceBuildRBAC, error) { + return fetch(q.log, q.auth, q.db.GetWorkspaceBuildByWorkspaceIDAndBuildNumber)(ctx, arg) } func (q *querier) GetWorkspaceBuildParameters(ctx context.Context, workspaceBuildID uuid.UUID) ([]database.WorkspaceBuildParameter, error) { @@ -1305,11 +1273,20 @@ func (q *querier) GetWorkspaceBuildParameters(ctx context.Context, workspaceBuil return q.db.GetWorkspaceBuildParameters(ctx, workspaceBuildID) } -func (q *querier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg database.GetWorkspaceBuildsByWorkspaceIDParams) ([]database.WorkspaceBuild, error) { - if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { +func (q *querier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg database.GetWorkspaceBuildsByWorkspaceIDParams) ([]database.WorkspaceBuildRBAC, error) { + builds, err := q.db.GetWorkspaceBuildsByWorkspaceID(ctx, arg) + if err != nil { + return nil, err + } + if len(builds) == 0 { + return []database.WorkspaceBuildRBAC{}, nil + } + // All builds come from the same workspace, so we only need to check the first one. + err = q.authorizeContext(ctx, rbac.ActionRead, builds[0]) + if err != nil { return nil, err } - return q.db.GetWorkspaceBuildsByWorkspaceID(ctx, arg) + return builds, nil } func (q *querier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUID) (database.Workspace, error) { @@ -1369,11 +1346,7 @@ func (q *querier) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.U if err != nil { return nil, err } - workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) - if err != nil { - return nil, err - } - obj = workspace + obj = build default: return nil, xerrors.Errorf("unknown job type: %s", job.Type) } @@ -1414,12 +1387,7 @@ func (q *querier) InsertWorkspaceBuildParameters(ctx context.Context, arg databa return err } - workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) - if err != nil { - return err - } - - err = q.authorizeContext(ctx, rbac.ActionUpdate, workspace) + err = q.authorizeContext(ctx, rbac.ActionUpdate, build) if err != nil { return err } @@ -1483,11 +1451,7 @@ func (q *querier) UpdateWorkspaceBuildByID(ctx context.Context, arg database.Upd return database.WorkspaceBuild{}, err } - workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) - if err != nil { - return database.WorkspaceBuild{}, err - } - err = q.authorizeContext(ctx, rbac.ActionUpdate, workspace.RBACObject()) + err = q.authorizeContext(ctx, rbac.ActionUpdate, build) if err != nil { return database.WorkspaceBuild{}, err } diff --git a/coderd/database/dbauthz/querier_test.go b/coderd/database/dbauthz/querier_test.go index d89a93319d9e0..d8be79596a6a4 100644 --- a/coderd/database/dbauthz/querier_test.go +++ b/coderd/database/dbauthz/querier_test.go @@ -956,16 +956,16 @@ func (s *MethodTestSuite) TestWorkspace() { s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID}) - check.Args(ws.ID).Asserts(ws, rbac.ActionRead).Returns(b) + check.Args(ws.ID).Asserts(ws, rbac.ActionRead).Returns(b.WithWorkspace(ws)) })) s.Run("GetLatestWorkspaceBuildsByWorkspaceIDs", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID}) + b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID}).WithWorkspace(ws) check.Args([]uuid.UUID{ws.ID}).Asserts(ws, rbac.ActionRead).Returns(slice.New(b)) })) s.Run("GetWorkspaceAgentByID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}).WithWorkspace(ws) res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) check.Args(agt.ID).Asserts(ws, rbac.ActionRead).Returns(agt) @@ -979,7 +979,7 @@ func (s *MethodTestSuite) TestWorkspace() { })) s.Run("GetWorkspaceAgentsByResourceIDs", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}).WithWorkspace(ws) res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) check.Args([]uuid.UUID{res.ID}).Asserts( /*ws, rbac.ActionRead*/ ). @@ -987,7 +987,7 @@ func (s *MethodTestSuite) TestWorkspace() { })) s.Run("UpdateWorkspaceAgentLifecycleStateByID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}).WithWorkspace(ws) res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) check.Args(database.UpdateWorkspaceAgentLifecycleStateByIDParams{ @@ -997,7 +997,7 @@ func (s *MethodTestSuite) TestWorkspace() { })) s.Run("UpdateWorkspaceAgentStartupByID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}).WithWorkspace(ws) res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) check.Args(database.UpdateWorkspaceAgentStartupByIDParams{ @@ -1006,7 +1006,7 @@ func (s *MethodTestSuite) TestWorkspace() { })) s.Run("GetWorkspaceAppByAgentIDAndSlug", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}).WithWorkspace(ws) res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) app := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agt.ID}) @@ -1018,7 +1018,7 @@ func (s *MethodTestSuite) TestWorkspace() { })) s.Run("GetWorkspaceAppsByAgentID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}).WithWorkspace(ws) res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) a := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agt.ID}) @@ -1028,13 +1028,13 @@ func (s *MethodTestSuite) TestWorkspace() { })) s.Run("GetWorkspaceAppsByAgentIDs", s.Subtest(func(db database.Store, check *expects) { aWs := dbgen.Workspace(s.T(), db, database.Workspace{}) - aBuild := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: aWs.ID, JobID: uuid.New()}) + aBuild := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: aWs.ID, JobID: uuid.New()}).WithWorkspace(aWs) aRes := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: aBuild.JobID}) aAgt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: aRes.ID}) a := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: aAgt.ID}) bWs := dbgen.Workspace(s.T(), db, database.Workspace{}) - bBuild := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: bWs.ID, JobID: uuid.New()}) + bBuild := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: bWs.ID, JobID: uuid.New()}).WithWorkspace(bWs) bRes := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: bBuild.JobID}) bAgt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: bRes.ID}) b := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: bAgt.ID}) @@ -1045,17 +1045,17 @@ func (s *MethodTestSuite) TestWorkspace() { })) s.Run("GetWorkspaceBuildByID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID}).WithWorkspace(ws) check.Args(build.ID).Asserts(ws, rbac.ActionRead).Returns(build) })) s.Run("GetWorkspaceBuildByJobID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID}).WithWorkspace(ws) check.Args(build.JobID).Asserts(ws, rbac.ActionRead).Returns(build) })) s.Run("GetWorkspaceBuildByWorkspaceIDAndBuildNumber", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, BuildNumber: 10}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, BuildNumber: 10}).WithWorkspace(ws) check.Args(database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{ WorkspaceID: ws.ID, BuildNumber: build.BuildNumber, @@ -1069,14 +1069,14 @@ func (s *MethodTestSuite) TestWorkspace() { })) s.Run("GetWorkspaceBuildsByWorkspaceID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, BuildNumber: 1}) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, BuildNumber: 2}) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, BuildNumber: 3}) + _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, BuildNumber: 1}).WithWorkspace(ws) + _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, BuildNumber: 2}).WithWorkspace(ws) + _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, BuildNumber: 3}).WithWorkspace(ws) check.Args(database.GetWorkspaceBuildsByWorkspaceIDParams{WorkspaceID: ws.ID}).Asserts(ws, rbac.ActionRead) // ordering })) s.Run("GetWorkspaceByAgentID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}).WithWorkspace(ws) res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) check.Args(agt.ID).Asserts(ws, rbac.ActionRead).Returns(ws) @@ -1091,14 +1091,14 @@ func (s *MethodTestSuite) TestWorkspace() { })) s.Run("GetWorkspaceResourceByID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}).WithWorkspace(ws) _ = dbgen.ProvisionerJob(s.T(), db, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) check.Args(res.ID).Asserts(ws, rbac.ActionRead).Returns(res) })) s.Run("GetWorkspaceResourceMetadataByResourceIDs", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}).WithWorkspace(ws) _ = dbgen.ProvisionerJob(s.T(), db, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) a := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) b := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) @@ -1107,7 +1107,7 @@ func (s *MethodTestSuite) TestWorkspace() { })) s.Run("Build/GetWorkspaceResourcesByJobID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}).WithWorkspace(ws) job := dbgen.ProvisionerJob(s.T(), db, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) check.Args(job.ID).Asserts(ws, rbac.ActionRead).Returns([]database.WorkspaceResource{}) })) @@ -1123,7 +1123,7 @@ func (s *MethodTestSuite) TestWorkspace() { tJob := dbgen.ProvisionerJob(s.T(), db, database.ProvisionerJob{ID: v.JobID, Type: database.ProvisionerJobTypeTemplateVersionImport}) ws := dbgen.Workspace(s.T(), db, database.Workspace{}) - build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}).WithWorkspace(ws) wJob := dbgen.ProvisionerJob(s.T(), db, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) check.Args([]uuid.UUID{tJob.ID, wJob.ID}). Asserts( /*v.RBACObject(tpl), rbac.ActionRead, ws, rbac.ActionRead*/ ). @@ -1207,9 +1207,11 @@ func (s *MethodTestSuite) TestWorkspace() { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) check.Args(database.UpdateWorkspaceBuildByIDParams{ - ID: build.ID, - UpdatedAt: build.UpdatedAt, - Deadline: build.Deadline, + ID: build.ID, + UpdatedAt: build.UpdatedAt, + Deadline: build.Deadline, + ProvisionerState: build.ProvisionerState, + MaxDeadline: build.MaxDeadline, }).Asserts(ws, rbac.ActionUpdate).Returns(build) })) s.Run("SoftDeleteWorkspaceByID", s.Subtest(func(db database.Store, check *expects) { diff --git a/coderd/database/dbauthz/system.go b/coderd/database/dbauthz/system.go index e83b0b0771f9d..55176abfb7eac 100644 --- a/coderd/database/dbauthz/system.go +++ b/coderd/database/dbauthz/system.go @@ -76,7 +76,7 @@ func (q *querier) GetUserLinkByUserIDLoginType(ctx context.Context, arg database return q.db.GetUserLinkByUserIDLoginType(ctx, arg) } -func (q *querier) GetLatestWorkspaceBuilds(ctx context.Context) ([]database.WorkspaceBuild, error) { +func (q *querier) GetLatestWorkspaceBuilds(ctx context.Context) ([]database.WorkspaceBuildRBAC, error) { // This function is a system function until we implement a join for workspace builds. // This is because we need to query for all related workspaces to the returned builds. // This is a very inefficient method of fetching the latest workspace builds. @@ -177,7 +177,7 @@ func (q *querier) GetLastUpdateCheck(ctx context.Context) (string, error) { // Telemetry related functions. These functions are system functions for returning // telemetry data. Never called by a user. -func (q *querier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]database.WorkspaceBuild, error) { +func (q *querier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]database.WorkspaceBuildRBAC, error) { return q.db.GetWorkspaceBuildsCreatedAfter(ctx, createdAt) } diff --git a/coderd/database/dbauthz/system_test.go b/coderd/database/dbauthz/system_test.go index d64727f1b6227..9965ae73a26c1 100644 --- a/coderd/database/dbauthz/system_test.go +++ b/coderd/database/dbauthz/system_test.go @@ -34,8 +34,8 @@ func (s *MethodTestSuite) TestSystemFunctions() { }).Asserts().Returns(l) })) s.Run("GetLatestWorkspaceBuilds", s.Subtest(func(db database.Store, check *expects) { - dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{}) - dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{}) + dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuildRBAC{}) + dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuildRBAC{}) check.Args().Asserts() })) s.Run("GetWorkspaceAgentByAuthToken", s.Subtest(func(db database.Store, check *expects) { @@ -92,7 +92,7 @@ func (s *MethodTestSuite) TestSystemFunctions() { check.Args().Asserts() })) s.Run("UpdateWorkspaceBuildCostByID", s.Subtest(func(db database.Store, check *expects) { - b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{}) + b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuildRBAC{}) o := b o.DailyCost = 10 check.Args(database.UpdateWorkspaceBuildCostByIDParams{ diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 8a8eb652ddc7e..3273d27bf97ac 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -1452,66 +1452,66 @@ func (q *fakeQuerier) GetWorkspaceAppsByAgentIDs(_ context.Context, ids []uuid.U return apps, nil } -func (q *fakeQuerier) GetWorkspaceBuildByID(_ context.Context, id uuid.UUID) (database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetWorkspaceBuildByID(_ context.Context, id uuid.UUID) (database.WorkspaceBuildRBAC, error) { q.mutex.RLock() defer q.mutex.RUnlock() for _, history := range q.workspaceBuilds { if history.ID == id { - return history, nil + return q.expandWorkspaceThin(history), nil } } - return database.WorkspaceBuild{}, sql.ErrNoRows + return database.WorkspaceBuildRBAC{}, sql.ErrNoRows } -func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUID) (database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUID) (database.WorkspaceBuildRBAC, error) { q.mutex.RLock() defer q.mutex.RUnlock() for _, build := range q.workspaceBuilds { if build.JobID == jobID { - return build, nil + return q.expandWorkspaceThin(build), nil } } - return database.WorkspaceBuild{}, sql.ErrNoRows + return database.WorkspaceBuildRBAC{}, sql.ErrNoRows } -func (q *fakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuildRBAC, error) { q.mutex.RLock() defer q.mutex.RUnlock() return q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspaceID) } -func (q *fakeQuerier) getLatestWorkspaceBuildByWorkspaceIDNoLock(_ context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { - var row database.WorkspaceBuild +func (q *fakeQuerier) getLatestWorkspaceBuildByWorkspaceIDNoLock(_ context.Context, workspaceID uuid.UUID) (database.WorkspaceBuildRBAC, error) { + var row database.WorkspaceBuildRBAC var buildNum int32 = -1 for _, workspaceBuild := range q.workspaceBuilds { if workspaceBuild.WorkspaceID == workspaceID && workspaceBuild.BuildNumber > buildNum { - row = workspaceBuild + row = q.expandWorkspaceThin(workspaceBuild) buildNum = workspaceBuild.BuildNumber } } if buildNum == -1 { - return database.WorkspaceBuild{}, sql.ErrNoRows + return database.WorkspaceBuildRBAC{}, sql.ErrNoRows } return row, nil } -func (q *fakeQuerier) GetLatestWorkspaceBuilds(_ context.Context) ([]database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetLatestWorkspaceBuilds(_ context.Context) ([]database.WorkspaceBuildRBAC, error) { q.mutex.RLock() defer q.mutex.RUnlock() - builds := make(map[uuid.UUID]database.WorkspaceBuild) + builds := make(map[uuid.UUID]database.WorkspaceBuildRBAC) buildNumbers := make(map[uuid.UUID]int32) for _, workspaceBuild := range q.workspaceBuilds { id := workspaceBuild.WorkspaceID if workspaceBuild.BuildNumber > buildNumbers[id] { - builds[id] = workspaceBuild + builds[id] = q.expandWorkspaceThin(workspaceBuild) buildNumbers[id] = workspaceBuild.BuildNumber } } - var returnBuilds []database.WorkspaceBuild + var returnBuilds []database.WorkspaceBuildRBAC for i, n := range buildNumbers { if n > 0 { b := builds[i] @@ -1524,21 +1524,21 @@ func (q *fakeQuerier) GetLatestWorkspaceBuilds(_ context.Context) ([]database.Wo return returnBuilds, nil } -func (q *fakeQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceBuildRBAC, error) { q.mutex.RLock() defer q.mutex.RUnlock() - builds := make(map[uuid.UUID]database.WorkspaceBuild) + builds := make(map[uuid.UUID]database.WorkspaceBuildRBAC) buildNumbers := make(map[uuid.UUID]int32) for _, workspaceBuild := range q.workspaceBuilds { for _, id := range ids { if id == workspaceBuild.WorkspaceID && workspaceBuild.BuildNumber > buildNumbers[id] { - builds[id] = workspaceBuild + builds[id] = q.expandWorkspaceThin(workspaceBuild) buildNumbers[id] = workspaceBuild.BuildNumber } } } - var returnBuilds []database.WorkspaceBuild + var returnBuilds []database.WorkspaceBuildRBAC for i, n := range buildNumbers { if n > 0 { b := builds[i] @@ -1553,7 +1553,7 @@ func (q *fakeQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(_ context.Context, func (q *fakeQuerier) GetWorkspaceBuildsByWorkspaceID(_ context.Context, params database.GetWorkspaceBuildsByWorkspaceIDParams, -) ([]database.WorkspaceBuild, error) { +) ([]database.WorkspaceBuildRBAC, error) { if err := validateDatabaseType(params); err != nil { return nil, err } @@ -1561,18 +1561,18 @@ func (q *fakeQuerier) GetWorkspaceBuildsByWorkspaceID(_ context.Context, q.mutex.RLock() defer q.mutex.RUnlock() - history := make([]database.WorkspaceBuild, 0) + history := make([]database.WorkspaceBuildRBAC, 0) for _, workspaceBuild := range q.workspaceBuilds { if workspaceBuild.CreatedAt.Before(params.Since) { continue } if workspaceBuild.WorkspaceID == params.WorkspaceID { - history = append(history, workspaceBuild) + history = append(history, q.expandWorkspaceThin(workspaceBuild)) } } // Order by build_number - slices.SortFunc(history, func(a, b database.WorkspaceBuild) bool { + slices.SortFunc(history, func(a, b database.WorkspaceBuildRBAC) bool { // use greater than since we want descending order return a.BuildNumber > b.BuildNumber }) @@ -1614,9 +1614,9 @@ func (q *fakeQuerier) GetWorkspaceBuildsByWorkspaceID(_ context.Context, return history, nil } -func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(_ context.Context, arg database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(_ context.Context, arg database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (database.WorkspaceBuildRBAC, error) { if err := validateDatabaseType(arg); err != nil { - return database.WorkspaceBuild{}, err + return database.WorkspaceBuildRBAC{}, err } q.mutex.RLock() @@ -1629,9 +1629,9 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(_ context.Con if workspaceBuild.BuildNumber != arg.BuildNumber { continue } - return workspaceBuild, nil + return q.expandWorkspaceThin(workspaceBuild), nil } - return database.WorkspaceBuild{}, sql.ErrNoRows + return database.WorkspaceBuildRBAC{}, sql.ErrNoRows } func (q *fakeQuerier) GetWorkspaceBuildParameters(_ context.Context, workspaceBuildID uuid.UUID) ([]database.WorkspaceBuildParameter, error) { @@ -1648,14 +1648,14 @@ func (q *fakeQuerier) GetWorkspaceBuildParameters(_ context.Context, workspaceBu return params, nil } -func (q *fakeQuerier) GetWorkspaceBuildsCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetWorkspaceBuildsCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceBuildRBAC, error) { q.mutex.RLock() defer q.mutex.RUnlock() - workspaceBuilds := make([]database.WorkspaceBuild, 0) + workspaceBuilds := make([]database.WorkspaceBuildRBAC, 0) for _, workspaceBuild := range q.workspaceBuilds { if workspaceBuild.CreatedAt.After(after) { - workspaceBuilds = append(workspaceBuilds, workspaceBuild) + workspaceBuilds = append(workspaceBuilds, q.expandWorkspaceThin(workspaceBuild)) } } return workspaceBuilds, nil @@ -3250,6 +3250,7 @@ func (q *fakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.Inser JobID: arg.JobID, ProvisionerState: arg.ProvisionerState, Deadline: arg.Deadline, + MaxDeadline: arg.MaxDeadline, Reason: arg.Reason, } q.workspaceBuilds = append(q.workspaceBuilds, workspaceBuild) @@ -4627,13 +4628,13 @@ func (q *fakeQuerier) GetQuotaConsumedForUser(_ context.Context, userID uuid.UUI continue } - var lastBuild database.WorkspaceBuild + var lastBuild database.WorkspaceBuildRBAC for _, build := range q.workspaceBuilds { if build.WorkspaceID != workspace.ID { continue } if build.CreatedAt.After(lastBuild.CreatedAt) { - lastBuild = build + lastBuild = q.expandWorkspaceThin(build) } } sum += int64(lastBuild.DailyCost) @@ -4657,3 +4658,16 @@ func (q *fakeQuerier) UpdateWorkspaceAgentLifecycleStateByID(_ context.Context, } return sql.ErrNoRows } + +// expandWorkspaceThin must be called from a locked context. +func (q *fakeQuerier) expandWorkspaceThin(thin database.WorkspaceBuild) database.WorkspaceBuildRBAC { + for _, workspace := range q.workspaces { + if workspace.ID == thin.WorkspaceID { + return thin.WithWorkspace(workspace) + } + } + + return database.WorkspaceBuildRBAC{ + WorkspaceBuild: thin, + } +} diff --git a/coderd/database/dbgen/generator.go b/coderd/database/dbgen/generator.go index 42dbb599dadcb..1c7105623aaca 100644 --- a/coderd/database/dbgen/generator.go +++ b/coderd/database/dbgen/generator.go @@ -153,20 +153,34 @@ func Workspace(t testing.TB, db database.Store, orig database.Workspace) databas return workspace } -func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuild) database.WorkspaceBuild { +type AnyWorkspaceBuild interface { + database.WorkspaceBuildRBAC | database.WorkspaceBuild +} + +func WorkspaceBuild[B AnyWorkspaceBuild](t testing.TB, db database.Store, orig B) database.WorkspaceBuild { + var thin database.WorkspaceBuild + switch v := any(orig).(type) { + case database.WorkspaceBuildRBAC: + thin = v.WorkspaceBuild + case database.WorkspaceBuild: + thin = v + default: + panic(fmt.Sprintf("developer error: invalid type %T", v)) + } build, err := db.InsertWorkspaceBuild(context.Background(), database.InsertWorkspaceBuildParams{ - ID: takeFirst(orig.ID, uuid.New()), - CreatedAt: takeFirst(orig.CreatedAt, database.Now()), - UpdatedAt: takeFirst(orig.UpdatedAt, database.Now()), - WorkspaceID: takeFirst(orig.WorkspaceID, uuid.New()), - TemplateVersionID: takeFirst(orig.TemplateVersionID, uuid.New()), - BuildNumber: takeFirst(orig.BuildNumber, 1), - Transition: takeFirst(orig.Transition, database.WorkspaceTransitionStart), - InitiatorID: takeFirst(orig.InitiatorID, uuid.New()), - JobID: takeFirst(orig.JobID, uuid.New()), - ProvisionerState: takeFirstSlice(orig.ProvisionerState, []byte{}), - Deadline: takeFirst(orig.Deadline, database.Now().Add(time.Hour)), - Reason: takeFirst(orig.Reason, database.BuildReasonInitiator), + ID: takeFirst(thin.ID, uuid.New()), + CreatedAt: takeFirst(thin.CreatedAt, database.Now()), + UpdatedAt: takeFirst(thin.UpdatedAt, database.Now()), + WorkspaceID: takeFirst(thin.WorkspaceID, uuid.New()), + TemplateVersionID: takeFirst(thin.TemplateVersionID, uuid.New()), + BuildNumber: takeFirst(thin.BuildNumber, 1), + Transition: takeFirst(thin.Transition, database.WorkspaceTransitionStart), + InitiatorID: takeFirst(thin.InitiatorID, uuid.New()), + JobID: takeFirst(thin.JobID, uuid.New()), + ProvisionerState: takeFirstSlice(thin.ProvisionerState, []byte{}), + Deadline: takeFirst(thin.Deadline, database.Now().Add(time.Hour)), + MaxDeadline: takeFirst(thin.MaxDeadline, database.Now().Add(time.Hour*24*7)), + Reason: takeFirst(thin.Reason, database.BuildReasonInitiator), }) require.NoError(t, err, "insert workspace build") return build @@ -219,7 +233,7 @@ func OrganizationMember(t testing.TB, db database.Store, orig database.Organizat UpdatedAt: takeFirst(orig.UpdatedAt, database.Now()), Roles: takeFirstSlice(orig.Roles, []string{}), }) - require.NoError(t, err, "insert organization") + require.NoError(t, err, "insert organization member") return mem } diff --git a/coderd/database/dbgen/generator_test.go b/coderd/database/dbgen/generator_test.go index c09cc6df8a466..8fc5ed3976c1b 100644 --- a/coderd/database/dbgen/generator_test.go +++ b/coderd/database/dbgen/generator_test.go @@ -166,8 +166,8 @@ func TestGenerator(t *testing.T) { t.Run("WorkspaceBuild", func(t *testing.T) { t.Parallel() db := dbfake.New() - exp := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{}) - require.Equal(t, exp, must(db.GetWorkspaceBuildByID(context.Background(), exp.ID))) + exp := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuildRBAC{}) + require.Equal(t, exp, must(db.GetWorkspaceBuildByID(context.Background(), exp.ID)).WorkspaceBuild) }) t.Run("User", func(t *testing.T) { diff --git a/coderd/database/dbgen/take.go b/coderd/database/dbgen/take.go index caa4a1b4036db..1f0c2ae6a5f46 100644 --- a/coderd/database/dbgen/take.go +++ b/coderd/database/dbgen/take.go @@ -13,9 +13,14 @@ func takeFirstIP(values ...net.IPNet) net.IPNet { // takeFirstSlice implements takeFirst for []any. // []any is not a comparable type. func takeFirstSlice[T any](values ...[]T) []T { - return takeFirstF(values, func(v []T) bool { + out := takeFirstF(values, func(v []T) bool { return len(v) != 0 }) + // Prevent nil slices + if out == nil { + return []T{} + } + return out } // takeFirstF takes the first value that returns true diff --git a/coderd/database/dbtestutil/db.go b/coderd/database/dbtestutil/db.go index d6c2f106b2619..f048c847663bb 100644 --- a/coderd/database/dbtestutil/db.go +++ b/coderd/database/dbtestutil/db.go @@ -13,12 +13,16 @@ import ( "github.com/coder/coder/coderd/database/postgres" ) +func UsingRealDatabase() bool { + return os.Getenv("DB") != "" +} + func NewDB(t *testing.T) (database.Store, database.Pubsub) { t.Helper() db := dbfake.New() pubsub := database.NewPubsubInMemory() - if os.Getenv("DB") != "" { + if UsingRealDatabase() { connectionURL := os.Getenv("CODER_PG_CONNECTION_URL") if connectionURL == "" { var ( diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 9a5051bd7d5ac..bbc0b0815e328 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -4,6 +4,8 @@ import ( "sort" "strconv" + "github.com/google/uuid" + "github.com/coder/coder/coderd/rbac" ) @@ -101,6 +103,12 @@ func (g Group) RBACObject() rbac.Object { InOrg(g.OrganizationID) } +func (b WorkspaceBuildRBAC) RBACObject() rbac.Object { + return rbac.ResourceWorkspace.WithID(b.WorkspaceID). + InOrg(b.OrganizationID). + WithOwner(b.WorkspaceOwnerID.String()) +} + func (w Workspace) RBACObject() rbac.Object { return rbac.ResourceWorkspace.WithID(w.ID). InOrg(w.OrganizationID). @@ -183,6 +191,18 @@ func (l License) RBACObject() rbac.Object { return rbac.ResourceLicense.WithIDString(strconv.FormatInt(int64(l.ID), 10)) } +func (b WorkspaceBuild) WithWorkspace(workspace Workspace) WorkspaceBuildRBAC { + return b.Expand(workspace.OrganizationID, workspace.OwnerID) +} + +func (b WorkspaceBuild) Expand(orgID, ownerID uuid.UUID) WorkspaceBuildRBAC { + return WorkspaceBuildRBAC{ + WorkspaceBuild: b, + OrganizationID: orgID, + WorkspaceOwnerID: ownerID, + } +} + func ConvertUserRows(rows []GetUsersRow) []User { users := make([]User, len(rows)) for i, r := range rows { diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index a9bdfca75d46f..3ce2526fa029e 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "strings" + "time" "github.com/google/uuid" "github.com/lib/pq" "golang.org/x/xerrors" + "github.com/coder/coder/coderd/database/sqlxqueries" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/rbac/regosql" ) @@ -178,6 +180,95 @@ func (q *sqlQuerier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([ type workspaceQuerier interface { GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]GetWorkspacesRow, error) + GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuildRBAC, error) + GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuildRBAC, error) + GetWorkspaceBuildsCreatedAfter(ctx context.Context, after time.Time) ([]WorkspaceBuildRBAC, error) + GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (WorkspaceBuildRBAC, error) + GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildsByWorkspaceIDParams) ([]WorkspaceBuildRBAC, error) + GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuildRBAC, error) + GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuildRBAC, error) + GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspacedID uuid.UUID) (WorkspaceBuildRBAC, error) +} + +// WorkspaceBuildRBAC extends WorkspaceBuild with fields that are used for RBAC. +// This allows WorkspaceBuild to be used in Authorize() calls. +type WorkspaceBuildRBAC struct { + WorkspaceBuild + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` +} + +type getWorkspaceBuildParams struct { + BuildID uuid.UUID `db:"build_id"` + JobID uuid.UUID `db:"job_id"` + CreatedAfter time.Time `db:"created_after"` + WorkspaceID uuid.UUID `db:"workspace_id"` + BuildNumber int32 `db:"build_number"` + LimitOpt int32 `db:"limit_opt"` + Latest bool `db:"-"` +} + +func (q *sqlQuerier) getWorkspaceBuild(ctx context.Context, arg getWorkspaceBuildParams) (WorkspaceBuildRBAC, error) { + var res WorkspaceBuildRBAC + arg.LimitOpt = 1 + return res, sqlxqueries.GetContext(ctx, q.db, "GetWorkspaceBuild", arg, &res) +} + +func (q *sqlQuerier) selectWorkspaceBuild(ctx context.Context, arg getWorkspaceBuildParams) ([]WorkspaceBuildRBAC, error) { + var res []WorkspaceBuildRBAC + arg.LimitOpt = -1 + return res, sqlxqueries.SelectContext(ctx, q.db, "GetWorkspaceBuild", arg, &res) +} + +func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuildRBAC, error) { + return q.getWorkspaceBuild(ctx, getWorkspaceBuildParams{BuildID: id}) +} + +func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuildRBAC, error) { + return q.getWorkspaceBuild(ctx, getWorkspaceBuildParams{JobID: jobID}) +} + +func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, after time.Time) ([]WorkspaceBuildRBAC, error) { + return q.selectWorkspaceBuild(ctx, getWorkspaceBuildParams{CreatedAfter: after}) +} + +type GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams struct { + BuildNumber int32 + WorkspaceID uuid.UUID +} + +func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (WorkspaceBuildRBAC, error) { + return q.getWorkspaceBuild(ctx, getWorkspaceBuildParams{ + BuildNumber: arg.BuildNumber, + WorkspaceID: arg.WorkspaceID, + }) +} + +type GetWorkspaceBuildsByWorkspaceIDParams struct { + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + Since time.Time `db:"since" json:"since"` + AfterID uuid.UUID `db:"after_id" json:"after_id"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildsByWorkspaceIDParams) ([]WorkspaceBuildRBAC, error) { + var res []WorkspaceBuildRBAC + return res, sqlxqueries.SelectContext(ctx, q.db, "GetWorkspaceBuildsByWorkspaceID", arg, &res) +} + +func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspacedID uuid.UUID) (WorkspaceBuildRBAC, error) { + return q.getWorkspaceBuild(ctx, getWorkspaceBuildParams{WorkspaceID: workspacedID, Latest: true}) +} + +func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuildRBAC, error) { + var res []WorkspaceBuildRBAC + return res, sqlxqueries.SelectContext(ctx, q.db, "GetLatestWorkspaceBuildsByWorkspaceIDs", ids, &res) +} + +func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuildRBAC, error) { + var res []WorkspaceBuildRBAC + return res, sqlxqueries.SelectContext(ctx, q.db, "GetLatestWorkspaceBuilds", nil, &res) } // GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access. diff --git a/coderd/database/modelqueries_test.go b/coderd/database/modelqueries_test.go new file mode 100644 index 0000000000000..5e85ec984cac2 --- /dev/null +++ b/coderd/database/modelqueries_test.go @@ -0,0 +1,189 @@ +package database_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbgen" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/coderd/rbac" +) + +func TestGetWorkspaceBuild(t *testing.T) { + t.Parallel() + if !dbtestutil.UsingRealDatabase() { + t.Skip("Test only runs against a real database") + } + + db, _ := dbtestutil.NewDB(t) + + // Seed the database with some workspace builds. + var ( + now = database.Now() + org = dbgen.Organization(t, db, database.Organization{}) + user = dbgen.User(t, db, database.User{ + RBACRoles: []string{rbac.RoleOwner()}, + }) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + template = dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + version = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + workspace = dbgen.Workspace(t, db, database.Workspace{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: template.ID, + }) + jobs = []database.ProvisionerJob{ + dbgen.ProvisionerJob(t, db, database.ProvisionerJob{ + OrganizationID: org.ID, + InitiatorID: user.ID, + }), + dbgen.ProvisionerJob(t, db, database.ProvisionerJob{ + OrganizationID: org.ID, + InitiatorID: user.ID, + }), + } + builds = []database.WorkspaceBuild{ + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + TemplateVersionID: version.ID, + BuildNumber: 1, + Transition: database.WorkspaceTransitionStart, + InitiatorID: user.ID, + JobID: jobs[0].ID, + Reason: database.BuildReasonInitiator, + CreatedAt: now, + }), + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + TemplateVersionID: version.ID, + BuildNumber: 2, + Transition: database.WorkspaceTransitionStart, + InitiatorID: user.ID, + JobID: jobs[1].ID, + Reason: database.BuildReasonInitiator, + CreatedAt: now.Add(time.Hour), + }), + } + orderBuilds = []database.WorkspaceBuild{ + builds[1], + builds[0], + } + ctx = context.Background() + ) + + t.Run("GetWorkspaceBuildByID", func(t *testing.T) { + t.Parallel() + for _, expected := range builds { + build, err := db.GetWorkspaceBuildByID(ctx, expected.ID) + if err != nil { + t.Fatal(err) + } + require.Equal(t, expected, build.WorkspaceBuild, "builds should be equal") + } + }) + + t.Run("GetWorkspaceBuildByJobID", func(t *testing.T) { + t.Parallel() + for i, job := range jobs { + build, err := db.GetWorkspaceBuildByJobID(ctx, job.ID) + if err != nil { + t.Fatal(err) + } + expected := builds[i] + require.Equal(t, expected, build.WorkspaceBuild, "builds should be equal") + } + }) + + t.Run("GetWorkspaceBuildsCreatedAfter", func(t *testing.T) { + t.Parallel() + found, err := db.GetWorkspaceBuildsCreatedAfter(ctx, builds[0].CreatedAt.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + expected := builds[1] + require.Len(t, found, 1, "should only be one build") + require.Equal(t, expected, found[0].WorkspaceBuild, "builds should be equal") + }) + + t.Run("GetWorkspaceBuildByWorkspaceIDAndBuildNumber", func(t *testing.T) { + t.Parallel() + for _, expected := range builds { + build, err := db.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{ + BuildNumber: expected.BuildNumber, + WorkspaceID: expected.WorkspaceID, + }) + if err != nil { + t.Fatal(err) + } + require.Equal(t, expected, build.WorkspaceBuild, "builds should be equal") + } + }) + + t.Run("GetWorkspaceBuildsByWorkspaceID", func(t *testing.T) { + t.Parallel() + found, err := db.GetWorkspaceBuildsByWorkspaceID(ctx, database.GetWorkspaceBuildsByWorkspaceIDParams{ + WorkspaceID: workspace.ID, + Since: builds[0].CreatedAt.Add(-1 * time.Hour), + }) + if err != nil { + t.Fatal(err) + } + require.Len(t, found, 2, "should be two builds") + require.Equal(t, orderBuilds, toThins(found), "builds should be equal") + }) + + t.Run("GetLatestWorkspaceBuildsByWorkspaceIDs", func(t *testing.T) { + t.Parallel() + found, err := db.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, []uuid.UUID{workspace.ID}) + if err != nil { + t.Fatal(err) + } + require.Len(t, found, 1, "should be only one build") + require.Equal(t, builds[1], found[0].WorkspaceBuild, "builds should be equal") + }) + + t.Run("GetLatestWorkspaceBuilds", func(t *testing.T) { + t.Parallel() + found, err := db.GetLatestWorkspaceBuilds(ctx) + if err != nil { + t.Fatal(err) + } + require.Len(t, found, 1, "should be only 1 build") + require.Equal(t, builds[1], found[0].WorkspaceBuild, "builds should be equal") + }) + + t.Run("GetLatestWorkspaceBuildByWorkspaceID", func(t *testing.T) { + t.Parallel() + found, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + t.Fatal(err) + } + require.Equal(t, builds[1], found.WorkspaceBuild, "builds should be equal") + }) +} + +func toThins(builds []database.WorkspaceBuildRBAC) []database.WorkspaceBuild { + thins := make([]database.WorkspaceBuild, len(builds)) + for i, build := range builds { + thins[i] = build.WorkspaceBuild + } + return thins +} diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 14a0f2d7053f2..1dddd7dd26146 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -66,9 +66,6 @@ type sqlcQuerier interface { GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error) GetLastUpdateCheck(ctx context.Context) (string, error) - GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) - GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) - GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) GetLicenseByID(ctx context.Context, id int32) (License, error) GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) @@ -127,12 +124,7 @@ type sqlcQuerier interface { GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) - GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) - GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) - GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (WorkspaceBuild, error) GetWorkspaceBuildParameters(ctx context.Context, workspaceBuildID uuid.UUID) ([]WorkspaceBuildParameter, error) - GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildsByWorkspaceIDParams) ([]WorkspaceBuild, error) - GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUID) (Workspace, error) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b13551978fad6..e3a5006b066f2 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6033,380 +6033,6 @@ func (q *sqlQuerier) InsertWorkspaceBuildParameters(ctx context.Context, arg Ins return err } -const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one -SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline -FROM - workspace_builds -WHERE - workspace_id = $1 -ORDER BY - build_number desc -LIMIT - 1 -` - -func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getLatestWorkspaceBuildByWorkspaceID, workspaceID) - var i WorkspaceBuild - err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.WorkspaceID, - &i.TemplateVersionID, - &i.BuildNumber, - &i.Transition, - &i.InitiatorID, - &i.ProvisionerState, - &i.JobID, - &i.Deadline, - &i.Reason, - &i.DailyCost, - &i.MaxDeadline, - ) - return i, err -} - -const getLatestWorkspaceBuilds = `-- name: GetLatestWorkspaceBuilds :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_builds - GROUP BY - workspace_id -) m -JOIN - workspace_builds wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number -` - -func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) { - rows, err := q.db.QueryContext(ctx, getLatestWorkspaceBuilds) - if err != nil { - return nil, err - } - defer rows.Close() - var items []WorkspaceBuild - for rows.Next() { - var i WorkspaceBuild - if err := rows.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.WorkspaceID, - &i.TemplateVersionID, - &i.BuildNumber, - &i.Transition, - &i.InitiatorID, - &i.ProvisionerState, - &i.JobID, - &i.Deadline, - &i.Reason, - &i.DailyCost, - &i.MaxDeadline, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_builds - WHERE - workspace_id = ANY($1 :: uuid [ ]) - GROUP BY - workspace_id -) m -JOIN - workspace_builds wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number -` - -func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) { - rows, err := q.db.QueryContext(ctx, getLatestWorkspaceBuildsByWorkspaceIDs, pq.Array(ids)) - if err != nil { - return nil, err - } - defer rows.Close() - var items []WorkspaceBuild - for rows.Next() { - var i WorkspaceBuild - if err := rows.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.WorkspaceID, - &i.TemplateVersionID, - &i.BuildNumber, - &i.Transition, - &i.InitiatorID, - &i.ProvisionerState, - &i.JobID, - &i.Deadline, - &i.Reason, - &i.DailyCost, - &i.MaxDeadline, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one -SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline -FROM - workspace_builds -WHERE - id = $1 -LIMIT - 1 -` - -func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceBuildByID, id) - var i WorkspaceBuild - err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.WorkspaceID, - &i.TemplateVersionID, - &i.BuildNumber, - &i.Transition, - &i.InitiatorID, - &i.ProvisionerState, - &i.JobID, - &i.Deadline, - &i.Reason, - &i.DailyCost, - &i.MaxDeadline, - ) - return i, err -} - -const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one -SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline -FROM - workspace_builds -WHERE - job_id = $1 -LIMIT - 1 -` - -func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceBuildByJobID, jobID) - var i WorkspaceBuild - err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.WorkspaceID, - &i.TemplateVersionID, - &i.BuildNumber, - &i.Transition, - &i.InitiatorID, - &i.ProvisionerState, - &i.JobID, - &i.Deadline, - &i.Reason, - &i.DailyCost, - &i.MaxDeadline, - ) - return i, err -} - -const getWorkspaceBuildByWorkspaceIDAndBuildNumber = `-- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :one -SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline -FROM - workspace_builds -WHERE - workspace_id = $1 - AND build_number = $2 -` - -type GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams struct { - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - BuildNumber int32 `db:"build_number" json:"build_number"` -} - -func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (WorkspaceBuild, error) { - row := q.db.QueryRowContext(ctx, getWorkspaceBuildByWorkspaceIDAndBuildNumber, arg.WorkspaceID, arg.BuildNumber) - var i WorkspaceBuild - err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.WorkspaceID, - &i.TemplateVersionID, - &i.BuildNumber, - &i.Transition, - &i.InitiatorID, - &i.ProvisionerState, - &i.JobID, - &i.Deadline, - &i.Reason, - &i.DailyCost, - &i.MaxDeadline, - ) - return i, err -} - -const getWorkspaceBuildsByWorkspaceID = `-- name: GetWorkspaceBuildsByWorkspaceID :many -SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline -FROM - workspace_builds -WHERE - workspace_builds.workspace_id = $1 - AND workspace_builds.created_at > $2 - AND CASE - -- This allows using the last element on a page as effectively a cursor. - -- This is an important option for scripts that need to paginate without - -- duplicating or missing data. - WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ( - -- The pagination cursor is the last ID of the previous page. - -- The query is ordered by the build_number field, so select all - -- rows after the cursor. - build_number > ( - SELECT - build_number - FROM - workspace_builds - WHERE - id = $3 - ) - ) - ELSE true -END -ORDER BY - build_number desc OFFSET $4 -LIMIT - -- A null limit means "no limit", so 0 means return all - NULLIF($5 :: int, 0) -` - -type GetWorkspaceBuildsByWorkspaceIDParams struct { - WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` - Since time.Time `db:"since" json:"since"` - AfterID uuid.UUID `db:"after_id" json:"after_id"` - OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` - LimitOpt int32 `db:"limit_opt" json:"limit_opt"` -} - -func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildsByWorkspaceIDParams) ([]WorkspaceBuild, error) { - rows, err := q.db.QueryContext(ctx, getWorkspaceBuildsByWorkspaceID, - arg.WorkspaceID, - arg.Since, - arg.AfterID, - arg.OffsetOpt, - arg.LimitOpt, - ) - if err != nil { - return nil, err - } - defer rows.Close() - var items []WorkspaceBuild - for rows.Next() { - var i WorkspaceBuild - if err := rows.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.WorkspaceID, - &i.TemplateVersionID, - &i.BuildNumber, - &i.Transition, - &i.InitiatorID, - &i.ProvisionerState, - &i.JobID, - &i.Deadline, - &i.Reason, - &i.DailyCost, - &i.MaxDeadline, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getWorkspaceBuildsCreatedAfter = `-- name: GetWorkspaceBuildsCreatedAfter :many -SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE created_at > $1 -` - -func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) { - rows, err := q.db.QueryContext(ctx, getWorkspaceBuildsCreatedAfter, createdAt) - if err != nil { - return nil, err - } - defer rows.Close() - var items []WorkspaceBuild - for rows.Next() { - var i WorkspaceBuild - if err := rows.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.WorkspaceID, - &i.TemplateVersionID, - &i.BuildNumber, - &i.Transition, - &i.InitiatorID, - &i.ProvisionerState, - &i.JobID, - &i.Deadline, - &i.Reason, - &i.DailyCost, - &i.MaxDeadline, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const insertWorkspaceBuild = `-- name: InsertWorkspaceBuild :one INSERT INTO workspace_builds ( diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index b56be8f1d1de5..376dc36f31011 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -1,110 +1,3 @@ --- name: GetWorkspaceBuildByID :one -SELECT - * -FROM - workspace_builds -WHERE - id = $1 -LIMIT - 1; - --- name: GetWorkspaceBuildByJobID :one -SELECT - * -FROM - workspace_builds -WHERE - job_id = $1 -LIMIT - 1; - --- name: GetWorkspaceBuildsCreatedAfter :many -SELECT * FROM workspace_builds WHERE created_at > $1; - --- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :one -SELECT - * -FROM - workspace_builds -WHERE - workspace_id = $1 - AND build_number = $2; - --- name: GetWorkspaceBuildsByWorkspaceID :many -SELECT - * -FROM - workspace_builds -WHERE - workspace_builds.workspace_id = $1 - AND workspace_builds.created_at > @since - AND CASE - -- This allows using the last element on a page as effectively a cursor. - -- This is an important option for scripts that need to paginate without - -- duplicating or missing data. - WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ( - -- The pagination cursor is the last ID of the previous page. - -- The query is ordered by the build_number field, so select all - -- rows after the cursor. - build_number > ( - SELECT - build_number - FROM - workspace_builds - WHERE - id = @after_id - ) - ) - ELSE true -END -ORDER BY - build_number desc OFFSET @offset_opt -LIMIT - -- A null limit means "no limit", so 0 means return all - NULLIF(@limit_opt :: int, 0); - --- name: GetLatestWorkspaceBuildByWorkspaceID :one -SELECT - * -FROM - workspace_builds -WHERE - workspace_id = $1 -ORDER BY - build_number desc -LIMIT - 1; - --- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.* -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_builds - WHERE - workspace_id = ANY(@ids :: uuid [ ]) - GROUP BY - workspace_id -) m -JOIN - workspace_builds wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number; - --- name: GetLatestWorkspaceBuilds :many -SELECT wb.* -FROM ( - SELECT - workspace_id, MAX(build_number) as max_build_number - FROM - workspace_builds - GROUP BY - workspace_id -) m -JOIN - workspace_builds wb -ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number; - -- name: InsertWorkspaceBuild :one INSERT INTO workspace_builds ( diff --git a/coderd/database/sqlxqueries/README.md b/coderd/database/sqlxqueries/README.md new file mode 100644 index 0000000000000..7a88fa8963d52 --- /dev/null +++ b/coderd/database/sqlxqueries/README.md @@ -0,0 +1,35 @@ +# Editor/IDE config + +To edit template files, it is best to configure your IDE to work with go template files. VSCode gives better highlighting support, as the Goland highlighting tends to recognize the sql as invalid and shows many sql errors in the template file. + +## VSCode + +Required extension (Default Golang Extension): https://marketplace.visualstudio.com/items?itemName=golang.Go + +The default extension [supports syntax highlighting](https://github.com/golang/vscode-go/wiki/features#go-template-syntax-highlighting), but requires a configuration change. You must add this section to your golang extension settings: + +```json + "gopls": { + "ui.semanticTokens": true + }, +``` + +The VSCode extension does not support both go template and postgres highlighting. I suggest you use Postgres highlighting, as it is much easier to work with. You can switch between the two with: + +1. `ctl + shift + p` +1. "Change language Mode" +1. "Postgres" or "Go Template File" + +- Feel free to create a permanent file association with `*.gosql` files. + +## Goland + +Goland supports [template highlighting](https://www.jetbrains.com/help/go/integration-with-go-templates.html) out of the box. To associate sql files, add a new file type in **Editor** settings. Select "Go template files". Add a new filename of `*.gosql` and select "postgres" as the "Template Data Language". + +![Goland language configuration](./imgs/goland-gosql.png) + +It also helps to support the sqlc type variables. You can do this by adding ["User Parameters"](https://www.jetbrains.com/help/datagrip/settings-tools-database-user-parameters.html) in database queries. + +![Goland language configuration](./imgs/goland-user-params.png) + +You can also add `dump.sql` as a DDL data source for proper table column recognition. diff --git a/coderd/database/sqlxqueries/bindvars.go b/coderd/database/sqlxqueries/bindvars.go new file mode 100644 index 0000000000000..04dc7a791da51 --- /dev/null +++ b/coderd/database/sqlxqueries/bindvars.go @@ -0,0 +1,95 @@ +package sqlxqueries + +import ( + "fmt" + "reflect" + "regexp" + "strings" + + "golang.org/x/xerrors" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx/reflectx" + "github.com/lib/pq" + + "github.com/coder/coder/coderd/util/slice" +) + +var nameRegex = regexp.MustCompile(`@([a-zA-Z0-9_]+)`) + +// dbmapper grabs struct 'db' tags. +var dbmapper = reflectx.NewMapper("db") + +// bindNamed is an implementation that improves on the SQLx implementation. This +// adjusts the query to use "$#" syntax for arguments instead of "@argument". The +// returned args are the values of the struct fields that match the names in the +// correct order and indexing. +// +// 1. SQLx does not reuse arguments, so "@arg, @arg" will result in two arguments +// "$1, $2" instead of "$1, $1". +// 2. SQLx does not handle uuid arrays. +// 3. SQLx only supports ":name" style arguments and breaks "::" type casting. +func bindNamed(query string, arg interface{}) (newQuery string, args []interface{}, err error) { + // We do not need to implement a sql parser to extract and replace the variable names. + // All names follow a simple regex. + names := nameRegex.FindAllString(query, -1) + // Get all unique names + names = slice.Unique(names) + + // Replace all names with the correct index + for i, name := range names { + rpl := fmt.Sprintf("$%d", i+1) + if strings.Contains(query, rpl) { + return "", nil, + xerrors.Errorf("query contains both named params %q, and unnamed %q: choose one", name, rpl) + } + query = strings.ReplaceAll(query, name, rpl) + // Remove the "@" prefix to match to the "db" struct tag. + names[i] = strings.TrimPrefix(name, "@") + } + + arglist := make([]interface{}, 0, len(names)) + + // This comes straight from SQLx's implementation to get the values + // of the struct fields. + var v reflect.Value + for v = reflect.ValueOf(arg); v.Kind() == reflect.Ptr; { + v = v.Elem() + } + + // If there is only 1 argument, and the argument is not a struct, then + // the only argument is the value passed in. This is a nice shortcut + // for simple queries with 1 param like "id". + if v.Type().Kind() != reflect.Struct && len(names) == 1 { + arglist = append(arglist, pqValue(v)) + return query, arglist, nil + } + + err = dbmapper.TraversalsByNameFunc(v.Type(), names, func(i int, t []int) error { + if len(t) == 0 { + return xerrors.Errorf("could not find name %s in %#v", names[i], arg) + } + + val := reflectx.FieldByIndexesReadOnly(v, t) + arglist = append(arglist, pqValue(val)) + + return nil + }) + if err != nil { + return "", nil, err + } + + return query, arglist, nil +} + +func pqValue(val reflect.Value) interface{} { + valI := val.Interface() + // Handle some custom types to make arguments easier to use. + switch valI.(type) { + // Feel free to add more types here as needed. + case []uuid.UUID: + return pq.Array(valI) + default: + return valI + } +} diff --git a/coderd/database/sqlxqueries/imgs/goland-gosql.png b/coderd/database/sqlxqueries/imgs/goland-gosql.png new file mode 100644 index 0000000000000000000000000000000000000000..ce66ce025376ad7699677616f825ac6768e257ef GIT binary patch literal 32805 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 A82|tP literal 0 HcmV?d00001 diff --git a/coderd/database/sqlxqueries/imgs/goland-user-params.png b/coderd/database/sqlxqueries/imgs/goland-user-params.png new file mode 100644 index 0000000000000000000000000000000000000000..594d208c4a8b04f95d12a7cd9a178b5e6057169c GIT binary patch literal 25338 zcmZ^~Wn7#+&^L;8p|D6x3yT#f#frPT%i^w!ySuk66e}*p9g4fVySqc7NTIm4Ah?^LaFf%f3A=afykEc?HDte-<<}G#DEj^NY%Ug$C&x82I@3 z`uct0;^z7C<%^?}Q+|GaRaLcs2v|W$Swu>jUq~bR%q^{;NuBCChS@oJ<(1VDkx`yL z{y8OW_D&uaAw8wlt#R=QhGw>DMfJ>PLs{jma>`oHk=^RrMn4jhGmF}!>Pg3UTFv2 zrHtyV2xo%{=+=9GY&AMf;8!~M!AManvAKU64GG~Iy1#Qkkku?xj{Ebs@)=uJU}N+x zQ=1Q0=#@;2f=Y;BqhFD~s;Ur7f;-duJGCc|Q9rUk)4T~nH-+T1NBRQW0I2?YlrS%( zHHE*kTr&}MrM+LCo|gYBRI1B7o+2wBGD}6c!faoP;LXjM-IKl@1A-f^EZBtX9rdvM zxsS%>5OzB6o_h51zVMDp|K`@)Ayk1evi>lwLc+?yO#cc=UOb0~s+fbG4=>&=Azgdk z0nSkI#7M)lZx#Q?(L;HC{cg~PC84bT-J!|`#;2ZSphl2Jji+-!e~!GkSgHqukC}o7 zNB@?rkrC$rWu%k7Z?_voS>Acpi5F6i6hb=K+Jrv?RZ>at?&t%qA}Tl}@P`?^#noUz zQzeW(@T1=Q^Ug)FN?*j7PoQ3wJGznI>t^H@zm_wvR({b(3j1-Bw-Tyy;2__!iG~>p z>ZsTu48rRb*9EE@?@UkKh|@acj}Ci1_$7NwuO><@6UqL-m7KJf!V9tjrebL#pzdLt6gWa8xDFvWn4v+GiB{m@BtKD;a9aSsfZ?k;h2W5Xk-*3>5I9kg;R%uK zu{wk}Q8xZHb^UvJ2UyTSCeU{Ms@trE|BFO23Q9*;rm|sa@fAuUgmL8WUlq{2K_VFk zJ&_0ekCXtI9e*0@%z-{WLM47}ED`dy*Lx88;sk~9?M2z2f9;EnrTo8WM0E9JWf<(GVP+6cb;3$ z6;l#&ZdR?UoKqXQJC_a>zpTHN7F_mNGK?!(D&6gP$G>IARxSU-F-7`R8$4D%gRi%S zAOCYsZ8-Q!OMl3$>nTazwv1Kp_A!=*x}m)6pAcNYFNm?s<$|@G zV}?TjbeHbY^0Caf4C2pdN*qP52 z?1KD5RggYZF(CFgyQ*F)v(h5}N6KgZAK#a2r!=a=rj8tVG!*Fx39f_89-dOH-4q!L1hQw9wuY*Mq z&L<#6^y+2ZR~SR{hBR!0PFy3QgJ$y>1zb(3V}uEKOqgtDKH|VHRqI6Gpnba8_O7@B zdix4STqgVjCeI@Wd)2de^E+OR#_!QhkyB==7L^u5+3`bll~+4W-baOH*M4iwjKy6j zXyG)G)*Z|DoEr+VdvvX3eX}TLam`7TiiZ)VQKJ};s(<@DLh<+{o?e}CuR4RMwW>wec6Yce1K{dIlVL?<90-3+v zLsGrCJeZKv-DU)9gS3*;3Iyl+8)W{hUOHa}WZYVk$rc|O5T*O&hYO$H>9mxx*~M?-{?$Vj5%D4p!>;TvD0A;WYpZ>{dAI> zzLv_CG&6GmeME#x__f=R>xj@UWgZV4U|xBvO_#bI?VSsaM}gcz3&B8au95zJ_~z($ zx|YE!u7_WebB3u+4a%k#uq%L;TR9VWw@v)?U*3jUx>57h)+B_wPnf!#@S2#M4qsUD z$$faP9QLHJd_7ueE~0`=*K3hCwxgt{JYI#GNTd=9DQscO<1$|RwzRWn!`JZJ8-}htI?1<9N-qcm(!*8hBNX3pK2k ze!t=rtEhi00^6mI+e7|(%*spZ0yF>n~FOx|l8 zWfq?GlPM)b`wlHFN?vUAX5RQ)JR6=L=im0hT`0@6A)UBPwPPh!_2l>-`0W!AqtBaN zQD@J*h~wCMZoky(&(#w1nV|sN!D)0(iIT6WT84pBTy0ZgasNTH<$SCzm|c5UXsac` zNX#Lmd)b52b&htLhWk;KX@sq&ndm426YN$<)V`>prAle^Y`L3WF|A^uW3@j0+n$(- zSy83{sATyvX1cLc_6)kM+%&r44oBq6q2f2y)OkDw>tzXj$+~f_k;_6?f%avF)|`at zI~a07-Q7mA^3{FvH+6>H`V67-)^>yQ);~Ycyv2l5M+saIvMho?&{Z^+4~D^>t+0rt z=s0D7?DHR~aF3?qiN?(XHNnjXPd!41<36e|mbdT2TqoCmA0A^YNc$SBMH4-k z1Ti#kZP{#P?oKeJiP+sW&n7MD(-7R*EBj?z-MD)EWdCed@r2<^;x}++C(F~-8R_$E zcKqO*tg*z|CO5L!yxQ_jTszQN`%LK%CKa9Vd;|Pz1^M^C)k)||Yu?7=Q+aG-z3vNe za>!=-WO@sr^t=W4Zcm(+P$l@>e8WaRpTKZq=BnK2ds94Oa;2srr@V}O z%V{*o@kU*^$9;s;7HpQv;$<>}?+}>XNM*tp=1ddjAJgU6e6~$W1GV*xck>#z#G6@Z z+>Y3TX#34$!iBqdC z04h$aCU`nF?ukGF6)*5|cK7ulaM=RRXwYVkq02jmITam4)Svp6HLl9j#|)`%9Avn+ z2$SsaTc=nu3clWABr&_IOHHJIcTfDPi4(pEl0|~9rgrWp2!VBD|5UyUmDKdxJf^#y z^dw+%Z{5l~mr&&1D`_D9&Ri$wlHyt~lM&YB_Gf}9h7tq;KzXnJgsNw}+XtR0Xg!BL z$P!A$sOS!=HVIc%6>${TN1LdL?>NXb(bjWSU82r=N}o<2Bi9cnyCtbW+=^lcOpGS1 z9{8F~6j;*17Xk5W*K=Ln{4P6MQt?}Sht2{w-98USLdgd;%!x92N9C6l{8OR8W(!U(yLd(#7q~=3i=_a(4u|g~*$f z+sLK>lViJTb%#hWO^p0L@hT+e#DZtJaaXJANO4$)PeYwwHDwwdyf0(RU8`Y9?Boce z{Ozjr_YQ>bXgX18g2lM-mo3TKw|ROD;sByC`hH~h)Me49n^{bJ?fAKEe2Dm&w8?m0 z=yx+BbTee%D2xoAi;?&1F=DvF3Tt z!whGfngerB39IdhybiN2GG}#DffpaNMnCj>QS=nWo1T~qCllu;D)}D~e|kI3xidHSg0YRu_?Gkm+$*c)9Tt?< zaEX|=qj|LB6tsVCxT3E^&C)|@w$ntohgYZLCa5$?_UX+f*M?3oFIUGhorW{lO;6fM zBp$EaW_`uk+F04Ac;-eKaE`lGA0hv{$-sN^L3nW$AP0w9^UX1 zjIi9d`!Qy0Z)W0LF-T@;{~1A}+`nip!o@c$wND)N^cGP8T5FZN}Pvcw48 zjb@Lk=?Q+*IBMjURP8Z5earms6CTIk1MWpHfqlE2W|$3-$Z_BScSWe!vSb6q%=W4Us*UiRLegi%gfGji@l(5kV?l1!0qd;a>29yvMzf25RMS;>Kh_dojth0C63*`Zlpjq zC)UpqCXH#Npb?DLHJmLWLlJw|A3PLR1t1_Iw{H8<@LmY` zp5f>t2PQsvSNZ|kdvNXr>KUQ9M;rzq4o;?EGJj)vPf7rdHTCyg&OXCe4mw!Z2t)NI z?@Az1+hyJ6T_}C!;^M0}UDl;T2#5uiBv6(dQu2#a6nzA@Pnudi68T%9OENn8o?X5+ zvD{q&I9iFFXxAx>-wA{5_J+4x z5<=rMXV{Y~7|mZaJTgZ}$0*nV>7FyDU$E<9v|T_a7O$EGh6etgsrbPpxkMVISumR5 zPua_)MiXq*OOEmRDbBpmeCK7RT_ofGsZtB3$_~g@#-x7gdVs+SW1{w?Ef*uFN4AHf zx!TO~i;UHTF3C`%5;ZL48#x8q4+l2MT+ZvY=P+Xjo{e{WXQ1UPtLT6$O74WD`0Cdf z_+r<-7X`cTs4bg+1m3SBLe-CXmT_|qC9h5z@Di|Vzf&+H-f;s_K-TT+^l1SmHuYQ$ z88)nMFh=32J0Q)>|G$0!<~u-0|GdHlgK@RzOZ-yB0ZtuL0(U z_WVIDhX%`cPa*k%ZkFT2bhlX~@9aq_>|k^~FA>{Wr_-H?6@4YFNO@NTkxXsW8P0iSPJq9$wOVfV(8w-fl2k&olxBB7x z;}=#{MvY~vE>idf;Tlxvy;PmuuQzuic*d_rGAno4S#ks)AAGD>2EYWCEE?cd`CGOH zypnM$1R$xF6ZqQ-9O}JK7P%9BQ6C7}m%a>u{txj$)K3C^0)A|E%GcA!ox#=1;=!(d z$gT9%9w=f?lV4`5Hwklsx#?&d7+&ptW@?Cwuw7Z#iJgB{MNus|Z_FOIu(_U&?lf+b zCiK)+i^nQUAPaEG2tlQMeRSP{SFS-S1jhXa|mOAxaP1xNcBd&mip3^j*-)$r+!(FDHMG*z}7 z8d?~v=TIijw(a#rH~<(RX1PRSS&Rb+85fO@MWVrTa9u{JG(6W=`_aVJVgS? zxw)E|xz^VZdfupRc<9wMSFbOElOLT;>z8*2Wn&;REn|cPq{k@P3U{BQjf8lNCNzd3 zllZ8F*PH?Z6J+4iI03JBvbmujYqFNKR%ePB3$|td{$DX(VdRbER`%${&c6@PS&D*-iims2s5i2E1rb`cOg@& z(2rLj1A~#gFC7~eo@$2$wO$IM7(`M5uaE$dyGe&RbGAZnG+m6};9!AxX`Xe5h~7o3 zN`MUDzjju4k{|(K&sQNvl$QsF{y^cE2ezf56L^LdWF;+av;ZiUvnY_C-~?ZtXy4D- zY|0~JvvRf+Ugm5;s=4D8ASBF0th^=Gp*)RO?s$!XJlm7N3m41^Ic*wH7>~^QpWjb*z%`8 zaxU(ClVrwG;rAksF)fc0>MInF1}Bo;y6!dZkjMKRd9cT@r2JpQqiG2?3J1}mLr;ZDNYP^ zQ9tLRF-PjniC`LIZ04R7u$pc(k~OlY;em$Ml1jPf6{P`-QW9gP;uyE&G$l5Ro$LAL z3&rjEP`-ASEG?k+6o|ifvth?|c~vzg#x}vd%Cnmd00>CR2jaQSL|@%YW?TmQS@ifQ z?in+2>J_d;kgCp^eS&}~yqT&F@vh&WdNGfC(C4k5)~1 zZo6N+pcAWrw{0#;a0pNZQsxEb{Iy{%N=ssvUhrI+p2h_-%$_ zI3di_w2aGoz9%yodx(bsG*U1oxf9s2p=oM=B5}xd_te2Pvu^hbm^`wh3^#<3p7GK{OkFakGV1^ z8?p8t@L~~(HMS>W85f^upr@`D9q%)^KLPZqBf+@k+f>O?8Sen3dv?s6^wfUeug9i; zd?CB%+#~vnO6UG@vfE&SM@{tl=nu~yCqVd8w(XwKX!@x-ImY6`}q@7b_K(jh$A_tfQe)E;}^kqpFUC^@Dkkbru8Z;7^w+ z*G_O#{4v6sG3N(RfZXIIT@xoA=`kfX!*~XwfdIU8@Xth#gotq8rO0Kqnf)z6%<`#)_GDyc; zD%i}sGBzSuK7_GXZ8tjsPdr40TB{GlB%2uph39#~4 zd_Mbn(=WaCnLFjW5nXPsLmSQ2uGN@H2KMt%0iNN7kh33|5?!xcZgQyIG>5o^1E}jz zdsKIf_Ab8}h1ggjTg@E$NR-DUeBQl$PhIjqqPsk_S+*kz*KSgUwks{rD0Tk&Cpohe z8}Jbh0fqOl>}1ES86y_r6B_GsukNh1C_YY#IIYcr{-OcCJ1K6JYE+_`PL1wl_dYCl zx$`ojq~Qa2(uO+TIuSf1s0qlX)@yJ%bXkVzI zx~SX!Y8gfUN}2k#Cx(mw+C;;_@<$C{EcV|zzlW0Ud^gz6_pIf)??0~=*QKqizTK`v;*-eto ze{cqav;qrTjNP~YKn~Ce;g|kd&8pW(>-_2`B`5$Rw)0|Tr3?$<#hXQUa%GLgtZ1ts zxdmdO_zUeR;HkVxT^@Q(iUothtTC}^!ZE^sq(q;g7Cr$Zs4p+Dd{e%3kbnX@NCM1e zVTWY^DPx2B`|{movb4|cXHV2ffGmGg1&12fS62!P1~pDx2m(hwEE>75fx>}8(naef zW58$%Vf=jS$Ys6d2?GHSCU=3%W-S_KEqk&qPreMSpo#vk46M`1AJWB~Mw|8!Qj zGOXLcFSOQt!~_3j-F9^ zGW*?KnL15zG&Vluh1dB$*4_4(RO%gOMAGY5#p^{M&`5qOG46 zxkw0gX{@;y@&K1kY0c|e*~Qr{Q}YQ}zjqe#xFuUY{4QOYfADyM9tge_$OF&5KdY5# zEp>s#0O_gs*?#;9+X8JgPSak_Hx^fwb7%sZ-AXJ(`V^Ggb>Ja`z_XA0=PH4R`Dc!e zhKX|T7}!Ew*te~#sY}~e=o`uB*XRnP>}skS&2w4>a+FH&jx(1I$|vh_OW$`3{MOPo zS|x++BzX*|e%rvAsj!iPUcR#nn4W<_zUo_r{(MZ$-DUS^V;|3o_R1DXr?h zlaDVEKU=tU_?DXRc=J1azJ`R9pLiiPy;X-UGe2zSjjyAEEBw z8i>)ecb$YJ9L-rn`g1o4BHEIr=al>@p|KdeoOLBR!4`4n25-L#K`M-PpxWCb`?j_& zY&{^Ijn;f9n?Zy z&1d=@wh29KaQdEf+NbmmmgnO!rA^-9n7AU1>CaB=oOLwZ(GUo}8Y1`eR@W_OGtwEc z>^;fikEPDFlmQ+|^qzj=2(WfqVR;W_i~q~(m`~@<%xNN*O;vV@j!tqRkHNAhwRr(Y z3Ckg{?2Ub#$xvVkW)Ae`*Df-^dQaoQETyu~X0|>iX7=XKf%vRVh>-S-(44AHA~~yN zRUuwUVca;G$&jp_imaFQ%G+fVkyz6hkUavh>sl-&+~oCBK1>4&^_*!37z7VsL}*8* zZ(62>E8J7=*UmnRy{$R=zW@MfM`qmT@GJGHiT>5N3Ib379Q0GR*}XuCk41$hKp(&N z0SPGlrPIzJ%Ck=YffzV;^Vq(2cK>MAL4pK&zyXN;=q;w%1^#$miDO%uUHgkHFHr#2 zRa9pN;b6f2ReYf{{8P*q3f99{l@aE$u4H&WNnzhLBVZ;67C0qVdnZZf- z%|_QU8g>!7PG-3^Y#`1H-u68Zjh3t7YrEf-=CvFtPfymsY0vDXGSid6>z;?1=0fWz z1Rs;-4w76b=;0cOvF2_>XnNjhX;)`GZCZ3_2@p_TKOQX}0#Hw=*ZF(~;;eG(sU5P{ zw)vdy{zjF&j}SwTlsJlx$CJv@-5yNev{mye32Am+E&9e2GFlap_cs&K9A4KxvmF z|0tONtv@PO=|U;pA7(~2I*ezGiF^AO6L|9*HH(VB_NQJLp5zhlp06GV;7=LjP+8T$ zw2~y0zeK@*w9l5*&_Y3GyWw9KZNssvO0&(J(bfAR_t%JMrT2{VPpxy9jWtr20+$HW z&E}4M`w4L9caoFYBH^Lj<%1wEfha%6lg(1fP;u>3Qlo6qLiv71;ayz!Vo1gIx- zL;N--Ztmd|;;crE{@*>}MkSY3VfvC^A8RM_*~6~o8Pu5uii9J!sg8JToGO=YGugQ7 zBbwV#hyr5j|JnVn7k2a}?>ezDGh5$=VmiHhu7{thuIPmGR<;X!?FZ&iX9s>ZsC0b% z5=Pq8fHI$UO(R7k=&76@mac=`uMqQYZpr57GY(7(E#vs<>jtGh^3&lK!dO8rs#tOF z0P#f&gm3IHXAGi71V`r}&YJr_C0_QMAHe6~O>7|(K>2)ALxrmFphhn@VR6QPG7;Ns zQI8KNz9=U3I%4(Pa-}CwoUt*Iv#A4n3jzj!W|BG&1D#1ak&Tm*8Ja~tT^l^Z6XOu5 z6x4m3&#H);bdJ|DQO+6W$|VD5YmUP`Di&E^fkH*MohDFIjDjQ7g7Ra^G=X{pM*A{N z6r-YeuTa?`3K9S)@*=Z9h5<8MsBYoM-@DzC)|rNRsXT@*T6os+V4JCrEk>ZQKPrPJ;*dJ-wgVNUSk z9k3Inf?0}IMPxN5yd>-h=<6!wmZD|6&ja@w6tIg-3Ul%(U*TZaB1kajNg5RasYsJu zg6-F3Ja50#n>JVGU<7ECoaqUYCj0QRTUrmKoe+XG^#+(uGR)6p1{KmBwcJqS;W1KF zlyn#=;d8vN&*(3YrjIJ1Z7W)`6#!W2rCb)x4trxiPtiBblKkv}glpcbKc{G4uhFfr zjs*jKRclwj0Yh6M)xaU!njZKa>S-WUiTs6j2trhS732lzUTPHz=`1g-<4G4<3|FJh zW?lc^G$LEN&|iIGft^i7hY@B`O_Nwl-|x3zKAx&o8jL~+0q;pDvR)#HO zc3k+`xX^3FeYN9WdNYNKY?G=BrrUsa24Cv|9fI?gw5sihRyGhaXE2A;TmPPqzs-ND2)#yO0SRA{vr2ZL1 zU>ysT0q4)~CR!oi!q>08m>)(xeI&Fd)abi|^(t47O z%EGpk0oO-r0V2E>I@@M@TXKtJGZ^34?oi6cS18lSerHC`(?|$ZhY>K{r`8~!U9&XY z1`0OZm?)c`us^wYxEn`P*D&CP_i>cW-nY56aM&sUmoe=gww6SChgXTLnEAGA%BwcU zLRLXO-+lStNBlf%kcbw8WsTz9zpkGjC%8#HtLt!KrNS;OTeg)t1ah{MYey=?@qi9Y ze+UOEkDSHhazeCi(j@6^}#ZMjG2AlS5`TZ)Q>5f#DcHc-xQ@V!qcMZzSQGC=Q<6-9QqmLIRg?BP1 zYqy`r+ZeZKHZEl;0m~tajPdN`I%CXg#C-s-T2_BB!4&;*OR{4pETmv~au$fZIdHDO zUrA*#S!sT(UAL8bO1NmKcVyehVQ`)`u-A*=`I5uG4D@sT87Oz(px+lt5Y`jk)t&e|`S+u_FE(E3g$1wx<$v4Z1hc}H7gA2{V&{H<%7sPL;8u%RWiC8PjLP*Y*XD;l?4 z5~+27?erDHf%tJuc;lqkb9GwMxi6J{C=qAiG36W2ossXAqgx|MT)TzWx5`!WZ^Wta z=8S=kKB=}BdMqM;tTN>m@$xvQj4{-yB3@)nY{Rf<5=#X{9PLiA|g zo43+3izj}TF_C#{;=lXy_chdh+%=eR_742^n3BxTNcI!Z>fYJ{5kR{Bup(O5w@X?j zLk;wQ!&q|IoQDp}FPuaF#~;Cd5MVCvr3OQr7ncPOb72298c3Llvtrf2Ls>y9wdmp5 z;^6PLvWz6=h=4y#prPV?>UV6G%?43koD&cknZ9eU^%C-N2Nmt}JZ92t@9^gC6jl|q zMZpOG@Q4UX+G4pRteA6!4t&zH>c#F8J=(L=0FlSNezR@G444&Wf9c1oAA(Yz%$}zO z!e?Wu@wWU3efs^N+<$t!l!BBjt(i1{%b>5!NEKJOZ%u9vg@eA)i&gZTW&J$cg~^tY zJOIhgau1bj_cm-4D52GC+nH&uNtqgT6Q!o-hr{P39r7tH@facR@@svAhSza zls~(qD4vQ}JlcBU6%! zvY7ep%o+45%tEgHYf0utC7PSlpdt8^b*Rah6Aw#48MYQ|vGy)Arbzqsv+JXjE$1@WMAgos1Z?ZE z*+|!}2E@o8ZaSbvt0Ohgq4*tAuV@#?rl^LRSTMMnD(IS_2@!nCwqiy-t{Z@a4rCV0 z?6mp+`6ZH%5E&TUIm|}2Cnj(J`E*(LgARRk1C>iLs*`D9@M_PBz6d(ulJ#Euu(=S? zzm87>H{Pq>Oomhc7pft>Q=H(Z&$V2D>e#ZTZ+N#lag9;RQ)9vVhxqA2ZCtyDYa@_1 zDd@n@II8B&6Zo-@2J`Sj%lv=Aaa#v$rgPDn<3P+aCq-YYNFzp4np!)RPDs$=@-{^D z(zP`uCG2bzX2d&-|m<+PbsW^cg8Sb7rRl{;|QJ0BAcPp<{{3U@qPmv-phWsk<2$oE<9?Oe3K8Jr|B-xx%omnT~{qEY(9PiT~5njLF~&xaTn$`#aio5Id09a}ou>#$qfi zWL-dnAwnB^Rh8f14fA;`5`d5ohZn#7PG}CLYwc?-YXU4|Fb@rx=R1ySX0k(5QAQOK ztV{XQ3oO%d`Pak8X_I^y+tWJT>*TO;b7V=$)QxielYt81&A2IDT{+-)tL*-^>BU+3^zIk zqkv=&CsgRJqWMAJOa2Ka={N0!8g&fn`h0vBf>OG0C(DH!&sHuD-SD8%Tz{=)0HzoM z(O!O29_gPiF0^}?@F{c~$BUaY-lLi|e?9xjkzVsWGQHo5xqWv#&Fb>}V@>9^(w57C zi|YA>vPt+A1(IInv}4KS2NHdjH}>q=gz%Zp10=hvdeQOLq=&0(+ruoJhNh$F+v@cu zj8#e-t4tt^Jbd!3Wb2E~Yl^c}H5ro}65;AJ`Dzudvf|{Q88Ra6C#H!4C9vhop9>-U$Y+~%`8e4XQz%o@*`G}U@hj{Ox(o1$vQjPZiRh~t>BotFkOrvLp7QjtTmrmjS? zRAUu9qX)q%UTA8`j`O<8x)%mh0RpjMhmM`>O&#Z0P6A!Jt3sb8AWOUo<3)!G2|kdk zwXS1hI~g^DOeoHgMK%ozj_2+Ko)I}vei0fl(2s2toHI5hI!#OIUEJ%>T>3f6xcFD~ z+n*RviCgnKC&heLa@9D;tvEC3G}Z=CC7B}vD1guvzyltoBU0_Ae*CVLx_V-6$Y>&| zJHlwDJVf)&c#C8@<#$m^@Q>2Lh80BrIIn$IvBDhr{oI2gxcO-N{{$p?s+Vy+0o!tw zL#l(orpl}NhTp6?8d#J}*bkS{rA~eL-a^Ij%#f(vULvC$@%>yzj)VhAPMAN!;Dc)S zzVpI5Ui>EbrPIgMem}YI=hkvrYu?kMzb-n67YIc4t(lD^)S_g~zP!>(5xRC|8~AE^ zlF2JDa61{Q5B8*J&+c|}(OSQW_z7rTm?i0eu(H5j>8++D7HP17tkZ={?Yy)4^KqN; zkwD)~0bBx0PEU*EU775S_y!8ZuCNm$=+TOK{>WqS*l;{btx+rRC+A3HXtFMPHU}Ua zOVjtBCu6<0MFLff5$b94?{ zDS1);EW(De0T032mX}}E*MD@@D%LsN&Cm<1#~1m>h6#ZFF%W(LiGcOz` zBqL(CUb9^d@;kb@0@BI@HOLqYQlT?h4aKE(B=~U>{5+pbl(1L_MnTPl^Nx$C^0mWV-END`?xm@8#+jI z0rRF!{qx`Zn$KC#A}3YOo)#`H@C{DT?kWe`Sl955(;MyHjiE>kv!J2r&#~wCAA|Dx z&}hT(shT(Mkzo=b=75ac6@C4#j_>8A0_lxbg!e5C7gEOd5xOj6bfxQNKfXWUo$d{9 z4xj$vk*U|v+zQ-oY{VQY3wU}#A5e0qiz`w4-9&g_k^J9m2W!2=FyFTOgUvqHU(s=) zW<5Fg)8;6tzGQ3^PKqY#-p?i*y z2lMbpLA~Jc*U+J|n}59&i~!Ot@a;w+MIWl|PIAv*4ID;afVHqlH8yhP&hjcgGojXWZftn`sNuYb3&j3J#wbso2w# zD4i5t)Q7k3$2WSS>aKmV{h!Rx^w=OL&wta_<(D;Z`d9BO1{g#)qorv`1v14*1R1s9 zUlqnrd_)rFC0XFF75gk=rKR;gPnGGaW-TARkJF+=atv{eU$!R6xMj&aJ2+@$#INo; zsQfTM5F07Dg9}sY=(3Sx;55kT2-7c|j*Jp5uqN*PuqAbhn!VQNpM>=j0iECDXf%Zg zOK2Pb&_2~s7nw`l)2H&}PJnLhV>#X0yPM(ucDJu(_zUkDsS>8XDLR}qQ%?sXG1(1i zwY-KJ3Pq!{j7N&fz(ZRcxWMzDvvprR>N7#nn=GFnb9&F8A|u;fUbs~m-KVd^<_?M; zOE3AB{hrXAXFI-hqCqP3)DPG7pO=ctmY!NoCR7m-0SuOM-wukRn|HKlR1dNT&3OP# zFS#4{{lcv#%W_FxYp{ChK_qORGBClgh$P)%Kd}tIIj~Jkw^qHq{{LzA*?(%O$M5Sr zJ}9asLKad-hn<_o`CUZS!^4rar&O)Hcu|@x>w|L0)#t4~2sN zh~;@;Ltnytw$M}$FT<=e%PfA$IsMGw_A6gHF4r2ReZF26dyf0BSy$KZmV0W+<9U0l z_b3ux7q+Z*ECV*Vz4xTzI}DsC{{P16|MlgANf6^g*`=M1YK{y9OHIC$zKcGs^Y%}5 z#8?YffE(ZXx#bk>s}sx@I?2C2KyZY7OT^5@6-NUQ`wivA$DTijl8sor^Y%raFRdTp{-+(;=V!zh;$rn!EO?B#sgcE7AEMk z(2!dXGkbJ{=NIZHe<^-fcEh}W>-xm$)L_H@PfIa z!?S^hospZDuH^exWGg5AhaS_%q6!WO*53q1D%u=A0}mGmTpLJJx~%*&DDESWKk4r} zD9iy+I8R^ZiS6~JxJ}l^JDczJu<5uwqJ@APNMMk%*ZamEk*@l~95U?R&;O^f?|^Em z=@zvns1X6B7ilUeNbk+iL3;1K6Cjk(1?3<}6O|$$9Vyal=)r)1^ddb1QF;kQAar=a z?|bjw^{>0ueK|R6%AVP?`>f2FGYPk=B|YQal_${)^V6NckaHV+Pj|1JUQam5bGlr* zW;}qMp18t^h?T2S(fU1y*0hZIz~4sAYUUg}7J(TkR(^wIAy;tVfypX$vTq)Lydu#+ z(e{3WJ;nd2%MGz+z7X!M5dG18?|MjL%(V|{FW%@V@09))vTg0Qs7*)5*f;Ze-c5y? zF+Yv{SYa7LK}#(zy~EmcTb61br`R6r@B}8CFSBV4^PvJ1*urkp6MeeddU}QeXAF92 zAnGChhmoA0+#lAMm%O>Dr7kwvkXWY%v2#kXBr}&N`|dr(gy?j%_?U@Rs=!aKr=IFt8UTJC%o}9X6rN}pP%(fq;xFA2#hbjClt#zm(} z2FJdqWy+2}%qyqA!FAN3C$)P7(cC-@_Z@LY?ThU~prWesv zy^RJ%+)A-^{bxZlcb-pjI%xB#^INP>-g@NZQ3zi904A5R85y5Va~hH z=U&8J6>bX@J#nz(V!Q6VI~`KKUtYGqKp_+AcjJ@~=E9sARyzj)#T4K=QK7F&gA3f= z73-~MIYJK*+C)VJDL#N^ILGn2pHzMGN*GoMAtMeqyUEm<^`PG(_jH9U_L{-82I9|W zT0YWv!|s)?Y9t%E?0BL?g?F#V5c6Yx|36y7szs9`QFTWISK{aPy-L-j8-VOOj5DRd za03ioS3LPqgVEYhwRZXXd)*OxHIn!<9n?Je4oE(bpBO989!NGriZTksMxt3 zzqpKCjRoUm0eg+eUefwbEyD9zsWRm+gda#2OFV2M*<|?;BVl@*H=`u=i+9YY?dpt44wh10S}TC0?HMjRb&lkM>l= z*{X})MO^{XdNLC!?}p7StTPiU!o=Ach-6-s`P~o^VXXjHrUi=^#3^JQJ)upZVL$$n zVfQ;p9HgK;(NMH7Z1vru*v5{NU9)w!9K;Nc{B@NGuFGB=DAAeWYjI ztI~!)YCiANu-6VRzKfOXHz~Gs{tD!)g*`8u-ESI(!^7TPKF{o z(xR$L4`F=QL1J;x53FHTG3%&^drqP?n((KRL^Lb6Xqw1xJ;q*wcs~X#EG`!RE~cXY zIY)49<8I7&IQm;X#bl~Sz*(ea>V$hvhOz2xuL)NQzYIIms#=K;R#hPk%ue8cTUWwmZTV`px3M2{>ibU6ZtVl1xy~fp` zKR!1;6}xRUp<)$qu1DBVi89JjxZOVRr{B4w;yzn*or}&5k1knjAw!#cTpyu>mTN>H z%Fl9ATblT6s|jhgO-yEf^A_A|_YA!_&p5S8Z zjrX}1H%&NQM^ufMnp<&n%J&DWvdLp0vu%tNGJ|_DAi2I@(mQ-7i%QUTi|{EOEXbce z{V{^?{n_7f&AsTyQV?g!{TBwPen>-PG0!yrq@GR&l(~BG=*Vg0W(q8S_Zb#)zc!9o z-=d8qS6$OOCr|fA-YznX*ND9;}ah`cql9j|$un=0Kgu5bOmpZ&`` z-smA*{%zwt^S*?GtYcvZ1^khd2nA2qDAyMR{>6ktT8!ufOTkLtSR9>5Cg zPk2vP(Q`E;d(tB${oYLHu$&4C^Y zHzfYYtHGwibA zvT<{NIaZmcd7@OuKF5D|>zn^K>M&f35Ra%j#trTzxv!(FPG!D>7-~sROFtXGw&iq) z?F&Ez`kwAC09O%x3;On`saJl9_|5Wj;R9^#!hQ=%f62E08d86Zs9-0rBCTF%d;AlS zmVdwF_Eh89uT&Y{a9lcVll>;8#ES-)d5hG9-*&C2MOtA*%RmsZakz+pInHY-Uuu*- z+7@B^<1FL2bJp9eDQNrb)egkdz3{2BnRpD&!v3(vGxp59<>a)tLHO{;_vUBLK3&21>2vQNK?;&U)0!*bRnX=~kcqv(G0dAM2kLQnHrya#AUezdd_jQe1-S{LTxy`cOa3UduM@DX;HYV2!?5kJx?lqm1qK zR7Y5_a^uchBdMN>{oLfnAYVlCA)3&VG2?y{ObS(f{Pp}Z)i=hBUk@Nb37~VR*L6!0 zAQTEA1M$3pwlacl00ijYs#eAq&q3qIRICm306r6Td^d!UWe3Jmai)8RCIO0Epa|Cy z?66O=&AzW(to6HPJxCrxz%P4C#-WQkp8n12YGnQ6G_`)#=u|O7T0nN(8vd@_o>r|Z zQi9F+T6D9km>@}?L-5QS^3<*=0%`_eBYHMOz_vb1cf`EP$JHegdPD(v+O-ie z(mT&X7!byJG{$G}Zo9C!H<{D&-LijK*GRE5?^TxSc}TZ6M3BFe0TfX>iq*2Hm@I^a z9rtI##xoV2y8KV52o%6H_&>07@)x0qMrh)3)c4*eF_aLHG~E7^?!pYQddK|+`v0F~ zWB4WtCf&$n@(25T`Uq2t?H{dQZ0OiZ5r{=rZGM5Or^j8ZR$GUZZm^FG97>tqr z{#9o0O$Rb1!W)ga+`1Cs_6YC*>YG!YU!{ICF@V=30lXCY!N*w+@Yz~{&uP5N#3$r2 zayu%T*JdW~rm5LXCa>F^aoodEf0Y4d_C7uv-f!lyH?vY!GaWFx2Y7XsMo}EmOYr`r zNvoV3D?r~BaXV8P@YSvj2YIfl+uuftW(D%#_1fWniF$O&ett6l1Ee13?vsPImQ z3akwB8R&)c*>X25@YR*&3#Efq0Ji}lhuhDKX?a@@87&8y$NnH$#%+psYWbXpNP*|S z(p})5efHd?_{}6xv*EP8xwL6|gokPTMkCsz#C)^%uEK`*2v)PWZgf@f$!du5G%AzV zRpWCMW-`;UJb!PMd>m_1r!%9`iT+i*vHfvk_UQoalb<>H$FAj~w4*uUk7m5r8BR6X z^C&UoOw4}A9j%7IYb{Be?-D#jVbu=y{fEyV=jtbU^=> z%UY}wA=F3}-M9ch(&CHjlmy5f83eV_Sx^D15&F^?Xe)LJF_h{uZlSBU*qay#nyX~` z*4;(`^ANy&1TAmvG{F0jwG;@~D{6wvx5v$}4Q!TMm8$7gII}pV3tvCb1~vbhO2a?i zU%ml+Y?71m>qlB^$XfYVmXNo>D>| z8BHDi{8+&M4-pWG9$>7?zTSAM6`YMOO<{cu5oi&juDma54L2<<=$i}OGa-!1Wyo&g zP1;_KkI;~*nSB^7>n3b!+(#ink)n7xtrO)RM_ds6~OsUs{afo@vX6<@{@H)UnEwVgHH8jzD4c_bAZu3aZSJv_@Lav7Mr zNc-`()rk&yd6e=#g!dV@v_R_fO^19EEQJ*8KQe_(jZ3umIs&r?W7YQea@_%^R%AIS zYAm`HV~rRcX?a2rXdDg4SO;XPh2WGcQ+7p_W&zAJ$<11w@At>}3i4=fu0=i6YpV%_ zo@mfc65;?l!Z80Oi}R4muxZqh>shYy4Nu#8B<-@upLIXHByh;-De+h}IehakIdCn7 zir;!gnBoFwUc@aMb@j1*>E>TvE^B|jl!l$!A(B1ko1+kO+ChMvr)J~!H}T!(@JnZ%}!@XQ*~YxW&Sy!2WU(dn_b`5(c@L+eaMYZ$lO%o5Dh#$6I5}C#sycBh zI`lkG*d>h61pHL@bR7r6zKr!4J4L#wyNx}md~zjxatyWaU?GGVpd2@LG$b$jwb*4w z4#?_T!;PN!&CZf3MoJGQPm7(XeSFC`7gWUdEt85=+at1uZ76{jhLAqAGMXProy~$S zX|9=*8az7b8ddCW4^`eh!i*LN?b3Ls-GF zCUFrxonw>=9Ipc;fsGt%&}%k19>v{u(0N=*neqNZ6-&=wT7EM0+r#ZR*r33&#HvtT zi)p%$9=GCdE+~9|`Qo(B^kg3Wq<^C8QXpX*H~8b+N08DevKJNTsGhFW15hjDv=VT7 z7FgTN8v`Xsj;h!ButuRtKW15QJiEy}{;v;Py*ulisc-uB9_QX9-i5QJOrrjt#9dE+ z1B653CLm7Md^bTEq9D(9d+FaIJdbX~6RALkMuzT10+$}R!#(`Q4#(1}rs>>P=_}0;c_&?4+C`O05ZX?^ ziWuP1p_uS7zC>Hvr{B9!>bQ_6Ej1VxCh1sA#L{3T$Q%YI$$VK~U`zlb&53y2nw`Xl z!kQZ5bq(pAoQLn^)My(;EgmV>nO20<*>co2MG|PrPaI6E!l_r+4j7fun4%;42eWmP zCsxNnt&{UsF%PF@;T1x3tCj!NVfHg$+LkaIUr-+;AJ%|xdZTzf;q8J8LOJ=38FiYz zyiQ-65-Hzv0{$1|1Q~eJfm?oK{oxYzrMpi*Ik)RQAl;b43<+tN-_n&?Ps-&@#Go$d zs4oFmd1`(HVr!U6gob~*y-hi@TX1(_p2-zgcGv^bXnxpziC#dK{bs~sSN7%7L@*5C z{M~$#GG`~T7@uu~i)3LZ#trA}sd;#v=SZ`|1EcRw0^b@L0Gs}8x}RA4NXV%n#*I~{ zLI2)DSBl_woRm(yU5#Izqe5c0fvevIwdyfq_LwRLx|_8Rw41LMZo^UDGOq9X9k>u# z&$yW`nEETUb8&mAL|X8#V?;TxRlPe2${hTJP(9?W5$pOvK-WLDfUxJK{Kyfh267DmYHe@jX#26lL&_9bnmF> zaH+C{L2F2gM$>*Oq4KrU9HY)#;QBlaL!1))6thZvBo_8bPo~lK)@TQ`pkU8pMg)Td z@K2*>#^B{f>JoHp#sz}J((S?q2K~q$=%k+Lvf@++^eft}47}4n=`HL~n?URxk@pYg zB*1S8CPr6{5vAk!+b@^d3Mw5> z{%FcWY!-)QOw|P2&!P~@!j+kN1Gn{zNmX6JX;{W*6^q%fH;$dbbcx9;Q^QbuGo-g< zWjP}E3f(T9J4yeH{~-IXgcRE)Mf*HwG5UDF$+Okz@FJE3ZsHUcCgN7ZnYa~|N9&_m z^n_K)<&-GVKb19e9$tEA=u75UAg*=7$I$#B5%^)Hwyp5(T?lZg-R#cB!x*LtQP(M? zPd4B$pVh*mL#nMg252}QXZJYew?X+#k7dC{kLD7}AGD#^QvJXZ1K?Vj>76!h<3zx& z!TX@EyaU+@T`DOJj5Y9U+C$H~e8FfAb&V?EZ$LDTVMyhE`t%GA`!&2`h zQVuDjT9>;{NVZPKje~V7;<=LvCeKA2rmb6%klFG?0jv;m3-Cld!4J(@6zW^^-T{YO zRrL9CP+;1j!a~HW8@&-p9=Y|BWUfLXH7xXEd@fLyUHF5~{Y`o{muibO;~$s2-dT1Z zguG5ED$y!!n%qaGZ~6G#nYuhf>n>2ep;O#q=6+^#EXzf85eS8e?5EMem!FAf9=H)@ zHq9}fUn3lE$A_^`lEM;ETKk9OU@nHIF#a0@?@FR-e5mv~^@NtL2)%PQv*e+ZtvpI_lsX_n4v8j@5F<+8< zG3|)0)g;XYb?2g1uwzTE=o4wSSYY$pM|FkH&Cmmw@PX1B^}{~7yETdIE!VGRU44=C zWm8Xo)A5nix2sw5=62#EX$>D6fn;o-PWbj!`8XGU@Z{Z+qP_xey4a;f?Cs9G?;2MM z_MXjwYH!51p>tkJo{#Ju%LMyvHy#T5fcf65`soM5#7U;&`H!~n3mLzu&8z(3$NsTy zftyDevC_$@^i2%CKW{8MBbVD^YqeZB-xAOp{~la=jw9gL!qJeoh<+Mj{;+|E13K&RCGr_`48bz=}lbrY=8Sx`UfWu z=FQ2jIdQ;$d{i?Y-&mz9dkb4 z<0D~^5#)LvLc0bPz8kewd!}R6~%U83DqKlCzcYQgpV=@ly~6ekJ82KJnc`8cBW+K zo_7i_)9QChnFX!T-f;lCs2O&uY6ex>cI^(Mx z+@ZpY^WIOo)sc1|!c+2>T-eWprrBk`R)4#uC~Dmbk@%38KRYn|>5T66eUrNLQ~mGQ zQfkwz`y&C7p1$6qJo(BEZG;q~ z;oJ+i3OtwLY4FFmex}x;DV-F*84PQtPS-DMC?V&axc|`nQ-8X)&m!lgF6PIh&UdaK z+n{XUNmAcSggW{o)5iWVOO{N9IkYTWksrxH345T0C6Y%r{Ddy^8W@0_fwSwIU?^xI zpsh#Dzj*Ri6-OeEI%sXA%Q`PEq3`6ulg$*{#X`%wHhdUv0}g5f!(OIpk1|6$6r13) z^0>!H+^;r3!R0YVh+Cy@S4lTww$*QD1uoy39*ND4eqaXC}JJ>4evab(Qcd zQAIl2$1T1+QD4$&hM4cut6n+2Ba{+&63I%t_f5j-z%aF%+rwP)GVr4;e?h?ZD&apV OE-A^W$(Bl6g!~_{uFF0E literal 0 HcmV?d00001 diff --git a/coderd/database/sqlxqueries/sqlx.go b/coderd/database/sqlxqueries/sqlx.go new file mode 100644 index 0000000000000..eae23e00dbc4c --- /dev/null +++ b/coderd/database/sqlxqueries/sqlx.go @@ -0,0 +1,72 @@ +package sqlxqueries + +import ( + "context" + + "github.com/jmoiron/sqlx" + + "golang.org/x/xerrors" +) + +// constructQuery will return a SQL query by the given template name. +// It will also return the arguments in order for the query based on the input +// argument. +func constructQuery(queryName string, argument any) (string, []any, error) { + // No argument was given, use an empty struct. + if argument == nil { + argument = struct{}{} + } + + query, err := query(queryName, argument) + if err != nil { + return "", nil, xerrors.Errorf("get query: %w", err) + } + + query, args, err := bindNamed(query, argument) + if err != nil { + return "", nil, xerrors.Errorf("bind named: %w", err) + } + return query, args, nil +} + +// SelectContext runs the named query on the given database. +// If the query returns no rows, an empty slice is returned. +func SelectContext(ctx context.Context, q sqlx.QueryerContext, queryName string, argument any, res any) error { + if q == nil { + return xerrors.New("queryer is nil") + } + + query, args, err := constructQuery(queryName, argument) + if err != nil { + return xerrors.Errorf("get query: %w", err) + } + + err = sqlx.SelectContext(ctx, q, res, query, args...) + if err != nil { + return xerrors.Errorf("%s: %w", queryName, err) + } + + return nil +} + +// GetContext runs the named query on the given database. +// If the query returns no rows, sql.ErrNoRows is returned. +func GetContext(ctx context.Context, q sqlx.QueryerContext, queryName string, argument interface{}, res any) error { + if q == nil { + return xerrors.New("queryer is nil") + } + + query, args, err := constructQuery(queryName, argument) + if err != nil { + return xerrors.Errorf("get query: %w", err) + } + + // GetContext maps the results of the query to the items slice by struct + // db tags. + err = sqlx.GetContext(ctx, q, res, query, args...) + if err != nil { + return xerrors.Errorf("%s: %w", queryName, err) + } + + return nil +} diff --git a/coderd/database/sqlxqueries/sqlxqueries.go b/coderd/database/sqlxqueries/sqlxqueries.go new file mode 100644 index 0000000000000..abcad453290cf --- /dev/null +++ b/coderd/database/sqlxqueries/sqlxqueries.go @@ -0,0 +1,55 @@ +package sqlxqueries + +import ( + "bytes" + "embed" + "sync" + "text/template" + + "golang.org/x/xerrors" +) + +//go:embed *.gosql +var sqlxQueries embed.FS + +var ( + // Only parse the queries once. + once sync.Once + cached *template.Template + //nolint:errname + cachedError error +) + +// LoadQueries parses the embedded queries and returns the template. +// Results are cached. +func LoadQueries() (*template.Template, error) { + once.Do(func() { + tpls, err := template.New(""). + Funcs(template.FuncMap{ + "int32": func(i int) int32 { return int32(i) }, + }).ParseFS(sqlxQueries, "*.gosql") + if err != nil { + cachedError = xerrors.Errorf("developer error parse sqlx queries: %w", err) + return + } + cached = tpls + }) + + return cached, cachedError +} + +// query executes the named template with the given data and returns the result. +// The returned query string is SQL. +func query(name string, data interface{}) (string, error) { + tpls, err := LoadQueries() + if err != nil { + return "", err + } + + var out bytes.Buffer + err = tpls.ExecuteTemplate(&out, name, data) + if err != nil { + return "", xerrors.Errorf("execute template %s: %w", name, err) + } + return out.String(), nil +} diff --git a/coderd/database/sqlxqueries/sqlxqueries_test.go b/coderd/database/sqlxqueries/sqlxqueries_test.go new file mode 100644 index 0000000000000..b273ce6fbc18b --- /dev/null +++ b/coderd/database/sqlxqueries/sqlxqueries_test.go @@ -0,0 +1,15 @@ +package sqlxqueries_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database/sqlxqueries" +) + +func Test_loadQueries(t *testing.T) { + t.Parallel() + _, err := sqlxqueries.LoadQueries() + require.NoError(t, err) +} diff --git a/coderd/database/sqlxqueries/workspace.gosql b/coderd/database/sqlxqueries/workspace.gosql new file mode 100644 index 0000000000000..acf274b8a5abd --- /dev/null +++ b/coderd/database/sqlxqueries/workspace.gosql @@ -0,0 +1,128 @@ +{{ define "workspace_builds_rbac" }} +( +SELECT + workspace_builds.*, + workspaces.organization_id AS organization_id, + workspaces.owner_id AS workspace_owner_id +FROM + workspace_builds +INNER JOIN + workspaces ON workspace_builds.workspace_id = workspaces.id +) +{{ end }}; + + +{{ define "GetWorkspaceBuild" }} +-- name: GetWorkspaceBuild :one +SELECT + * +FROM + {{ template "workspace_builds_rbac" }} workspace_builds +WHERE + CASE + WHEN @build_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + id = @build_id + ELSE true + END + AND CASE + WHEN @job_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + job_id = @job_id + ELSE true + END + AND CASE + WHEN @job_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + job_id = @job_id + ELSE true + END + AND CASE + WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + created_at > @created_after + ELSE true + END + AND CASE + WHEN @workspace_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspace_id = @workspace_id + ELSE true + END + AND CASE + WHEN @build_number :: integer != 0 THEN + build_number = @build_number + ELSE true + END +{{ if .Latest }} +ORDER BY + build_number desc +{{ end }} +{{ if gt .LimitOpt 0 }} LIMIT @limit_opt {{ end }} +; +{{ end }} + + +{{ define "GetWorkspaceBuildsByWorkspaceID" }} +-- name: GetWorkspaceBuildsByWorkspaceID :many +SELECT + * +FROM + {{ template "workspace_builds_rbac" }} workspace_builds +WHERE + workspace_builds.workspace_id = @workspace_id + AND workspace_builds.created_at > @since + AND CASE + -- This allows using the last element on a page as effectively a cursor. + -- This is an important option for scripts that need to paginate without + -- duplicating or missing data. + WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ( + -- The pagination cursor is the last ID of the previous page. + -- The query is ordered by the build_number field, so select all + -- rows after the cursor. + build_number > ( + SELECT + build_number + FROM + workspace_builds + WHERE + id = @after_id + ) + ) + ELSE true +END +ORDER BY + build_number desc OFFSET @offset_opt +LIMIT + -- A null limit means "no limit", so 0 means return all + NULLIF(@limit_opt :: int, 0); +{{ end }} + +{{ define "GetLatestWorkspaceBuildsByWorkspaceIDs" }} +-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many +SELECT wb.* +FROM ( + SELECT + workspace_id, MAX(build_number) as max_build_number + FROM + workspace_builds + WHERE + workspace_id = ANY(@ids :: uuid [ ]) + GROUP BY + workspace_id +) m +JOIN + {{ template "workspace_builds_rbac" }} wb +ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number; +{{ end }} + +{{ define "GetLatestWorkspaceBuilds" }} +-- name: GetLatestWorkspaceBuilds :many +SELECT wb.* +FROM ( + SELECT + workspace_id, MAX(build_number) as max_build_number + FROM + workspace_builds + GROUP BY + workspace_id +) m +JOIN + {{ template "workspace_builds_rbac" }} wb +ON m.workspace_id = wb.workspace_id AND m.max_build_number = wb.build_number; +{{ end }} diff --git a/coderd/httpmw/workspacebuildparam.go b/coderd/httpmw/workspacebuildparam.go index 7ae728dfa6734..7ee9589768fb4 100644 --- a/coderd/httpmw/workspacebuildparam.go +++ b/coderd/httpmw/workspacebuildparam.go @@ -16,8 +16,8 @@ import ( type workspaceBuildParamContextKey struct{} // WorkspaceBuildParam returns the workspace build from the ExtractWorkspaceBuildParam handler. -func WorkspaceBuildParam(r *http.Request) database.WorkspaceBuild { - workspaceBuild, ok := r.Context().Value(workspaceBuildParamContextKey{}).(database.WorkspaceBuild) +func WorkspaceBuildParam(r *http.Request) database.WorkspaceBuildRBAC { + workspaceBuild, ok := r.Context().Value(workspaceBuildParamContextKey{}).(database.WorkspaceBuildRBAC) if !ok { panic("developer error: workspace build param middleware not provided") } diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 71580400dbf55..de4b8bf2d3423 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -670,14 +670,14 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p return nil, xerrors.Errorf("unmarshal workspace provision input: %w", err) } - var build database.WorkspaceBuild + var build database.WorkspaceBuildRBAC err := server.Database.InTx(func(db database.Store) error { workspaceBuild, err := db.GetWorkspaceBuildByID(ctx, input.WorkspaceBuildID) if err != nil { return xerrors.Errorf("get workspace build: %w", err) } - build, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ + thinBuild, err := db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ ID: input.WorkspaceBuildID, UpdatedAt: database.Now(), ProvisionerState: jobType.WorkspaceBuild.State, @@ -687,6 +687,8 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p if err != nil { return xerrors.Errorf("update workspace build state: %w", err) } + // Keep the same owner args as the original build. + build = thinBuild.Expand(workspaceBuild.OrganizationID, workspaceBuild.WorkspaceOwnerID) return nil }, nil) @@ -719,7 +721,7 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p BuildNumber: previousBuildNumber, }) if prevBuildErr != nil { - previousBuild = database.WorkspaceBuild{} + previousBuild = database.WorkspaceBuildRBAC{} } // We pass the below information to the Auditor so that it @@ -735,7 +737,7 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p server.Logger.Error(ctx, "marshal workspace resource info for failed job", slog.Error(err)) } - audit.BuildAudit(ctx, &audit.BuildAuditParams[database.WorkspaceBuild]{ + audit.BuildAudit(ctx, &audit.BuildAuditParams[database.WorkspaceBuildRBAC]{ Audit: *auditor, Log: server.Logger, UserID: job.InitiatorID, @@ -1039,7 +1041,7 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete BuildNumber: previousBuildNumber, }) if prevBuildErr != nil { - previousBuild = database.WorkspaceBuild{} + previousBuild = database.WorkspaceBuildRBAC{} } // We pass the below information to the Auditor so that it @@ -1055,7 +1057,7 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete server.Logger.Error(ctx, "marshal resource info for successful job", slog.Error(err)) } - audit.BuildAudit(ctx, &audit.BuildAuditParams[database.WorkspaceBuild]{ + audit.BuildAudit(ctx, &audit.BuildAuditParams[database.WorkspaceBuildRBAC]{ Audit: *auditor, Log: server.Logger, UserID: job.InitiatorID, diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 7663dd6d50090..1449655d228b6 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -644,13 +644,14 @@ func TestFailJob(t *testing.T) { ID: uuid.New(), }) require.NoError(t, err) - build, err := srv.Database.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + buildThin, err := srv.Database.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ ID: uuid.New(), WorkspaceID: workspace.ID, Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator, }) require.NoError(t, err) + build := buildThin.WithWorkspace(workspace) input, err := json.Marshal(provisionerdserver.WorkspaceProvisionJob{ WorkspaceBuildID: build.ID, }) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 1200ddbb42464..e92296178aff8 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -503,7 +503,7 @@ func ConvertWorkspace(workspace database.Workspace) Workspace { } // ConvertWorkspaceBuild anonymizes a workspace build. -func ConvertWorkspaceBuild(build database.WorkspaceBuild) WorkspaceBuild { +func ConvertWorkspaceBuild(build database.WorkspaceBuildRBAC) WorkspaceBuild { return WorkspaceBuild{ ID: build.ID, CreatedAt: build.CreatedAt, diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 8c89a47b63754..dae70f0c3c470 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -41,7 +41,7 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) { return } - data, err := api.workspaceBuildsData(ctx, []database.Workspace{workspace}, []database.WorkspaceBuild{workspaceBuild}) + data, err := api.workspaceBuildsData(ctx, []database.Workspace{workspace}, []database.WorkspaceBuildRBAC{workspaceBuild}) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error getting workspace build data.", @@ -113,7 +113,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { } } - var workspaceBuilds []database.WorkspaceBuild + var workspaceBuilds []database.WorkspaceBuildRBAC // Ensure all db calls happen in the same tx err := api.Database.InTx(func(store database.Store) error { var err error @@ -253,7 +253,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ return } - data, err := api.workspaceBuildsData(ctx, []database.Workspace{workspace}, []database.WorkspaceBuild{workspaceBuild}) + data, err := api.workspaceBuildsData(ctx, []database.Workspace{workspace}, []database.WorkspaceBuildRBAC{workspaceBuild}) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error getting workspace build data.", @@ -526,7 +526,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } - var workspaceBuild database.WorkspaceBuild + var workspaceBuild database.WorkspaceBuildRBAC var provisionerJob database.ProvisionerJob // This must happen in a transaction to ensure history can be inserted, and // the prior history can update it's "after" column to point at the new. @@ -584,7 +584,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("insert provisioner job: %w", err) } - workspaceBuild, err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + thinBuild, err := db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ ID: workspaceBuildID, CreatedAt: database.Now(), UpdatedAt: database.Now(), @@ -601,6 +601,9 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("insert workspace build: %w", err) } + // Assign owning fields. + workspaceBuild = thinBuild.WithWorkspace(workspace) + names := make([]string, 0, len(parameters)) values := make([]string, 0, len(parameters)) for _, param := range parameters { @@ -923,7 +926,7 @@ type workspaceBuildsData struct { apps []database.WorkspaceApp } -func (api *API) workspaceBuildsData(ctx context.Context, workspaces []database.Workspace, workspaceBuilds []database.WorkspaceBuild) (workspaceBuildsData, error) { +func (api *API) workspaceBuildsData(ctx context.Context, workspaces []database.Workspace, workspaceBuilds []database.WorkspaceBuildRBAC) (workspaceBuildsData, error) { userIDs := make([]uuid.UUID, 0, len(workspaceBuilds)) for _, build := range workspaceBuilds { userIDs = append(userIDs, build.InitiatorID) @@ -1014,7 +1017,7 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaces []database.W } func (api *API) convertWorkspaceBuilds( - workspaceBuilds []database.WorkspaceBuild, + workspaceBuilds []database.WorkspaceBuildRBAC, workspaces []database.Workspace, jobs []database.ProvisionerJob, users []database.User, @@ -1075,7 +1078,7 @@ func (api *API) convertWorkspaceBuilds( } func (api *API) convertWorkspaceBuild( - build database.WorkspaceBuild, + build database.WorkspaceBuildRBAC, workspace database.Workspace, job database.ProvisionerJob, users []database.User, diff --git a/coderd/workspaces.go b/coderd/workspaces.go index f97759d0e9d7e..1472f1815d6cd 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -475,7 +475,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req tags := provisionerdserver.MutateTags(user.ID, templateVersionJob.Tags) var ( provisionerJob database.ProvisionerJob - workspaceBuild database.WorkspaceBuild + workspaceBuild database.WorkspaceBuildRBAC ) err = api.Database.InTx(func(db database.Store) error { now := database.Now() @@ -540,7 +540,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req if err != nil { return xerrors.Errorf("insert provisioner job: %w", err) } - workspaceBuild, err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + workspaceBuildThin, err := db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ ID: workspaceBuildID, CreatedAt: now, UpdatedAt: now, @@ -556,6 +556,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req if err != nil { return xerrors.Errorf("insert workspace build: %w", err) } + workspaceBuild = workspaceBuildThin.WithWorkspace(workspace) names := make([]string, 0, len(createWorkspace.RichParameterValues)) values := make([]string, 0, len(createWorkspace.RichParameterValues)) diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 3e06f307c2efd..51a239b7e6cd9 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -19,7 +19,7 @@ We track the following resources: | TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| | User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
organization_idfalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
workspace_owner_idfalse
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 7e4611cc2d6c3..16ec0ee3be19b 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -126,6 +126,8 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "reason": ActionIgnore, "daily_cost": ActionIgnore, "max_deadline": ActionIgnore, + "organization_id": ActionIgnore, + "workspace_owner_id": ActionIgnore, }, &database.AuditableGroup{}: { "id": ActionTrack, From 0a6fd1d79ffddbbbf48929b8f357f997cd9bb276 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 10 Mar 2023 10:56:59 -0600 Subject: [PATCH 2/4] Add WorkspaceBuildRBAC to audit list --- enterprise/audit/table.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 16ec0ee3be19b..8c1a66552a207 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -111,7 +111,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "ttl": ActionTrack, "last_used_at": ActionIgnore, }, - &database.WorkspaceBuild{}: { + &database.WorkspaceBuildRBAC{}: { "id": ActionIgnore, "created_at": ActionIgnore, "updated_at": ActionIgnore, From be0fc713e7e5e9ea2c5202b1d73945500d10cad2 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 10 Mar 2023 11:10:51 -0600 Subject: [PATCH 3/4] Make gen --- docs/admin/audit-logs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 51a239b7e6cd9..624018c92f070 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -19,7 +19,7 @@ We track the following resources: | TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| | User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
organization_idfalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
workspace_owner_idfalse
| +| WorkspaceBuildRBAC
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
organization_idfalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
workspace_owner_idfalse
| From ca0d9a821749e071700265afd2228b34f3b0c5de Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 10 Mar 2023 13:18:00 -0600 Subject: [PATCH 4/4] Delete duplciate code --- coderd/database/sqlxqueries/workspace.gosql | 5 ----- 1 file changed, 5 deletions(-) diff --git a/coderd/database/sqlxqueries/workspace.gosql b/coderd/database/sqlxqueries/workspace.gosql index acf274b8a5abd..abb2d171f435e 100644 --- a/coderd/database/sqlxqueries/workspace.gosql +++ b/coderd/database/sqlxqueries/workspace.gosql @@ -29,11 +29,6 @@ WHERE job_id = @job_id ELSE true END - AND CASE - WHEN @job_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - job_id = @job_id - ELSE true - END AND CASE WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN created_at > @created_after