@stacksjs/defaults 0.70.366 → 0.70.368
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai/skills/stacks-browse/SKILL.md +9 -1
- package/ai/skills/stacks-dashboard/SKILL.md +49 -1
- package/ai/skills/stacks-deploy/SKILL.md +18 -0
- package/ai/skills/stacks-desktop/SKILL.md +11 -2
- package/ai/skills/stacks-stx/SKILL.md +25 -0
- package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +64 -44
- package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +26 -0
- package/app/Actions/Dashboard/Deployments/PreviewDeployment.ts +39 -0
- package/app/Actions/Dashboard/Deployments/PreviewDeploymentRollback.ts +22 -0
- package/app/Actions/Dashboard/Deployments/deployment-input.test.ts +12 -2
- package/app/Actions/Dashboard/Deployments/deployment-input.ts +5 -3
- package/app/Actions/Dashboard/Deployments/deployment-preview.test.ts +29 -0
- package/app/Actions/Dashboard/Deployments/deployment-preview.ts +41 -0
- package/app/Actions/Dashboard/Deployments/deployment-rollback.test.ts +16 -0
- package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +89 -0
- package/app/Actions/Dashboard/Operations/AuditIndexAction.ts +25 -0
- package/app/Actions/Dashboard/Operations/ChangeApprovalAction.ts +28 -0
- package/app/Actions/Dashboard/Operations/ChangeIndexAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/IncidentIndexAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/IncidentUpdateAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/MigrationApplyAction.ts +30 -0
- package/app/Actions/Dashboard/Operations/MigrationIndexAction.ts +24 -0
- package/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +23 -0
- package/app/Actions/Dashboard/Operations/RecoveryDestinationStoreAction.ts +91 -0
- package/app/Actions/Dashboard/Operations/RecoveryDestinationTestAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/RecoveryIndexAction.ts +58 -0
- package/app/Actions/Dashboard/Operations/RecoveryPolicyStoreAction.ts +90 -0
- package/app/Actions/Dashboard/Operations/RecoveryProtectAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/RecoveryRestoreAction.ts +72 -0
- package/app/Actions/Dashboard/Operations/RecoveryRetentionAction.ts +17 -0
- package/app/Actions/Dashboard/Operations/RecoveryRunAction.ts +25 -0
- package/app/Actions/Dashboard/Operations/RecoveryVerifyAction.ts +24 -0
- package/app/Actions/Dashboard/Operations/SchedulerIndexAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/SchedulerRunAction.ts +33 -0
- package/app/Actions/Dashboard/Operations/SchedulerToggleAction.ts +36 -0
- package/app/Actions/Dashboard/Operations/control-plane.ts +150 -0
- package/app/Actions/Dashboard/Operations/migration-operations.ts +80 -0
- package/app/Actions/Dashboard/Operations/operations-runtime.ts +11 -0
- package/app/Actions/Dashboard/Operations/recovery-input.test.ts +22 -0
- package/app/Actions/Dashboard/Operations/recovery-input.ts +33 -0
- package/app/Actions/Dashboard/Operations/recovery-runtime.ts +99 -0
- package/app/Actions/Dashboard/Operations/scheduler-operations.test.ts +23 -0
- package/app/Actions/Dashboard/Operations/scheduler-operations.ts +138 -0
- package/functions/deployments.ts +18 -0
- package/functions/operations.ts +361 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/resources/components/Dashboard/Ci/CiDashboard.stx +4 -4
- package/resources/components/Dashboard/Deployments/DeploymentList.stx +147 -14
- package/resources/components/Dashboard/Deployments/DeploymentPreviewDialog.stx +118 -0
- package/resources/components/Dashboard/GlobalSearch.stx +87 -91
- package/resources/components/Dashboard/Operations/AuditDashboard.stx +16 -0
- package/resources/components/Dashboard/Operations/ChangeDashboard.stx +66 -0
- package/resources/components/Dashboard/Operations/IncidentDashboard.stx +39 -0
- package/resources/components/Dashboard/Operations/MigrationDashboard.stx +146 -0
- package/resources/components/Dashboard/Operations/OperationHistory.stx +61 -0
- package/resources/components/Dashboard/Operations/OperationsNavigation.stx +32 -0
- package/resources/components/Dashboard/Operations/RecoveryDashboard.stx +229 -0
- package/resources/components/Dashboard/Operations/RecoveryDestinationDialog.stx +126 -0
- package/resources/components/Dashboard/Operations/RecoveryJobList.stx +32 -0
- package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +82 -0
- package/resources/components/Dashboard/Operations/RecoveryPolicyDialog.stx +155 -0
- package/resources/components/Dashboard/Operations/RecoveryRestoreDialog.stx +90 -0
- package/resources/components/Dashboard/Operations/SchedulerDashboard.stx +144 -0
- package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +81 -0
- package/resources/components/Dashboard/Settings/SettingsDashboard.stx +45 -3
- package/resources/components/Dashboard/UI/Modal.stx +33 -8
- package/resources/functions/dashboard/sidebar.ts +1 -0
- package/routes/dashboard-api.ts +23 -0
- package/routes/dashboard.ts +1 -0
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
- package/views/dashboard/operations/audit.stx +5 -0
- package/views/dashboard/operations/changes.stx +5 -0
- package/views/dashboard/operations/incidents.stx +5 -0
- package/views/dashboard/operations/migrations.stx +8 -0
- package/views/dashboard/operations/recovery.stx +8 -0
- package/views/dashboard/operations/scheduler.stx +8 -0
- package/views/dashboard/stores/ci.ts +2 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { appendOperatorEvent, dashboardOperator } from './control-plane'
|
|
5
|
+
import { jsonObject, stringValue } from './recovery-input'
|
|
6
|
+
import { recoveryEnvironment, recoveryRuntime } from './recovery-runtime'
|
|
7
|
+
|
|
8
|
+
export default new Action({
|
|
9
|
+
name: 'RecoveryRestoreAction',
|
|
10
|
+
description: 'Validates or queues an isolated, in-place, or drill restore.',
|
|
11
|
+
method: 'POST',
|
|
12
|
+
apiResponse: true,
|
|
13
|
+
async handle(request: RequestInstance) {
|
|
14
|
+
const input = request.all() as Record<string, unknown>
|
|
15
|
+
const runtime = recoveryRuntime()
|
|
16
|
+
const { controlPlane } = recoveryEnvironment()
|
|
17
|
+
const point = runtime.store.getRecoveryPoint(String(request.getParam('id') || ''))
|
|
18
|
+
if (!point || point.projectId !== controlPlane.project.id)
|
|
19
|
+
return response.json({ message: 'Recovery point not found.' }, 404)
|
|
20
|
+
|
|
21
|
+
const mode = stringValue(input.mode) || 'isolated'
|
|
22
|
+
const targetName = stringValue(input.targetName)
|
|
23
|
+
const drill = input.drill === true
|
|
24
|
+
const execute = input.execute === true
|
|
25
|
+
if (!['isolated', 'in_place'].includes(mode))
|
|
26
|
+
return response.json({ message: 'Restore mode must be isolated or in_place.' }, 422)
|
|
27
|
+
if (!targetName)
|
|
28
|
+
return response.json({ message: 'A restore target name is required.' }, 422)
|
|
29
|
+
if (drill && mode === 'in_place')
|
|
30
|
+
return response.json({ message: 'Recovery drills require an isolated target.' }, 422)
|
|
31
|
+
|
|
32
|
+
const safetyBackupId = stringValue(input.safetyBackupId) || undefined
|
|
33
|
+
if (safetyBackupId) {
|
|
34
|
+
const safety = runtime.store.getRecoveryPoint(safetyBackupId)
|
|
35
|
+
if (!safety || safety.projectId !== controlPlane.project.id)
|
|
36
|
+
return response.json({ message: 'The safety recovery point was not found.' }, 422)
|
|
37
|
+
}
|
|
38
|
+
const target = point.kind === 'volume'
|
|
39
|
+
? { ...jsonObject(input.target), volumeName: targetName, inPlace: mode === 'in_place' }
|
|
40
|
+
: point.kind === 'logical_database'
|
|
41
|
+
? { ...jsonObject(input.target), targetId: targetName, dataServiceId: targetName, inPlace: mode === 'in_place' }
|
|
42
|
+
: { ...jsonObject(input.target), targetId: targetName, inPlace: mode === 'in_place' }
|
|
43
|
+
const restoreInput = {
|
|
44
|
+
mode: mode as 'isolated' | 'in_place',
|
|
45
|
+
target,
|
|
46
|
+
targetName,
|
|
47
|
+
confirm: stringValue(input.confirm) || undefined,
|
|
48
|
+
recentAuth: true,
|
|
49
|
+
downtimeAcknowledged: input.downtimeAcknowledged === true,
|
|
50
|
+
safetyBackupId,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const plan = runtime.coordinator.planRestore(point, restoreInput)
|
|
55
|
+
if (!execute)
|
|
56
|
+
return { success: true, plan, productionExecutionCreated: false }
|
|
57
|
+
|
|
58
|
+
const actor = await dashboardOperator(request)
|
|
59
|
+
const job = runtime.coordinator.enqueueRestore(point, { ...restoreInput, drill, actorId: actor.id })
|
|
60
|
+
await appendOperatorEvent(request, drill ? 'backup.drill_queued' : 'backup.restore_queued', {
|
|
61
|
+
recoveryPointId: point.id,
|
|
62
|
+
backupJobId: job.id,
|
|
63
|
+
mode: restoreInput.mode,
|
|
64
|
+
target: targetName,
|
|
65
|
+
}, point.resourceId)
|
|
66
|
+
return response.json({ success: true, plan, job, productionExecutionCreated: true }, 202)
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return response.json({ message: error instanceof Error ? error.message : String(error) }, 409)
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { appendOperatorEvent } from './control-plane'
|
|
5
|
+
import { recoveryRuntime } from './recovery-runtime'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'RecoveryRetentionAction',
|
|
9
|
+
description: 'Queues cleanup of expired, unlocked, unheld recovery points.',
|
|
10
|
+
method: 'POST',
|
|
11
|
+
apiResponse: true,
|
|
12
|
+
async handle(request: RequestInstance) {
|
|
13
|
+
const jobs = recoveryRuntime().coordinator.enqueueRetention()
|
|
14
|
+
await appendOperatorEvent(request, 'backup.retention_queued', { jobs: jobs.length })
|
|
15
|
+
return response.json({ success: true, jobs, message: `Queued ${jobs.length} retention cleanup job${jobs.length === 1 ? '' : 's'}.` }, 202)
|
|
16
|
+
},
|
|
17
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { appendOperatorEvent, dashboardOperator } from './control-plane'
|
|
5
|
+
import { recoveryEnvironment, recoveryRuntime } from './recovery-runtime'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'RecoveryRunAction',
|
|
9
|
+
description: 'Queues an on-demand run for one configured recovery policy.',
|
|
10
|
+
method: 'POST',
|
|
11
|
+
apiResponse: true,
|
|
12
|
+
async handle(request: RequestInstance) {
|
|
13
|
+
const runtime = recoveryRuntime()
|
|
14
|
+
const { controlPlane, environment } = recoveryEnvironment()
|
|
15
|
+
const policy = runtime.store.getPolicy(String(request.getParam('id') || ''))
|
|
16
|
+
if (!policy || policy.projectId !== controlPlane.project.id || policy.environmentId !== environment.id)
|
|
17
|
+
return response.json({ message: 'Recovery policy not found.' }, 404)
|
|
18
|
+
if (!policy.enabled)
|
|
19
|
+
return response.json({ message: 'Enable this recovery policy before running it.' }, 409)
|
|
20
|
+
const actor = await dashboardOperator(request)
|
|
21
|
+
const job = runtime.coordinator.enqueueBackup(policy, new Date().toISOString(), actor.id)
|
|
22
|
+
await appendOperatorEvent(request, 'backup.run_queued', { policyId: policy.id, backupJobId: job.id }, policy.resourceId)
|
|
23
|
+
return response.json({ success: true, job, message: `Backup job ${job.id} was queued.` }, 202)
|
|
24
|
+
},
|
|
25
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { appendOperatorEvent } from './control-plane'
|
|
5
|
+
import { recoveryEnvironment, recoveryRuntime } from './recovery-runtime'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'RecoveryVerifyAction',
|
|
9
|
+
description: 'Queues independent verification for one available recovery point.',
|
|
10
|
+
method: 'POST',
|
|
11
|
+
apiResponse: true,
|
|
12
|
+
async handle(request: RequestInstance) {
|
|
13
|
+
const runtime = recoveryRuntime()
|
|
14
|
+
const { controlPlane } = recoveryEnvironment()
|
|
15
|
+
const point = runtime.store.getRecoveryPoint(String(request.getParam('id') || ''))
|
|
16
|
+
if (!point || point.projectId !== controlPlane.project.id)
|
|
17
|
+
return response.json({ message: 'Recovery point not found.' }, 404)
|
|
18
|
+
if (point.status !== 'available')
|
|
19
|
+
return response.json({ message: 'Only available recovery points can be verified.' }, 409)
|
|
20
|
+
const job = runtime.coordinator.enqueueVerification(point)
|
|
21
|
+
await appendOperatorEvent(request, 'backup.verification_queued', { recoveryPointId: point.id, backupJobId: job.id }, point.resourceId)
|
|
22
|
+
return response.json({ success: true, job, message: `Verification job ${job.id} was queued.` }, 202)
|
|
23
|
+
},
|
|
24
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
4
|
+
import { recentOperatorOperations } from './control-plane'
|
|
5
|
+
import { listSchedulerTasks } from './scheduler-operations'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'SchedulerIndexAction',
|
|
9
|
+
description: 'Lists registered scheduled tasks and their durable operator history.',
|
|
10
|
+
method: 'GET',
|
|
11
|
+
apiResponse: true,
|
|
12
|
+
async handle(_request: RequestInstance) {
|
|
13
|
+
try {
|
|
14
|
+
const tasks = await listSchedulerTasks()
|
|
15
|
+
const operations = recentOperatorOperations('dashboard.scheduler.', 20)
|
|
16
|
+
return {
|
|
17
|
+
tasks,
|
|
18
|
+
operations,
|
|
19
|
+
summary: {
|
|
20
|
+
total: tasks.length,
|
|
21
|
+
active: tasks.filter(task => task.enabled).length,
|
|
22
|
+
paused: tasks.filter(task => !task.enabled).length,
|
|
23
|
+
nextRun: tasks
|
|
24
|
+
.map(task => task.nextRun)
|
|
25
|
+
.filter((value): value is string => Boolean(value))
|
|
26
|
+
.sort()[0] || null,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return dashboardOperationalError(error, 'Scheduler operations could not be loaded.', 'SchedulerIndexAction')
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
|
+
import { trackOperatorOperation } from './control-plane'
|
|
6
|
+
import { runScheduledTask, SchedulerOperationError } from './scheduler-operations'
|
|
7
|
+
|
|
8
|
+
export default new Action({
|
|
9
|
+
name: 'SchedulerRunAction',
|
|
10
|
+
description: 'Runs one registered scheduled task immediately and records the operation.',
|
|
11
|
+
method: 'POST',
|
|
12
|
+
apiResponse: true,
|
|
13
|
+
async handle(request: RequestInstance) {
|
|
14
|
+
const name = String(request.getParam('name') || '').trim()
|
|
15
|
+
if (!name)
|
|
16
|
+
return response.json({ message: 'A scheduled task name is required.' }, 422)
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const tracked = await trackOperatorOperation(
|
|
20
|
+
request,
|
|
21
|
+
'dashboard.scheduler.run',
|
|
22
|
+
{ task: name },
|
|
23
|
+
() => runScheduledTask(name),
|
|
24
|
+
)
|
|
25
|
+
return { success: true, ...tracked.result, operation: tracked.operation }
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof SchedulerOperationError)
|
|
29
|
+
return response.json({ message: error.message }, error.message.includes('not found') ? 404 : 409)
|
|
30
|
+
return dashboardOperationalError(error, 'The scheduled task could not be run.', 'SchedulerRunAction', 500)
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
|
+
import { trackOperatorOperation } from './control-plane'
|
|
6
|
+
import { SchedulerOperationError, setScheduledTaskEnabled } from './scheduler-operations'
|
|
7
|
+
|
|
8
|
+
export default new Action({
|
|
9
|
+
name: 'SchedulerToggleAction',
|
|
10
|
+
description: 'Persists the active state of one scheduled task and records the operation.',
|
|
11
|
+
method: 'PATCH',
|
|
12
|
+
apiResponse: true,
|
|
13
|
+
async handle(request: RequestInstance) {
|
|
14
|
+
const name = String(request.getParam('name') || '').trim()
|
|
15
|
+
const enabled = request.get<unknown>('enabled')
|
|
16
|
+
if (!name)
|
|
17
|
+
return response.json({ message: 'A scheduled task name is required.' }, 422)
|
|
18
|
+
if (typeof enabled !== 'boolean')
|
|
19
|
+
return response.json({ message: 'The enabled field must be a boolean.' }, 422)
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const tracked = await trackOperatorOperation(
|
|
23
|
+
request,
|
|
24
|
+
`dashboard.scheduler.${enabled ? 'enable' : 'disable'}`,
|
|
25
|
+
{ enabled, task: name },
|
|
26
|
+
() => setScheduledTaskEnabled(name, enabled),
|
|
27
|
+
)
|
|
28
|
+
return { success: true, ...tracked.result, operation: tracked.operation }
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error instanceof SchedulerOperationError)
|
|
32
|
+
return response.json({ message: error.message }, error.message.includes('not found') ? 404 : 409)
|
|
33
|
+
return dashboardOperationalError(error, 'The scheduled task could not be updated.', 'SchedulerToggleAction', 500)
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import type {
|
|
3
|
+
ControlPlaneActor,
|
|
4
|
+
ControlPlaneOperation,
|
|
5
|
+
DashboardControlPlane,
|
|
6
|
+
JsonValue,
|
|
7
|
+
} from '@stacksjs/ts-cloud'
|
|
8
|
+
import process from 'node:process'
|
|
9
|
+
import { setStateDir } from '@stacksjs/ts-cloud'
|
|
10
|
+
import { initializeDashboardControlPlane } from '@stacksjs/ts-cloud/deploy'
|
|
11
|
+
import { tsCloud } from '~/config/cloud'
|
|
12
|
+
|
|
13
|
+
let dashboardControlPlane: DashboardControlPlane | undefined
|
|
14
|
+
|
|
15
|
+
export function operationsControlPlane(): DashboardControlPlane {
|
|
16
|
+
if (dashboardControlPlane)
|
|
17
|
+
return dashboardControlPlane
|
|
18
|
+
|
|
19
|
+
setStateDir(tsCloud.stateDir)
|
|
20
|
+
dashboardControlPlane = initializeDashboardControlPlane(process.cwd(), tsCloud)
|
|
21
|
+
return dashboardControlPlane
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function dashboardOperator(request: RequestInstance): Promise<ControlPlaneActor> {
|
|
25
|
+
const controlPlane = operationsControlPlane()
|
|
26
|
+
const user = await request.user()
|
|
27
|
+
const id = Number(user?.id)
|
|
28
|
+
const externalId = Number.isSafeInteger(id) && id > 0 ? `stacks-user:${id}` : 'stacks-dashboard:local'
|
|
29
|
+
const kind = Number.isSafeInteger(id) && id > 0 ? 'user' : 'system'
|
|
30
|
+
const displayName = String(user?.name || user?.email || (kind === 'system' ? 'Local dashboard' : `User ${id}`))
|
|
31
|
+
|
|
32
|
+
return controlPlane.store.getActorByExternalId(kind, externalId)
|
|
33
|
+
?? controlPlane.store.createActor({
|
|
34
|
+
kind,
|
|
35
|
+
externalId,
|
|
36
|
+
displayName,
|
|
37
|
+
metadata: {
|
|
38
|
+
source: 'stacks-dashboard',
|
|
39
|
+
...(user?.email ? { email: String(user.email) } : {}),
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function operationsEnvironment(controlPlane = operationsControlPlane()) {
|
|
45
|
+
const requested = String(process.env.CLOUD_ENV || process.env.APP_ENV || process.env.NODE_ENV || 'development').toLowerCase()
|
|
46
|
+
return controlPlane.environments.get(requested)
|
|
47
|
+
?? controlPlane.environments.get('development')
|
|
48
|
+
?? controlPlane.environments.values().next().value
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function trackOperatorOperation<T extends JsonValue>(
|
|
52
|
+
request: RequestInstance,
|
|
53
|
+
kind: string,
|
|
54
|
+
input: JsonValue,
|
|
55
|
+
execute: () => Promise<T>,
|
|
56
|
+
): Promise<{ result: T, operation: ControlPlaneOperation }> {
|
|
57
|
+
const controlPlane = operationsControlPlane()
|
|
58
|
+
const actor = await dashboardOperator(request)
|
|
59
|
+
let operation = controlPlane.store.createOperation({
|
|
60
|
+
projectId: controlPlane.project.id,
|
|
61
|
+
environmentId: operationsEnvironment(controlPlane)?.id,
|
|
62
|
+
actorId: actor.id,
|
|
63
|
+
kind,
|
|
64
|
+
input,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
operation = controlPlane.store.transitionOperation(operation.id, {
|
|
68
|
+
to: 'running',
|
|
69
|
+
expectedVersion: operation.version,
|
|
70
|
+
})
|
|
71
|
+
controlPlane.store.appendEvent({
|
|
72
|
+
organizationId: controlPlane.organization.id,
|
|
73
|
+
projectId: controlPlane.project.id,
|
|
74
|
+
operationId: operation.id,
|
|
75
|
+
actorId: actor.id,
|
|
76
|
+
correlationId: operation.correlationId,
|
|
77
|
+
type: `${kind}.started`,
|
|
78
|
+
payload: input,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const result = await execute()
|
|
83
|
+
operation = controlPlane.store.transitionOperation(operation.id, {
|
|
84
|
+
to: 'succeeded',
|
|
85
|
+
expectedVersion: operation.version,
|
|
86
|
+
output: result,
|
|
87
|
+
})
|
|
88
|
+
controlPlane.store.appendEvent({
|
|
89
|
+
organizationId: controlPlane.organization.id,
|
|
90
|
+
projectId: controlPlane.project.id,
|
|
91
|
+
operationId: operation.id,
|
|
92
|
+
actorId: actor.id,
|
|
93
|
+
correlationId: operation.correlationId,
|
|
94
|
+
type: `${kind}.succeeded`,
|
|
95
|
+
payload: result,
|
|
96
|
+
})
|
|
97
|
+
return { result, operation }
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
101
|
+
operation = controlPlane.store.transitionOperation(operation.id, {
|
|
102
|
+
to: 'failed',
|
|
103
|
+
expectedVersion: operation.version,
|
|
104
|
+
error: message,
|
|
105
|
+
})
|
|
106
|
+
controlPlane.store.appendEvent({
|
|
107
|
+
organizationId: controlPlane.organization.id,
|
|
108
|
+
projectId: controlPlane.project.id,
|
|
109
|
+
operationId: operation.id,
|
|
110
|
+
actorId: actor.id,
|
|
111
|
+
correlationId: operation.correlationId,
|
|
112
|
+
type: `${kind}.failed`,
|
|
113
|
+
level: 'error',
|
|
114
|
+
payload: { message },
|
|
115
|
+
})
|
|
116
|
+
throw error
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function appendOperatorEvent(
|
|
121
|
+
request: RequestInstance,
|
|
122
|
+
type: string,
|
|
123
|
+
payload: JsonValue,
|
|
124
|
+
resourceId?: string,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const controlPlane = operationsControlPlane()
|
|
127
|
+
const actor = await dashboardOperator(request)
|
|
128
|
+
controlPlane.store.appendEvent({
|
|
129
|
+
organizationId: controlPlane.organization.id,
|
|
130
|
+
projectId: controlPlane.project.id,
|
|
131
|
+
resourceId,
|
|
132
|
+
actorId: actor.id,
|
|
133
|
+
type,
|
|
134
|
+
payload,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function recentOperatorOperations(prefix: string, limit = 20): Array<ControlPlaneOperation & { actorName: string }> {
|
|
139
|
+
const controlPlane = operationsControlPlane()
|
|
140
|
+
return controlPlane.store
|
|
141
|
+
.listOperations({ projectId: controlPlane.project.id, limit: Math.max(limit * 5, 100) })
|
|
142
|
+
.filter(operation => operation.kind.startsWith(prefix))
|
|
143
|
+
.slice(0, limit)
|
|
144
|
+
.map(operation => ({
|
|
145
|
+
...operation,
|
|
146
|
+
actorName: operation.actorId
|
|
147
|
+
? controlPlane.store.getActor(operation.actorId)?.displayName || 'Unknown operator'
|
|
148
|
+
: 'System',
|
|
149
|
+
}))
|
|
150
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import { auditMigrationLedger, previewPendingMigrations, reconcileMigrationLedger } from '@stacksjs/database'
|
|
3
|
+
|
|
4
|
+
export interface MigrationPlanOperation {
|
|
5
|
+
kind: string
|
|
6
|
+
table: string
|
|
7
|
+
column?: string
|
|
8
|
+
sql?: string
|
|
9
|
+
destructive: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class MigrationOperationError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message)
|
|
15
|
+
this.name = 'MigrationOperationError'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const destructiveKinds = new Set(['drop_table', 'drop_column', 'alter_column', 'truncate_table'])
|
|
20
|
+
|
|
21
|
+
export async function migrationPlan() {
|
|
22
|
+
const [rawOperations, ledger] = await Promise.all([
|
|
23
|
+
previewPendingMigrations(),
|
|
24
|
+
auditMigrationLedger(),
|
|
25
|
+
])
|
|
26
|
+
const operations: MigrationPlanOperation[] = rawOperations.map((operation) => ({
|
|
27
|
+
kind: String(operation.kind),
|
|
28
|
+
table: String(operation.table),
|
|
29
|
+
...('column' in operation && operation.column ? { column: String(operation.column) } : {}),
|
|
30
|
+
...('sql' in operation && operation.sql ? { sql: String(operation.sql) } : {}),
|
|
31
|
+
destructive: destructiveKinds.has(String(operation.kind)),
|
|
32
|
+
}))
|
|
33
|
+
const revision = new Bun.CryptoHasher('sha256')
|
|
34
|
+
.update(JSON.stringify({ operations, ledger: ledger.entries.map(entry => [entry.file, entry.status]) }))
|
|
35
|
+
.digest('hex')
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
environment: String(process.env.APP_ENV || process.env.NODE_ENV || 'development'),
|
|
39
|
+
dialect: ledger.dialect,
|
|
40
|
+
operations,
|
|
41
|
+
revision,
|
|
42
|
+
applied: ledger.recordedCount,
|
|
43
|
+
ledger,
|
|
44
|
+
summary: {
|
|
45
|
+
pending: operations.length,
|
|
46
|
+
destructive: operations.filter(operation => operation.destructive).length,
|
|
47
|
+
drift: ledger.drift,
|
|
48
|
+
ledgerIssues: ledger.entries.filter(entry => !['applied', 'pending'].includes(entry.status)).length + ledger.orphans.length,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function applyMigrationPlan(input: { revision: string, confirmation: string }): Promise<{ message: string }> {
|
|
54
|
+
const plan = await migrationPlan()
|
|
55
|
+
if (input.revision !== plan.revision)
|
|
56
|
+
throw new MigrationOperationError('The migration plan changed. Refresh and review the new plan before applying it.')
|
|
57
|
+
if (input.confirmation !== `migrate ${plan.environment}`)
|
|
58
|
+
throw new MigrationOperationError(`Type migrate ${plan.environment} to confirm this schema change.`)
|
|
59
|
+
if (!plan.operations.length)
|
|
60
|
+
return { message: 'The database schema is already current.' }
|
|
61
|
+
|
|
62
|
+
const child = Bun.spawn([`${process.cwd()}/buddy`, 'migrate', '--force'], {
|
|
63
|
+
cwd: process.cwd(),
|
|
64
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
65
|
+
stdout: 'pipe',
|
|
66
|
+
stderr: 'pipe',
|
|
67
|
+
})
|
|
68
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
69
|
+
new Response(child.stdout).text(),
|
|
70
|
+
new Response(child.stderr).text(),
|
|
71
|
+
child.exited,
|
|
72
|
+
])
|
|
73
|
+
if (exitCode !== 0)
|
|
74
|
+
throw new MigrationOperationError(stderr.trim() || stdout.trim() || 'The migration command failed.')
|
|
75
|
+
return { message: `Applied ${plan.operations.length} planned schema operation${plan.operations.length === 1 ? '' : 's'}.` }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function reconcileMigrationLedgerPlan(apply = false) {
|
|
79
|
+
return await reconcileMigrationLedger({ dryRun: !apply })
|
|
80
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import { AlertStore, ReleaseStore, resolveAuthEncryptionKey } from '@stacksjs/ts-cloud'
|
|
3
|
+
import { operationsControlPlane } from './control-plane'
|
|
4
|
+
|
|
5
|
+
export function releaseStore(): ReleaseStore {
|
|
6
|
+
return new ReleaseStore(operationsControlPlane().store)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function alertStore(): AlertStore {
|
|
10
|
+
return new AlertStore(operationsControlPlane().store, { encryptionKey: resolveAuthEncryptionKey(process.cwd()) })
|
|
11
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { jsonObject, positiveNumber, stringList, stringValue } from './recovery-input'
|
|
3
|
+
|
|
4
|
+
describe('recovery input normalization', () => {
|
|
5
|
+
it('normalizes scalar and list input without accepting nested values', () => {
|
|
6
|
+
expect(stringValue(' backups ')).toBe('backups')
|
|
7
|
+
expect(stringValue({ value: 'backups' })).toBe('')
|
|
8
|
+
expect(stringList('storage/app, public/uploads, ')).toEqual(['storage/app', 'public/uploads'])
|
|
9
|
+
expect(stringList([' storage/app ', null, 'public/uploads'])).toEqual(['storage/app', 'public/uploads'])
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('bounds positive recovery objectives and retention values', () => {
|
|
13
|
+
expect(positiveNumber('30', 7, 100)).toBe(30)
|
|
14
|
+
expect(positiveNumber(500, 7, 100)).toBe(100)
|
|
15
|
+
expect(positiveNumber(0, 7, 100)).toBe(7)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('accepts only record-shaped restore metadata', () => {
|
|
19
|
+
expect(jsonObject({ engine: 'postgres' })).toEqual({ engine: 'postgres' })
|
|
20
|
+
expect(jsonObject(['postgres'])).toEqual({})
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BackupResourceKind, JsonValue } from '@stacksjs/ts-cloud'
|
|
2
|
+
|
|
3
|
+
export const RECOVERY_KINDS: BackupResourceKind[] = [
|
|
4
|
+
'managed_database',
|
|
5
|
+
'logical_database',
|
|
6
|
+
'volume',
|
|
7
|
+
'files',
|
|
8
|
+
'control_plane',
|
|
9
|
+
'infrastructure',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
export function stringValue(value: unknown): string {
|
|
13
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stringList(value: unknown): string[] {
|
|
17
|
+
return Array.isArray(value)
|
|
18
|
+
? value.map(stringValue).filter(Boolean)
|
|
19
|
+
: typeof value === 'string'
|
|
20
|
+
? value.split(',').map(entry => entry.trim()).filter(Boolean)
|
|
21
|
+
: []
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function positiveNumber(value: unknown, fallback: number, maximum = Number.MAX_SAFE_INTEGER): number {
|
|
25
|
+
const number = Number(value)
|
|
26
|
+
return Number.isFinite(number) && number > 0 ? Math.min(maximum, number) : fallback
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function jsonObject(value: unknown): Record<string, JsonValue> {
|
|
30
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
31
|
+
return {}
|
|
32
|
+
return value as Record<string, JsonValue>
|
|
33
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BackupDestination,
|
|
3
|
+
BackupPolicy,
|
|
4
|
+
BackupSourceAdapter,
|
|
5
|
+
JsonValue,
|
|
6
|
+
} from '@stacksjs/ts-cloud'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import process from 'node:process'
|
|
9
|
+
import {
|
|
10
|
+
AwsDatabaseBackupSource,
|
|
11
|
+
AwsInfrastructureBackupSource,
|
|
12
|
+
BackupCoordinator,
|
|
13
|
+
BackupStore,
|
|
14
|
+
ControlPlaneBackupSource,
|
|
15
|
+
createBackupQueueHandlers,
|
|
16
|
+
DataServiceStore,
|
|
17
|
+
DockerVolumeBackupSource,
|
|
18
|
+
DurableOperationQueue,
|
|
19
|
+
DurableQueueWorker,
|
|
20
|
+
EncryptedDataSecretStore,
|
|
21
|
+
FilesystemBackupSource,
|
|
22
|
+
LogicalDatabaseBackupSource,
|
|
23
|
+
resolveAuthEncryptionKey,
|
|
24
|
+
S3BackupDestinationAdapter,
|
|
25
|
+
} from '@stacksjs/ts-cloud'
|
|
26
|
+
import { operationsControlPlane, operationsEnvironment } from './control-plane'
|
|
27
|
+
|
|
28
|
+
export interface RecoveryRuntime {
|
|
29
|
+
store: BackupStore
|
|
30
|
+
coordinator: BackupCoordinator
|
|
31
|
+
queue: DurableOperationQueue
|
|
32
|
+
worker: DurableQueueWorker
|
|
33
|
+
dataServices: DataServiceStore
|
|
34
|
+
secrets: EncryptedDataSecretStore
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let runtime: RecoveryRuntime | undefined
|
|
38
|
+
|
|
39
|
+
export function recoveryRuntime(): RecoveryRuntime {
|
|
40
|
+
if (runtime)
|
|
41
|
+
return runtime
|
|
42
|
+
|
|
43
|
+
const controlPlane = operationsControlPlane()
|
|
44
|
+
const store = new BackupStore(controlPlane.store)
|
|
45
|
+
const queue = new DurableOperationQueue(controlPlane.store, { workerId: `stacks-dashboard:${process.pid}` })
|
|
46
|
+
const coordinator = new BackupCoordinator(store, queue)
|
|
47
|
+
const dataServices = new DataServiceStore(controlPlane.store)
|
|
48
|
+
const secrets = new EncryptedDataSecretStore(controlPlane.store, resolveAuthEncryptionKey(process.cwd()))
|
|
49
|
+
const destination = new S3BackupDestinationAdapter(secrets)
|
|
50
|
+
|
|
51
|
+
const resolveSource = (policy: BackupPolicy): BackupSourceAdapter | undefined => {
|
|
52
|
+
if (policy.resourceKind === 'managed_database')
|
|
53
|
+
return new AwsDatabaseBackupSource(dataServices)
|
|
54
|
+
if (policy.resourceKind === 'logical_database')
|
|
55
|
+
return new LogicalDatabaseBackupSource(dataServices, secrets)
|
|
56
|
+
if (policy.resourceKind === 'volume')
|
|
57
|
+
return new DockerVolumeBackupSource()
|
|
58
|
+
if (policy.resourceKind === 'files')
|
|
59
|
+
return new FilesystemBackupSource(process.cwd(), undefined, join(process.cwd(), 'storage', 'cloud', 'restores'))
|
|
60
|
+
if (policy.resourceKind === 'control_plane')
|
|
61
|
+
return new ControlPlaneBackupSource(controlPlane.store, process.cwd(), join(process.cwd(), 'storage', 'cloud', 'restores'))
|
|
62
|
+
if (policy.resourceKind === 'infrastructure')
|
|
63
|
+
return new AwsInfrastructureBackupSource()
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handlers = createBackupQueueHandlers({
|
|
68
|
+
store,
|
|
69
|
+
queue,
|
|
70
|
+
resolveSource,
|
|
71
|
+
resolveDestination: (value: BackupDestination) => value.provider === 'aws_backup' ? undefined : destination,
|
|
72
|
+
})
|
|
73
|
+
const worker = new DurableQueueWorker(queue, handlers, {
|
|
74
|
+
parallelism: 2,
|
|
75
|
+
pollIntervalMs: 2_000,
|
|
76
|
+
onError: error => console.error('[dashboard/recovery] worker failed:', error),
|
|
77
|
+
}).start()
|
|
78
|
+
|
|
79
|
+
runtime = { store, coordinator, queue, worker, dataServices, secrets }
|
|
80
|
+
return runtime
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function recoveryEnvironment() {
|
|
84
|
+
const controlPlane = operationsControlPlane()
|
|
85
|
+
const environment = operationsEnvironment(controlPlane)
|
|
86
|
+
if (!environment)
|
|
87
|
+
throw new Error('No cloud environment is configured for recovery operations.')
|
|
88
|
+
return { controlPlane, environment }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function safeRecoveryDestination(destination: BackupDestination): Record<string, JsonValue> {
|
|
92
|
+
return {
|
|
93
|
+
...destination,
|
|
94
|
+
credentialRef: null,
|
|
95
|
+
encryptionKeyRef: null,
|
|
96
|
+
credentialsConfigured: Boolean(destination.credentialRef),
|
|
97
|
+
clientEncryptionConfigured: Boolean(destination.encryptionKeyRef),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { parseSchedulerRegistry, SchedulerOperationError } from './scheduler-operations'
|
|
3
|
+
|
|
4
|
+
describe('scheduler operations', () => {
|
|
5
|
+
it('parses the Buddy scheduler registry marker', () => {
|
|
6
|
+
const tasks = parseSchedulerRegistry([
|
|
7
|
+
'loading application',
|
|
8
|
+
'STACKS_SCHEDULE_JSON={"jobs":[{"name":"Cleanup","pattern":"0 * * * *","timezone":"UTC","nextRun":"2026-08-12T12:00:00.000Z","enabled":false}],"locks":[]}',
|
|
9
|
+
].join('\n'))
|
|
10
|
+
|
|
11
|
+
expect(tasks).toEqual([{
|
|
12
|
+
name: 'Cleanup',
|
|
13
|
+
pattern: '0 * * * *',
|
|
14
|
+
timezone: 'UTC',
|
|
15
|
+
nextRun: '2026-08-12T12:00:00.000Z',
|
|
16
|
+
enabled: false,
|
|
17
|
+
}])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('rejects unmarked output instead of guessing', () => {
|
|
21
|
+
expect(() => parseSchedulerRegistry('{"jobs":[]}')).toThrow(SchedulerOperationError)
|
|
22
|
+
})
|
|
23
|
+
})
|