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.
Files changed (67) hide show
  1. package/README.md +88 -75
  2. package/dist/backend/cloud/cloud-backend/apps-deploy.d.ts +69 -0
  3. package/dist/backend/cloud/cloud-backend/apps-deploy.js +94 -0
  4. package/dist/backend/cloud/cloud-backend/apps-versions.d.ts +69 -0
  5. package/dist/backend/cloud/cloud-backend/apps-versions.js +113 -0
  6. package/dist/backend/cloud/cloud-backend/executor-broker.d.ts +25 -0
  7. package/dist/backend/cloud/cloud-backend/executor-broker.js +38 -0
  8. package/dist/backend/cloud/cloud-backend/executor-jobs.d.ts +75 -0
  9. package/dist/backend/cloud/cloud-backend/executor-jobs.js +110 -0
  10. package/dist/backend/cloud/cloud-backend/misc.d.ts +5 -0
  11. package/dist/backend/cloud/cloud-backend/misc.js +78 -0
  12. package/dist/backend/cloud/cloud-backend/packages-publish.d.ts +3 -0
  13. package/dist/backend/cloud/cloud-backend/packages-publish.js +99 -0
  14. package/dist/backend/cloud/cloud-backend/packages-registry.d.ts +6 -0
  15. package/dist/backend/cloud/cloud-backend/packages-registry.js +146 -0
  16. package/dist/backend/cloud/cloud-backend/packages-workspace.d.ts +4 -0
  17. package/dist/backend/cloud/cloud-backend/packages-workspace.js +50 -0
  18. package/dist/backend/cloud/cloud-backend/projects.d.ts +7 -0
  19. package/dist/backend/cloud/cloud-backend/projects.js +122 -0
  20. package/dist/backend/cloud/cloud-backend/state.d.ts +6 -0
  21. package/dist/backend/cloud/cloud-backend/state.js +86 -0
  22. package/dist/backend/cloud/cloud-backend/utils.d.ts +55 -0
  23. package/dist/backend/cloud/cloud-backend/utils.js +56 -0
  24. package/dist/backend/cloud/cloud-backend/workspace-env.d.ts +5 -0
  25. package/dist/backend/cloud/cloud-backend/workspace-env.js +63 -0
  26. package/dist/backend/cloud/cloud-backend/workspaces.d.ts +7 -0
  27. package/dist/backend/cloud/cloud-backend/workspaces.js +124 -0
  28. package/dist/backend/cloud/cloud-backend.d.ts +59 -126
  29. package/dist/backend/cloud/cloud-backend.js +100 -1087
  30. package/dist/backend/cloud/cloud-backend.test.setup.d.ts +13 -0
  31. package/dist/backend/cloud/cloud-backend.test.setup.js +14 -0
  32. package/dist/backend/common.d.ts +2 -2
  33. package/dist/backend/local.setup.d.ts +10 -0
  34. package/dist/backend/local.setup.js +20 -0
  35. package/dist/commands/executor/start/index.d.ts +4 -11
  36. package/dist/commands/executor/start/index.js +118 -520
  37. package/dist/commands/workspace/executor/install/index.d.ts +3 -0
  38. package/dist/commands/workspace/executor/install/index.js +210 -76
  39. package/dist/commands/workspace/executor/uninstall/index.d.ts +1 -0
  40. package/dist/commands/workspace/executor/uninstall/index.js +30 -4
  41. package/dist/executor/local.d.ts +0 -1
  42. package/dist/executor/local.js +10 -62
  43. package/dist/executor/resolve-env.d.ts +88 -0
  44. package/dist/executor/resolve-env.js +77 -0
  45. package/dist/index.d.ts +3 -0
  46. package/dist/index.js +7 -0
  47. package/dist/infrastructure/index.d.ts +12 -16
  48. package/dist/infrastructure/index.js +18 -25
  49. package/dist/infrastructure/registry.d.ts +38 -0
  50. package/dist/infrastructure/registry.js +61 -0
  51. package/dist/lib/env/test.setup.d.ts +7 -0
  52. package/dist/lib/env/test.setup.js +18 -0
  53. package/dist/lib/executor-start/auth.d.ts +2 -0
  54. package/dist/lib/executor-start/auth.js +21 -0
  55. package/dist/lib/executor-start/execute-app-job.d.ts +13 -0
  56. package/dist/lib/executor-start/execute-app-job.js +146 -0
  57. package/dist/lib/executor-start/execute-deploy-job.d.ts +14 -0
  58. package/dist/lib/executor-start/execute-deploy-job.js +64 -0
  59. package/dist/lib/executor-start/execute-init-job.d.ts +14 -0
  60. package/dist/lib/executor-start/execute-init-job.js +135 -0
  61. package/dist/lib/executor-start/format.d.ts +13 -0
  62. package/dist/lib/executor-start/format.js +22 -0
  63. package/dist/lib/executor-start/job-dispatch.d.ts +15 -0
  64. package/dist/lib/executor-start/job-dispatch.js +89 -0
  65. package/dist/lib/package/index.d.ts +25 -25
  66. package/oclif.manifest.json +43 -3
  67. 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 * as appSourceLib from '../../../lib/app-source.js';
12
- import { SimpleConfigManager } from '../../../lib/config/simple.js';
13
- import { cloneWithCredentialHelper, gitUtils } from '../../../lib/git-utils.js';
14
- import { runShell, shellUtils } from '../../../lib/shell.js';
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
- this.log(`Starting executor ${executorId} for workspace: ${flags.workspace} (concurrency: ${concurrency})`);
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
- while (!state.shuttingDown) {
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 pollResult = await cloudBackend.pollExecutorJobs({
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
- if (!pollResult.success) {
126
- if ('unauthorized' in pollResult && pollResult.unauthorized && hereyaToken) {
127
- this.log('Access token expired. Re-authenticating...');
128
- clearBackend();
129
- // eslint-disable-next-line no-await-in-loop
130
- cloudBackend = await createAuthenticatedBackend(hereyaToken, cloudUrl);
131
- continue;
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
- const { job } = pollResult;
145
- this.log(`Received job ${job.id} (${job.type}) [${activeJobs.size + 1}/${concurrency} slots used]`);
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
- async executeAppJob(job, cloudBackend, logger) {
169
- const isDeploy = job.type === 'app-deploy';
170
- const subcommand = isDeploy ? 'deploy' : 'destroy';
171
- const payload = job.payload;
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
- async executeDeployJob(job, executor, cloudBackend, logger) {
251
- const { gitBranch, project, workspace } = job.payload;
252
- if (!project || !workspace) {
253
- return { reason: 'Missing project or workspace in job payload', success: false };
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
- async executeInitJob(job, executor, logger) {
315
- const payload = job.payload;
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 executeJob(job, executor, cloudBackend) {
433
- // Set up buffered logging
434
- let logBuffer = '';
435
- const logInterval = setInterval(async () => {
436
- if (logBuffer) {
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
- catch (error) {
546
- clearInterval(logInterval);
547
- clearInterval(heartbeatInterval);
548
- await cloudBackend.updateExecutorJob({
549
- jobId: job.id,
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 markAppDeploymentFailed(input, cloudBackend) {
558
- try {
559
- await cloudBackend.updateAppDeployment({
560
- lastJobId: input.jobId,
561
- name: input.appName,
562
- status: 'failed',
563
- workspace: input.workspace,
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
- catch {
567
- // best-effort; failure of the PATCH must not bubble out
568
- }
569
- }
570
- async patchAppFinalState(input) {
571
- const { appName, cloudBackend, isDeploy, jobId, rootDir, workspace } = input;
572
- if (!isDeploy) {
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
- if (!updateResult.success) {
581
- return { reason: `Destroy succeeded but PATCH to deployment failed: ${updateResult.reason}`, success: false };
582
- }
583
- return { success: true };
584
- }
585
- // Read .hereya/deploy-env.<workspace>.json that was written by `app deploy --local`.
586
- // Filename sanitization MUST match the write side (see `sanitizeWorkspaceForFilename`).
587
- const deployEnvFile = path.join(rootDir, '.hereya', `deploy-env.${sanitizeWorkspaceForFilename(workspace)}.json`);
588
- let parsed;
589
- try {
590
- const content = await fs.readFile(deployEnvFile, 'utf8');
591
- parsed = JSON.parse(content);
592
- }
593
- catch (error) {
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
- const updateResult = await cloudBackend.updateAppDeployment({
599
- env: parsed.env ?? {},
600
- lastJobId: jobId,
601
- name: appName,
602
- state: parsed.state,
603
- status: 'deployed',
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 { success: true };
207
+ return { consecutive401s, lastJobAt, lastSuccessfulPollAt };
610
208
  }
611
209
  }