hereya-cli 0.89.1 → 0.91.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 +88 -75
- package/dist/backend/cloud/cloud-backend/apps-deploy.d.ts +69 -0
- package/dist/backend/cloud/cloud-backend/apps-deploy.js +94 -0
- package/dist/backend/cloud/cloud-backend/apps-versions.d.ts +69 -0
- package/dist/backend/cloud/cloud-backend/apps-versions.js +113 -0
- package/dist/backend/cloud/cloud-backend/executor-broker.d.ts +25 -0
- package/dist/backend/cloud/cloud-backend/executor-broker.js +38 -0
- package/dist/backend/cloud/cloud-backend/executor-jobs.d.ts +75 -0
- package/dist/backend/cloud/cloud-backend/executor-jobs.js +110 -0
- package/dist/backend/cloud/cloud-backend/misc.d.ts +5 -0
- package/dist/backend/cloud/cloud-backend/misc.js +78 -0
- package/dist/backend/cloud/cloud-backend/packages-publish.d.ts +3 -0
- package/dist/backend/cloud/cloud-backend/packages-publish.js +99 -0
- package/dist/backend/cloud/cloud-backend/packages-registry.d.ts +6 -0
- package/dist/backend/cloud/cloud-backend/packages-registry.js +146 -0
- package/dist/backend/cloud/cloud-backend/packages-workspace.d.ts +4 -0
- package/dist/backend/cloud/cloud-backend/packages-workspace.js +50 -0
- package/dist/backend/cloud/cloud-backend/projects.d.ts +7 -0
- package/dist/backend/cloud/cloud-backend/projects.js +122 -0
- package/dist/backend/cloud/cloud-backend/state.d.ts +6 -0
- package/dist/backend/cloud/cloud-backend/state.js +86 -0
- package/dist/backend/cloud/cloud-backend/utils.d.ts +55 -0
- package/dist/backend/cloud/cloud-backend/utils.js +56 -0
- package/dist/backend/cloud/cloud-backend/workspace-env.d.ts +5 -0
- package/dist/backend/cloud/cloud-backend/workspace-env.js +63 -0
- package/dist/backend/cloud/cloud-backend/workspaces.d.ts +7 -0
- package/dist/backend/cloud/cloud-backend/workspaces.js +124 -0
- package/dist/backend/cloud/cloud-backend.d.ts +59 -126
- package/dist/backend/cloud/cloud-backend.js +100 -1087
- package/dist/backend/cloud/cloud-backend.test.setup.d.ts +13 -0
- package/dist/backend/cloud/cloud-backend.test.setup.js +14 -0
- package/dist/backend/common.d.ts +2 -2
- package/dist/backend/local.setup.d.ts +10 -0
- package/dist/backend/local.setup.js +20 -0
- package/dist/commands/executor/start/index.d.ts +4 -11
- package/dist/commands/executor/start/index.js +118 -520
- package/dist/commands/workspace/executor/install/index.d.ts +3 -0
- package/dist/commands/workspace/executor/install/index.js +210 -76
- package/dist/commands/workspace/executor/uninstall/index.d.ts +1 -0
- package/dist/commands/workspace/executor/uninstall/index.js +30 -4
- package/dist/executor/local.d.ts +0 -1
- package/dist/executor/local.js +10 -62
- package/dist/executor/resolve-env.d.ts +88 -0
- package/dist/executor/resolve-env.js +77 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/infrastructure/index.d.ts +12 -16
- package/dist/infrastructure/index.js +18 -25
- package/dist/infrastructure/registry.d.ts +38 -0
- package/dist/infrastructure/registry.js +61 -0
- package/dist/lib/env/test.setup.d.ts +7 -0
- package/dist/lib/env/test.setup.js +18 -0
- package/dist/lib/executor-start/auth.d.ts +2 -0
- package/dist/lib/executor-start/auth.js +21 -0
- package/dist/lib/executor-start/execute-app-job.d.ts +13 -0
- package/dist/lib/executor-start/execute-app-job.js +146 -0
- package/dist/lib/executor-start/execute-deploy-job.d.ts +14 -0
- package/dist/lib/executor-start/execute-deploy-job.js +64 -0
- package/dist/lib/executor-start/execute-init-job.d.ts +14 -0
- package/dist/lib/executor-start/execute-init-job.js +135 -0
- package/dist/lib/executor-start/format.d.ts +13 -0
- package/dist/lib/executor-start/format.js +22 -0
- package/dist/lib/executor-start/job-dispatch.d.ts +15 -0
- package/dist/lib/executor-start/job-dispatch.js +89 -0
- package/dist/lib/package/index.d.ts +25 -25
- package/oclif.manifest.json +43 -3
- package/package.json +1 -1
|
@@ -1,59 +1,14 @@
|
|
|
1
1
|
import { Command, Flags } from '@oclif/core';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import fs from 'node:fs/promises';
|
|
4
3
|
import os from 'node:os';
|
|
5
|
-
import path from 'node:path';
|
|
6
4
|
import { CloudBackend } from '../../../backend/cloud/cloud-backend.js';
|
|
7
|
-
import { loginWithToken } from '../../../backend/cloud/login.js';
|
|
8
|
-
import { saveCloudCredentials } from '../../../backend/config.js';
|
|
9
5
|
import { clearBackend, getBackend } from '../../../backend/index.js';
|
|
10
6
|
import { LocalExecutor } from '../../../executor/local.js';
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
* Workspaces look like `org/name`, which is not filesystem-safe. The
|
|
17
|
-
* write-side in `app deploy --local` (commands/app/deploy/index.ts) MUST use
|
|
18
|
-
* the same sanitization.
|
|
19
|
-
*/
|
|
20
|
-
function sanitizeWorkspaceForFilename(workspace) {
|
|
21
|
-
return workspace.replaceAll('/', '-');
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Strip credentials from log strings before flushing them to the cloud.
|
|
25
|
-
* Removes:
|
|
26
|
-
* - any occurrence of the actual hereyaGitPassword value
|
|
27
|
-
* - userinfo from URLs (https://user:pass@host -> https://host)
|
|
28
|
-
*/
|
|
29
|
-
export function redactCredentials(str, password) {
|
|
30
|
-
let out = str;
|
|
31
|
-
if (password) {
|
|
32
|
-
// Replace every exact occurrence of the password with a placeholder.
|
|
33
|
-
out = out.split(password).join('[REDACTED]');
|
|
34
|
-
}
|
|
35
|
-
// Strip userinfo from URLs (http/https/git).
|
|
36
|
-
out = out.replaceAll(/(https?:\/\/|git:\/\/)([^/\s@]+@)/g, '$1');
|
|
37
|
-
return out;
|
|
38
|
-
}
|
|
39
|
-
async function createAuthenticatedBackend(hereyaToken, cloudUrl) {
|
|
40
|
-
const loginResult = await loginWithToken(cloudUrl, hereyaToken);
|
|
41
|
-
if (!loginResult.success) {
|
|
42
|
-
throw new Error(`Failed to authenticate: ${loginResult.error}`);
|
|
43
|
-
}
|
|
44
|
-
await saveCloudCredentials({
|
|
45
|
-
accessToken: loginResult.accessToken,
|
|
46
|
-
clientId: loginResult.clientId,
|
|
47
|
-
refreshToken: loginResult.refreshToken,
|
|
48
|
-
url: cloudUrl,
|
|
49
|
-
});
|
|
50
|
-
return new CloudBackend({
|
|
51
|
-
accessToken: loginResult.accessToken,
|
|
52
|
-
clientId: loginResult.clientId,
|
|
53
|
-
refreshToken: loginResult.refreshToken,
|
|
54
|
-
url: cloudUrl,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
7
|
+
import { createAuthenticatedBackend } from '../../../lib/executor-start/auth.js';
|
|
8
|
+
import { executeAppJob } from '../../../lib/executor-start/execute-app-job.js';
|
|
9
|
+
import { executeInitJob } from '../../../lib/executor-start/execute-init-job.js';
|
|
10
|
+
import { dispatchJob } from '../../../lib/executor-start/job-dispatch.js';
|
|
11
|
+
export { redactCredentials } from '../../../lib/executor-start/format.js';
|
|
57
12
|
export default class ExecutorStart extends Command {
|
|
58
13
|
static description = `Start the remote executor process (polls for jobs from hereya cloud).
|
|
59
14
|
|
|
@@ -74,6 +29,11 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
|
|
|
74
29
|
description: 'maximum number of parallel jobs',
|
|
75
30
|
min: 1,
|
|
76
31
|
}),
|
|
32
|
+
idleTimeout: Flags.integer({
|
|
33
|
+
char: 'i',
|
|
34
|
+
description: 'seconds idle before exit; omit to keep running indefinitely',
|
|
35
|
+
min: 1,
|
|
36
|
+
}),
|
|
77
37
|
workspace: Flags.string({
|
|
78
38
|
char: 'w',
|
|
79
39
|
description: 'name of the workspace to poll jobs for',
|
|
@@ -82,7 +42,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
|
|
|
82
42
|
};
|
|
83
43
|
async run() {
|
|
84
44
|
const { flags } = await this.parse(ExecutorStart);
|
|
85
|
-
const { concurrency } = flags;
|
|
45
|
+
const { concurrency, idleTimeout } = flags;
|
|
86
46
|
const executor = new LocalExecutor();
|
|
87
47
|
const hereyaToken = process.env.HEREYA_TOKEN;
|
|
88
48
|
const cloudUrl = process.env.HEREYA_CLOUD_URL || 'https://cloud.hereya.dev';
|
|
@@ -98,9 +58,9 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
|
|
|
98
58
|
cloudBackend = backend;
|
|
99
59
|
}
|
|
100
60
|
const executorId = `${os.hostname()}-${randomUUID().slice(0, 8)}`;
|
|
101
|
-
|
|
61
|
+
const idleSuffix = idleTimeout ? `, idle-timeout: ${idleTimeout}s` : '';
|
|
62
|
+
this.log(`Starting executor ${executorId} for workspace: ${flags.workspace} (concurrency: ${concurrency}${idleSuffix})`);
|
|
102
63
|
this.log('Polling for jobs...');
|
|
103
|
-
// Handle graceful shutdown
|
|
104
64
|
const state = { shuttingDown: false };
|
|
105
65
|
const shutdown = () => {
|
|
106
66
|
this.log('Shutting down executor...');
|
|
@@ -109,503 +69,141 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
|
|
|
109
69
|
process.on('SIGINT', shutdown);
|
|
110
70
|
process.on('SIGTERM', shutdown);
|
|
111
71
|
const activeJobs = new Set();
|
|
112
|
-
|
|
72
|
+
const hooks = { log: (m) => this.log(m), warn: (m) => this.warn(m) };
|
|
73
|
+
// Idle clock advances on every successful poll OR while jobs are running.
|
|
74
|
+
// Generic transient errors (5xx, network) deliberately do NOT advance it
|
|
75
|
+
// — a flaky cloud must not keep the EC2 alive forever. Persistent 401s
|
|
76
|
+
// are handled separately by `consecutive401s` below.
|
|
77
|
+
const startedAt = Date.now();
|
|
78
|
+
let lastJobAt = startedAt;
|
|
79
|
+
let lastSuccessfulPollAt = startedAt;
|
|
80
|
+
const MAX_CONSECUTIVE_401S = 3;
|
|
81
|
+
let consecutive401s = 0;
|
|
82
|
+
let authFailureExit = false;
|
|
83
|
+
while (!state.shuttingDown && !authFailureExit) {
|
|
113
84
|
try {
|
|
114
|
-
// Wait for a slot to open up if at concurrency limit
|
|
115
|
-
if (activeJobs.size >= concurrency) {
|
|
116
|
-
// eslint-disable-next-line no-await-in-loop
|
|
117
|
-
await Promise.race(activeJobs);
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
85
|
// eslint-disable-next-line no-await-in-loop
|
|
121
|
-
const
|
|
86
|
+
const step = await this.runPollIteration({
|
|
87
|
+
activeJobs,
|
|
88
|
+
cloudBackend,
|
|
89
|
+
cloudUrl,
|
|
90
|
+
concurrency,
|
|
91
|
+
consecutive401s,
|
|
92
|
+
executor,
|
|
122
93
|
executorId,
|
|
94
|
+
hereyaToken,
|
|
95
|
+
hooks,
|
|
96
|
+
idleTimeout,
|
|
97
|
+
lastJobAt,
|
|
98
|
+
lastSuccessfulPollAt,
|
|
99
|
+
maxConsecutive401s: MAX_CONSECUTIVE_401S,
|
|
123
100
|
workspace: flags.workspace,
|
|
124
101
|
});
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
this.warn(`Poll error: ${pollResult.reason}`);
|
|
134
|
-
// eslint-disable-next-line no-await-in-loop
|
|
135
|
-
await new Promise(resolve => {
|
|
136
|
-
setTimeout(resolve, 5000);
|
|
137
|
-
});
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
if (!pollResult.job) {
|
|
141
|
-
// No job available, continue polling
|
|
142
|
-
continue;
|
|
102
|
+
consecutive401s = step.consecutive401s;
|
|
103
|
+
lastJobAt = step.lastJobAt;
|
|
104
|
+
lastSuccessfulPollAt = step.lastSuccessfulPollAt;
|
|
105
|
+
if (step.cloudBackend)
|
|
106
|
+
cloudBackend = step.cloudBackend;
|
|
107
|
+
if (step.exit) {
|
|
108
|
+
authFailureExit = true;
|
|
109
|
+
break;
|
|
143
110
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// Fire off the job without awaiting it
|
|
147
|
-
const jobPromise = this.executeJob(job, executor, cloudBackend).finally(() => {
|
|
148
|
-
activeJobs.delete(jobPromise);
|
|
149
|
-
});
|
|
150
|
-
activeJobs.add(jobPromise);
|
|
111
|
+
if (step.idleShutdown)
|
|
112
|
+
break;
|
|
151
113
|
}
|
|
152
114
|
catch (error) {
|
|
153
115
|
this.warn(`Executor error: ${error.message}`);
|
|
154
|
-
// Wait before retrying
|
|
155
116
|
// eslint-disable-next-line no-await-in-loop
|
|
156
117
|
await new Promise(resolve => {
|
|
157
118
|
setTimeout(resolve, 5000);
|
|
158
119
|
});
|
|
159
120
|
}
|
|
160
121
|
}
|
|
161
|
-
// Wait for all active jobs to complete before exiting
|
|
162
122
|
if (activeJobs.size > 0) {
|
|
163
123
|
this.log(`Waiting for ${activeJobs.size} active job(s) to complete...`);
|
|
164
124
|
await Promise.allSettled(activeJobs);
|
|
165
125
|
}
|
|
166
126
|
this.log('Executor stopped.');
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const { appName, commit, parameters: providedParameters, repository, sha256, version, workspace } = payload;
|
|
173
|
-
if (!appName || !workspace || !repository) {
|
|
174
|
-
return {
|
|
175
|
-
reason: `Missing appName, workspace, or repository in ${job.type} job payload`,
|
|
176
|
-
success: false,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
let cleanup;
|
|
180
|
-
try {
|
|
181
|
-
logger.info(`Downloading app source ${repository}${commit ? `@${commit}` : ''}...`);
|
|
182
|
-
const downloadResult = await appSourceLib.appSource.download({ commit, logger, repository, sha256 });
|
|
183
|
-
cleanup = downloadResult.cleanup;
|
|
184
|
-
const { rootDir } = downloadResult;
|
|
185
|
-
// Mark deployment as deploying / destroying.
|
|
186
|
-
await cloudBackend.updateAppDeployment({
|
|
187
|
-
lastJobId: job.id,
|
|
188
|
-
name: appName,
|
|
189
|
-
status: isDeploy ? 'deploying' : 'destroying',
|
|
190
|
-
workspace,
|
|
191
|
-
});
|
|
192
|
-
// Build -p flag args from job payload parameters.
|
|
193
|
-
const parameters = providedParameters ?? {};
|
|
194
|
-
const paramFlags = [];
|
|
195
|
-
for (const [key, value] of Object.entries(parameters)) {
|
|
196
|
-
paramFlags.push('-p', `${key}=${value}`);
|
|
197
|
-
}
|
|
198
|
-
// For app-deploy, forward --version so the local command can serialize it into
|
|
199
|
-
// the deploy-env file's `state` block (which the cloud reads back via PATCH).
|
|
200
|
-
const versionFlags = isDeploy && version ? ['--version', version] : [];
|
|
201
|
-
// Shell out to `hereya app <subcommand> <appName> -w <workspace> [-p ...] [--version ...] --local`.
|
|
202
|
-
// The local command holds the provisioning logic (mirrors deploy/undeploy shell-out pattern).
|
|
203
|
-
logger.info(`Running hereya app ${subcommand} ${appName} -w ${workspace} --local...`);
|
|
204
|
-
let runResult;
|
|
205
|
-
try {
|
|
206
|
-
runResult = await shellUtils.runShell('hereya', ['app', subcommand, appName, '-w', workspace, ...paramFlags, ...versionFlags, '--local'], {
|
|
207
|
-
directory: rootDir,
|
|
208
|
-
env: { ...process.env, HEREYA_LOCAL_EXECUTION: 'true', HEREYA_PROJECT_ROOT_DIR: rootDir },
|
|
209
|
-
logger,
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
catch (error) {
|
|
213
|
-
await this.markAppDeploymentFailed({ appName, jobId: job.id, reason: error.message, workspace }, cloudBackend);
|
|
214
|
-
return { reason: `hereya app ${subcommand} failed: ${error.message}`, success: false };
|
|
215
|
-
}
|
|
216
|
-
if (runResult && runResult.status !== 0) {
|
|
217
|
-
const reason = `hereya app ${subcommand} exited with code ${runResult.status}`;
|
|
218
|
-
await this.markAppDeploymentFailed({ appName, jobId: job.id, reason, workspace }, cloudBackend);
|
|
219
|
-
return { reason, success: false };
|
|
220
|
-
}
|
|
221
|
-
// PATCH final state to the cloud.
|
|
222
|
-
const patchResult = await this.patchAppFinalState({
|
|
223
|
-
appName,
|
|
224
|
-
cloudBackend,
|
|
225
|
-
isDeploy,
|
|
226
|
-
jobId: job.id,
|
|
227
|
-
rootDir,
|
|
228
|
-
workspace,
|
|
229
|
-
});
|
|
230
|
-
if (!patchResult.success) {
|
|
231
|
-
return patchResult;
|
|
232
|
-
}
|
|
233
|
-
return { success: true };
|
|
234
|
-
}
|
|
235
|
-
catch (error) {
|
|
236
|
-
try {
|
|
237
|
-
await this.markAppDeploymentFailed({ appName, jobId: job.id, reason: error.message, workspace }, cloudBackend);
|
|
238
|
-
}
|
|
239
|
-
catch {
|
|
240
|
-
// best-effort
|
|
241
|
-
}
|
|
242
|
-
return { reason: `app-${subcommand} failed: ${error.message}`, success: false };
|
|
243
|
-
}
|
|
244
|
-
finally {
|
|
245
|
-
if (cleanup) {
|
|
246
|
-
await cleanup();
|
|
247
|
-
}
|
|
127
|
+
if (authFailureExit) {
|
|
128
|
+
// Hard exit so systemd's `OnFailure=shutdown.target` triggers an EC2
|
|
129
|
+
// halt on persistent authentication failure (e.g. token revoked).
|
|
130
|
+
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
|
|
131
|
+
process.exit(1);
|
|
248
132
|
}
|
|
249
133
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
// 1. Get project metadata to find git credentials
|
|
256
|
-
const metadata$ = await cloudBackend.getProjectMetadata({ project });
|
|
257
|
-
if (!metadata$.found) {
|
|
258
|
-
return { reason: `No metadata found for project ${project}`, success: false };
|
|
259
|
-
}
|
|
260
|
-
const { metadata } = metadata$;
|
|
261
|
-
if (!metadata.env?.hereyaGitRemoteUrl) {
|
|
262
|
-
return { reason: 'Project metadata missing hereyaGitRemoteUrl', success: false };
|
|
263
|
-
}
|
|
264
|
-
// 2. Resolve git credentials using the local executor
|
|
265
|
-
const gitEnvToResolve = {};
|
|
266
|
-
for (const [key, value] of Object.entries(metadata.env)) {
|
|
267
|
-
if (key.startsWith('hereyaGit'))
|
|
268
|
-
gitEnvToResolve[key] = value;
|
|
269
|
-
}
|
|
270
|
-
const resolvedGitEnv = await executor.resolveEnvValues({ env: gitEnvToResolve, project, workspace });
|
|
271
|
-
// 3. Clone repo at specified branch into temp dir
|
|
272
|
-
const tempDir = path.join(os.tmpdir(), `hereya-deploy-${randomUUID()}`);
|
|
273
|
-
try {
|
|
274
|
-
const branch = gitBranch || 'main';
|
|
275
|
-
logger.info(`Cloning ${resolvedGitEnv.hereyaGitRemoteUrl} (branch: ${branch})...`);
|
|
276
|
-
const cloneResult = await cloneWithCredentialHelper({
|
|
277
|
-
gitUrl: resolvedGitEnv.hereyaGitRemoteUrl,
|
|
278
|
-
hereyaBinPath: process.argv[1],
|
|
279
|
-
password: resolvedGitEnv.hereyaGitPassword,
|
|
280
|
-
targetDir: tempDir,
|
|
281
|
-
username: resolvedGitEnv.hereyaGitUsername,
|
|
282
|
-
});
|
|
283
|
-
if (!cloneResult.success) {
|
|
284
|
-
return { reason: `Git clone failed: ${cloneResult.reason}`, success: false };
|
|
285
|
-
}
|
|
286
|
-
// Checkout the correct branch if not main/master
|
|
287
|
-
if (branch !== 'main' && branch !== 'master') {
|
|
288
|
-
await runShell('git', ['checkout', branch], { directory: tempDir, logger });
|
|
289
|
-
}
|
|
290
|
-
// 4. Run hereya deploy/undeploy --local inside the cloned repo
|
|
291
|
-
const command = job.type === 'deploy' ? 'deploy' : 'undeploy';
|
|
292
|
-
logger.info(`Running hereya ${command} -w ${workspace} --local...`);
|
|
293
|
-
const result = await runShell('hereya', [command, '-w', workspace, '--local'], {
|
|
294
|
-
directory: tempDir,
|
|
295
|
-
env: { ...process.env, HEREYA_LOCAL_EXECUTION: 'true', HEREYA_PROJECT_ROOT_DIR: tempDir },
|
|
296
|
-
logger,
|
|
297
|
-
});
|
|
298
|
-
logger.info(`hereya ${command} exited with code ${result.status}`);
|
|
299
|
-
if (result.status !== 0) {
|
|
300
|
-
return { reason: `hereya ${command} failed (exit code ${result.status})`, success: false };
|
|
301
|
-
}
|
|
302
|
-
return { success: true };
|
|
303
|
-
}
|
|
304
|
-
finally {
|
|
305
|
-
// 5. Clean up temp dir
|
|
306
|
-
try {
|
|
307
|
-
await fs.rm(tempDir, { force: true, recursive: true });
|
|
308
|
-
}
|
|
309
|
-
catch {
|
|
310
|
-
// Ignore cleanup errors
|
|
311
|
-
}
|
|
312
|
-
}
|
|
134
|
+
// Test-friendly delegators kept on the class for backwards compatibility with
|
|
135
|
+
// tests that call `(cmd as any).executeInitJob(...)` / `executeAppJob(...)`.
|
|
136
|
+
executeAppJob(job, cloudBackend, logger) {
|
|
137
|
+
return executeAppJob(job, cloudBackend, logger);
|
|
313
138
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const { deployWorkspace, parameters, projectName, template, workspace } = payload;
|
|
317
|
-
if (!projectName || !workspace || !template || !deployWorkspace) {
|
|
318
|
-
return {
|
|
319
|
-
reason: 'Missing projectName, workspace, template, or deployWorkspace in job payload',
|
|
320
|
-
success: false,
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
// Track the password here (captured after provision) so the redacting logger knows what
|
|
324
|
-
// to strip. Until we have it, redaction still runs for URL userinfo.
|
|
325
|
-
let password;
|
|
326
|
-
const redactingLogger = {
|
|
327
|
-
debug(msg) {
|
|
328
|
-
logger.debug(redactCredentials(msg, password));
|
|
329
|
-
},
|
|
330
|
-
error(msg) {
|
|
331
|
-
logger.error(redactCredentials(msg, password));
|
|
332
|
-
},
|
|
333
|
-
info(msg) {
|
|
334
|
-
logger.info(redactCredentials(msg, password));
|
|
335
|
-
},
|
|
336
|
-
};
|
|
337
|
-
const scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hereya-init-'));
|
|
338
|
-
try {
|
|
339
|
-
// 1. Provision template
|
|
340
|
-
redactingLogger.info(`Provisioning template ${template}...`);
|
|
341
|
-
const provisionResult = await executor.provision({
|
|
342
|
-
logger: redactingLogger,
|
|
343
|
-
package: template,
|
|
344
|
-
parameters: {
|
|
345
|
-
...parameters,
|
|
346
|
-
deployWorkspace,
|
|
347
|
-
projectName,
|
|
348
|
-
workspace,
|
|
349
|
-
},
|
|
350
|
-
project: projectName,
|
|
351
|
-
workspace,
|
|
352
|
-
});
|
|
353
|
-
if (!provisionResult.success) {
|
|
354
|
-
return { reason: `Template provisioning failed: ${provisionResult.reason}`, success: false };
|
|
355
|
-
}
|
|
356
|
-
const { env } = provisionResult;
|
|
357
|
-
const { hereyaGitPassword, hereyaGitRemoteUrl, hereyaGitUsername } = env;
|
|
358
|
-
password = hereyaGitPassword;
|
|
359
|
-
if (!hereyaGitRemoteUrl) {
|
|
360
|
-
return { reason: 'Template did not export hereyaGitRemoteUrl', success: false };
|
|
361
|
-
}
|
|
362
|
-
// 2. Clone repo into scratch dir.
|
|
363
|
-
redactingLogger.info(`Cloning ${hereyaGitRemoteUrl}...`);
|
|
364
|
-
// Clone into a subdirectory so we control the target exactly.
|
|
365
|
-
const repoDir = path.join(scratchDir, 'repo');
|
|
366
|
-
const cloneResult = await gitUtils.cloneWithCredentialHelper({
|
|
367
|
-
gitUrl: hereyaGitRemoteUrl,
|
|
368
|
-
hereyaBinPath: process.argv[1],
|
|
369
|
-
password: hereyaGitPassword,
|
|
370
|
-
targetDir: repoDir,
|
|
371
|
-
username: hereyaGitUsername,
|
|
372
|
-
});
|
|
373
|
-
if (!cloneResult.success) {
|
|
374
|
-
return { reason: `Git clone failed: ${cloneResult.reason}`, success: false };
|
|
375
|
-
}
|
|
376
|
-
// 3. Write hereya.yaml
|
|
377
|
-
redactingLogger.info('Writing hereya.yaml...');
|
|
378
|
-
const config = {
|
|
379
|
-
project: projectName,
|
|
380
|
-
workspace,
|
|
381
|
-
};
|
|
382
|
-
await new SimpleConfigManager().saveConfig({ config, projectRootDir: repoDir });
|
|
383
|
-
// 4. Commit + push
|
|
384
|
-
redactingLogger.info('Committing and pushing initial project config...');
|
|
385
|
-
const gitEnv = {
|
|
386
|
-
...process.env,
|
|
387
|
-
GIT_AUTHOR_EMAIL: 'executor@hereya.dev',
|
|
388
|
-
GIT_AUTHOR_NAME: 'hereya-executor',
|
|
389
|
-
GIT_COMMITTER_EMAIL: 'executor@hereya.dev',
|
|
390
|
-
GIT_COMMITTER_NAME: 'hereya-executor',
|
|
391
|
-
};
|
|
392
|
-
if (hereyaGitUsername)
|
|
393
|
-
gitEnv.hereyaGitUsername = hereyaGitUsername;
|
|
394
|
-
if (hereyaGitPassword)
|
|
395
|
-
gitEnv.hereyaGitPassword = hereyaGitPassword;
|
|
396
|
-
try {
|
|
397
|
-
await shellUtils.runShell('git', ['add', '.'], { directory: repoDir, env: gitEnv, logger: redactingLogger });
|
|
398
|
-
await shellUtils.runShell('git', ['commit', '-m', '"chore: hereya init"'], {
|
|
399
|
-
directory: repoDir,
|
|
400
|
-
env: gitEnv,
|
|
401
|
-
logger: redactingLogger,
|
|
402
|
-
});
|
|
403
|
-
await shellUtils.runShell('git', ['push', 'origin', 'HEAD'], {
|
|
404
|
-
directory: repoDir,
|
|
405
|
-
env: gitEnv,
|
|
406
|
-
logger: redactingLogger,
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
catch (error) {
|
|
410
|
-
return {
|
|
411
|
-
reason: `Git commit/push failed: ${redactCredentials(error.message ?? String(error), password)}`,
|
|
412
|
-
success: false,
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
return { hereyaGitRemoteUrl, success: true };
|
|
416
|
-
}
|
|
417
|
-
catch (error) {
|
|
418
|
-
return {
|
|
419
|
-
reason: `Init failed: ${redactCredentials(error.message ?? String(error), password)}`,
|
|
420
|
-
success: false,
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
|
-
finally {
|
|
424
|
-
try {
|
|
425
|
-
await fs.rm(scratchDir, { force: true, recursive: true });
|
|
426
|
-
}
|
|
427
|
-
catch {
|
|
428
|
-
// Ignore cleanup errors
|
|
429
|
-
}
|
|
430
|
-
}
|
|
139
|
+
executeInitJob(job, executor, logger) {
|
|
140
|
+
return executeInitJob(job, executor, logger);
|
|
431
141
|
}
|
|
432
|
-
async
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const logs = logBuffer;
|
|
438
|
-
logBuffer = '';
|
|
439
|
-
try {
|
|
440
|
-
await cloudBackend.updateExecutorJob({
|
|
441
|
-
jobId: job.id,
|
|
442
|
-
logs,
|
|
443
|
-
});
|
|
444
|
-
}
|
|
445
|
-
catch {
|
|
446
|
-
// Log flush errors are non-fatal
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}, 10_000);
|
|
450
|
-
// Heartbeat every 30 seconds (independent of log flushing)
|
|
451
|
-
const heartbeatInterval = setInterval(async () => {
|
|
452
|
-
try {
|
|
453
|
-
await cloudBackend.updateExecutorJob({ jobId: job.id });
|
|
454
|
-
}
|
|
455
|
-
catch {
|
|
456
|
-
// Heartbeat errors are non-fatal
|
|
457
|
-
}
|
|
458
|
-
}, 30_000);
|
|
459
|
-
const logger = {
|
|
460
|
-
debug(msg) {
|
|
461
|
-
logBuffer += msg + '\n';
|
|
462
|
-
},
|
|
463
|
-
error(msg) {
|
|
464
|
-
logBuffer += msg + '\n';
|
|
465
|
-
},
|
|
466
|
-
info(msg) {
|
|
467
|
-
logBuffer += msg + '\n';
|
|
468
|
-
},
|
|
469
|
-
};
|
|
470
|
-
try {
|
|
471
|
-
if (job.type === 'resolve-env') {
|
|
472
|
-
// resolve-env job: resolve env values using local executor
|
|
473
|
-
const resolved = await executor.resolveEnvValues(job.payload);
|
|
474
|
-
// Send resolved env as result
|
|
475
|
-
clearInterval(logInterval);
|
|
476
|
-
clearInterval(heartbeatInterval);
|
|
477
|
-
await cloudBackend.updateExecutorJob({
|
|
478
|
-
jobId: job.id,
|
|
479
|
-
logs: logBuffer,
|
|
480
|
-
result: { env: resolved, success: true },
|
|
481
|
-
status: 'completed',
|
|
482
|
-
});
|
|
483
|
-
this.log(`Job ${job.id} completed (resolve-env)`);
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
if (job.type === 'deploy' || job.type === 'undeploy') {
|
|
487
|
-
const deployResult = await this.executeDeployJob(job, executor, cloudBackend, logger);
|
|
488
|
-
clearInterval(logInterval);
|
|
489
|
-
clearInterval(heartbeatInterval);
|
|
490
|
-
await cloudBackend.updateExecutorJob({
|
|
491
|
-
jobId: job.id,
|
|
492
|
-
logs: logBuffer,
|
|
493
|
-
result: deployResult,
|
|
494
|
-
status: deployResult.success ? 'completed' : 'failed',
|
|
495
|
-
});
|
|
496
|
-
this.log(`Job ${job.id} ${deployResult.success ? 'completed' : 'failed'} (${job.type})`);
|
|
497
|
-
return;
|
|
498
|
-
}
|
|
499
|
-
if (job.type === 'init') {
|
|
500
|
-
const initResult = await this.executeInitJob(job, executor, logger);
|
|
501
|
-
clearInterval(logInterval);
|
|
502
|
-
clearInterval(heartbeatInterval);
|
|
503
|
-
await cloudBackend.updateExecutorJob({
|
|
504
|
-
jobId: job.id,
|
|
505
|
-
logs: logBuffer,
|
|
506
|
-
result: initResult,
|
|
507
|
-
status: initResult.success ? 'completed' : 'failed',
|
|
508
|
-
});
|
|
509
|
-
this.log(`Job ${job.id} ${initResult.success ? 'completed' : 'failed'} (init)`);
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
if (job.type === 'app-deploy' || job.type === 'app-destroy') {
|
|
513
|
-
const appJobResult = await this.executeAppJob(job, cloudBackend, logger);
|
|
514
|
-
clearInterval(logInterval);
|
|
515
|
-
clearInterval(heartbeatInterval);
|
|
516
|
-
await cloudBackend.updateExecutorJob({
|
|
517
|
-
jobId: job.id,
|
|
518
|
-
logs: logBuffer,
|
|
519
|
-
result: appJobResult,
|
|
520
|
-
status: appJobResult.success ? 'completed' : 'failed',
|
|
521
|
-
});
|
|
522
|
-
this.log(`Job ${job.id} ${appJobResult.success ? 'completed' : 'failed'} (${job.type})`);
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
const result = job.type === 'provision'
|
|
526
|
-
? await executor.provision({
|
|
527
|
-
...job.payload,
|
|
528
|
-
logger,
|
|
529
|
-
})
|
|
530
|
-
: await executor.destroy({
|
|
531
|
-
...job.payload,
|
|
532
|
-
logger,
|
|
533
|
-
});
|
|
534
|
-
// Flush remaining logs and send result
|
|
535
|
-
clearInterval(logInterval);
|
|
536
|
-
clearInterval(heartbeatInterval);
|
|
537
|
-
await cloudBackend.updateExecutorJob({
|
|
538
|
-
jobId: job.id,
|
|
539
|
-
logs: logBuffer,
|
|
540
|
-
result,
|
|
541
|
-
status: result.success ? 'completed' : 'failed',
|
|
542
|
-
});
|
|
543
|
-
this.log(`Job ${job.id} ${result.success ? 'completed' : 'failed'}`);
|
|
142
|
+
async handleUnauthorizedPoll(args) {
|
|
143
|
+
const { cloudUrl, consecutive401s, hereyaToken, max } = args;
|
|
144
|
+
if (consecutive401s >= max) {
|
|
145
|
+
this.warn(`Persistent authentication failure: ${consecutive401s} consecutive 401 responses from hereya cloud. Token likely revoked or expired. Exiting.`);
|
|
146
|
+
return { exit: true };
|
|
544
147
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
await
|
|
549
|
-
|
|
550
|
-
logs: logBuffer + `\nError: ${error.message}\n`,
|
|
551
|
-
result: { reason: error.message, success: false },
|
|
552
|
-
status: 'failed',
|
|
553
|
-
});
|
|
554
|
-
this.warn(`Job ${job.id} failed: ${error.message}`);
|
|
148
|
+
if (hereyaToken) {
|
|
149
|
+
this.log(`Access token expired (attempt ${consecutive401s}/${max}). Re-authenticating...`);
|
|
150
|
+
clearBackend();
|
|
151
|
+
const cloudBackend = await createAuthenticatedBackend(hereyaToken, cloudUrl);
|
|
152
|
+
return { cloudBackend, exit: false };
|
|
555
153
|
}
|
|
154
|
+
this.warn(`Unauthorized poll (attempt ${consecutive401s}/${max}); no HEREYA_TOKEN available to re-authenticate.`);
|
|
155
|
+
return { exit: false };
|
|
556
156
|
}
|
|
557
|
-
async
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
157
|
+
async runPollIteration(args) {
|
|
158
|
+
const { activeJobs, cloudBackend, cloudUrl, concurrency, executor, executorId, hereyaToken, hooks, idleTimeout, maxConsecutive401s, workspace, } = args;
|
|
159
|
+
let { consecutive401s, lastJobAt, lastSuccessfulPollAt } = args;
|
|
160
|
+
if (activeJobs.size > 0) {
|
|
161
|
+
lastJobAt = Date.now();
|
|
162
|
+
}
|
|
163
|
+
if (activeJobs.size >= concurrency) {
|
|
164
|
+
await Promise.race(activeJobs);
|
|
165
|
+
return { consecutive401s, lastJobAt, lastSuccessfulPollAt };
|
|
166
|
+
}
|
|
167
|
+
const pollResult = await cloudBackend.pollExecutorJobs({ executorId, workspace });
|
|
168
|
+
if (!pollResult.success && 'unauthorized' in pollResult && pollResult.unauthorized) {
|
|
169
|
+
consecutive401s += 1;
|
|
170
|
+
const action = await this.handleUnauthorizedPoll({
|
|
171
|
+
cloudUrl,
|
|
172
|
+
consecutive401s,
|
|
173
|
+
hereyaToken,
|
|
174
|
+
max: maxConsecutive401s,
|
|
564
175
|
});
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const updateResult = await cloudBackend.updateAppDeployment({
|
|
574
|
-
env: {},
|
|
575
|
-
lastJobId: jobId,
|
|
576
|
-
name: appName,
|
|
577
|
-
status: 'destroyed',
|
|
578
|
-
workspace,
|
|
176
|
+
if (action.exit)
|
|
177
|
+
return { consecutive401s, exit: true, lastJobAt, lastSuccessfulPollAt };
|
|
178
|
+
return { cloudBackend: action.cloudBackend, consecutive401s, lastJobAt, lastSuccessfulPollAt };
|
|
179
|
+
}
|
|
180
|
+
if (!pollResult.success) {
|
|
181
|
+
this.warn(`Poll error: ${pollResult.reason}`);
|
|
182
|
+
await new Promise((resolve) => {
|
|
183
|
+
setTimeout(resolve, 5000);
|
|
579
184
|
});
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const reason = `Failed to read deploy env file at ${deployEnvFile}: ${error.message}`;
|
|
595
|
-
await this.markAppDeploymentFailed({ appName, jobId, reason, workspace }, cloudBackend);
|
|
596
|
-
return { reason, success: false };
|
|
185
|
+
return { consecutive401s, lastJobAt, lastSuccessfulPollAt };
|
|
186
|
+
}
|
|
187
|
+
// Successful poll: clear the 401 counter and advance the idle clock.
|
|
188
|
+
consecutive401s = 0;
|
|
189
|
+
lastSuccessfulPollAt = Date.now();
|
|
190
|
+
if (pollResult.job) {
|
|
191
|
+
const { job } = pollResult;
|
|
192
|
+
this.log(`Received job ${job.id} (${job.type}) [${activeJobs.size + 1}/${concurrency} slots used]`);
|
|
193
|
+
lastJobAt = Date.now();
|
|
194
|
+
const jobPromise = dispatchJob(job, executor, cloudBackend, hooks).finally(() => {
|
|
195
|
+
activeJobs.delete(jobPromise);
|
|
196
|
+
});
|
|
197
|
+
activeJobs.add(jobPromise);
|
|
198
|
+
return { consecutive401s, lastJobAt, lastSuccessfulPollAt };
|
|
597
199
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
workspace,
|
|
605
|
-
});
|
|
606
|
-
if (!updateResult.success) {
|
|
607
|
-
return { reason: `Provisioning succeeded but PATCH to deployment failed: ${updateResult.reason}`, success: false };
|
|
200
|
+
if (idleTimeout !== undefined &&
|
|
201
|
+
activeJobs.size === 0 &&
|
|
202
|
+
Date.now() - lastJobAt > idleTimeout * 1000) {
|
|
203
|
+
const afterMs = Date.now() - lastJobAt;
|
|
204
|
+
this.log(JSON.stringify({ afterMs, event: 'executor.idle.shutdown', lastSuccessfulPollAt }));
|
|
205
|
+
return { consecutive401s, idleShutdown: true, lastJobAt, lastSuccessfulPollAt };
|
|
608
206
|
}
|
|
609
|
-
return {
|
|
207
|
+
return { consecutive401s, lastJobAt, lastSuccessfulPollAt };
|
|
610
208
|
}
|
|
611
209
|
}
|