feat(scaffolder): implementing secrets schema for scaffolder templates (#32320)
* feat: implementing secrets schema for scaffolder templates Signed-off-by: benjdlambert <ben@blam.sh> * chore: fix and regenerate openapi Signed-off-by: benjdlambert <ben@blam.sh> Signed-off-by: benjdlambert <ben@blam.sh> * chore: fix review feedback Signed-off-by: benjdlambert <ben@blam.sh> * fix: address code review feedback for secrets validation - Extract validateSecrets helper to deduplicate validation logic - Add auditorEvent.fail() call on secrets validation failure - Sanitize instance field in error responses to prevent secret leakage - Add retry endpoint test coverage for secrets validation - Split changeset into per-package entries Signed-off-by: benjdlambert <ben@blam.sh> * refactor: nest secrets schema under secrets.schema Move the JSON Schema definition from spec.secrets to spec.secrets.schema to leave room for future extensions like secret sources. Signed-off-by: benjdlambert <ben@blam.sh> * chore: update API reports Signed-off-by: benjdlambert <ben@blam.sh> * chore: use InputError for secrets validation audit event Signed-off-by: benjdlambert <ben@blam.sh> --------- Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-common': minor
|
||||
---
|
||||
|
||||
Added an optional `secrets` field to `TemplateEntityV1beta3` for configuring secrets validation. The schema for validating secrets is defined under `secrets.schema` as a JSON Schema object.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': minor
|
||||
---
|
||||
|
||||
Added secrets schema validation for task creation, retry, and dry-run endpoints. When a template defines `spec.secrets.schema`, the API validates provided secrets against the schema and returns a `400` error if validation fails.
|
||||
@@ -325,6 +325,57 @@ spec:
|
||||
token: ${{ each.value.token }}
|
||||
```
|
||||
|
||||
### Defining a Secrets Schema
|
||||
|
||||
You can define a JSON Schema for secrets that will be validated when a task is created. This is useful when secrets are passed programmatically (e.g., via CI/CD pipelines or API calls) rather than through the UI form. The schema ensures that required secrets are provided before task execution begins.
|
||||
|
||||
```yaml
|
||||
apiVersion: scaffolder.backstage.io/v1beta3
|
||||
kind: Template
|
||||
metadata:
|
||||
name: publish-to-npm
|
||||
title: Publish to NPM
|
||||
spec:
|
||||
owner: backstage/techdocs-core
|
||||
type: service
|
||||
|
||||
# Define required secrets with a JSON Schema
|
||||
secrets:
|
||||
schema:
|
||||
required:
|
||||
- NPM_TOKEN
|
||||
properties:
|
||||
NPM_TOKEN:
|
||||
type: string
|
||||
description: NPM authentication token for publishing
|
||||
|
||||
parameters:
|
||||
- title: Package Details
|
||||
properties:
|
||||
packageName:
|
||||
type: string
|
||||
title: Package Name
|
||||
|
||||
steps:
|
||||
- id: publish
|
||||
action: npm:publish
|
||||
input:
|
||||
packageName: ${{ parameters.packageName }}
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
```
|
||||
|
||||
When a task is created without the required secrets, the API returns a `400` error with a descriptive message:
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"message": "secrets.NPM_TOKEN is required"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Custom step layouts
|
||||
|
||||
If you find that the default layout of the form used in a particular step does not meet your needs then you can supply your own [custom step layout](./writing-custom-step-layouts.md).
|
||||
|
||||
@@ -686,6 +686,19 @@ paths:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
'400':
|
||||
description: Validation errors.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
required:
|
||||
- errors
|
||||
security:
|
||||
- {}
|
||||
- JWT: []
|
||||
|
||||
@@ -116,7 +116,7 @@ export type Retry = {
|
||||
taskId: string;
|
||||
};
|
||||
body: RetryRequest;
|
||||
response: Scaffold201Response;
|
||||
response: Scaffold201Response | Scaffold400Response;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
|
||||
@@ -1023,6 +1023,25 @@ export const spec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'400': {
|
||||
description: 'Validation errors.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
errors: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/ValidationError',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['errors'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [
|
||||
{},
|
||||
|
||||
@@ -20,11 +20,7 @@ import express from 'express';
|
||||
import request from 'supertest';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
import {
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { stringifyEntityRef, UserEntity } from '@backstage/catalog-model';
|
||||
import {
|
||||
createTemplateAction,
|
||||
TaskBroker,
|
||||
@@ -83,9 +79,9 @@ function createDatabase(): DatabaseService {
|
||||
|
||||
const config = new ConfigReader({});
|
||||
|
||||
// todo: this needs to return a new object every time as there seems to
|
||||
// be some mutation in the tests.
|
||||
const generateMockTemplate = () => ({
|
||||
// Returns a new mock template object each time to avoid mutation issues.
|
||||
// Accepts optional spec overrides that are merged with the base spec.
|
||||
const generateMockTemplate = (specOverrides?: Record<string, unknown>) => ({
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
@@ -146,6 +142,7 @@ const generateMockTemplate = () => ({
|
||||
},
|
||||
},
|
||||
],
|
||||
...specOverrides,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -153,7 +150,7 @@ const mockUser: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'guest',
|
||||
name: 'mock',
|
||||
annotations: {
|
||||
'google.com/email': 'bobby@tables.com',
|
||||
},
|
||||
@@ -175,6 +172,7 @@ const createTestRouter = async (
|
||||
| CreatedTemplateGlobal[];
|
||||
autocompleteHandlers?: Record<string, AutocompleteHandler>;
|
||||
actionsRegistry?: ActionsService;
|
||||
entities?: any[];
|
||||
} = {},
|
||||
) => {
|
||||
const logger = mockServices.logger.mock({
|
||||
@@ -196,26 +194,13 @@ const createTestRouter = async (
|
||||
jest.spyOn(taskBroker, 'vacuumTasks');
|
||||
jest.spyOn(taskBroker, 'event$');
|
||||
|
||||
const catalog = catalogServiceMock.mock();
|
||||
const entities = overrides.entities ?? [generateMockTemplate(), mockUser];
|
||||
const catalog = catalogServiceMock({ entities });
|
||||
const permissions = mockServices.permissions();
|
||||
const auth = mockServices.auth();
|
||||
const httpAuth = mockServices.httpAuth();
|
||||
const events = mockServices.events();
|
||||
|
||||
catalog.getEntityByRef.mockImplementation(async ref => {
|
||||
const { kind } = parseEntityRef(ref);
|
||||
|
||||
if (kind.toLocaleLowerCase() === 'template') {
|
||||
return generateMockTemplate();
|
||||
}
|
||||
|
||||
if (kind.toLocaleLowerCase() === 'user') {
|
||||
return mockUser;
|
||||
}
|
||||
|
||||
throw new Error(`no mock found for kind: ${kind}`);
|
||||
});
|
||||
|
||||
const router = await createRouter({
|
||||
logger,
|
||||
config: new ConfigReader({}),
|
||||
@@ -651,6 +636,132 @@ describe('scaffolder router', () => {
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
|
||||
it('rejects when required secrets are missing', async () => {
|
||||
const templateWithSecrets = generateMockTemplate({
|
||||
secrets: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['NPM_TOKEN'],
|
||||
properties: {
|
||||
NPM_TOKEN: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { router } = await createTestRouter({
|
||||
entities: [templateWithSecrets, mockUser],
|
||||
});
|
||||
|
||||
const response = await request(router)
|
||||
.post('/v2/tasks')
|
||||
.send({
|
||||
templateRef: stringifyEntityRef({
|
||||
kind: 'template',
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
// No secrets provided
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.body.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
property: 'secrets',
|
||||
message: 'secrets.NPM_TOKEN is required',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when required secrets are missing without explicit type', async () => {
|
||||
const templateWithSecrets = generateMockTemplate({
|
||||
secrets: {
|
||||
schema: {
|
||||
// No explicit type: 'object' - should still work
|
||||
required: ['NPM_TOKEN'],
|
||||
properties: {
|
||||
NPM_TOKEN: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { router } = await createTestRouter({
|
||||
entities: [templateWithSecrets, mockUser],
|
||||
});
|
||||
|
||||
const response = await request(router)
|
||||
.post('/v2/tasks')
|
||||
.send({
|
||||
templateRef: stringifyEntityRef({
|
||||
kind: 'template',
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
// No secrets provided
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.body.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
property: 'secrets',
|
||||
message: 'secrets.NPM_TOKEN is required',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts valid secrets matching the schema', async () => {
|
||||
const templateWithSecrets = generateMockTemplate({
|
||||
secrets: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['NPM_TOKEN'],
|
||||
properties: {
|
||||
NPM_TOKEN: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { router, taskBroker } = await createTestRouter({
|
||||
entities: [templateWithSecrets, mockUser],
|
||||
});
|
||||
const broker = taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
|
||||
|
||||
broker.mockResolvedValue({
|
||||
taskId: 'a-random-id',
|
||||
});
|
||||
|
||||
const response = await request(router)
|
||||
.post('/v2/tasks')
|
||||
.send({
|
||||
templateRef: stringifyEntityRef({
|
||||
kind: 'template',
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
secrets: {
|
||||
NPM_TOKEN: 'my-secret-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(201);
|
||||
expect(response.body.id).toBe('a-random-id');
|
||||
});
|
||||
|
||||
it('return the template id', async () => {
|
||||
const { router, taskBroker } = await createTestRouter();
|
||||
const broker = taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
|
||||
@@ -1087,6 +1198,97 @@ describe('scaffolder router', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /v2/tasks/:taskId/retry', () => {
|
||||
it('rejects when required secrets are missing', async () => {
|
||||
const templateWithSecrets = generateMockTemplate({
|
||||
secrets: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['NPM_TOKEN'],
|
||||
properties: {
|
||||
NPM_TOKEN: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { router, taskBroker } = await createTestRouter({
|
||||
entities: [templateWithSecrets, mockUser],
|
||||
});
|
||||
|
||||
(taskBroker.get as jest.Mocked<TaskBroker>['get']).mockResolvedValue({
|
||||
id: 'a-random-id',
|
||||
spec: {
|
||||
templateInfo: {
|
||||
entityRef: 'template:default/create-react-app-template',
|
||||
baseUrl: 'https://example.com',
|
||||
entity: { metadata: templateWithSecrets.metadata },
|
||||
},
|
||||
} as any,
|
||||
status: 'failed',
|
||||
createdAt: '',
|
||||
createdBy: 'user:default/mock',
|
||||
});
|
||||
|
||||
const response = await request(router)
|
||||
.post('/v2/tasks/a-random-id/retry')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.body.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
property: 'secrets',
|
||||
message: 'secrets.NPM_TOKEN is required',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts valid secrets on retry', async () => {
|
||||
const templateWithSecrets = generateMockTemplate({
|
||||
secrets: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['NPM_TOKEN'],
|
||||
properties: {
|
||||
NPM_TOKEN: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { router, taskBroker } = await createTestRouter({
|
||||
entities: [templateWithSecrets, mockUser],
|
||||
});
|
||||
|
||||
(taskBroker.get as jest.Mocked<TaskBroker>['get']).mockResolvedValue({
|
||||
id: 'a-random-id',
|
||||
spec: {
|
||||
templateInfo: {
|
||||
entityRef: 'template:default/create-react-app-template',
|
||||
baseUrl: 'https://example.com',
|
||||
entity: { metadata: templateWithSecrets.metadata },
|
||||
},
|
||||
} as any,
|
||||
status: 'failed',
|
||||
createdAt: '',
|
||||
createdBy: 'user:default/mock',
|
||||
});
|
||||
|
||||
const response = await request(router)
|
||||
.post('/v2/tasks/a-random-id/retry')
|
||||
.send({
|
||||
secrets: {
|
||||
NPM_TOKEN: 'my-secret-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(201);
|
||||
expect(taskBroker.retry).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /v2/tasks/:taskId/eventstream', () => {
|
||||
it('should return log messages', async () => {
|
||||
const { unwrappedRouter: router, taskBroker } = await createTestRouter();
|
||||
@@ -1407,6 +1609,9 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
const mockToken = mockCredentials.user.token();
|
||||
const mockTemplate = generateMockTemplate();
|
||||
|
||||
// Spy on the catalog method to verify it's called correctly
|
||||
const getEntityByRefSpy = jest.spyOn(catalog, 'getEntityByRef');
|
||||
|
||||
await request(router)
|
||||
.post('/v2/dry-run')
|
||||
.set('Authorization', `Bearer ${mockToken}`)
|
||||
@@ -1419,13 +1624,54 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{
|
||||
directoryContents: [],
|
||||
});
|
||||
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
|
||||
expect(getEntityByRefSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
|
||||
expect(getEntityByRefSpy).toHaveBeenCalledWith(
|
||||
'user:default/mock',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when required secrets are missing', async () => {
|
||||
const { router } = await createTestRouter();
|
||||
const mockToken = mockCredentials.user.token();
|
||||
|
||||
const templateWithSecrets = generateMockTemplate({
|
||||
secrets: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['NPM_TOKEN'],
|
||||
properties: {
|
||||
NPM_TOKEN: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(router)
|
||||
.post('/v2/dry-run')
|
||||
.set('Authorization', `Bearer ${mockToken}`)
|
||||
.send({
|
||||
template: templateWithSecrets,
|
||||
values: {
|
||||
requiredParameter1: 'required-value-1',
|
||||
requiredParameter2: 'required-value-2',
|
||||
},
|
||||
directoryContents: [],
|
||||
// No secrets provided
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.body.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
property: 'secrets',
|
||||
message: 'secrets.NPM_TOKEN is required',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows payloads up to 10MB', async () => {
|
||||
const { unwrappedRouter } = await createTestRouter();
|
||||
const mockToken = mockCredentials.user.token();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import {
|
||||
AuditorService,
|
||||
AuditorServiceEvent,
|
||||
AuthService,
|
||||
BackstageCredentials,
|
||||
DatabaseService,
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
resolveSafeChildPath,
|
||||
SchedulerService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { validate } from 'jsonschema';
|
||||
import { validate, ValidatorResult } from 'jsonschema';
|
||||
import {
|
||||
CompoundEntityRef,
|
||||
Entity,
|
||||
@@ -181,6 +182,49 @@ const readDuration = (
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
function formatSecretsValidationErrors(result: ValidatorResult) {
|
||||
return result.errors.map(err => {
|
||||
const property = err.property.replace(/^instance/, 'secrets');
|
||||
const secretName = err.argument;
|
||||
const message =
|
||||
err.name === 'required'
|
||||
? `secrets.${secretName} is required`
|
||||
: `${property} ${err.message}`;
|
||||
return {
|
||||
...err,
|
||||
property,
|
||||
message,
|
||||
instance: {},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function validateSecrets(options: {
|
||||
template: TemplateEntityV1beta3;
|
||||
secrets: Record<string, unknown>;
|
||||
res: express.Response;
|
||||
auditorEvent?: AuditorServiceEvent;
|
||||
}): Promise<boolean> {
|
||||
const { template, secrets, res, auditorEvent } = options;
|
||||
if (!template.spec.secrets?.schema) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = validate(secrets, template.spec.secrets.schema);
|
||||
if (result.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await auditorEvent?.fail({
|
||||
error: new InputError('Secrets validation failed'),
|
||||
});
|
||||
|
||||
res.status(400).json({
|
||||
errors: formatSecretsValidationErrors(result),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A method to create a router for the scaffolder backend plugin.
|
||||
*/
|
||||
@@ -527,6 +571,16 @@ export async function createRouter(
|
||||
}
|
||||
}
|
||||
|
||||
const secretsValid = await validateSecrets({
|
||||
template,
|
||||
secrets: req.body.secrets ?? {},
|
||||
res,
|
||||
auditorEvent,
|
||||
});
|
||||
if (!secretsValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = getEntityBaseUrl(template);
|
||||
|
||||
const taskSpec: TaskSpec = {
|
||||
@@ -747,6 +801,28 @@ export async function createRouter(
|
||||
isTaskAuthorized,
|
||||
});
|
||||
|
||||
// Validate secrets against template schema if defined
|
||||
if (task.spec.templateInfo?.entityRef) {
|
||||
const templateEntityRef = parseEntityRef(
|
||||
task.spec.templateInfo.entityRef,
|
||||
{ defaultKind: 'template' },
|
||||
);
|
||||
const template = await authorizeTemplate(
|
||||
templateEntityRef,
|
||||
credentials,
|
||||
);
|
||||
|
||||
const secretsValid = await validateSecrets({
|
||||
template,
|
||||
secrets: req.body.secrets ?? {},
|
||||
res,
|
||||
auditorEvent,
|
||||
});
|
||||
if (!secretsValid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await auditorEvent?.success();
|
||||
|
||||
const { token } = await auth.getPluginRequestToken({
|
||||
@@ -975,6 +1051,16 @@ export async function createRouter(
|
||||
}
|
||||
}
|
||||
|
||||
const secretsValid = await validateSecrets({
|
||||
template,
|
||||
secrets: body.secrets ?? {},
|
||||
res,
|
||||
auditorEvent,
|
||||
});
|
||||
if (!secretsValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const steps = template.spec.steps.map((step, index) => ({
|
||||
...step,
|
||||
id: step.id ?? `step-${index + 1}`,
|
||||
|
||||
@@ -418,6 +418,9 @@ export interface TemplateEntityV1beta3 extends Entity {
|
||||
input?: JsonObject;
|
||||
}[];
|
||||
parameters?: TemplateParametersV1beta3 | TemplateParametersV1beta3[];
|
||||
secrets?: {
|
||||
schema?: JsonObject;
|
||||
};
|
||||
steps: Array<TemplateEntityStepV1beta3>;
|
||||
output?: {
|
||||
[name: string]: string;
|
||||
|
||||
@@ -146,6 +146,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"secrets": {
|
||||
"type": "object",
|
||||
"description": "Configuration for secrets that are passed during task creation.",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "A JSONSchema for validating secrets passed during task creation."
|
||||
}
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"type": "object",
|
||||
"description": "A way to redefine the presentation of the scaffolder.",
|
||||
|
||||
@@ -67,6 +67,13 @@ export interface TemplateEntityV1beta3 extends Entity {
|
||||
* variables passed from the user into each action in the template.
|
||||
*/
|
||||
parameters?: TemplateParametersV1beta3 | TemplateParametersV1beta3[];
|
||||
/**
|
||||
* Configuration for secrets that are passed during task creation.
|
||||
* The schema field contains a JSONSchema used to validate secrets.
|
||||
*/
|
||||
secrets?: {
|
||||
schema?: JsonObject;
|
||||
};
|
||||
/**
|
||||
* A list of steps to be executed in sequence which are defined by the template. These steps are a list of the underlying
|
||||
* javascript action and some optional input parameters that may or may not have been collected from the end user.
|
||||
|
||||
Reference in New Issue
Block a user