@backstage/plugin-scaffolder-backend 4.1.0 → 4.2.0-next.1
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/CHANGELOG.md +33 -17
- package/README.md +23 -0
- package/config.schema.json +64 -4
- package/dist/actions/createDryRunTemplateAction.cjs.js +2 -0
- package/dist/actions/createDryRunTemplateAction.cjs.js.map +1 -1
- package/dist/scaffolder/tasks/DatabaseTaskStore.cjs.js +60 -36
- package/dist/scaffolder/tasks/DatabaseTaskStore.cjs.js.map +1 -1
- package/dist/scaffolder/tasks/NunjucksWorkflowRunner.cjs.js +52 -12
- package/dist/scaffolder/tasks/NunjucksWorkflowRunner.cjs.js.map +1 -1
- package/dist/scaffolder/tasks/StorageTaskBroker.cjs.js +53 -29
- package/dist/scaffolder/tasks/StorageTaskBroker.cjs.js.map +1 -1
- package/dist/scaffolder/tasks/TaskWorker.cjs.js +14 -4
- package/dist/scaffolder/tasks/TaskWorker.cjs.js.map +1 -1
- package/dist/scaffolder/tasks/WorkspaceService.cjs.js +26 -18
- package/dist/scaffolder/tasks/WorkspaceService.cjs.js.map +1 -1
- package/dist/scaffolder/tasks/taskRecoveryHelper.cjs.js +4 -0
- package/dist/scaffolder/tasks/taskRecoveryHelper.cjs.js.map +1 -1
- package/dist/service/router.cjs.js +5 -2
- package/dist/service/router.cjs.js.map +1 -1
- package/package.json +20 -20
- package/dist/scaffolder/tasks/DatabaseWorkspaceProvider.cjs.js +0 -23
- package/dist/scaffolder/tasks/DatabaseWorkspaceProvider.cjs.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,36 +1,52 @@
|
|
|
1
1
|
# @backstage/plugin-scaffolder-backend
|
|
2
2
|
|
|
3
|
-
## 4.
|
|
3
|
+
## 4.2.0-next.1
|
|
4
4
|
|
|
5
5
|
### Minor Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
7
|
+
- bbba6b5: Added the `scaffolder.requireScmUserCredentials` configuration option to require user-provided credentials for supported SCM mutation and fetch actions.
|
|
8
8
|
|
|
9
9
|
### Patch Changes
|
|
10
10
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
11
|
+
- 7fba55a: Removed user entity references from scaffolder task count metrics to avoid exposing user identities and creating high-cardinality metric labels.
|
|
12
|
+
- b1256aa: Respect task read permission decisions when listing scaffolder tasks.
|
|
13
|
+
- 15fa029: Restrict task list ordering to supported fields.
|
|
14
|
+
- be0a75a: Exclude internal task data from task responses.
|
|
15
|
+
- f389dd2: Improve denied scaffolder action error handling.
|
|
16
|
+
- 2eebeb7: Ensure task failure details use the configured log redactions.
|
|
17
|
+
- 84ebbb9: Fixed log redaction for transformed secret values used to iterate Scaffolder steps.
|
|
18
|
+
- b4172dd: Restrict task retries to terminal states.
|
|
19
|
+
- ee9c48d: Fixed matching of action input values in scaffolder permission policies.
|
|
20
|
+
- 79f0b91: Fixed the scaffolder task worker silently giving up after a transient failure. A single error while picking up a task, such as a dropped database connection, would stop the backend from running any further software templates for the rest of its lifetime. New tasks stayed queued indefinitely with no error shown to the user and no failing health check, and the only way to recover was to restart the backend. Picking up tasks is now retried instead.
|
|
19
21
|
- Updated dependencies
|
|
20
|
-
- @backstage/plugin-scaffolder-common@2.
|
|
21
|
-
- @backstage/integration@2.1.
|
|
22
|
+
- @backstage/plugin-scaffolder-common@2.3.0-next.1
|
|
23
|
+
- @backstage/integration@2.1.2-next.0
|
|
24
|
+
- @backstage/backend-openapi-utils@0.7.2-next.0
|
|
25
|
+
- @backstage/plugin-permission-node@0.11.4-next.0
|
|
26
|
+
- @backstage/plugin-scaffolder-node@0.13.7-next.1
|
|
27
|
+
- @backstage/backend-plugin-api@1.10.1-next.0
|
|
28
|
+
- @backstage/plugin-catalog-node@2.2.5-next.0
|
|
29
|
+
- @backstage/plugin-events-node@0.4.26-next.0
|
|
22
30
|
|
|
23
|
-
## 4.0.
|
|
31
|
+
## 4.1.0-next.0
|
|
24
32
|
|
|
25
|
-
###
|
|
33
|
+
### Minor Changes
|
|
26
34
|
|
|
27
|
-
-
|
|
35
|
+
- 1a705ca: Applied `templateDryRunPermission` to inline Software Template dry runs and the corresponding backend action. Permission policies that deny unknown permissions must explicitly allow `scaffolder.template.dry-run` to retain existing dry-run access.
|
|
36
|
+
- e95b649: Added task recovery feature with new `scaffolder.taskRecovery` config section. When enabled, tasks that crash or timeout are automatically recovered and resume from the last completed step, task secrets are retained until the task reaches a terminal state so recovery can continue, and completed step outputs are persisted. Enabling recovery applies to all scaffolder tasks, so actions used by those tasks should be idempotent or use checkpoints. When recovery is disabled (the default), the previous behavior is unchanged: secrets are cleared as soon as a task is claimed and retries re-run every step. The new config consolidates previous experimental flags (`EXPERIMENTAL_recoverTasks`, `EXPERIMENTAL_workspaceSerialization`, `EXPERIMENTAL_recoverTasksTimeout`) which remain supported as fallbacks. The legacy workspace provider setting continues to select a provider only when `EXPERIMENTAL_workspaceSerialization` is `true`.
|
|
28
37
|
|
|
29
|
-
|
|
38
|
+
Workspace serialization for task recovery now requires installing a separate workspace provider module, including when you use the legacy configuration. For development, use `@backstage/plugin-scaffolder-backend-module-workspace-database` (50 MB limit, not recommended for production). On first startup, that module migrates existing database workspace snapshots from the legacy task storage. For production, use `@backstage/plugin-scaffolder-backend-module-gcp` or a similar external storage provider. The scaffolder rejects a configured provider that has not been installed and registered.
|
|
39
|
+
|
|
40
|
+
Enabling crash recovery does not keep completed task event streams open; normal task completion remains terminal for event-stream clients.
|
|
30
41
|
|
|
31
42
|
### Patch Changes
|
|
32
43
|
|
|
33
|
-
-
|
|
44
|
+
- 2bf1392: Software template inline conditionals without an `else` branch now render an empty string when their condition is false, matching Nunjucks behavior.
|
|
45
|
+
- beaa3db: Reject Scaffolder steps where `each` resolves to a primitive value instead of an array or object.
|
|
46
|
+
- c1a30ef: Restored support for intrinsic string, number, array, `Map`, and `Set` methods in software templates.
|
|
47
|
+
- Updated dependencies
|
|
48
|
+
- @backstage/plugin-scaffolder-common@2.3.0-next.0
|
|
49
|
+
- @backstage/plugin-scaffolder-node@0.13.7-next.0
|
|
34
50
|
|
|
35
51
|
## 4.0.3
|
|
36
52
|
|
package/README.md
CHANGED
|
@@ -124,3 +124,26 @@ Default secrets are resolved from environment variables and accessible via `${{
|
|
|
124
124
|
```
|
|
125
125
|
|
|
126
126
|
**Security Note:** Secrets are automatically masked in logs and are only available to backend actions, never exposed to the frontend.
|
|
127
|
+
|
|
128
|
+
### Task Recovery
|
|
129
|
+
|
|
130
|
+
The scaffolder supports automatic task recovery when workers restart or crash. When enabled, tasks that were in a `processing` state will be recovered and can be resumed from where they left off.
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
scaffolder:
|
|
134
|
+
taskRecovery:
|
|
135
|
+
enabled: true
|
|
136
|
+
staleTimeout: { seconds: 30 } # Optional: how long before a task is considered stale
|
|
137
|
+
workspaceProvider: database # Optional: enables workspace serialization with specified provider
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
When task recovery is enabled:
|
|
141
|
+
|
|
142
|
+
- Tasks in `processing` state with stale heartbeats are automatically recovered to `open` state
|
|
143
|
+
- Secrets are preserved until the task reaches a terminal state (completed/failed)
|
|
144
|
+
- Completed steps are skipped on retry, resuming from the last incomplete step
|
|
145
|
+
- Step outputs are restored so subsequent steps can access previous results
|
|
146
|
+
|
|
147
|
+
**Note:** `workspaceProvider` is a separate option from `enabled`. Setting `enabled: true` activates task recovery (secrets preservation, step skipping), but workspace serialization only happens if you also set `workspaceProvider`. The `workspaceProvider` value specifies which storage backend to use (for example, `database` from `@backstage/plugin-scaffolder-backend-module-workspace-database`, or `gcpBucket` from `@backstage/plugin-scaffolder-backend-module-gcp`). Install and register the corresponding module before selecting a provider.
|
|
148
|
+
|
|
149
|
+
This replaces the previous experimental flags (`EXPERIMENTAL_recoverTasks`, `EXPERIMENTAL_workspaceSerialization`, `EXPERIMENTAL_recoverTasksTimeout`) which are still supported as fallbacks. Legacy workspace serialization also requires a separately installed provider module.
|
package/config.schema.json
CHANGED
|
@@ -30,6 +30,62 @@
|
|
|
30
30
|
"type": "number",
|
|
31
31
|
"description": "Sets the number of concurrent tasks that can be run at any given time on the TaskWorker.\n\nDefaults to 10.\n\nSet to 0 to disable task workers altogether."
|
|
32
32
|
},
|
|
33
|
+
"taskRecovery": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"enabled": {
|
|
37
|
+
"type": "boolean",
|
|
38
|
+
"description": "Enable automatic task recovery. When enabled:\n- Secrets are preserved until task completion\n- Step state is saved after each step\n- Crashed tasks resume from last completed step",
|
|
39
|
+
"visibility": "backend"
|
|
40
|
+
},
|
|
41
|
+
"staleTimeout": {
|
|
42
|
+
"anyOf": [
|
|
43
|
+
{
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"years": {
|
|
47
|
+
"type": "number"
|
|
48
|
+
},
|
|
49
|
+
"months": {
|
|
50
|
+
"type": "number"
|
|
51
|
+
},
|
|
52
|
+
"weeks": {
|
|
53
|
+
"type": "number"
|
|
54
|
+
},
|
|
55
|
+
"days": {
|
|
56
|
+
"type": "number"
|
|
57
|
+
},
|
|
58
|
+
"hours": {
|
|
59
|
+
"type": "number"
|
|
60
|
+
},
|
|
61
|
+
"minutes": {
|
|
62
|
+
"type": "number"
|
|
63
|
+
},
|
|
64
|
+
"seconds": {
|
|
65
|
+
"type": "number"
|
|
66
|
+
},
|
|
67
|
+
"milliseconds": {
|
|
68
|
+
"type": "number"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"description": "Human friendly durations object."
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"type": "string"
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
"description": "How long before a task is considered stale and eligible for recovery. Default: 30 seconds",
|
|
78
|
+
"visibility": "backend"
|
|
79
|
+
},
|
|
80
|
+
"workspaceProvider": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"description": "The workspace provider to use for serializing and restoring workspaces. Setting this value enables workspace serialization. If not set, workspace serialization is disabled. Common values: 'database'",
|
|
83
|
+
"visibility": "backend"
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"description": "Task recovery configuration",
|
|
87
|
+
"visibility": "backend"
|
|
88
|
+
},
|
|
33
89
|
"requireScmUserCredentials": {
|
|
34
90
|
"type": "boolean",
|
|
35
91
|
"description": "Requires supported SCM actions to only operate with credentials explicitly provided by the signed-in user.\n\nDefaults to false."
|
|
@@ -40,15 +96,18 @@
|
|
|
40
96
|
},
|
|
41
97
|
"EXPERIMENTAL_recoverTasks": {
|
|
42
98
|
"type": "boolean",
|
|
43
|
-
"description": "Sets the tasks recoverability on system start up.\n\nIf not specified, the default value is false."
|
|
99
|
+
"description": "Sets the tasks recoverability on system start up.\n\nIf not specified, the default value is false.",
|
|
100
|
+
"deprecated": "Use scaffolder.taskRecovery.enabled instead."
|
|
44
101
|
},
|
|
45
102
|
"EXPERIMENTAL_workspaceSerialization": {
|
|
46
103
|
"type": "boolean",
|
|
47
|
-
"description": "Sets the serialization of the workspace to have an ability to rerun the failed task."
|
|
104
|
+
"description": "Sets the serialization of the workspace to have an ability to rerun the failed task.",
|
|
105
|
+
"deprecated": "Use scaffolder.taskRecovery.workspaceProvider instead."
|
|
48
106
|
},
|
|
49
107
|
"EXPERIMENTAL_workspaceSerializationProvider": {
|
|
50
108
|
"type": "string",
|
|
51
|
-
"description": "Sets the provider for workspace serialization.\n\
|
|
109
|
+
"description": "Sets the provider for workspace serialization.\n\nDefaults to the database provider when workspace serialization is enabled.",
|
|
110
|
+
"deprecated": "Use scaffolder.taskRecovery.workspaceProvider instead."
|
|
52
111
|
},
|
|
53
112
|
"EXPERIMENTAL_recoverTasksTimeout": {
|
|
54
113
|
"anyOf": [
|
|
@@ -86,7 +145,8 @@
|
|
|
86
145
|
"type": "string"
|
|
87
146
|
}
|
|
88
147
|
],
|
|
89
|
-
"description": "Every task which is in progress state and having a last heartbeat longer than a specified timeout is going to be attempted to recover.\n\nIf not specified, the default value is
|
|
148
|
+
"description": "Every task which is in progress state and having a last heartbeat longer than a specified timeout is going to be attempted to recover.\n\nIf not specified, the default value is 30 seconds.",
|
|
149
|
+
"deprecated": "Use scaffolder.taskRecovery.staleTimeout instead."
|
|
90
150
|
},
|
|
91
151
|
"taskTimeoutJanitorFrequency": {
|
|
92
152
|
"anyOf": [
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var alpha = require('@backstage/plugin-scaffolder-common/alpha');
|
|
3
4
|
var yaml = require('yaml');
|
|
4
5
|
|
|
5
6
|
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
|
|
@@ -26,6 +27,7 @@ const createDryRunTemplateAction = ({
|
|
|
26
27
|
idempotent: true
|
|
27
28
|
},
|
|
28
29
|
description: "Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures.",
|
|
30
|
+
visibilityPermission: alpha.templateDryRunPermission,
|
|
29
31
|
schema: {
|
|
30
32
|
input: (z) => z.object({
|
|
31
33
|
templateYaml: z.string().describe(
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createDryRunTemplateAction.cjs.js","sources":["../../src/actions/createDryRunTemplateAction.ts"],"sourcesContent":["/*\n * Copyright 2026 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport { ScaffolderService } from '@backstage/plugin-scaffolder-node';\nimport { JsonObject } from '@backstage/types';\nimport yaml from 'yaml';\n\nconst MAX_CONTENT_SIZE = 64 * 1024;\n\nfunction base64EncodeContent(content: string): string {\n if (content.length > MAX_CONTENT_SIZE) {\n return Buffer.from('<file too large>', 'utf8').toString('base64');\n }\n return Buffer.from(content, 'utf8').toString('base64');\n}\n\nexport const createDryRunTemplateAction = ({\n actionsRegistry,\n scaffolderService,\n}: {\n actionsRegistry: ActionsRegistryService;\n scaffolderService: ScaffolderService;\n}) => {\n actionsRegistry.register({\n name: 'dry-run-template',\n title: 'Dry Run Scaffolder Template',\n attributes: {\n destructive: false,\n readOnly: true,\n idempotent: true,\n },\n description:\n 'Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures.',\n schema: {\n input: z =>\n z.object({\n templateYaml: z\n .string()\n .describe(\n 'The YAML content of the scaffolder template to validate',\n ),\n values: z\n .record(z.unknown())\n .optional()\n .describe('Input values for template parameters'),\n files: z\n .array(\n z.object({\n path: z\n .string()\n .describe('File path relative to template root'),\n content: z.string().describe('File content'),\n }),\n )\n .optional()\n .describe('Files required for running the template'),\n }),\n output: z =>\n z.object({\n valid: z.boolean().describe('Whether the template is valid'),\n message: z\n .string()\n .describe('Success message or validation error details'),\n errors: z\n .array(z.string())\n .optional()\n .describe('List of validation errors'),\n log: z\n .array(\n z.object({\n message: z.string(),\n stepId: z.string().optional(),\n status: z.string().optional(),\n }),\n )\n .optional()\n .describe('Execution log from dry-run'),\n output: z\n .record(z.unknown())\n .optional()\n .describe('Template output values'),\n steps: z\n .array(\n z.object({\n id: z.string(),\n name: z.string(),\n action: z.string(),\n }),\n )\n .optional()\n .describe('Parsed template steps'),\n }),\n },\n action: async ({ input, credentials }) => {\n const { templateYaml, values = {}, files = [] } = input;\n\n let template;\n try {\n template = yaml.parse(templateYaml);\n } catch (parseError) {\n const yamlError = parseError as yaml.YAMLParseError;\n return {\n output: {\n valid: false,\n message: 'Failed to parse YAML template',\n errors: [\n `YAML parsing error: ${yamlError.message}`,\n yamlError.linePos\n ? `At line ${yamlError.linePos[0].line}, column ${yamlError.linePos[0].col}`\n : '',\n ].filter(Boolean),\n },\n };\n }\n\n const result = await scaffolderService.dryRun(\n {\n template,\n values: values as JsonObject,\n directoryContents: files.map(file => ({\n path: file.path,\n base64Content: base64EncodeContent(file.content),\n })),\n },\n { credentials },\n );\n\n return {\n output: {\n valid: true,\n message: 'Template validation successful',\n log: result.log?.map(entry => ({\n message: entry.body.message,\n stepId: entry.body.stepId,\n status: entry.body.status,\n })),\n output: result.output,\n steps: result.steps,\n },\n };\n },\n });\n};\n"],"names":["yaml"],"mappings":"
|
|
1
|
+
{"version":3,"file":"createDryRunTemplateAction.cjs.js","sources":["../../src/actions/createDryRunTemplateAction.ts"],"sourcesContent":["/*\n * Copyright 2026 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';\nimport { templateDryRunPermission } from '@backstage/plugin-scaffolder-common/alpha';\nimport { ScaffolderService } from '@backstage/plugin-scaffolder-node';\nimport { JsonObject } from '@backstage/types';\nimport yaml from 'yaml';\n\nconst MAX_CONTENT_SIZE = 64 * 1024;\n\nfunction base64EncodeContent(content: string): string {\n if (content.length > MAX_CONTENT_SIZE) {\n return Buffer.from('<file too large>', 'utf8').toString('base64');\n }\n return Buffer.from(content, 'utf8').toString('base64');\n}\n\nexport const createDryRunTemplateAction = ({\n actionsRegistry,\n scaffolderService,\n}: {\n actionsRegistry: ActionsRegistryService;\n scaffolderService: ScaffolderService;\n}) => {\n actionsRegistry.register({\n name: 'dry-run-template',\n title: 'Dry Run Scaffolder Template',\n attributes: {\n destructive: false,\n readOnly: true,\n idempotent: true,\n },\n description:\n 'Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures.',\n visibilityPermission: templateDryRunPermission,\n schema: {\n input: z =>\n z.object({\n templateYaml: z\n .string()\n .describe(\n 'The YAML content of the scaffolder template to validate',\n ),\n values: z\n .record(z.unknown())\n .optional()\n .describe('Input values for template parameters'),\n files: z\n .array(\n z.object({\n path: z\n .string()\n .describe('File path relative to template root'),\n content: z.string().describe('File content'),\n }),\n )\n .optional()\n .describe('Files required for running the template'),\n }),\n output: z =>\n z.object({\n valid: z.boolean().describe('Whether the template is valid'),\n message: z\n .string()\n .describe('Success message or validation error details'),\n errors: z\n .array(z.string())\n .optional()\n .describe('List of validation errors'),\n log: z\n .array(\n z.object({\n message: z.string(),\n stepId: z.string().optional(),\n status: z.string().optional(),\n }),\n )\n .optional()\n .describe('Execution log from dry-run'),\n output: z\n .record(z.unknown())\n .optional()\n .describe('Template output values'),\n steps: z\n .array(\n z.object({\n id: z.string(),\n name: z.string(),\n action: z.string(),\n }),\n )\n .optional()\n .describe('Parsed template steps'),\n }),\n },\n action: async ({ input, credentials }) => {\n const { templateYaml, values = {}, files = [] } = input;\n\n let template;\n try {\n template = yaml.parse(templateYaml);\n } catch (parseError) {\n const yamlError = parseError as yaml.YAMLParseError;\n return {\n output: {\n valid: false,\n message: 'Failed to parse YAML template',\n errors: [\n `YAML parsing error: ${yamlError.message}`,\n yamlError.linePos\n ? `At line ${yamlError.linePos[0].line}, column ${yamlError.linePos[0].col}`\n : '',\n ].filter(Boolean),\n },\n };\n }\n\n const result = await scaffolderService.dryRun(\n {\n template,\n values: values as JsonObject,\n directoryContents: files.map(file => ({\n path: file.path,\n base64Content: base64EncodeContent(file.content),\n })),\n },\n { credentials },\n );\n\n return {\n output: {\n valid: true,\n message: 'Template validation successful',\n log: result.log?.map(entry => ({\n message: entry.body.message,\n stepId: entry.body.stepId,\n status: entry.body.status,\n })),\n output: result.output,\n steps: result.steps,\n },\n };\n },\n });\n};\n"],"names":["templateDryRunPermission","yaml"],"mappings":"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"}
|
|
@@ -6,7 +6,6 @@ var node_crypto = require('node:crypto');
|
|
|
6
6
|
var luxon = require('luxon');
|
|
7
7
|
var taskRecoveryHelper = require('./taskRecoveryHelper.cjs.js');
|
|
8
8
|
var dbUtil = require('./dbUtil.cjs.js');
|
|
9
|
-
var alpha = require('@backstage/plugin-scaffolder-node/alpha');
|
|
10
9
|
var helpers = require('../../service/helpers.cjs.js');
|
|
11
10
|
var pluginPermissionNode = require('@backstage/plugin-permission-node');
|
|
12
11
|
var lodash = require('lodash');
|
|
@@ -33,11 +32,16 @@ const parseSqlDateToIsoString = (input) => {
|
|
|
33
32
|
class DatabaseTaskStore {
|
|
34
33
|
db;
|
|
35
34
|
events;
|
|
35
|
+
recoverTasksEnabled;
|
|
36
36
|
static async create(options) {
|
|
37
37
|
const { database } = options;
|
|
38
38
|
const client = await this.getClient(database);
|
|
39
39
|
await this.runMigrations(database, client);
|
|
40
|
-
return new DatabaseTaskStore(
|
|
40
|
+
return new DatabaseTaskStore(
|
|
41
|
+
client,
|
|
42
|
+
options.events,
|
|
43
|
+
options.recoverTasksEnabled ?? false
|
|
44
|
+
);
|
|
41
45
|
}
|
|
42
46
|
isRecoverableTask(spec) {
|
|
43
47
|
return ["startOver"].includes(
|
|
@@ -79,9 +83,10 @@ class DatabaseTaskStore {
|
|
|
79
83
|
});
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
|
-
constructor(client, events) {
|
|
86
|
+
constructor(client, events, recoverTasksEnabled) {
|
|
83
87
|
this.db = client;
|
|
84
88
|
this.events = events;
|
|
89
|
+
this.recoverTasksEnabled = recoverTasksEnabled;
|
|
85
90
|
}
|
|
86
91
|
getState(task) {
|
|
87
92
|
try {
|
|
@@ -163,7 +168,14 @@ class DatabaseTaskStore {
|
|
|
163
168
|
queryBuilder.offset(pagination.offset);
|
|
164
169
|
}
|
|
165
170
|
const [results, [{ count }]] = await Promise.all([
|
|
166
|
-
queryBuilder.select(
|
|
171
|
+
queryBuilder.select([
|
|
172
|
+
"id",
|
|
173
|
+
"spec",
|
|
174
|
+
"status",
|
|
175
|
+
"created_by",
|
|
176
|
+
"last_heartbeat_at",
|
|
177
|
+
"created_at"
|
|
178
|
+
]),
|
|
167
179
|
countQuery
|
|
168
180
|
]);
|
|
169
181
|
const tasks = results.map((result) => ({
|
|
@@ -183,7 +195,16 @@ class DatabaseTaskStore {
|
|
|
183
195
|
return { tasks, totalTasks };
|
|
184
196
|
}
|
|
185
197
|
async getTask(taskId) {
|
|
186
|
-
const [result] = await this.db("tasks").where({ id: taskId }).select(
|
|
198
|
+
const [result] = await this.db("tasks").where({ id: taskId }).select([
|
|
199
|
+
"id",
|
|
200
|
+
"spec",
|
|
201
|
+
"status",
|
|
202
|
+
"state",
|
|
203
|
+
"last_heartbeat_at",
|
|
204
|
+
"created_at",
|
|
205
|
+
"created_by",
|
|
206
|
+
"secrets"
|
|
207
|
+
]);
|
|
187
208
|
if (!result) {
|
|
188
209
|
throw new errors.NotFoundError(`No task with id '${taskId}' found`);
|
|
189
210
|
}
|
|
@@ -232,7 +253,16 @@ class DatabaseTaskStore {
|
|
|
232
253
|
return this.db.transaction(async (tx) => {
|
|
233
254
|
const [task] = await tx("tasks").where({
|
|
234
255
|
status: "open"
|
|
235
|
-
}).limit(1).select(
|
|
256
|
+
}).limit(1).select([
|
|
257
|
+
"id",
|
|
258
|
+
"spec",
|
|
259
|
+
"status",
|
|
260
|
+
"state",
|
|
261
|
+
"last_heartbeat_at",
|
|
262
|
+
"created_at",
|
|
263
|
+
"created_by",
|
|
264
|
+
"secrets"
|
|
265
|
+
]);
|
|
236
266
|
if (!task) {
|
|
237
267
|
return void 0;
|
|
238
268
|
}
|
|
@@ -240,8 +270,11 @@ class DatabaseTaskStore {
|
|
|
240
270
|
const updateCount = await tx("tasks").where({ id: task.id, status: "open" }).update({
|
|
241
271
|
status: "processing",
|
|
242
272
|
last_heartbeat_at: this.db.fn.now(),
|
|
243
|
-
//
|
|
244
|
-
|
|
273
|
+
// Retain secrets only when recovery is enabled (globally or via the
|
|
274
|
+
// legacy per-template strategy) so recovered tasks can resume.
|
|
275
|
+
// Otherwise clear them on claim to preserve the default security
|
|
276
|
+
// lifecycle.
|
|
277
|
+
secrets: this.recoverTasksEnabled || this.isRecoverableTask(spec) ? task.secrets : null
|
|
245
278
|
});
|
|
246
279
|
if (updateCount < 1) {
|
|
247
280
|
return void 0;
|
|
@@ -274,7 +307,7 @@ class DatabaseTaskStore {
|
|
|
274
307
|
async listStaleTasks(options) {
|
|
275
308
|
const { timeoutS } = options;
|
|
276
309
|
const heartbeatInterval = dbUtil.intervalFromNowTill(timeoutS, this.db);
|
|
277
|
-
const rawRows = await this.db("tasks").where("status", "processing").andWhere("last_heartbeat_at", "<=", heartbeatInterval);
|
|
310
|
+
const rawRows = await this.db("tasks").where("status", "processing").andWhere("last_heartbeat_at", "<=", heartbeatInterval).select(["id", "spec"]);
|
|
278
311
|
const tasks = rawRows.map((row) => ({
|
|
279
312
|
recovery: JSON.parse(row.spec).EXPERIMENTAL_recovery,
|
|
280
313
|
taskId: row.id
|
|
@@ -294,7 +327,14 @@ class DatabaseTaskStore {
|
|
|
294
327
|
await this.db.transaction(async (tx) => {
|
|
295
328
|
const [task] = await tx("tasks").where({
|
|
296
329
|
id: taskId
|
|
297
|
-
}).limit(1).select(
|
|
330
|
+
}).limit(1).select([
|
|
331
|
+
"id",
|
|
332
|
+
"status",
|
|
333
|
+
"state",
|
|
334
|
+
"last_heartbeat_at",
|
|
335
|
+
"created_at",
|
|
336
|
+
"created_by"
|
|
337
|
+
]);
|
|
298
338
|
const updateTask = async (criteria) => {
|
|
299
339
|
const updateCount = await tx("tasks").where(criteria).update({
|
|
300
340
|
status,
|
|
@@ -367,7 +407,7 @@ class DatabaseTaskStore {
|
|
|
367
407
|
}
|
|
368
408
|
}
|
|
369
409
|
async listEvents(options) {
|
|
370
|
-
const {
|
|
410
|
+
const { taskId, after, isTaskRecoverable } = options;
|
|
371
411
|
const rawEvents = await this.db("task_events").where({
|
|
372
412
|
task_id: taskId
|
|
373
413
|
}).andWhere((builder) => {
|
|
@@ -422,34 +462,18 @@ class DatabaseTaskStore {
|
|
|
422
462
|
}
|
|
423
463
|
});
|
|
424
464
|
}
|
|
425
|
-
async rehydrateWorkspace(options) {
|
|
426
|
-
const [result] = await this.db("tasks").where({ id: options.taskId }).select("workspace");
|
|
427
|
-
await alpha.restoreWorkspace({
|
|
428
|
-
path: options.targetPath,
|
|
429
|
-
buffer: result.workspace
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
async cleanWorkspace({ taskId }) {
|
|
433
|
-
await this.db("tasks").where({ id: taskId }).update({
|
|
434
|
-
workspace: null
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
async serializeWorkspace(options) {
|
|
438
|
-
if (options.path) {
|
|
439
|
-
const workspace = (await alpha.serializeWorkspace(options)).contents;
|
|
440
|
-
await this.db("tasks").where({ id: options.taskId }).update({
|
|
441
|
-
workspace
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
465
|
async cancelTask(options) {
|
|
446
466
|
const { taskId, body } = options;
|
|
447
467
|
const serializedBody = JSON.stringify(body);
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
468
|
+
const ret = await this.db.transaction(async (tx) => {
|
|
469
|
+
await tx("tasks").where({ id: taskId }).update({ secrets: null });
|
|
470
|
+
const [event] = await tx("task_events").insert({
|
|
471
|
+
task_id: taskId,
|
|
472
|
+
event_type: "cancelled",
|
|
473
|
+
body: serializedBody
|
|
474
|
+
}).returning("id");
|
|
475
|
+
return event;
|
|
476
|
+
});
|
|
453
477
|
this.events?.publish({
|
|
454
478
|
topic: "scaffolder.task",
|
|
455
479
|
eventPayload: {
|