@friggframework/admin-scripts 2.0.0--canary.545.b4ca16d.0 → 2.0.0--canary.517.8eaf5df.0
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/README.md +277 -0
- package/index.js +23 -6
- package/package.json +6 -6
- package/src/adapters/__tests__/aws-scheduler-adapter.test.js +106 -45
- package/src/adapters/__tests__/local-scheduler-adapter.test.js +24 -10
- package/src/adapters/__tests__/scheduler-adapter-factory.test.js +30 -9
- package/src/adapters/__tests__/scheduler-adapter.test.js +6 -2
- package/src/adapters/aws-scheduler-adapter.js +55 -28
- package/src/adapters/local-scheduler-adapter.js +2 -2
- package/src/adapters/scheduler-adapter-factory.js +3 -1
- package/src/adapters/scheduler-adapter.js +9 -3
- package/src/application/__tests__/admin-frigg-commands.test.js +73 -30
- package/src/application/__tests__/admin-script-base.test.js +23 -6
- package/src/application/__tests__/script-factory.test.js +30 -6
- package/src/application/__tests__/script-runner.test.js +113 -24
- package/src/application/__tests__/validate-script-input.test.js +54 -15
- package/src/application/admin-frigg-commands.js +21 -9
- package/src/application/admin-script-base.js +3 -2
- package/src/application/script-factory.js +3 -1
- package/src/application/script-runner.js +90 -48
- package/src/application/use-cases/__tests__/delete-schedule-use-case.test.js +16 -7
- package/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js +9 -4
- package/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js +21 -10
- package/src/application/use-cases/delete-schedule-use-case.js +6 -4
- package/src/application/use-cases/get-effective-schedule-use-case.js +3 -1
- package/src/application/use-cases/index.js +3 -1
- package/src/application/use-cases/upsert-schedule-use-case.js +30 -11
- package/src/application/validate-script-input.js +10 -3
- package/src/builtins/__tests__/integration-health-check.test.js +232 -127
- package/src/builtins/__tests__/oauth-token-refresh.test.js +128 -75
- package/src/builtins/index.js +1 -4
- package/src/builtins/integration-health-check.js +63 -30
- package/src/builtins/oauth-token-refresh.js +64 -29
- package/src/infrastructure/__tests__/admin-auth-middleware.test.js +9 -3
- package/src/infrastructure/__tests__/admin-script-router.test.js +125 -52
- package/src/infrastructure/admin-auth-middleware.js +3 -1
- package/src/infrastructure/admin-script-router.js +129 -14
- package/src/infrastructure/bootstrap.js +87 -0
- package/src/infrastructure/script-executor-handler.js +119 -52
- package/src/application/__tests__/dry-run-http-interceptor.test.js +0 -313
- package/src/application/__tests__/dry-run-repository-wrapper.test.js +0 -257
- package/src/application/__tests__/schedule-management-use-case.test.js +0 -276
- package/src/application/dry-run-http-interceptor.js +0 -296
- package/src/application/dry-run-repository-wrapper.js +0 -261
- package/src/application/schedule-management-use-case.js +0 -230
package/README.md
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# @friggframework/admin-scripts
|
|
2
|
+
|
|
3
|
+
Admin Script Runner for Frigg — write and run operational/maintenance scripts inside your deployed Frigg app, with VPC/KMS-secured database access, the same repositories your integrations use, sync or async (queued) execution, dry-run validation, and optional cron scheduling via AWS EventBridge Scheduler.
|
|
4
|
+
|
|
5
|
+
Typical use cases:
|
|
6
|
+
|
|
7
|
+
- **Healing scripts** — repair broken integration state (e.g. corrupted config).
|
|
8
|
+
- **Recurring maintenance** — refresh webhooks/subscriptions before they expire.
|
|
9
|
+
- **Built-in utilities** — OAuth token refresh, integration health checks.
|
|
10
|
+
|
|
11
|
+
> Admin scripts are a **high-privilege** surface. Every endpoint is protected by an admin API key (`x-frigg-admin-api-key`), scripts run in your private VPC subnets, and every execution is tracked in the `AdminProcess` table. Never expose the admin API key to browsers or end users.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @friggframework/admin-scripts
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then register scripts in your app definition (`backend/index.js`):
|
|
22
|
+
|
|
23
|
+
```javascript
|
|
24
|
+
const {
|
|
25
|
+
Definition: HubSpotIntegration,
|
|
26
|
+
} = require('./src/integrations/HubSpotIntegration');
|
|
27
|
+
const {
|
|
28
|
+
AttioHealingScript,
|
|
29
|
+
} = require('./src/admin-scripts/AttioHealingScript');
|
|
30
|
+
|
|
31
|
+
const Definition = {
|
|
32
|
+
name: 'my-frigg-app',
|
|
33
|
+
integrations: [HubSpotIntegration],
|
|
34
|
+
|
|
35
|
+
// Admin scripts (optional)
|
|
36
|
+
adminScripts: [AttioHealingScript],
|
|
37
|
+
|
|
38
|
+
admin: {
|
|
39
|
+
includeBuiltinScripts: true, // register oauth-token-refresh + integration-health-check
|
|
40
|
+
enableScheduling: true, // provision EventBridge Scheduler resources
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
module.exports = { Definition };
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
At deploy time the framework's `AdminScriptBuilder` provisions the SQS queue, the router + worker Lambdas, and (when `enableScheduling` is set) the EventBridge Scheduler group and IAM role. At runtime the router/worker load this app definition and register your scripts (plus the built-ins) into the script registry.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Writing a script
|
|
52
|
+
|
|
53
|
+
Extend `AdminScriptBase`, declare a static `Definition`, and implement `execute(params)`. The execution context is injected for you and available as `this.context`.
|
|
54
|
+
|
|
55
|
+
```javascript
|
|
56
|
+
const { AdminScriptBase } = require('@friggframework/admin-scripts');
|
|
57
|
+
|
|
58
|
+
class AttioHealingScript extends AdminScriptBase {
|
|
59
|
+
static Definition = {
|
|
60
|
+
name: 'attio-healing',
|
|
61
|
+
version: '1.0.0',
|
|
62
|
+
description: 'Repairs corrupted Attio integration config',
|
|
63
|
+
source: 'USER_DEFINED',
|
|
64
|
+
|
|
65
|
+
// JSON Schema — validated on /validate and before every execution
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
required: ['integrationId'],
|
|
69
|
+
properties: {
|
|
70
|
+
integrationId: { type: 'string' },
|
|
71
|
+
dryRun: { type: 'boolean', default: false },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
config: {
|
|
76
|
+
timeout: 300000, // ms; sync mode is capped (see below)
|
|
77
|
+
requireIntegrationInstance: true, // needs this.context.instantiate()
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// Optional default schedule (can be overridden at runtime via the API)
|
|
81
|
+
schedule: { enabled: false, cronExpression: 'cron(0 12 * * ? *)' },
|
|
82
|
+
|
|
83
|
+
display: { category: 'maintenance' },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
async execute(params) {
|
|
87
|
+
const { integrationId } = params;
|
|
88
|
+
|
|
89
|
+
this.context.log('info', 'Starting Attio healing', { integrationId });
|
|
90
|
+
|
|
91
|
+
const integration =
|
|
92
|
+
await this.context.integrationRepository.findIntegrationById(
|
|
93
|
+
integrationId
|
|
94
|
+
);
|
|
95
|
+
if (!integration) {
|
|
96
|
+
throw new Error(`Integration ${integrationId} not found`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Call the live integration/API when you need it
|
|
100
|
+
const instance = await this.context.instantiate(integrationId);
|
|
101
|
+
await instance.primary.api.refreshMetadata();
|
|
102
|
+
|
|
103
|
+
this.context.log('info', 'Healing complete', { integrationId });
|
|
104
|
+
return { healed: true, integrationId };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { AttioHealingScript };
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### The execution context (`this.context`)
|
|
112
|
+
|
|
113
|
+
| Member | Description |
|
|
114
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
115
|
+
| `log(level, message, data?)` | Records a structured log entry (`level`: `debug`/`info`/`warn`/`error`). Logs are persisted to the execution's `results.logs`. |
|
|
116
|
+
| `integrationRepository` | Read/query integrations. |
|
|
117
|
+
| `userRepository` | Read/query users. |
|
|
118
|
+
| `moduleRepository` | Read/query modules. |
|
|
119
|
+
| `credentialRepository` | Read credentials (decrypted transparently). |
|
|
120
|
+
| `instantiate(integrationId)` | Returns a hydrated integration instance for calling external APIs. Requires `config.requireIntegrationInstance: true`. |
|
|
121
|
+
| `queueScript(name, params?)` | Enqueue another script as a follow-up (tracked as a child of the current execution). |
|
|
122
|
+
| `queueScriptBatch(entries)` | Enqueue many follow-up scripts at once. |
|
|
123
|
+
| `getExecutionId()` | The current `AdminProcess` record id. |
|
|
124
|
+
|
|
125
|
+
Repositories return **plain, decrypted data** — field-level encryption is handled transparently.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## HTTP API
|
|
130
|
+
|
|
131
|
+
All routes are mounted under `/admin` and require the `x-frigg-admin-api-key` header.
|
|
132
|
+
|
|
133
|
+
| Method | Path | Description |
|
|
134
|
+
| -------- | ------------------------------------------------ | ------------------------------------------------ |
|
|
135
|
+
| `GET` | `/admin/scripts` | List registered scripts |
|
|
136
|
+
| `GET` | `/admin/scripts/{name}` | Get a script's details/schema |
|
|
137
|
+
| `POST` | `/admin/scripts/{name}` | Execute a script (`sync` or `async`) |
|
|
138
|
+
| `POST` | `/admin/scripts/{name}/validate` | Validate input against the schema (no execution) |
|
|
139
|
+
| `GET` | `/admin/scripts/{name}/executions` | List recent executions (`?status=`, `?limit=`) |
|
|
140
|
+
| `GET` | `/admin/scripts/{name}/executions/{executionId}` | Get one execution |
|
|
141
|
+
| `GET` | `/admin/scripts/{name}/schedule` | Get the effective schedule |
|
|
142
|
+
| `PUT` | `/admin/scripts/{name}/schedule` | Create/update a schedule override |
|
|
143
|
+
| `DELETE` | `/admin/scripts/{name}/schedule` | Remove the schedule override |
|
|
144
|
+
|
|
145
|
+
### Execute a script
|
|
146
|
+
|
|
147
|
+
**Async (default)** — queued to SQS, returns immediately with an execution id to poll:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
curl -X POST https://<your-app>/admin/scripts/attio-healing \
|
|
151
|
+
-H "x-frigg-admin-api-key: $ADMIN_API_KEY" \
|
|
152
|
+
-H "Content-Type: application/json" \
|
|
153
|
+
-d '{ "params": { "integrationId": "abc123" }, "mode": "async" }'
|
|
154
|
+
|
|
155
|
+
# 202 Accepted
|
|
156
|
+
# { "executionId": "665f...", "status": "QUEUED", "scriptName": "attio-healing" }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Sync** — runs inline and returns the result. Only for fast scripts: in a deployed environment, sync is rejected (`400 SYNC_TIMEOUT_TOO_LONG`) when the script's `config.timeout` exceeds the API Lambda budget (~25s). Use `async` for anything longer.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
curl -X POST https://<your-app>/admin/scripts/integration-health-check \
|
|
163
|
+
-H "x-frigg-admin-api-key: $ADMIN_API_KEY" \
|
|
164
|
+
-H "Content-Type: application/json" \
|
|
165
|
+
-d '{ "params": {}, "mode": "sync" }'
|
|
166
|
+
|
|
167
|
+
# 200 OK
|
|
168
|
+
# { "executionId": "...", "status": "COMPLETED", "output": { ... }, "metrics": { "durationMs": 812 } }
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Invalid input is rejected up front:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# 400 Bad Request → { "error": "Invalid input: Missing required parameter: integrationId", "code": "INVALID_INPUT" }
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Validate without executing
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
curl -X POST https://<your-app>/admin/scripts/attio-healing/validate \
|
|
181
|
+
-H "x-frigg-admin-api-key: $ADMIN_API_KEY" \
|
|
182
|
+
-H "Content-Type: application/json" \
|
|
183
|
+
-d '{ "params": { "integrationId": "abc123" } }'
|
|
184
|
+
|
|
185
|
+
# { "status": "VALID", "preview": { ... }, "message": "Validation passed. ..." }
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Poll an execution
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
curl https://<your-app>/admin/scripts/attio-healing/executions/665f... \
|
|
192
|
+
-H "x-frigg-admin-api-key: $ADMIN_API_KEY"
|
|
193
|
+
|
|
194
|
+
curl "https://<your-app>/admin/scripts/attio-healing/executions?status=FAILED&limit=20" \
|
|
195
|
+
-H "x-frigg-admin-api-key: $ADMIN_API_KEY"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Scheduling
|
|
201
|
+
|
|
202
|
+
A script can ship a default schedule in its `Definition.schedule`, and operators can override it at runtime. The **effective** schedule resolves as: runtime override (DB) → definition default → none.
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
# Enable a daily 6am UTC run
|
|
206
|
+
curl -X PUT https://<your-app>/admin/scripts/integration-health-check/schedule \
|
|
207
|
+
-H "x-frigg-admin-api-key: $ADMIN_API_KEY" \
|
|
208
|
+
-H "Content-Type: application/json" \
|
|
209
|
+
-d '{ "enabled": true, "cronExpression": "cron(0 6 * * ? *)", "timezone": "UTC" }'
|
|
210
|
+
|
|
211
|
+
# Inspect / remove
|
|
212
|
+
curl https://<your-app>/admin/scripts/integration-health-check/schedule -H "x-frigg-admin-api-key: $ADMIN_API_KEY"
|
|
213
|
+
curl -X DELETE https://<your-app>/admin/scripts/integration-health-check/schedule -H "x-frigg-admin-api-key: $ADMIN_API_KEY"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Scheduling requires `admin.enableScheduling: true` in the app definition (so the EventBridge Scheduler group, IAM role, and env vars are provisioned). In a deployed environment the router refuses to fall back to the in-memory local scheduler, returning `503 SCHEDULER_NOT_CONFIGURED` if the provider isn't wired.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Built-in scripts
|
|
221
|
+
|
|
222
|
+
Set `admin.includeBuiltinScripts: true` to register:
|
|
223
|
+
|
|
224
|
+
- **`oauth-token-refresh`** — refreshes OAuth tokens for integrations nearing expiry. Params: `integrationIds?`, `expiryThresholdHours` (default 24), `dryRun`.
|
|
225
|
+
- **`integration-health-check`** — checks credential validity and API connectivity. Params: `integrationIds?`, `checkCredentials` (default true), `checkConnectivity` (default true), `updateStatus` (default false).
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Script chaining
|
|
230
|
+
|
|
231
|
+
Long or fan-out work can enqueue follow-up scripts. Continuations are tracked with `parentExecutionId` so you can trace the lineage:
|
|
232
|
+
|
|
233
|
+
```javascript
|
|
234
|
+
async execute(params) {
|
|
235
|
+
const ids = await this.getWorkBatch();
|
|
236
|
+
await this.context.queueScriptBatch(
|
|
237
|
+
ids.map((integrationId) => ({ scriptName: 'attio-healing', params: { integrationId } }))
|
|
238
|
+
);
|
|
239
|
+
return { queued: ids.length };
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## Execution modes & reliability
|
|
246
|
+
|
|
247
|
+
- **Sync** (`mode: 'sync'`) — runs in the API Lambda, result returned in the response. Capped by the API timeout.
|
|
248
|
+
- **Async** (`mode: 'async'`, default) — queued to SQS and run by the worker Lambda (15-min budget). The SQS queue has a redrive policy (up to 3 receives) to a dead-letter queue, so a crashed invocation is retried at the infrastructure level. There is no application-level per-script retry — model idempotency accordingly.
|
|
249
|
+
|
|
250
|
+
Every execution is persisted as an `AdminProcess` record with its `state` (`PENDING` → `RUNNING` → `COMPLETED`/`FAILED`), input, output, metrics, and logs.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Environment variables
|
|
255
|
+
|
|
256
|
+
| Variable | Set by | Purpose |
|
|
257
|
+
| ---------------------------------- | ---------------------------------- | -------------------------------------------------------------------------- |
|
|
258
|
+
| `ADMIN_API_KEY` | **Operator** (SSM/Secrets Manager) | Shared admin API key checked by the auth middleware. Required. |
|
|
259
|
+
| `ADMIN_SCRIPT_QUEUE_URL` | `AdminScriptBuilder` | SQS queue URL for async execution. |
|
|
260
|
+
| `SCHEDULER_PROVIDER` | `AdminScriptBuilder` (`'aws'`) | Scheduler adapter type. Falls back to `local` only outside AWS (dev/test). |
|
|
261
|
+
| `ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN` | `AdminScriptBuilder` | Worker Lambda ARN that EventBridge Scheduler invokes. |
|
|
262
|
+
| `ADMIN_SCRIPT_SCHEDULE_GROUP` | `AdminScriptBuilder` | EventBridge Scheduler group name. |
|
|
263
|
+
| `SCHEDULER_ROLE_ARN` | `AdminScriptBuilder` | IAM role EventBridge assumes to invoke the worker. |
|
|
264
|
+
|
|
265
|
+
You must provision `ADMIN_API_KEY` yourself (e.g. via SSM Parameter Store or Secrets Manager) — the builder does not generate it.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Local development
|
|
270
|
+
|
|
271
|
+
Without AWS, the scheduler uses an in-memory `LocalSchedulerAdapter` (schedules do not persist across restarts) and async execution needs a queue URL. Set `ADMIN_API_KEY` in your local env to exercise the endpoints.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Architecture
|
|
276
|
+
|
|
277
|
+
See [ADR-005: Admin Script Runner Service](../../docs/architecture-decisions/005-admin-script-runner.md) for the full design (layering, security model, scheduling, and validation). The package follows Frigg's hexagonal architecture: HTTP handlers → `ScriptRunner` (application) → command layer → repositories, with the scheduler as a swappable port (`SchedulerAdapter` → AWS/local adapters).
|
package/index.js
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
// Application Services
|
|
9
|
-
const {
|
|
9
|
+
const {
|
|
10
|
+
ScriptFactory,
|
|
11
|
+
getScriptFactory,
|
|
12
|
+
createScriptFactory,
|
|
13
|
+
} = require('./src/application/script-factory');
|
|
10
14
|
const { AdminScriptBase } = require('./src/application/admin-script-base');
|
|
11
15
|
const {
|
|
12
16
|
AdminScriptContext,
|
|
@@ -15,12 +19,23 @@ const {
|
|
|
15
19
|
AdminFriggCommands,
|
|
16
20
|
createAdminFriggCommands,
|
|
17
21
|
} = require('./src/application/admin-frigg-commands');
|
|
18
|
-
const {
|
|
22
|
+
const {
|
|
23
|
+
ScriptRunner,
|
|
24
|
+
createScriptRunner,
|
|
25
|
+
} = require('./src/application/script-runner');
|
|
19
26
|
|
|
20
27
|
// Infrastructure
|
|
21
|
-
const {
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
const {
|
|
29
|
+
validateAdminApiKey,
|
|
30
|
+
} = require('./src/infrastructure/admin-auth-middleware');
|
|
31
|
+
const {
|
|
32
|
+
router,
|
|
33
|
+
app,
|
|
34
|
+
handler: routerHandler,
|
|
35
|
+
} = require('./src/infrastructure/admin-script-router');
|
|
36
|
+
const {
|
|
37
|
+
handler: executorHandler,
|
|
38
|
+
} = require('./src/infrastructure/script-executor-handler');
|
|
24
39
|
|
|
25
40
|
// Built-in Scripts
|
|
26
41
|
const {
|
|
@@ -33,7 +48,9 @@ const {
|
|
|
33
48
|
// Adapters
|
|
34
49
|
const { SchedulerAdapter } = require('./src/adapters/scheduler-adapter');
|
|
35
50
|
const { AWSSchedulerAdapter } = require('./src/adapters/aws-scheduler-adapter');
|
|
36
|
-
const {
|
|
51
|
+
const {
|
|
52
|
+
LocalSchedulerAdapter,
|
|
53
|
+
} = require('./src/adapters/local-scheduler-adapter');
|
|
37
54
|
const {
|
|
38
55
|
createSchedulerAdapter,
|
|
39
56
|
} = require('./src/adapters/scheduler-adapter-factory');
|
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/admin-scripts",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.
|
|
4
|
+
"version": "2.0.0--canary.517.8eaf5df.0",
|
|
5
5
|
"description": "Admin Script Runner for Frigg - Execute maintenance and operational scripts in hosted environments",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@aws-sdk/client-scheduler": "^3.588.0",
|
|
8
|
-
"@friggframework/core": "2.0.0--canary.
|
|
8
|
+
"@friggframework/core": "2.0.0--canary.517.8eaf5df.0",
|
|
9
9
|
"express": "^4.18.2",
|
|
10
10
|
"serverless-http": "^3.2.0"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
14
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
15
|
-
"@friggframework/test": "2.0.0--canary.
|
|
13
|
+
"@friggframework/eslint-config": "2.0.0--canary.517.8eaf5df.0",
|
|
14
|
+
"@friggframework/prettier-config": "2.0.0--canary.517.8eaf5df.0",
|
|
15
|
+
"@friggframework/test": "2.0.0--canary.517.8eaf5df.0",
|
|
16
16
|
"eslint": "^8.22.0",
|
|
17
17
|
"jest": "^29.7.0",
|
|
18
18
|
"prettier": "^2.7.1",
|
|
@@ -43,5 +43,5 @@
|
|
|
43
43
|
"maintenance",
|
|
44
44
|
"operations"
|
|
45
45
|
],
|
|
46
|
-
"gitHead": "
|
|
46
|
+
"gitHead": "8eaf5df0746c979fc5cb7877d51a861becd5fbce"
|
|
47
47
|
}
|
|
@@ -9,17 +9,33 @@ jest.mock('@aws-sdk/client-scheduler', () => {
|
|
|
9
9
|
SchedulerClient: jest.fn(() => ({
|
|
10
10
|
send: mockSend,
|
|
11
11
|
})),
|
|
12
|
-
CreateScheduleCommand: jest.fn((params) => ({
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
CreateScheduleCommand: jest.fn((params) => ({
|
|
13
|
+
_type: 'CreateScheduleCommand',
|
|
14
|
+
params,
|
|
15
|
+
})),
|
|
16
|
+
DeleteScheduleCommand: jest.fn((params) => ({
|
|
17
|
+
_type: 'DeleteScheduleCommand',
|
|
18
|
+
params,
|
|
19
|
+
})),
|
|
20
|
+
GetScheduleCommand: jest.fn((params) => ({
|
|
21
|
+
_type: 'GetScheduleCommand',
|
|
22
|
+
params,
|
|
23
|
+
})),
|
|
24
|
+
UpdateScheduleCommand: jest.fn((params) => ({
|
|
25
|
+
_type: 'UpdateScheduleCommand',
|
|
26
|
+
params,
|
|
27
|
+
})),
|
|
28
|
+
ListSchedulesCommand: jest.fn((params) => ({
|
|
29
|
+
_type: 'ListSchedulesCommand',
|
|
30
|
+
params,
|
|
31
|
+
})),
|
|
17
32
|
_mockSend: mockSend,
|
|
18
33
|
};
|
|
19
34
|
});
|
|
20
35
|
|
|
21
36
|
const defaultParams = {
|
|
22
|
-
targetLambdaArn:
|
|
37
|
+
targetLambdaArn:
|
|
38
|
+
'arn:aws:lambda:us-east-1:123456789012:function:admin-script-executor',
|
|
23
39
|
scheduleGroupName: 'frigg-admin-scripts',
|
|
24
40
|
roleArn: 'arn:aws:iam::123456789012:role/test-role',
|
|
25
41
|
};
|
|
@@ -57,50 +73,70 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
57
73
|
it('should use provided configuration and AWS_REGION from env', () => {
|
|
58
74
|
process.env.AWS_REGION = 'eu-west-1';
|
|
59
75
|
const customAdapter = new AWSSchedulerAdapter({
|
|
60
|
-
targetLambdaArn:
|
|
76
|
+
targetLambdaArn:
|
|
77
|
+
'arn:aws:lambda:eu-west-1:123456789012:function:custom',
|
|
61
78
|
scheduleGroupName: 'custom-group',
|
|
62
79
|
roleArn: 'arn:aws:iam::123456789012:role/custom-role',
|
|
63
80
|
});
|
|
64
81
|
|
|
65
82
|
expect(customAdapter.region).toBe('eu-west-1');
|
|
66
|
-
expect(customAdapter.targetLambdaArn).toBe(
|
|
83
|
+
expect(customAdapter.targetLambdaArn).toBe(
|
|
84
|
+
'arn:aws:lambda:eu-west-1:123456789012:function:custom'
|
|
85
|
+
);
|
|
67
86
|
expect(customAdapter.scheduleGroupName).toBe('custom-group');
|
|
68
|
-
expect(customAdapter.roleArn).toBe(
|
|
87
|
+
expect(customAdapter.roleArn).toBe(
|
|
88
|
+
'arn:aws:iam::123456789012:role/custom-role'
|
|
89
|
+
);
|
|
69
90
|
});
|
|
70
91
|
|
|
71
92
|
it('should throw if AWS_REGION is not set', () => {
|
|
72
93
|
delete process.env.AWS_REGION;
|
|
73
|
-
expect(
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
expect(
|
|
95
|
+
() =>
|
|
96
|
+
new AWSSchedulerAdapter({
|
|
97
|
+
...defaultParams,
|
|
98
|
+
})
|
|
99
|
+
).toThrow(
|
|
100
|
+
'AWSSchedulerAdapter requires AWS_REGION environment variable'
|
|
101
|
+
);
|
|
76
102
|
});
|
|
77
103
|
|
|
78
104
|
it('should throw if targetLambdaArn is missing', () => {
|
|
79
|
-
expect(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
expect(
|
|
106
|
+
() =>
|
|
107
|
+
new AWSSchedulerAdapter({
|
|
108
|
+
scheduleGroupName: defaultParams.scheduleGroupName,
|
|
109
|
+
roleArn: defaultParams.roleArn,
|
|
110
|
+
})
|
|
111
|
+
).toThrow('AWSSchedulerAdapter requires targetLambdaArn');
|
|
83
112
|
});
|
|
84
113
|
|
|
85
114
|
it('should throw if scheduleGroupName is missing', () => {
|
|
86
|
-
expect(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
115
|
+
expect(
|
|
116
|
+
() =>
|
|
117
|
+
new AWSSchedulerAdapter({
|
|
118
|
+
targetLambdaArn: defaultParams.targetLambdaArn,
|
|
119
|
+
roleArn: defaultParams.roleArn,
|
|
120
|
+
})
|
|
121
|
+
).toThrow('AWSSchedulerAdapter requires scheduleGroupName');
|
|
90
122
|
});
|
|
91
123
|
|
|
92
124
|
it('should throw if roleArn is missing', () => {
|
|
93
|
-
expect(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
125
|
+
expect(
|
|
126
|
+
() =>
|
|
127
|
+
new AWSSchedulerAdapter({
|
|
128
|
+
targetLambdaArn: defaultParams.targetLambdaArn,
|
|
129
|
+
scheduleGroupName: defaultParams.scheduleGroupName,
|
|
130
|
+
})
|
|
131
|
+
).toThrow('AWSSchedulerAdapter requires roleArn');
|
|
97
132
|
});
|
|
98
133
|
});
|
|
99
134
|
|
|
100
135
|
describe('createSchedule()', () => {
|
|
101
136
|
it('should create a schedule with required fields', async () => {
|
|
102
137
|
mockSend.mockResolvedValue({
|
|
103
|
-
ScheduleArn:
|
|
138
|
+
ScheduleArn:
|
|
139
|
+
'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
104
140
|
});
|
|
105
141
|
|
|
106
142
|
const result = await adapter.createSchedule({
|
|
@@ -109,7 +145,8 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
109
145
|
});
|
|
110
146
|
|
|
111
147
|
expect(result).toEqual({
|
|
112
|
-
scheduleArn:
|
|
148
|
+
scheduleArn:
|
|
149
|
+
'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
113
150
|
scheduleName: 'frigg-script-test-script',
|
|
114
151
|
});
|
|
115
152
|
|
|
@@ -123,7 +160,8 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
123
160
|
|
|
124
161
|
it('should create a schedule with all optional fields', async () => {
|
|
125
162
|
mockSend.mockResolvedValue({
|
|
126
|
-
ScheduleArn:
|
|
163
|
+
ScheduleArn:
|
|
164
|
+
'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
127
165
|
});
|
|
128
166
|
|
|
129
167
|
await adapter.createSchedule({
|
|
@@ -134,7 +172,9 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
134
172
|
});
|
|
135
173
|
|
|
136
174
|
const command = mockSend.mock.calls[0][0];
|
|
137
|
-
expect(command.params.ScheduleExpressionTimezone).toBe(
|
|
175
|
+
expect(command.params.ScheduleExpressionTimezone).toBe(
|
|
176
|
+
'America/New_York'
|
|
177
|
+
);
|
|
138
178
|
|
|
139
179
|
const targetInput = JSON.parse(command.params.Target.Input);
|
|
140
180
|
expect(targetInput).toEqual({
|
|
@@ -146,7 +186,8 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
146
186
|
|
|
147
187
|
it('should configure target with Lambda ARN and constructor roleArn', async () => {
|
|
148
188
|
mockSend.mockResolvedValue({
|
|
149
|
-
ScheduleArn:
|
|
189
|
+
ScheduleArn:
|
|
190
|
+
'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
150
191
|
});
|
|
151
192
|
|
|
152
193
|
await adapter.createSchedule({
|
|
@@ -155,19 +196,25 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
155
196
|
});
|
|
156
197
|
|
|
157
198
|
const command = mockSend.mock.calls[0][0];
|
|
158
|
-
expect(command.params.Target.Arn).toBe(
|
|
159
|
-
|
|
199
|
+
expect(command.params.Target.Arn).toBe(
|
|
200
|
+
'arn:aws:lambda:us-east-1:123456789012:function:admin-script-executor'
|
|
201
|
+
);
|
|
202
|
+
expect(command.params.Target.RoleArn).toBe(
|
|
203
|
+
'arn:aws:iam::123456789012:role/test-role'
|
|
204
|
+
);
|
|
160
205
|
});
|
|
161
206
|
|
|
162
207
|
it('should use roleArn from constructor, not process.env', async () => {
|
|
163
|
-
const customRoleArn =
|
|
208
|
+
const customRoleArn =
|
|
209
|
+
'arn:aws:iam::999999999999:role/custom-scheduler-role';
|
|
164
210
|
const customAdapter = new AWSSchedulerAdapter({
|
|
165
211
|
...defaultParams,
|
|
166
212
|
roleArn: customRoleArn,
|
|
167
213
|
});
|
|
168
214
|
|
|
169
215
|
mockSend.mockResolvedValue({
|
|
170
|
-
ScheduleArn:
|
|
216
|
+
ScheduleArn:
|
|
217
|
+
'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
171
218
|
});
|
|
172
219
|
|
|
173
220
|
await customAdapter.createSchedule({
|
|
@@ -181,7 +228,8 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
181
228
|
|
|
182
229
|
it('should enable schedule by default', async () => {
|
|
183
230
|
mockSend.mockResolvedValue({
|
|
184
|
-
ScheduleArn:
|
|
231
|
+
ScheduleArn:
|
|
232
|
+
'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
185
233
|
});
|
|
186
234
|
|
|
187
235
|
await adapter.createSchedule({
|
|
@@ -195,7 +243,8 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
195
243
|
|
|
196
244
|
it('should set flexible time window to OFF', async () => {
|
|
197
245
|
mockSend.mockResolvedValue({
|
|
198
|
-
ScheduleArn:
|
|
246
|
+
ScheduleArn:
|
|
247
|
+
'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
199
248
|
});
|
|
200
249
|
|
|
201
250
|
await adapter.createSchedule({
|
|
@@ -214,7 +263,8 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
214
263
|
mockSend
|
|
215
264
|
.mockRejectedValueOnce(conflictError)
|
|
216
265
|
.mockResolvedValueOnce({
|
|
217
|
-
ScheduleArn:
|
|
266
|
+
ScheduleArn:
|
|
267
|
+
'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
218
268
|
});
|
|
219
269
|
|
|
220
270
|
const result = await adapter.createSchedule({
|
|
@@ -223,13 +273,18 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
223
273
|
});
|
|
224
274
|
|
|
225
275
|
expect(result).toEqual({
|
|
226
|
-
scheduleArn:
|
|
276
|
+
scheduleArn:
|
|
277
|
+
'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script',
|
|
227
278
|
scheduleName: 'frigg-script-test-script',
|
|
228
279
|
});
|
|
229
280
|
|
|
230
281
|
expect(mockSend).toHaveBeenCalledTimes(2);
|
|
231
|
-
expect(mockSend.mock.calls[0][0]._type).toBe(
|
|
232
|
-
|
|
282
|
+
expect(mockSend.mock.calls[0][0]._type).toBe(
|
|
283
|
+
'CreateScheduleCommand'
|
|
284
|
+
);
|
|
285
|
+
expect(mockSend.mock.calls[1][0]._type).toBe(
|
|
286
|
+
'UpdateScheduleCommand'
|
|
287
|
+
);
|
|
233
288
|
});
|
|
234
289
|
|
|
235
290
|
it('should rethrow non-conflict errors', async () => {
|
|
@@ -238,10 +293,12 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
238
293
|
|
|
239
294
|
mockSend.mockRejectedValue(otherError);
|
|
240
295
|
|
|
241
|
-
await expect(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
296
|
+
await expect(
|
|
297
|
+
adapter.createSchedule({
|
|
298
|
+
scriptName: 'test-script',
|
|
299
|
+
cronExpression: 'cron(0 0 * * ? *)',
|
|
300
|
+
})
|
|
301
|
+
).rejects.toThrow('Access denied');
|
|
245
302
|
});
|
|
246
303
|
});
|
|
247
304
|
|
|
@@ -304,9 +361,13 @@ describe('AWSSchedulerAdapter', () => {
|
|
|
304
361
|
await adapter.setScheduleEnabled('test-script', false);
|
|
305
362
|
|
|
306
363
|
const updateCommand = mockSend.mock.calls[1][0];
|
|
307
|
-
expect(updateCommand.params.ScheduleExpression).toBe(
|
|
364
|
+
expect(updateCommand.params.ScheduleExpression).toBe(
|
|
365
|
+
'cron(0 0 * * ? *)'
|
|
366
|
+
);
|
|
308
367
|
expect(updateCommand.params.ScheduleExpressionTimezone).toBe('UTC');
|
|
309
|
-
expect(updateCommand.params.FlexibleTimeWindow).toEqual({
|
|
368
|
+
expect(updateCommand.params.FlexibleTimeWindow).toEqual({
|
|
369
|
+
Mode: 'OFF',
|
|
370
|
+
});
|
|
310
371
|
expect(updateCommand.params.Target).toBeDefined();
|
|
311
372
|
});
|
|
312
373
|
});
|