Skip to content

Commit 004ebd3

Browse files
authored
Merge pull request #1699 from NASA-AMMOS/feature/action-transient-secrets
Feature/action transient secrets
2 parents 878b4bc + c03a514 commit 004ebd3

14 files changed

Lines changed: 280 additions & 55 deletions

File tree

action-server/eslint.config.mjs

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1-
import { sheriff, tseslint } from 'eslint-config-sheriff';
1+
import { sheriff, tseslint } from "eslint-config-sheriff";
22

33
const sheriffOptions = {
4-
"react": false,
5-
"lodash": false,
6-
"remeda": false,
7-
"next": false,
8-
"astro": false,
9-
"playwright": false,
10-
"jest": false,
11-
"vitest": false
4+
react: false,
5+
lodash: false,
6+
remeda: false,
7+
next: false,
8+
astro: false,
9+
playwright: false,
10+
jest: false,
11+
vitest: false,
1212
};
1313

14-
export default tseslint.config(sheriff(sheriffOptions),
15-
{
16-
rules: {
17-
"@typescript-eslint/no-explicit-any": ["off"]
18-
},
19-
},
20-
);
14+
export default tseslint.config(sheriff(sheriffOptions), {
15+
rules: {
16+
"@typescript-eslint/no-explicit-any": ["off"],
17+
"@typescript-eslint/no-extraneous-class": "off",
18+
"@typescript-eslint/naming-convention": "off",
19+
"func-style": "off",
20+
"no-restricted-syntax": "off",
21+
"@typescript-eslint/no-dynamic-delete": "off",
22+
},
23+
});

action-server/src/app.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { configuration } from "./config";
33
import { corsMiddleware, jsonErrorMiddleware } from "./middleware";
44
import { ActionWorkerPool } from "./threads/workerPool";
55
import { cleanup, setupListeners } from "./listeners/dbListeners";
6+
import { ActionRunner } from "./type/actionRunner";
67

78
const port = configuration().PORT;
89

@@ -34,6 +35,15 @@ app.get("/health", async (req, res, next) => {
3435
res.status(200).send();
3536
});
3637

38+
app.post("/secrets", async (req, res, next) => {
39+
const { action_run_id, secrets } = req.body;
40+
const actionRunId = action_run_id as string;
41+
42+
ActionRunner.addActionSecret(actionRunId, secrets as Record<string, string>);
43+
44+
res.status(200).send({ success: true });
45+
});
46+
3747
// handle termination signals
3848
process.on("SIGINT", () => cleanup(server));
3949
process.on("SIGTERM", () => cleanup(server));

action-server/src/listeners/dbListeners.ts

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
import { readFile } from "node:fs/promises";
22
import type http from "node:http";
33
import * as path from "node:path";
4-
import type { Pool, PoolClient } from "pg";
4+
import type { PoolClient } from "pg";
55
import { configuration } from "../config";
66
import { ActionsDbManager } from "../db";
77
import { ActionWorkerPool } from "../threads/workerPool";
88
import type { ActionDefinitionInsertedPayload, ActionResponse, ActionRunInsertedPayload } from "../type/types";
99
import { extractSchemas } from "../utils/codeRunner";
10-
import { createLogger, format, transports } from "winston";
1110
import logger from "../utils/logger";
1211
import { ActionRunCancellationRequestPayload } from "../type/types";
12+
import { ActionRunner } from "../type/actionRunner";
1313

1414
let listenClient: PoolClient | undefined;
1515

1616
async function readFileFromStore(fileName: string): Promise<string> {
1717
// read file from aerie file store and return [resolve] it as a string
1818
const fileStoreBasePath = configuration().ACTION_LOCAL_STORE;
1919
const filePath = path.join(fileStoreBasePath, fileName);
20+
2021
logger.info(`path is ${filePath}`);
22+
2123
return await readFile(filePath, "utf-8");
2224
}
2325

@@ -42,6 +44,7 @@ async function refreshActionDefinitionSchema(payload: ActionDefinitionInsertedPa
4244
JSON.stringify(schemas.settingDefinitions),
4345
payload.action_definition_id,
4446
]);
47+
4548
logger.info("Updated action_definition:", res.rows[0]);
4649
} catch (error) {
4750
logger.error("Error updating row:", error);
@@ -52,31 +55,36 @@ async function cancelAction(payload: ActionRunCancellationRequestPayload) {
5255
ActionWorkerPool.cancelTask(payload.action_run_id);
5356
}
5457

55-
async function runAction(payload: ActionRunInsertedPayload) {
56-
const actionRunId = payload.action_run_id;
57-
const actionFilePath = payload.action_file_path;
58-
logger.info(`action run ${actionRunId} inserted (${actionFilePath})`);
58+
export async function runAction(payload: ActionRunInsertedPayload, actionSecrets?: Record<string, any>): Promise<void> {
59+
const { action_file_path, action_run_id, parameters, settings, workspace_id } = payload;
60+
const actionRunId = Number(action_run_id);
61+
62+
logger.info(`action run ${action_run_id} inserted (${action_file_path})`);
63+
64+
let taskError;
65+
5966
// event payload contains a file path for the action file which should be run
60-
const actionJS = await readFileFromStore(actionFilePath);
67+
const actionJS = await readFileFromStore(action_file_path);
6168

6269
// NOTE: Authentication tokens are unavailable in PostgreSQL Listen/Notify
6370
// const authToken = req.header("authorization");
6471
// if (!authToken) console.warn("No valid `authorization` header in action-run request");
6572

66-
const { parameters, settings } = payload;
67-
const workspaceId = payload.workspace_id;
6873
const pool = ActionsDbManager.getDb();
69-
logger.info(`Submitting task to worker pool for action run ${actionRunId}`);
74+
75+
logger.info(`Submitting task to worker pool for action run ${action_run_id}`);
7076
const start = performance.now();
71-
let run, taskError;
77+
let run;
78+
7279
try {
7380
run = (await ActionWorkerPool.submitTask({
74-
actionJS: actionJS,
75-
action_run_id: actionRunId,
81+
actionJS,
82+
action_run_id,
7683
message_port: null,
77-
parameters: parameters,
78-
settings: settings,
79-
workspaceId: workspaceId,
84+
parameters,
85+
settings,
86+
workspaceId: workspace_id,
87+
secrets: actionSecrets,
8088
})) satisfies ActionResponse;
8189
} catch (error: any) {
8290
if (error?.name === "AbortError") {
@@ -92,7 +100,6 @@ async function runAction(payload: ActionRunInsertedPayload) {
92100
const status = taskError || run?.errors ? "failed" : "success";
93101
logger.info(`Finished run ${actionRunId} in ${duration / 1000}s - ${status}`);
94102
const errorValue = JSON.stringify(taskError || run?.errors || {});
95-
96103
const logStr = run ? run.console.join("\n") : "";
97104

98105
// update action_run row in DB with status/results/errors/logs
@@ -118,6 +125,7 @@ async function runAction(payload: ActionRunInsertedPayload) {
118125
payload.action_run_id,
119126
],
120127
);
128+
121129
logger.info("Updated action_run:", res.rows[0]);
122130
} catch (error) {
123131
logger.error("Error updating row:", error);
@@ -141,25 +149,29 @@ export async function setupListeners() {
141149

142150
listenClient.on("notification", async (msg) => {
143151
console.info(`PG notify event: ${JSON.stringify(msg, null, 2)}`);
152+
144153
if (!msg.payload) {
145154
console.warn(`warning: PG event with no message or payload: ${JSON.stringify(msg, null, 2)}`);
146155
return;
147156
}
157+
148158
const payload = JSON.parse(msg.payload);
149159

150160
if (msg.channel === "action_definition_inserted") {
151161
await refreshActionDefinitionSchema(payload);
152162
} else if (msg.channel === "action_run_inserted") {
153-
await runAction(payload);
163+
await ActionRunner.addActionRun(payload as ActionRunInsertedPayload);
154164
} else if (msg.channel === "action_run_cancel_requested") {
155165
await cancelAction(payload);
156166
}
157167
});
168+
158169
logger.info("Initialized PG event listeners");
159170
}
160171

161-
export function cleanup(server: http.Server) {
172+
export function cleanup(server: http.Server): void {
162173
console.log("shutting down...");
174+
163175
if (listenClient) {
164176
listenClient.release();
165177
}

action-server/src/threads/worker.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,16 @@ export async function runAction(task: ActionTask): Promise<ActionResponse> {
109109
}
110110

111111
let jsRun: ActionResponse;
112+
112113
try {
113-
jsRun = await jsExecute(task.actionJS, task.parameters, task.settings, task.auth, client, task.workspaceId);
114+
jsRun = await jsExecute(task.actionJS, task.parameters, task.settings, task.auth, client, task.workspaceId, task.secrets);
114115
logger.info(`[Action Run ${task.action_run_id}, Thread ${threadId}] done executing`);
115116
await releaseDbPoolAndClient();
116117
logger.info(`[Action Run ${task.action_run_id}, Thread ${threadId}] released DB connection`);
117118
// Send "I'm finished" back to main thread:
118119
task.message_port?.postMessage({ type: "finished" });
119120
task.message_port?.close();
121+
120122
return jsRun;
121123
} catch (e) {
122124
logger.info(`[Action Run ${task.action_run_id}, Thread ${threadId}] Error while executing`);

action-server/src/threads/workerPool.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,13 @@ export class ActionWorkerPool {
8686
// Case 1. Worker has not yet started -> use abortcontroller to remove from piscina task queue
8787
logger.info(`Action run ${action_run_id} has not yet started, removing it from the queue`);
8888
const abortController = this.abortControllerForActionRun.get(action_run_id);
89-
if(abortController) {
89+
if (abortController) {
9090
abortController.abort();
9191
} else {
9292
logger.warn(`No abort controller found for task ${action_run_id}`);
9393
}
9494
this.removeFromMaps(action_run_id);
9595
return;
96-
9796
} else {
9897
// Case 2. Worker has started, and is not completed -> ask it to close its database connection
9998
const port = this.messagePortsForActionRun.get(action_run_id);
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { runAction } from "../listeners/dbListeners";
2+
import logger from "../utils/logger";
3+
import type { ActionRunInsertedPayload } from "./types";
4+
5+
export class ActionRunner {
6+
// Wait up to 10 minutes for the action run associated with the secrets.
7+
private static WAIT_FOR_ACTION_RUN_TIMEOUT = 600000;
8+
// Wait up to 1 minute for the secrets associated with the action run.
9+
private static WAIT_FOR_SECRET_TIMEOUT = 60000;
10+
11+
private static actionRuns: Record<string, ActionRunInsertedPayload> = {};
12+
private static actionRunQueue: Map<string, (actionRunId: string) => Promise<void>> = new Map();
13+
private static actionSecretsMap: Map<string, Record<string, string>> = new Map();
14+
15+
static async addActionRun(actionRun: ActionRunInsertedPayload): Promise<void> {
16+
const actionRunId = actionRun.action_run_id;
17+
18+
this.actionRuns[actionRunId] = actionRun;
19+
const actionRunFunc = async (runId: string) => {
20+
try {
21+
await ActionRunner.runAction(runId);
22+
this.deleteActionRun(runId);
23+
} catch (error) {
24+
this.deleteActionRun(runId);
25+
}
26+
};
27+
28+
this.actionRunQueue.set(actionRunId, actionRunFunc);
29+
30+
// If there aren't any secrets execute the action run immediately.
31+
if (!actionRun.has_secrets) {
32+
await actionRunFunc(actionRunId);
33+
} else {
34+
logger.info(`Action Run: ${actionRunId} waiting for secrets...`);
35+
}
36+
37+
setTimeout(() => {
38+
if (this.actionRunQueue.get(actionRunId)) {
39+
logger.info(`Action Run: ${actionRunId} timed out waiting for the associated action secrets.`);
40+
this.deleteActionRun(actionRunId);
41+
}
42+
}, this.WAIT_FOR_SECRET_TIMEOUT);
43+
}
44+
45+
static async addActionSecret(actionRunId: string, actionSecrets: Record<string, string>): Promise<void> {
46+
this.actionSecretsMap.set(actionRunId, actionSecrets);
47+
48+
logger.info(`Secret found for Action Run: ${actionRunId}, running action...`);
49+
50+
const actionRunFunc = this.actionRunQueue.get(actionRunId);
51+
52+
if (actionRunFunc) {
53+
setTimeout(() => {
54+
if (this.actionSecretsMap.get(actionRunId)) {
55+
logger.info(`Secret for Action Run: ${actionRunId} timed out waiting for the associated action run.`);
56+
this.deleteActionSecret(actionRunId);
57+
}
58+
}, this.WAIT_FOR_ACTION_RUN_TIMEOUT);
59+
60+
await actionRunFunc(actionRunId);
61+
this.deleteActionSecret(actionRunId);
62+
} else {
63+
throw new Error(`Action Run ${actionRunId} not found in queue`);
64+
}
65+
}
66+
67+
static deleteActionRun(actionRunId: string): void {
68+
delete this.actionRuns[actionRunId];
69+
this.actionRunQueue.delete(actionRunId);
70+
}
71+
72+
static deleteActionSecret(actionRunId: string): void {
73+
this.actionSecretsMap.delete(actionRunId);
74+
}
75+
76+
private static async runAction(actionRunId: string): Promise<void> {
77+
const action = this.actionRuns[actionRunId];
78+
const secret = action.has_secrets ? this.actionSecretsMap.get(actionRunId) : undefined;
79+
80+
this.deleteActionRun(actionRunId);
81+
this.deleteActionSecret(actionRunId);
82+
83+
await runAction(action, secret);
84+
}
85+
}

action-server/src/type/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type ConsoleOutput = {
2424
export type ActionConfig = {
2525
ACTION_FILE_STORE: string;
2626
SEQUENCING_FILE_STORE: string;
27+
SECRETS?: Record<string, string> | undefined;
2728
WORKSPACE_BASE_URL: string;
2829
HASURA_GRAPHQL_ADMIN_SECRET: string;
2930
};
@@ -33,6 +34,7 @@ export type ActionTask = {
3334
action_run_id: string;
3435
parameters: Record<string, any>;
3536
settings: Record<string, any>;
37+
secrets?: Record<string, string>;
3638
auth?: string;
3739
workspaceId: number;
3840
message_port: MessagePort | null;
@@ -50,6 +52,7 @@ export type ActionRunInsertedPayload = {
5052
action_definition_id: number;
5153
workspace_id: number;
5254
action_file_path: string;
55+
has_secrets: boolean;
5356
};
5457

5558
export type ActionRunCancellationRequestPayload = {

action-server/src/utils/codeRunner.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,24 @@ const { ACTION_LOCAL_STORE, SEQUENCING_LOCAL_STORE, WORKSPACE_BASE_URL, HASURA_G
1010

1111
function injectLogger(oldConsole: any, logBuffer: string[], secrets?: Record<string, any> | undefined) {
1212
// secrets may be passed as last argument, to be censored in the logs
13-
secrets = secrets || {};
14-
secrets['HASURA_GRAPHQL_ADMIN_SECRET'] = HASURA_GRAPHQL_ADMIN_SECRET;
13+
const censoredSecrets = {
14+
...(secrets || {}),
15+
HASURA_GRAPHQL_ADMIN_SECRET
16+
};
17+
1518
// inject a winston logger to be passed to the action VM, replacing its normal `console`,
1619
// so we can capture the console outputs and return them with the action results
1720
const logger = createLogger({
1821
level: "debug", // todo allow user to set log level
1922
format: format.combine(
2023
format.timestamp(),
2124
format.printf(({ level, message, timestamp }) => {
22-
2325
const logLine = `${timestamp} [${level.toUpperCase()}] `;
2426
let output = message as string;
2527

2628
// If the action has secrets filter them out of the log.
27-
if (secrets !== undefined && Object.keys(secrets).length > 0) {
28-
const secretValues = Object.values(secrets);
29+
if (Object.keys(censoredSecrets).length > 0) {
30+
const secretValues = Object.values(censoredSecrets);
2931

3032
for (const secretValue of secretValues) {
3133
if(secretValue.length) {
@@ -80,14 +82,15 @@ export const jsExecute = async (
8082
authToken: string | undefined,
8183
client: PoolClient,
8284
workspaceId: number,
85+
secrets: Record<string, string> | undefined,
8386
): Promise<ActionResponse> => {
8487
// create a clone of the global object (including getters/setters/non-enumerable properties)
8588
// to be passed to the context so it has access to eg. node built-ins
8689
const aerieGlobal = getGlobals();
8790
// inject custom logger to capture logs from action run
8891
const logBuffer: string[] = [];
8992

90-
aerieGlobal.console = injectLogger(aerieGlobal.console, logBuffer);
93+
aerieGlobal.console = injectLogger(aerieGlobal.console, logBuffer, secrets);
9194

9295
const context = vm.createContext(aerieGlobal);
9396

@@ -97,6 +100,7 @@ export const jsExecute = async (
97100
const actionConfig: ActionConfig = {
98101
ACTION_FILE_STORE: ACTION_LOCAL_STORE,
99102
SEQUENCING_FILE_STORE: SEQUENCING_LOCAL_STORE,
103+
SECRETS: secrets,
100104
WORKSPACE_BASE_URL: WORKSPACE_BASE_URL,
101105
HASURA_GRAPHQL_ADMIN_SECRET: HASURA_GRAPHQL_ADMIN_SECRET
102106
};

0 commit comments

Comments
 (0)