diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index ac204f85f5..e80307ffc4 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -9,10 +9,10 @@ const ( CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users CheckUsersUsernameMinLength CheckConstraint = "users_username_min_length" // users CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs - CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters - CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events CheckMaxLogsLength CheckConstraint = "max_logs_length" // workspace_agents CheckSubsystemsNotNone CheckConstraint = "subsystems_not_none" // workspace_agents CheckWorkspaceBuildsAiTaskSidebarAppIDRequired CheckConstraint = "workspace_builds_ai_task_sidebar_app_id_required" // workspace_builds CheckWorkspaceBuildsDeadlineBelowMaxDeadline CheckConstraint = "workspace_builds_deadline_below_max_deadline" // workspace_builds + CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters + CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events ) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 716d6e3825..0d2a5f4435 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2885,6 +2885,14 @@ func (q *querier) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) return q.db.GetTailnetTunnelPeerIDs(ctx, srcID) } +func (q *querier) GetTaskByID(ctx context.Context, id uuid.UUID) (database.Task, error) { + return fetch(q.log, q.auth, q.db.GetTaskByID)(ctx, id) +} + +func (q *querier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.Task, error) { + return fetch(q.log, q.auth, q.db.GetTaskByWorkspaceID)(ctx, workspaceID) +} + func (q *querier) GetTelemetryItem(ctx context.Context, key string) (database.TelemetryItem, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return database.TelemetryItem{}, err @@ -4110,6 +4118,17 @@ func (q *querier) InsertReplica(ctx context.Context, arg database.InsertReplicaP return q.db.InsertReplica(ctx, arg) } +func (q *querier) InsertTask(ctx context.Context, arg database.InsertTaskParams) (database.TaskTable, error) { + // Ensure the actor can access the specified template version (and thus its template). + if _, err := q.GetTemplateVersionByID(ctx, arg.TemplateVersionID); err != nil { + return database.TaskTable{}, err + } + + obj := rbac.ResourceTask.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID) + + return insert(q.log, q.auth, obj, q.db.InsertTask)(ctx, arg) +} + func (q *querier) InsertTelemetryItemIfNotExists(ctx context.Context, arg database.InsertTelemetryItemIfNotExistsParams) error { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { return err @@ -5668,6 +5687,18 @@ func (q *querier) UpsertTailnetTunnel(ctx context.Context, arg database.UpsertTa return q.db.UpsertTailnetTunnel(ctx, arg) } +func (q *querier) UpsertTaskWorkspaceApp(ctx context.Context, arg database.UpsertTaskWorkspaceAppParams) (database.TaskWorkspaceApp, error) { + // Fetch the task to derive the RBAC object and authorize update on it. + task, err := q.db.GetTaskByID(ctx, arg.TaskID) + if err != nil { + return database.TaskWorkspaceApp{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, task); err != nil { + return database.TaskWorkspaceApp{}, err + } + return q.db.UpsertTaskWorkspaceApp(ctx, arg) +} + func (q *querier) UpsertTelemetryItem(ctx context.Context, arg database.UpsertTelemetryItemParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 19cac48cb4..4034a4878f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2347,6 +2347,53 @@ func (s *MethodTestSuite) TestWorkspacePortSharing() { })) } +func (s *MethodTestSuite) TestTasks() { + s.Run("GetTaskByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + task := testutil.Fake(s.T(), faker, database.Task{}) + dbm.EXPECT().GetTaskByID(gomock.Any(), task.ID).Return(task, nil).AnyTimes() + check.Args(task.ID).Asserts(task, policy.ActionRead).Returns(task) + })) + s.Run("InsertTask", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + tpl := testutil.Fake(s.T(), faker, database.Template{}) + tv := testutil.Fake(s.T(), faker, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, + OrganizationID: tpl.OrganizationID, + }) + + arg := testutil.Fake(s.T(), faker, database.InsertTaskParams{ + OrganizationID: tpl.OrganizationID, + TemplateVersionID: tv.ID, + }) + + dbm.EXPECT().GetTemplateVersionByID(gomock.Any(), tv.ID).Return(tv, nil).AnyTimes() + dbm.EXPECT().GetTemplateByID(gomock.Any(), tpl.ID).Return(tpl, nil).AnyTimes() + dbm.EXPECT().InsertTask(gomock.Any(), arg).Return(database.TaskTable{}, nil).AnyTimes() + + check.Args(arg).Asserts( + tpl, policy.ActionRead, + rbac.ResourceTask.InOrg(arg.OrganizationID).WithOwner(arg.OwnerID.String()), policy.ActionCreate, + ).Returns(database.TaskTable{}) + })) + s.Run("UpsertTaskWorkspaceApp", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + task := testutil.Fake(s.T(), faker, database.Task{}) + arg := database.UpsertTaskWorkspaceAppParams{ + TaskID: task.ID, + WorkspaceBuildNumber: 1, + } + + dbm.EXPECT().GetTaskByID(gomock.Any(), task.ID).Return(task, nil).AnyTimes() + dbm.EXPECT().UpsertTaskWorkspaceApp(gomock.Any(), arg).Return(database.TaskWorkspaceApp{}, nil).AnyTimes() + + check.Args(arg).Asserts(task, policy.ActionUpdate).Returns(database.TaskWorkspaceApp{}) + })) + s.Run("GetTaskByWorkspaceID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + task := testutil.Fake(s.T(), faker, database.Task{}) + task.WorkspaceID = uuid.NullUUID{UUID: uuid.New(), Valid: true} + dbm.EXPECT().GetTaskByWorkspaceID(gomock.Any(), task.WorkspaceID.UUID).Return(task, nil).AnyTimes() + check.Args(task.WorkspaceID.UUID).Asserts(task, policy.ActionRead).Returns(task) + })) +} + func (s *MethodTestSuite) TestProvisionerKeys() { s.Run("InsertProvisionerKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { org := testutil.Fake(s.T(), faker, database.Organization{}) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 289d664a1a..e00ff31b96 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -28,6 +28,7 @@ import ( "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/taskname" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/provisionerd/proto" @@ -1560,6 +1561,43 @@ func AIBridgeToolUsage(t testing.TB, db database.Store, seed database.InsertAIBr return toolUsage } +func Task(t testing.TB, db database.Store, orig database.TaskTable) database.TaskTable { + t.Helper() + + parameters := orig.TemplateParameters + if parameters == nil { + parameters = json.RawMessage([]byte("{}")) + } + + task, err := db.InsertTask(genCtx, database.InsertTaskParams{ + OrganizationID: orig.OrganizationID, + OwnerID: orig.OwnerID, + Name: takeFirst(orig.Name, taskname.GenerateFallback()), + WorkspaceID: orig.WorkspaceID, + TemplateVersionID: orig.TemplateVersionID, + TemplateParameters: parameters, + Prompt: orig.Prompt, + CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), + }) + require.NoError(t, err, "failed to insert task") + + return task +} + +func TaskWorkspaceApp(t testing.TB, db database.Store, orig database.TaskWorkspaceApp) database.TaskWorkspaceApp { + t.Helper() + + app, err := db.UpsertTaskWorkspaceApp(genCtx, database.UpsertTaskWorkspaceAppParams{ + TaskID: orig.TaskID, + WorkspaceBuildNumber: orig.WorkspaceBuildNumber, + WorkspaceAgentID: orig.WorkspaceAgentID, + WorkspaceAppID: orig.WorkspaceAppID, + }) + require.NoError(t, err, "failed to upsert task workspace app") + + return app +} + func provisionerJobTiming(t testing.TB, db database.Store, seed database.ProvisionerJobTiming) database.ProvisionerJobTiming { timing, err := db.InsertProvisionerJobTimings(genCtx, database.InsertProvisionerJobTimingsParams{ JobID: takeFirst(seed.JobID, uuid.New()), diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index d2f504964d..5f191fe663 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1482,6 +1482,20 @@ func (m queryMetricsStore) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uu return r0, r1 } +func (m queryMetricsStore) GetTaskByID(ctx context.Context, id uuid.UUID) (database.Task, error) { + start := time.Now() + r0, r1 := m.s.GetTaskByID(ctx, id) + m.queryLatencies.WithLabelValues("GetTaskByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.Task, error) { + start := time.Now() + r0, r1 := m.s.GetTaskByWorkspaceID(ctx, workspaceID) + m.queryLatencies.WithLabelValues("GetTaskByWorkspaceID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetTelemetryItem(ctx context.Context, key string) (database.TelemetryItem, error) { start := time.Now() r0, r1 := m.s.GetTelemetryItem(ctx, key) @@ -2455,6 +2469,13 @@ func (m queryMetricsStore) InsertReplica(ctx context.Context, arg database.Inser return replica, err } +func (m queryMetricsStore) InsertTask(ctx context.Context, arg database.InsertTaskParams) (database.TaskTable, error) { + start := time.Now() + r0, r1 := m.s.InsertTask(ctx, arg) + m.queryLatencies.WithLabelValues("InsertTask").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) InsertTelemetryItemIfNotExists(ctx context.Context, arg database.InsertTelemetryItemIfNotExistsParams) error { start := time.Now() r0 := m.s.InsertTelemetryItemIfNotExists(ctx, arg) @@ -3533,6 +3554,13 @@ func (m queryMetricsStore) UpsertTailnetTunnel(ctx context.Context, arg database return r0, r1 } +func (m queryMetricsStore) UpsertTaskWorkspaceApp(ctx context.Context, arg database.UpsertTaskWorkspaceAppParams) (database.TaskWorkspaceApp, error) { + start := time.Now() + r0, r1 := m.s.UpsertTaskWorkspaceApp(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertTaskWorkspaceApp").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpsertTelemetryItem(ctx context.Context, arg database.UpsertTelemetryItemParams) error { start := time.Now() r0 := m.s.UpsertTelemetryItem(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 09edffc9de..9dc5f1adf2 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3119,6 +3119,36 @@ func (mr *MockStoreMockRecorder) GetTailnetTunnelPeerIDs(ctx, srcID any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTailnetTunnelPeerIDs", reflect.TypeOf((*MockStore)(nil).GetTailnetTunnelPeerIDs), ctx, srcID) } +// GetTaskByID mocks base method. +func (m *MockStore) GetTaskByID(ctx context.Context, id uuid.UUID) (database.Task, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTaskByID", ctx, id) + ret0, _ := ret[0].(database.Task) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTaskByID indicates an expected call of GetTaskByID. +func (mr *MockStoreMockRecorder) GetTaskByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTaskByID", reflect.TypeOf((*MockStore)(nil).GetTaskByID), ctx, id) +} + +// GetTaskByWorkspaceID mocks base method. +func (m *MockStore) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.Task, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTaskByWorkspaceID", ctx, workspaceID) + ret0, _ := ret[0].(database.Task) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTaskByWorkspaceID indicates an expected call of GetTaskByWorkspaceID. +func (mr *MockStoreMockRecorder) GetTaskByWorkspaceID(ctx, workspaceID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTaskByWorkspaceID", reflect.TypeOf((*MockStore)(nil).GetTaskByWorkspaceID), ctx, workspaceID) +} + // GetTelemetryItem mocks base method. func (m *MockStore) GetTelemetryItem(ctx context.Context, key string) (database.TelemetryItem, error) { m.ctrl.T.Helper() @@ -5244,6 +5274,21 @@ func (mr *MockStoreMockRecorder) InsertReplica(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertReplica", reflect.TypeOf((*MockStore)(nil).InsertReplica), ctx, arg) } +// InsertTask mocks base method. +func (m *MockStore) InsertTask(ctx context.Context, arg database.InsertTaskParams) (database.TaskTable, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertTask", ctx, arg) + ret0, _ := ret[0].(database.TaskTable) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertTask indicates an expected call of InsertTask. +func (mr *MockStoreMockRecorder) InsertTask(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertTask", reflect.TypeOf((*MockStore)(nil).InsertTask), ctx, arg) +} + // InsertTelemetryItemIfNotExists mocks base method. func (m *MockStore) InsertTelemetryItemIfNotExists(ctx context.Context, arg database.InsertTelemetryItemIfNotExistsParams) error { m.ctrl.T.Helper() @@ -7517,6 +7562,21 @@ func (mr *MockStoreMockRecorder) UpsertTailnetTunnel(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTailnetTunnel", reflect.TypeOf((*MockStore)(nil).UpsertTailnetTunnel), ctx, arg) } +// UpsertTaskWorkspaceApp mocks base method. +func (m *MockStore) UpsertTaskWorkspaceApp(ctx context.Context, arg database.UpsertTaskWorkspaceAppParams) (database.TaskWorkspaceApp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertTaskWorkspaceApp", ctx, arg) + ret0, _ := ret[0].(database.TaskWorkspaceApp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertTaskWorkspaceApp indicates an expected call of UpsertTaskWorkspaceApp. +func (mr *MockStoreMockRecorder) UpsertTaskWorkspaceApp(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTaskWorkspaceApp", reflect.TypeOf((*MockStore)(nil).UpsertTaskWorkspaceApp), ctx, arg) +} + // UpsertTelemetryItem mocks base method. func (m *MockStore) UpsertTelemetryItem(ctx context.Context, arg database.UpsertTelemetryItemParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index cef9e37445..daa55fb1bb 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -477,6 +477,15 @@ CREATE TYPE tailnet_status AS ENUM ( 'lost' ); +CREATE TYPE task_status AS ENUM ( + 'pending', + 'initializing', + 'active', + 'paused', + 'unknown', + 'error' +); + CREATE TYPE user_status AS ENUM ( 'active', 'suspended', @@ -1796,9 +1805,9 @@ CREATE TABLE tailnet_tunnels ( CREATE TABLE task_workspace_apps ( task_id uuid NOT NULL, - workspace_build_id uuid NOT NULL, - workspace_agent_id uuid NOT NULL, - workspace_app_id uuid NOT NULL + workspace_agent_id uuid, + workspace_app_id uuid, + workspace_build_number integer NOT NULL ); CREATE TABLE tasks ( @@ -1814,6 +1823,180 @@ CREATE TABLE tasks ( deleted_at timestamp with time zone ); +CREATE TABLE workspace_agents ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + name character varying(64) NOT NULL, + first_connected_at timestamp with time zone, + last_connected_at timestamp with time zone, + disconnected_at timestamp with time zone, + resource_id uuid NOT NULL, + auth_token uuid NOT NULL, + auth_instance_id character varying, + architecture character varying(64) NOT NULL, + environment_variables jsonb, + operating_system character varying(64) NOT NULL, + instance_metadata jsonb, + resource_metadata jsonb, + directory character varying(4096) DEFAULT ''::character varying NOT NULL, + version text DEFAULT ''::text NOT NULL, + last_connected_replica_id uuid, + connection_timeout_seconds integer DEFAULT 0 NOT NULL, + troubleshooting_url text DEFAULT ''::text NOT NULL, + motd_file text DEFAULT ''::text NOT NULL, + lifecycle_state workspace_agent_lifecycle_state DEFAULT 'created'::workspace_agent_lifecycle_state NOT NULL, + expanded_directory character varying(4096) DEFAULT ''::character varying NOT NULL, + logs_length integer DEFAULT 0 NOT NULL, + logs_overflowed boolean DEFAULT false NOT NULL, + started_at timestamp with time zone, + ready_at timestamp with time zone, + subsystems workspace_agent_subsystem[] DEFAULT '{}'::workspace_agent_subsystem[], + display_apps display_app[] DEFAULT '{vscode,vscode_insiders,web_terminal,ssh_helper,port_forwarding_helper}'::display_app[], + api_version text DEFAULT ''::text NOT NULL, + display_order integer DEFAULT 0 NOT NULL, + parent_id uuid, + api_key_scope agent_key_scope_enum DEFAULT 'all'::agent_key_scope_enum NOT NULL, + deleted boolean DEFAULT false NOT NULL, + CONSTRAINT max_logs_length CHECK ((logs_length <= 1048576)), + CONSTRAINT subsystems_not_none CHECK ((NOT ('none'::workspace_agent_subsystem = ANY (subsystems)))) +); + +COMMENT ON COLUMN workspace_agents.version IS 'Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.'; + +COMMENT ON COLUMN workspace_agents.connection_timeout_seconds IS 'Connection timeout in seconds, 0 means disabled.'; + +COMMENT ON COLUMN workspace_agents.troubleshooting_url IS 'URL for troubleshooting the agent.'; + +COMMENT ON COLUMN workspace_agents.motd_file IS 'Path to file inside workspace containing the message of the day (MOTD) to show to the user when logging in via SSH.'; + +COMMENT ON COLUMN workspace_agents.lifecycle_state IS 'The current lifecycle state reported by the workspace agent.'; + +COMMENT ON COLUMN workspace_agents.expanded_directory IS 'The resolved path of a user-specified directory. e.g. ~/coder -> /home/coder/coder'; + +COMMENT ON COLUMN workspace_agents.logs_length IS 'Total length of startup logs'; + +COMMENT ON COLUMN workspace_agents.logs_overflowed IS 'Whether the startup logs overflowed in length'; + +COMMENT ON COLUMN workspace_agents.started_at IS 'The time the agent entered the starting lifecycle state'; + +COMMENT ON COLUMN workspace_agents.ready_at IS 'The time the agent entered the ready or start_error lifecycle state'; + +COMMENT ON COLUMN workspace_agents.display_order IS 'Specifies the order in which to display agents in user interfaces.'; + +COMMENT ON COLUMN workspace_agents.api_key_scope IS 'Defines the scope of the API key associated with the agent. ''all'' allows access to everything, ''no_user_data'' restricts it to exclude user data.'; + +COMMENT ON COLUMN workspace_agents.deleted IS 'Indicates whether or not the agent has been deleted. This is currently only applicable to sub agents.'; + +CREATE TABLE workspace_apps ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + agent_id uuid NOT NULL, + display_name character varying(64) NOT NULL, + icon character varying(256) NOT NULL, + command character varying(65534), + url character varying(65534), + healthcheck_url text DEFAULT ''::text NOT NULL, + healthcheck_interval integer DEFAULT 0 NOT NULL, + healthcheck_threshold integer DEFAULT 0 NOT NULL, + health workspace_app_health DEFAULT 'disabled'::workspace_app_health NOT NULL, + subdomain boolean DEFAULT false NOT NULL, + sharing_level app_sharing_level DEFAULT 'owner'::app_sharing_level NOT NULL, + slug text NOT NULL, + external boolean DEFAULT false NOT NULL, + display_order integer DEFAULT 0 NOT NULL, + hidden boolean DEFAULT false NOT NULL, + open_in workspace_app_open_in DEFAULT 'slim-window'::workspace_app_open_in NOT NULL, + display_group text, + tooltip character varying(2048) DEFAULT ''::character varying NOT NULL +); + +COMMENT ON COLUMN workspace_apps.display_order IS 'Specifies the order in which to display agent app in user interfaces.'; + +COMMENT ON COLUMN workspace_apps.hidden IS 'Determines if the app is not shown in user interfaces.'; + +COMMENT ON COLUMN workspace_apps.tooltip IS 'Markdown text that is displayed when hovering over workspace apps.'; + +CREATE TABLE workspace_builds ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + workspace_id uuid NOT NULL, + template_version_id uuid NOT NULL, + build_number integer NOT NULL, + transition workspace_transition NOT NULL, + initiator_id uuid NOT NULL, + provisioner_state bytea, + job_id uuid NOT NULL, + deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, + reason build_reason DEFAULT 'initiator'::build_reason NOT NULL, + daily_cost integer DEFAULT 0 NOT NULL, + max_deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, + template_version_preset_id uuid, + has_ai_task boolean, + ai_task_sidebar_app_id uuid, + has_external_agent boolean, + CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL)))), + CONSTRAINT workspace_builds_deadline_below_max_deadline CHECK ((((deadline <> '0001-01-01 00:00:00+00'::timestamp with time zone) AND (deadline <= max_deadline)) OR (max_deadline = '0001-01-01 00:00:00+00'::timestamp with time zone))) +); + +CREATE VIEW tasks_with_status AS + SELECT tasks.id, + tasks.organization_id, + tasks.owner_id, + tasks.name, + tasks.workspace_id, + tasks.template_version_id, + tasks.template_parameters, + tasks.prompt, + tasks.created_at, + tasks.deleted_at, + CASE + WHEN ((tasks.workspace_id IS NULL) OR (latest_build.job_status IS NULL)) THEN 'pending'::task_status + WHEN (latest_build.job_status = 'failed'::provisioner_job_status) THEN 'error'::task_status + WHEN ((latest_build.transition = ANY (ARRAY['stop'::workspace_transition, 'delete'::workspace_transition])) AND (latest_build.job_status = 'succeeded'::provisioner_job_status)) THEN 'paused'::task_status + WHEN ((latest_build.transition = 'start'::workspace_transition) AND (latest_build.job_status = 'pending'::provisioner_job_status)) THEN 'initializing'::task_status + WHEN ((latest_build.transition = 'start'::workspace_transition) AND (latest_build.job_status = ANY (ARRAY['running'::provisioner_job_status, 'succeeded'::provisioner_job_status]))) THEN + CASE + WHEN agent_status."none" THEN 'initializing'::task_status + WHEN agent_status.connecting THEN 'initializing'::task_status + WHEN agent_status.connected THEN + CASE + WHEN app_status.any_unhealthy THEN 'error'::task_status + WHEN app_status.any_initializing THEN 'initializing'::task_status + WHEN app_status.all_healthy_or_disabled THEN 'active'::task_status + ELSE 'unknown'::task_status + END + ELSE 'unknown'::task_status + END + ELSE 'unknown'::task_status + END AS status + FROM ((((tasks + LEFT JOIN LATERAL ( SELECT task_app_1.workspace_build_number, + task_app_1.workspace_agent_id, + task_app_1.workspace_app_id + FROM task_workspace_apps task_app_1 + WHERE (task_app_1.task_id = tasks.id) + ORDER BY task_app_1.workspace_build_number DESC + LIMIT 1) task_app ON (true)) + LEFT JOIN LATERAL ( SELECT workspace_build.transition, + provisioner_job.job_status, + workspace_build.job_id + FROM (workspace_builds workspace_build + JOIN provisioner_jobs provisioner_job ON ((provisioner_job.id = workspace_build.job_id))) + WHERE ((workspace_build.workspace_id = tasks.workspace_id) AND (workspace_build.build_number = task_app.workspace_build_number))) latest_build ON (true)) + CROSS JOIN LATERAL ( SELECT (count(*) = 0) AS "none", + bool_or((workspace_agent.lifecycle_state = ANY (ARRAY['created'::workspace_agent_lifecycle_state, 'starting'::workspace_agent_lifecycle_state]))) AS connecting, + bool_and((workspace_agent.lifecycle_state = 'ready'::workspace_agent_lifecycle_state)) AS connected + FROM workspace_agents workspace_agent + WHERE (workspace_agent.id = task_app.workspace_agent_id)) agent_status) + CROSS JOIN LATERAL ( SELECT bool_or((workspace_app.health = 'unhealthy'::workspace_app_health)) AS any_unhealthy, + bool_or((workspace_app.health = 'initializing'::workspace_app_health)) AS any_initializing, + bool_and((workspace_app.health = ANY (ARRAY['healthy'::workspace_app_health, 'disabled'::workspace_app_health]))) AS all_healthy_or_disabled + FROM workspace_apps workspace_app + WHERE (workspace_app.id = task_app.workspace_app_id)) app_status) + WHERE (tasks.deleted_at IS NULL); + CREATE TABLE telemetry_items ( key text NOT NULL, value text NOT NULL, @@ -2377,71 +2560,6 @@ CREATE TABLE workspace_agent_volume_resource_monitors ( debounced_until timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL ); -CREATE TABLE workspace_agents ( - id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - name character varying(64) NOT NULL, - first_connected_at timestamp with time zone, - last_connected_at timestamp with time zone, - disconnected_at timestamp with time zone, - resource_id uuid NOT NULL, - auth_token uuid NOT NULL, - auth_instance_id character varying, - architecture character varying(64) NOT NULL, - environment_variables jsonb, - operating_system character varying(64) NOT NULL, - instance_metadata jsonb, - resource_metadata jsonb, - directory character varying(4096) DEFAULT ''::character varying NOT NULL, - version text DEFAULT ''::text NOT NULL, - last_connected_replica_id uuid, - connection_timeout_seconds integer DEFAULT 0 NOT NULL, - troubleshooting_url text DEFAULT ''::text NOT NULL, - motd_file text DEFAULT ''::text NOT NULL, - lifecycle_state workspace_agent_lifecycle_state DEFAULT 'created'::workspace_agent_lifecycle_state NOT NULL, - expanded_directory character varying(4096) DEFAULT ''::character varying NOT NULL, - logs_length integer DEFAULT 0 NOT NULL, - logs_overflowed boolean DEFAULT false NOT NULL, - started_at timestamp with time zone, - ready_at timestamp with time zone, - subsystems workspace_agent_subsystem[] DEFAULT '{}'::workspace_agent_subsystem[], - display_apps display_app[] DEFAULT '{vscode,vscode_insiders,web_terminal,ssh_helper,port_forwarding_helper}'::display_app[], - api_version text DEFAULT ''::text NOT NULL, - display_order integer DEFAULT 0 NOT NULL, - parent_id uuid, - api_key_scope agent_key_scope_enum DEFAULT 'all'::agent_key_scope_enum NOT NULL, - deleted boolean DEFAULT false NOT NULL, - CONSTRAINT max_logs_length CHECK ((logs_length <= 1048576)), - CONSTRAINT subsystems_not_none CHECK ((NOT ('none'::workspace_agent_subsystem = ANY (subsystems)))) -); - -COMMENT ON COLUMN workspace_agents.version IS 'Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.'; - -COMMENT ON COLUMN workspace_agents.connection_timeout_seconds IS 'Connection timeout in seconds, 0 means disabled.'; - -COMMENT ON COLUMN workspace_agents.troubleshooting_url IS 'URL for troubleshooting the agent.'; - -COMMENT ON COLUMN workspace_agents.motd_file IS 'Path to file inside workspace containing the message of the day (MOTD) to show to the user when logging in via SSH.'; - -COMMENT ON COLUMN workspace_agents.lifecycle_state IS 'The current lifecycle state reported by the workspace agent.'; - -COMMENT ON COLUMN workspace_agents.expanded_directory IS 'The resolved path of a user-specified directory. e.g. ~/coder -> /home/coder/coder'; - -COMMENT ON COLUMN workspace_agents.logs_length IS 'Total length of startup logs'; - -COMMENT ON COLUMN workspace_agents.logs_overflowed IS 'Whether the startup logs overflowed in length'; - -COMMENT ON COLUMN workspace_agents.started_at IS 'The time the agent entered the starting lifecycle state'; - -COMMENT ON COLUMN workspace_agents.ready_at IS 'The time the agent entered the ready or start_error lifecycle state'; - -COMMENT ON COLUMN workspace_agents.display_order IS 'Specifies the order in which to display agents in user interfaces.'; - -COMMENT ON COLUMN workspace_agents.api_key_scope IS 'Defines the scope of the API key associated with the agent. ''all'' allows access to everything, ''no_user_data'' restricts it to exclude user data.'; - -COMMENT ON COLUMN workspace_agents.deleted IS 'Indicates whether or not the agent has been deleted. This is currently only applicable to sub agents.'; - CREATE UNLOGGED TABLE workspace_app_audit_sessions ( agent_id uuid NOT NULL, app_id uuid NOT NULL, @@ -2530,35 +2648,6 @@ CREATE TABLE workspace_app_statuses ( uri text ); -CREATE TABLE workspace_apps ( - id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - agent_id uuid NOT NULL, - display_name character varying(64) NOT NULL, - icon character varying(256) NOT NULL, - command character varying(65534), - url character varying(65534), - healthcheck_url text DEFAULT ''::text NOT NULL, - healthcheck_interval integer DEFAULT 0 NOT NULL, - healthcheck_threshold integer DEFAULT 0 NOT NULL, - health workspace_app_health DEFAULT 'disabled'::workspace_app_health NOT NULL, - subdomain boolean DEFAULT false NOT NULL, - sharing_level app_sharing_level DEFAULT 'owner'::app_sharing_level NOT NULL, - slug text NOT NULL, - external boolean DEFAULT false NOT NULL, - display_order integer DEFAULT 0 NOT NULL, - hidden boolean DEFAULT false NOT NULL, - open_in workspace_app_open_in DEFAULT 'slim-window'::workspace_app_open_in NOT NULL, - display_group text, - tooltip character varying(2048) DEFAULT ''::character varying NOT NULL -); - -COMMENT ON COLUMN workspace_apps.display_order IS 'Specifies the order in which to display agent app in user interfaces.'; - -COMMENT ON COLUMN workspace_apps.hidden IS 'Determines if the app is not shown in user interfaces.'; - -COMMENT ON COLUMN workspace_apps.tooltip IS 'Markdown text that is displayed when hovering over workspace apps.'; - CREATE TABLE workspace_build_parameters ( workspace_build_id uuid NOT NULL, name text NOT NULL, @@ -2569,29 +2658,6 @@ COMMENT ON COLUMN workspace_build_parameters.name IS 'Parameter name'; COMMENT ON COLUMN workspace_build_parameters.value IS 'Parameter value'; -CREATE TABLE workspace_builds ( - id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - workspace_id uuid NOT NULL, - template_version_id uuid NOT NULL, - build_number integer NOT NULL, - transition workspace_transition NOT NULL, - initiator_id uuid NOT NULL, - provisioner_state bytea, - job_id uuid NOT NULL, - deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, - reason build_reason DEFAULT 'initiator'::build_reason NOT NULL, - daily_cost integer DEFAULT 0 NOT NULL, - max_deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, - template_version_preset_id uuid, - has_ai_task boolean, - ai_task_sidebar_app_id uuid, - has_external_agent boolean, - CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL)))), - CONSTRAINT workspace_builds_deadline_below_max_deadline CHECK ((((deadline <> '0001-01-01 00:00:00+00'::timestamp with time zone) AND (deadline <= max_deadline)) OR (max_deadline = '0001-01-01 00:00:00+00'::timestamp with time zone))) -); - CREATE VIEW workspace_build_with_user AS SELECT workspace_builds.id, workspace_builds.created_at, @@ -3007,6 +3073,9 @@ ALTER TABLE ONLY tailnet_peers ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_pkey PRIMARY KEY (coordinator_id, src_id, dst_id); +ALTER TABLE ONLY task_workspace_apps + ADD CONSTRAINT task_workspace_apps_pkey PRIMARY KEY (task_id, workspace_build_number); + ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_pkey PRIMARY KEY (id); @@ -3272,6 +3341,12 @@ COMMENT ON INDEX provisioner_jobs_worker_id_organization_id_completed_at_idx IS CREATE UNIQUE INDEX provisioner_keys_organization_id_name_idx ON provisioner_keys USING btree (organization_id, lower((name)::text)); +CREATE INDEX tasks_organization_id_idx ON tasks USING btree (organization_id); + +CREATE INDEX tasks_owner_id_idx ON tasks USING btree (owner_id); + +CREATE INDEX tasks_workspace_id_idx ON tasks USING btree (workspace_id); + CREATE INDEX template_usage_stats_start_time_idx ON template_usage_stats USING btree (start_time DESC); COMMENT ON INDEX template_usage_stats_start_time_idx IS 'Index for querying MAX(start_time).'; @@ -3552,9 +3627,6 @@ ALTER TABLE ONLY task_workspace_apps ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_workspace_app_id_fkey FOREIGN KEY (workspace_app_id) REFERENCES workspace_apps(id) ON DELETE CASCADE; -ALTER TABLE ONLY task_workspace_apps - ADD CONSTRAINT task_workspace_apps_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE; - ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 8497ac2bcd..4c629797d1 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -48,7 +48,6 @@ const ( ForeignKeyTaskWorkspaceAppsTaskID ForeignKeyConstraint = "task_workspace_apps_task_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; ForeignKeyTaskWorkspaceAppsWorkspaceAgentID ForeignKeyConstraint = "task_workspace_apps_workspace_agent_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyTaskWorkspaceAppsWorkspaceAppID ForeignKeyConstraint = "task_workspace_apps_workspace_app_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_workspace_app_id_fkey FOREIGN KEY (workspace_app_id) REFERENCES workspace_apps(id) ON DELETE CASCADE; - ForeignKeyTaskWorkspaceAppsWorkspaceBuildID ForeignKeyConstraint = "task_workspace_apps_workspace_build_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE; ForeignKeyTasksOrganizationID ForeignKeyConstraint = "tasks_organization_id_fkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyTasksOwnerID ForeignKeyConstraint = "tasks_owner_id_fkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyTasksTemplateVersionID ForeignKeyConstraint = "tasks_template_version_id_fkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000379_create_tasks_with_status_view.down.sql b/coderd/database/migrations/000379_create_tasks_with_status_view.down.sql new file mode 100644 index 0000000000..45754139a7 --- /dev/null +++ b/coderd/database/migrations/000379_create_tasks_with_status_view.down.sql @@ -0,0 +1,33 @@ +DROP VIEW IF EXISTS tasks_with_status; +DROP TYPE IF EXISTS task_status; + +DROP INDEX IF EXISTS tasks_organization_id_idx; +DROP INDEX IF EXISTS tasks_owner_id_idx; +DROP INDEX IF EXISTS tasks_workspace_id_idx; + +ALTER TABLE task_workspace_apps + DROP CONSTRAINT IF EXISTS task_workspace_apps_pkey; + +-- Add back workspace_build_id column. +ALTER TABLE task_workspace_apps + ADD COLUMN workspace_build_id UUID; + +-- Try to populate workspace_build_id from workspace_builds. +UPDATE task_workspace_apps +SET workspace_build_id = workspace_builds.id +FROM workspace_builds +WHERE workspace_builds.build_number = task_workspace_apps.workspace_build_number + AND workspace_builds.workspace_id IN ( + SELECT workspace_id FROM tasks WHERE tasks.id = task_workspace_apps.task_id + ); + +-- Remove rows that couldn't be restored. +DELETE FROM task_workspace_apps +WHERE workspace_build_id IS NULL; + +-- Restore original schema. +ALTER TABLE task_workspace_apps + DROP COLUMN workspace_build_number, + ALTER COLUMN workspace_build_id SET NOT NULL, + ALTER COLUMN workspace_agent_id SET NOT NULL, + ALTER COLUMN workspace_app_id SET NOT NULL; diff --git a/coderd/database/migrations/000379_create_tasks_with_status_view.up.sql b/coderd/database/migrations/000379_create_tasks_with_status_view.up.sql new file mode 100644 index 0000000000..7af0e71482 --- /dev/null +++ b/coderd/database/migrations/000379_create_tasks_with_status_view.up.sql @@ -0,0 +1,104 @@ +-- Replace workspace_build_id with workspace_build_number. +ALTER TABLE task_workspace_apps + ADD COLUMN workspace_build_number INTEGER; + +-- Try to populate workspace_build_number from workspace_builds. +UPDATE task_workspace_apps +SET workspace_build_number = workspace_builds.build_number +FROM workspace_builds +WHERE workspace_builds.id = task_workspace_apps.workspace_build_id; + +-- Remove rows that couldn't be migrated. +DELETE FROM task_workspace_apps +WHERE workspace_build_number IS NULL; + +ALTER TABLE task_workspace_apps + DROP COLUMN workspace_build_id, + ALTER COLUMN workspace_build_number SET NOT NULL, + ALTER COLUMN workspace_agent_id DROP NOT NULL, + ALTER COLUMN workspace_app_id DROP NOT NULL, + ADD CONSTRAINT task_workspace_apps_pkey PRIMARY KEY (task_id, workspace_build_number); + +-- Add indexes for common joins or filters. +CREATE INDEX IF NOT EXISTS tasks_workspace_id_idx ON tasks (workspace_id); +CREATE INDEX IF NOT EXISTS tasks_owner_id_idx ON tasks (owner_id); +CREATE INDEX IF NOT EXISTS tasks_organization_id_idx ON tasks (organization_id); + +CREATE TYPE task_status AS ENUM ( + 'pending', + 'initializing', + 'active', + 'paused', + 'unknown', + 'error' +); + +CREATE VIEW + tasks_with_status +AS + SELECT + tasks.*, + CASE + WHEN tasks.workspace_id IS NULL OR latest_build.job_status IS NULL THEN 'pending'::task_status + + WHEN latest_build.job_status = 'failed' THEN 'error'::task_status + + WHEN latest_build.transition IN ('stop', 'delete') + AND latest_build.job_status = 'succeeded' THEN 'paused'::task_status + + WHEN latest_build.transition = 'start' + AND latest_build.job_status = 'pending' THEN 'initializing'::task_status + + WHEN latest_build.transition = 'start' AND latest_build.job_status IN ('running', 'succeeded') THEN + CASE + WHEN agent_status.none THEN 'initializing'::task_status + WHEN agent_status.connecting THEN 'initializing'::task_status + WHEN agent_status.connected THEN + CASE + WHEN app_status.any_unhealthy THEN 'error'::task_status + WHEN app_status.any_initializing THEN 'initializing'::task_status + WHEN app_status.all_healthy_or_disabled THEN 'active'::task_status + ELSE 'unknown'::task_status + END + ELSE 'unknown'::task_status + END + + ELSE 'unknown'::task_status + END AS status + FROM + tasks + LEFT JOIN LATERAL ( + SELECT workspace_build_number, workspace_agent_id, workspace_app_id + FROM task_workspace_apps task_app + WHERE task_id = tasks.id + ORDER BY workspace_build_number DESC + LIMIT 1 + ) task_app ON TRUE + LEFT JOIN LATERAL ( + SELECT + workspace_build.transition, + provisioner_job.job_status, + workspace_build.job_id + FROM workspace_builds workspace_build + JOIN provisioner_jobs provisioner_job ON provisioner_job.id = workspace_build.job_id + WHERE workspace_build.workspace_id = tasks.workspace_id + AND workspace_build.build_number = task_app.workspace_build_number + ) latest_build ON TRUE + CROSS JOIN LATERAL ( + SELECT + COUNT(*) = 0 AS none, + bool_or(workspace_agent.lifecycle_state IN ('created', 'starting')) AS connecting, + bool_and(workspace_agent.lifecycle_state = 'ready') AS connected + FROM workspace_agents workspace_agent + WHERE workspace_agent.id = task_app.workspace_agent_id + ) agent_status + CROSS JOIN LATERAL ( + SELECT + bool_or(workspace_app.health = 'unhealthy') AS any_unhealthy, + bool_or(workspace_app.health = 'initializing') AS any_initializing, + bool_and(workspace_app.health IN ('healthy', 'disabled')) AS all_healthy_or_disabled + FROM workspace_apps workspace_app + WHERE workspace_app.id = task_app.workspace_app_id + ) app_status + WHERE + tasks.deleted_at IS NULL; diff --git a/coderd/database/migrations/testdata/fixtures/000379_create_tasks_with_status_view.up.sql b/coderd/database/migrations/testdata/fixtures/000379_create_tasks_with_status_view.up.sql new file mode 100644 index 0000000000..c2d1bf1147 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000379_create_tasks_with_status_view.up.sql @@ -0,0 +1,6 @@ +INSERT INTO public.task_workspace_apps VALUES ( + 'f5a1c3e4-8b2d-4f6a-9d7e-2a8b5c9e1f3d', -- task_id + NULL, -- workspace_agent_id + NULL, -- workspace_app_id + 99 -- workspace_build_number +) ON CONFLICT DO NOTHING; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 6254125cc1..a50024f5f7 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -139,6 +139,13 @@ func (t Task) RBACObject() rbac.Object { InOrg(t.OrganizationID) } +func (t TaskTable) RBACObject() rbac.Object { + return rbac.ResourceTask. + WithID(t.ID). + WithOwner(t.OwnerID.String()). + InOrg(t.OrganizationID) +} + func (s APIKeyScope) ToRBAC() rbac.ScopeName { switch s { case ApiKeyScopeCoderAll: diff --git a/coderd/database/models.go b/coderd/database/models.go index 4f4632569e..8f62f1a307 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2885,6 +2885,76 @@ func AllTailnetStatusValues() []TailnetStatus { } } +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = "pending" + TaskStatusInitializing TaskStatus = "initializing" + TaskStatusActive TaskStatus = "active" + TaskStatusPaused TaskStatus = "paused" + TaskStatusUnknown TaskStatus = "unknown" + TaskStatusError TaskStatus = "error" +) + +func (e *TaskStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = TaskStatus(s) + case string: + *e = TaskStatus(s) + default: + return fmt.Errorf("unsupported scan type for TaskStatus: %T", src) + } + return nil +} + +type NullTaskStatus struct { + TaskStatus TaskStatus `json:"task_status"` + Valid bool `json:"valid"` // Valid is true if TaskStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullTaskStatus) Scan(value interface{}) error { + if value == nil { + ns.TaskStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.TaskStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullTaskStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.TaskStatus), nil +} + +func (e TaskStatus) Valid() bool { + switch e { + case TaskStatusPending, + TaskStatusInitializing, + TaskStatusActive, + TaskStatusPaused, + TaskStatusUnknown, + TaskStatusError: + return true + } + return false +} + +func AllTaskStatusValues() []TaskStatus { + return []TaskStatus{ + TaskStatusPending, + TaskStatusInitializing, + TaskStatusActive, + TaskStatusPaused, + TaskStatusUnknown, + TaskStatusError, + } +} + // Defines the users status: active, dormant, or suspended. type UserStatus string @@ -4137,13 +4207,27 @@ type Task struct { Prompt string `db:"prompt" json:"prompt"` CreatedAt time.Time `db:"created_at" json:"created_at"` DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"` + Status TaskStatus `db:"status" json:"status"` +} + +type TaskTable struct { + ID uuid.UUID `db:"id" json:"id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + Name string `db:"name" json:"name"` + WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + TemplateParameters json.RawMessage `db:"template_parameters" json:"template_parameters"` + Prompt string `db:"prompt" json:"prompt"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"` } type TaskWorkspaceApp struct { - TaskID uuid.UUID `db:"task_id" json:"task_id"` - WorkspaceBuildID uuid.UUID `db:"workspace_build_id" json:"workspace_build_id"` - WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` - WorkspaceAppID uuid.UUID `db:"workspace_app_id" json:"workspace_app_id"` + TaskID uuid.UUID `db:"task_id" json:"task_id"` + WorkspaceAgentID uuid.NullUUID `db:"workspace_agent_id" json:"workspace_agent_id"` + WorkspaceAppID uuid.NullUUID `db:"workspace_app_id" json:"workspace_app_id"` + WorkspaceBuildNumber int32 `db:"workspace_build_number" json:"workspace_build_number"` } type TelemetryItem struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 27e828e45a..48bd064e20 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -331,6 +331,8 @@ type sqlcQuerier interface { GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]TailnetPeer, error) GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerBindingsRow, error) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerIDsRow, error) + GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error) + GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (Task, error) GetTelemetryItem(ctx context.Context, key string) (TelemetryItem, error) GetTelemetryItems(ctx context.Context) ([]TelemetryItem, error) // GetTemplateAppInsights returns the aggregate usage of each app in a given @@ -550,6 +552,7 @@ type sqlcQuerier interface { InsertProvisionerJobTimings(ctx context.Context, arg InsertProvisionerJobTimingsParams) ([]ProvisionerJobTiming, error) InsertProvisionerKey(ctx context.Context, arg InsertProvisionerKeyParams) (ProvisionerKey, error) InsertReplica(ctx context.Context, arg InsertReplicaParams) (Replica, error) + InsertTask(ctx context.Context, arg InsertTaskParams) (TaskTable, error) InsertTelemetryItemIfNotExists(ctx context.Context, arg InsertTelemetryItemIfNotExistsParams) error InsertTemplate(ctx context.Context, arg InsertTemplateParams) error InsertTemplateVersion(ctx context.Context, arg InsertTemplateVersionParams) error @@ -729,6 +732,7 @@ type sqlcQuerier interface { UpsertTailnetCoordinator(ctx context.Context, id uuid.UUID) (TailnetCoordinator, error) UpsertTailnetPeer(ctx context.Context, arg UpsertTailnetPeerParams) (TailnetPeer, error) UpsertTailnetTunnel(ctx context.Context, arg UpsertTailnetTunnelParams) (TailnetTunnel, error) + UpsertTaskWorkspaceApp(ctx context.Context, arg UpsertTaskWorkspaceAppParams) (TaskWorkspaceApp, error) UpsertTelemetryItem(ctx context.Context, arg UpsertTelemetryItemParams) error // This query aggregates the workspace_agent_stats and workspace_app_stats data // into a single table for efficient storage and querying. Half-hour buckets are diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index c7daaaed35..1409a079f5 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -6653,6 +6653,458 @@ func TestGetLatestWorkspaceBuildsByWorkspaceIDs(t *testing.T) { } } +func TestTasksWithStatusView(t *testing.T) { + t.Parallel() + + createProvisionerJob := func(t *testing.T, db database.Store, org database.Organization, user database.User, buildStatus database.ProvisionerJobStatus) database.ProvisionerJob { + t.Helper() + + var jobParams database.ProvisionerJob + + switch buildStatus { + case database.ProvisionerJobStatusPending: + jobParams = database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + InitiatorID: user.ID, + } + case database.ProvisionerJobStatusRunning: + jobParams = database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + InitiatorID: user.ID, + StartedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + } + case database.ProvisionerJobStatusFailed: + jobParams = database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + InitiatorID: user.ID, + StartedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + Error: sql.NullString{Valid: true, String: "job failed"}, + } + case database.ProvisionerJobStatusSucceeded: + jobParams = database.ProvisionerJob{ + OrganizationID: org.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + InitiatorID: user.ID, + StartedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + } + default: + t.Errorf("invalid build status: %v", buildStatus) + } + + return dbgen.ProvisionerJob(t, db, nil, jobParams) + } + + createTask := func( + ctx context.Context, + t *testing.T, + db database.Store, + org database.Organization, + user database.User, + buildStatus database.ProvisionerJobStatus, + buildTransition database.WorkspaceTransition, + agentState database.WorkspaceAgentLifecycleState, + appHealths []database.WorkspaceAppHealth, + ) database.TaskTable { + t.Helper() + + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + + if buildStatus == "" { + return dbgen.Task(t, db, database.TaskTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + Name: "test-task", + TemplateVersionID: templateVersion.ID, + Prompt: "Test prompt", + }) + } + + job := createProvisionerJob(t, db, org, user, buildStatus) + + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: org.ID, + TemplateID: template.ID, + OwnerID: user.ID, + }) + workspaceID := uuid.NullUUID{Valid: true, UUID: workspace.ID} + + task := dbgen.Task(t, db, database.TaskTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + Name: "test-task", + WorkspaceID: workspaceID, + TemplateVersionID: templateVersion.ID, + Prompt: "Test prompt", + }) + + workspaceBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + TemplateVersionID: templateVersion.ID, + BuildNumber: 1, + Transition: buildTransition, + InitiatorID: user.ID, + JobID: job.ID, + }) + workspaceBuildNumber := workspaceBuild.BuildNumber + + _, err := db.UpsertTaskWorkspaceApp(ctx, database.UpsertTaskWorkspaceAppParams{ + TaskID: task.ID, + WorkspaceBuildNumber: workspaceBuildNumber, + }) + require.NoError(t, err) + + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: job.ID, + }) + + if agentState != "" { + agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + workspaceAgentID := agent.ID + + _, err := db.UpsertTaskWorkspaceApp(ctx, database.UpsertTaskWorkspaceAppParams{ + TaskID: task.ID, + WorkspaceBuildNumber: workspaceBuildNumber, + WorkspaceAgentID: uuid.NullUUID{UUID: workspaceAgentID, Valid: true}, + }) + require.NoError(t, err) + + err = db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: agent.ID, + LifecycleState: agentState, + }) + require.NoError(t, err) + + for i, health := range appHealths { + app := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{ + AgentID: workspaceAgentID, + Slug: fmt.Sprintf("test-app-%d", i), + DisplayName: fmt.Sprintf("Test App %d", i+1), + Health: health, + }) + if i == 0 { + // Assume the first app is the tasks app. + _, err := db.UpsertTaskWorkspaceApp(ctx, database.UpsertTaskWorkspaceAppParams{ + TaskID: task.ID, + WorkspaceBuildNumber: workspaceBuildNumber, + WorkspaceAgentID: uuid.NullUUID{UUID: workspaceAgentID, Valid: true}, + WorkspaceAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + }) + require.NoError(t, err) + } + } + } + + return task + } + + tests := []struct { + name string + buildStatus database.ProvisionerJobStatus + buildTransition database.WorkspaceTransition + agentState database.WorkspaceAgentLifecycleState + appHealths []database.WorkspaceAppHealth + expectedStatus database.TaskStatus + description string + }{ + { + name: "NoWorkspace", + expectedStatus: "pending", + description: "Task with no workspace assigned", + }, + { + name: "FailedBuild", + buildStatus: database.ProvisionerJobStatusFailed, + buildTransition: database.WorkspaceTransitionStart, + expectedStatus: database.TaskStatusError, + description: "Latest workspace build failed", + }, + { + name: "StoppedWorkspace", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionStop, + expectedStatus: database.TaskStatusPaused, + description: "Workspace is stopped", + }, + { + name: "DeletedWorkspace", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionDelete, + expectedStatus: database.TaskStatusPaused, + description: "Workspace is deleted", + }, + { + name: "PendingStart", + buildStatus: database.ProvisionerJobStatusPending, + buildTransition: database.WorkspaceTransitionStart, + expectedStatus: database.TaskStatusInitializing, + description: "Workspace build is starting (pending)", + }, + { + name: "RunningStart", + buildStatus: database.ProvisionerJobStatusRunning, + buildTransition: database.WorkspaceTransitionStart, + expectedStatus: database.TaskStatusInitializing, + description: "Workspace build is starting (running)", + }, + { + name: "StartingAgent", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateStarting, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthInitializing}, + expectedStatus: database.TaskStatusInitializing, + description: "Workspace is running but agent is starting", + }, + { + name: "CreatedAgent", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateCreated, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthInitializing}, + expectedStatus: database.TaskStatusInitializing, + description: "Workspace is running but agent is created", + }, + { + name: "ReadyAgentInitializingApp", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateReady, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthInitializing}, + expectedStatus: database.TaskStatusInitializing, + description: "Agent is ready but app is initializing", + }, + { + name: "ReadyAgentHealthyApp", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateReady, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthHealthy}, + expectedStatus: database.TaskStatusActive, + description: "Agent is ready and app is healthy", + }, + { + name: "ReadyAgentDisabledApp", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateReady, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthDisabled}, + expectedStatus: database.TaskStatusActive, + description: "Agent is ready and app health checking is disabled", + }, + { + name: "ReadyAgentUnhealthyApp", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateReady, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthUnhealthy}, + expectedStatus: database.TaskStatusError, + description: "Agent is ready but app is unhealthy", + }, + { + name: "AgentStartTimeout", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateStartTimeout, + expectedStatus: database.TaskStatusUnknown, + description: "Agent start timed out", + }, + { + name: "AgentStartError", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateStartError, + expectedStatus: database.TaskStatusUnknown, + description: "Agent failed to start", + }, + { + name: "AgentShuttingDown", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateShuttingDown, + expectedStatus: database.TaskStatusUnknown, + description: "Agent is shutting down", + }, + { + name: "AgentOff", + buildStatus: database.ProvisionerJobStatusSucceeded, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateOff, + expectedStatus: database.TaskStatusUnknown, + description: "Agent is off", + }, + { + name: "RunningJobReadyAgentHealthyApp", + buildStatus: database.ProvisionerJobStatusRunning, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateReady, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthHealthy}, + expectedStatus: database.TaskStatusActive, + description: "Running job with ready agent and healthy app should be active", + }, + { + name: "RunningJobReadyAgentInitializingApp", + buildStatus: database.ProvisionerJobStatusRunning, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateReady, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthInitializing}, + expectedStatus: database.TaskStatusInitializing, + description: "Running job with ready agent but initializing app should be initializing", + }, + { + name: "RunningJobReadyAgentUnhealthyApp", + buildStatus: database.ProvisionerJobStatusRunning, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateReady, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthUnhealthy}, + expectedStatus: database.TaskStatusError, + description: "Running job with ready agent but unhealthy app should be error", + }, + { + name: "RunningJobConnectingAgent", + buildStatus: database.ProvisionerJobStatusRunning, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateStarting, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthInitializing}, + expectedStatus: database.TaskStatusInitializing, + description: "Running job with connecting agent should be initializing", + }, + { + name: "RunningJobReadyAgentDisabledApp", + buildStatus: database.ProvisionerJobStatusRunning, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateReady, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthDisabled}, + expectedStatus: database.TaskStatusActive, + description: "Running job with ready agent and disabled app health checking should be active", + }, + { + name: "RunningJobReadyAgentHealthyTaskAppUnhealthyOtherAppIsOK", + buildStatus: database.ProvisionerJobStatusRunning, + buildTransition: database.WorkspaceTransitionStart, + agentState: database.WorkspaceAgentLifecycleStateReady, + appHealths: []database.WorkspaceAppHealth{database.WorkspaceAppHealthHealthy, database.WorkspaceAppHealthUnhealthy}, + expectedStatus: database.TaskStatusActive, + description: "Running job with ready agent and multiple healthy apps should be active", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + + task := createTask(ctx, t, db, org, user, tt.buildStatus, tt.buildTransition, tt.agentState, tt.appHealths) + + got, err := db.GetTaskByID(ctx, task.ID) + require.NoError(t, err) + + require.Equal(t, tt.expectedStatus, got.Status, "unexpected status for test case: %s", tt.description) + }) + } +} + +func TestGetTaskByWorkspaceID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupTask func(t *testing.T, db database.Store, org database.Organization, user database.User, templateVersion database.TemplateVersion, workspace database.WorkspaceTable) + wantErr bool + }{ + { + name: "task doesn't exist", + wantErr: true, + }, + { + name: "task with no workspace id", + setupTask: func(t *testing.T, db database.Store, org database.Organization, user database.User, templateVersion database.TemplateVersion, workspace database.WorkspaceTable) { + dbgen.Task(t, db, database.TaskTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + Name: "test-task", + TemplateVersionID: templateVersion.ID, + Prompt: "Test prompt", + }) + }, + wantErr: true, + }, + { + name: "task with workspace id", + setupTask: func(t *testing.T, db database.Store, org database.Organization, user database.User, templateVersion database.TemplateVersion, workspace database.WorkspaceTable) { + workspaceID := uuid.NullUUID{Valid: true, UUID: workspace.ID} + dbgen.Task(t, db, database.TaskTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + Name: "test-task", + WorkspaceID: workspaceID, + TemplateVersionID: templateVersion.ID, + Prompt: "Test prompt", + }) + }, + wantErr: false, + }, + } + + db, _ := dbtestutil.NewDB(t) + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + TemplateID: uuid.NullUUID{Valid: true, UUID: template.ID}, + CreatedBy: user.ID, + }) + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: org.ID, + OwnerID: user.ID, + TemplateID: template.ID, + }) + + if tt.setupTask != nil { + tt.setupTask(t, db, org, user, templateVersion, workspace) + } + + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := db.GetTaskByWorkspaceID(ctx, workspace.ID) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + func TestUsageEventsTrigger(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 605db1cffe..59ed569950 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12507,6 +12507,134 @@ func (q *sqlQuerier) UpsertTailnetTunnel(ctx context.Context, arg UpsertTailnetT return i, err } +const getTaskByID = `-- name: GetTaskByID :one +SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status FROM tasks_with_status WHERE id = $1::uuid +` + +func (q *sqlQuerier) GetTaskByID(ctx context.Context, id uuid.UUID) (Task, error) { + row := q.db.QueryRowContext(ctx, getTaskByID, id) + var i Task + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.OwnerID, + &i.Name, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.TemplateParameters, + &i.Prompt, + &i.CreatedAt, + &i.DeletedAt, + &i.Status, + ) + return i, err +} + +const getTaskByWorkspaceID = `-- name: GetTaskByWorkspaceID :one +SELECT id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at, status FROM tasks_with_status WHERE workspace_id = $1::uuid +` + +func (q *sqlQuerier) GetTaskByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (Task, error) { + row := q.db.QueryRowContext(ctx, getTaskByWorkspaceID, workspaceID) + var i Task + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.OwnerID, + &i.Name, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.TemplateParameters, + &i.Prompt, + &i.CreatedAt, + &i.DeletedAt, + &i.Status, + ) + return i, err +} + +const insertTask = `-- name: InsertTask :one +INSERT INTO tasks + (id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at) +VALUES + (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at, deleted_at +` + +type InsertTaskParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + Name string `db:"name" json:"name"` + WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + TemplateParameters json.RawMessage `db:"template_parameters" json:"template_parameters"` + Prompt string `db:"prompt" json:"prompt"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +func (q *sqlQuerier) InsertTask(ctx context.Context, arg InsertTaskParams) (TaskTable, error) { + row := q.db.QueryRowContext(ctx, insertTask, + arg.OrganizationID, + arg.OwnerID, + arg.Name, + arg.WorkspaceID, + arg.TemplateVersionID, + arg.TemplateParameters, + arg.Prompt, + arg.CreatedAt, + ) + var i TaskTable + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.OwnerID, + &i.Name, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.TemplateParameters, + &i.Prompt, + &i.CreatedAt, + &i.DeletedAt, + ) + return i, err +} + +const upsertTaskWorkspaceApp = `-- name: UpsertTaskWorkspaceApp :one +INSERT INTO task_workspace_apps + (task_id, workspace_build_number, workspace_agent_id, workspace_app_id) +VALUES + ($1, $2, $3, $4) +ON CONFLICT (task_id, workspace_build_number) +DO UPDATE SET + workspace_agent_id = EXCLUDED.workspace_agent_id, + workspace_app_id = EXCLUDED.workspace_app_id +RETURNING task_id, workspace_agent_id, workspace_app_id, workspace_build_number +` + +type UpsertTaskWorkspaceAppParams struct { + TaskID uuid.UUID `db:"task_id" json:"task_id"` + WorkspaceBuildNumber int32 `db:"workspace_build_number" json:"workspace_build_number"` + WorkspaceAgentID uuid.NullUUID `db:"workspace_agent_id" json:"workspace_agent_id"` + WorkspaceAppID uuid.NullUUID `db:"workspace_app_id" json:"workspace_app_id"` +} + +func (q *sqlQuerier) UpsertTaskWorkspaceApp(ctx context.Context, arg UpsertTaskWorkspaceAppParams) (TaskWorkspaceApp, error) { + row := q.db.QueryRowContext(ctx, upsertTaskWorkspaceApp, + arg.TaskID, + arg.WorkspaceBuildNumber, + arg.WorkspaceAgentID, + arg.WorkspaceAppID, + ) + var i TaskWorkspaceApp + err := row.Scan( + &i.TaskID, + &i.WorkspaceAgentID, + &i.WorkspaceAppID, + &i.WorkspaceBuildNumber, + ) + return i, err +} + const getTelemetryItem = `-- name: GetTelemetryItem :one SELECT key, value, created_at, updated_at FROM telemetry_items WHERE key = $1 ` diff --git a/coderd/database/queries/tasks.sql b/coderd/database/queries/tasks.sql new file mode 100644 index 0000000000..fbdb46d9f9 --- /dev/null +++ b/coderd/database/queries/tasks.sql @@ -0,0 +1,23 @@ +-- name: InsertTask :one +INSERT INTO tasks + (id, organization_id, owner_id, name, workspace_id, template_version_id, template_parameters, prompt, created_at) +VALUES + (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: UpsertTaskWorkspaceApp :one +INSERT INTO task_workspace_apps + (task_id, workspace_build_number, workspace_agent_id, workspace_app_id) +VALUES + ($1, $2, $3, $4) +ON CONFLICT (task_id, workspace_build_number) +DO UPDATE SET + workspace_agent_id = EXCLUDED.workspace_agent_id, + workspace_app_id = EXCLUDED.workspace_app_id +RETURNING *; + +-- name: GetTaskByID :one +SELECT * FROM tasks_with_status WHERE id = @id::uuid; + +-- name: GetTaskByWorkspaceID :one +SELECT * FROM tasks_with_status WHERE workspace_id = @workspace_id::uuid; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 702064ecf2..d12a7d399c 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -112,6 +112,8 @@ sql: workspace_build_with_user: WorkspaceBuild workspace: WorkspaceTable workspaces_expanded: Workspace + task: TaskTable + tasks_with_status: Task template_version: TemplateVersionTable template_version_with_user: TemplateVersion api_key: APIKey diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 36fca8f058..58206e8ad2 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -59,6 +59,7 @@ const ( UniqueTailnetCoordinatorsPkey UniqueConstraint = "tailnet_coordinators_pkey" // ALTER TABLE ONLY tailnet_coordinators ADD CONSTRAINT tailnet_coordinators_pkey PRIMARY KEY (id); UniqueTailnetPeersPkey UniqueConstraint = "tailnet_peers_pkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_pkey PRIMARY KEY (id, coordinator_id); UniqueTailnetTunnelsPkey UniqueConstraint = "tailnet_tunnels_pkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_pkey PRIMARY KEY (coordinator_id, src_id, dst_id); + UniqueTaskWorkspaceAppsPkey UniqueConstraint = "task_workspace_apps_pkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_pkey PRIMARY KEY (task_id, workspace_build_number); UniqueTasksPkey UniqueConstraint = "tasks_pkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_pkey PRIMARY KEY (id); UniqueTelemetryItemsPkey UniqueConstraint = "telemetry_items_pkey" // ALTER TABLE ONLY telemetry_items ADD CONSTRAINT telemetry_items_pkey PRIMARY KEY (key); UniqueTemplateUsageStatsPkey UniqueConstraint = "template_usage_stats_pkey" // ALTER TABLE ONLY template_usage_stats ADD CONSTRAINT template_usage_stats_pkey PRIMARY KEY (start_time, template_id, user_id);