hereya-cli 0.90.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.
@@ -0,0 +1,38 @@
1
+ import { safeResponseJson } from './utils.js';
2
+ export async function registerExecutorBroker(config, input) {
3
+ const response = await fetch(`${config.url}/api/workspaces/${encodeURIComponent(input.workspace)}/executor-broker`, {
4
+ body: JSON.stringify({
5
+ brokerVersion: input.brokerVersion,
6
+ brokerWebhookUrl: input.brokerWebhookUrl,
7
+ metadata: input.metadata,
8
+ provider: input.provider,
9
+ }),
10
+ headers: {
11
+ 'Authorization': `Bearer ${config.accessToken}`,
12
+ 'Content-Type': 'application/json',
13
+ },
14
+ method: 'POST',
15
+ });
16
+ if (!response.ok) {
17
+ const error = await safeResponseJson(response);
18
+ return { reason: error.error || 'Failed to register executor broker', success: false };
19
+ }
20
+ return { success: true };
21
+ }
22
+ export async function unregisterExecutorBroker(config, input) {
23
+ const response = await fetch(`${config.url}/api/workspaces/${encodeURIComponent(input.workspace)}/executor-broker`, {
24
+ headers: { 'Authorization': `Bearer ${config.accessToken}` },
25
+ method: 'DELETE',
26
+ });
27
+ if (!response.ok) {
28
+ if (response.status === 404) {
29
+ // No broker registered — treat as a successful no-op so callers can
30
+ // unconditionally invoke this during `executor uninstall` for both
31
+ // ephemeral and legacy always-on installs.
32
+ return { success: true };
33
+ }
34
+ const error = await safeResponseJson(response);
35
+ return { reason: error.error || 'Failed to unregister executor broker', success: false };
36
+ }
37
+ return { success: true };
38
+ }
@@ -2,6 +2,7 @@ import { Config } from '../../lib/config/common.js';
2
2
  import { Backend } from '../common.js';
3
3
  import * as appsDeploy from './cloud-backend/apps-deploy.js';
4
4
  import * as appsVersions from './cloud-backend/apps-versions.js';
5
+ import * as executorBroker from './cloud-backend/executor-broker.js';
5
6
  import * as executorJobs from './cloud-backend/executor-jobs.js';
6
7
  import * as misc from './cloud-backend/misc.js';
7
8
  import * as packagesPublish from './cloud-backend/packages-publish.js';
@@ -143,6 +144,7 @@ export declare class CloudBackend implements Backend {
143
144
  }>;
144
145
  publishAppVersion(input: appsVersions.PublishAppVersionInput): Promise<appsVersions.PublishAppVersionOutput>;
145
146
  publishPackage(input: Parameters<typeof packagesPublish.publishPackage>[1]): Promise<import("../common.js").PublishPackageOutput>;
147
+ registerExecutorBroker(input: executorBroker.RegisterExecutorBrokerInput): Promise<executorBroker.RegisterExecutorBrokerOutput>;
146
148
  removePackageFromWorkspace(input: Parameters<typeof packagesWorkspace.removePackageFromWorkspace>[1]): Promise<import("../common.js").AddPackageToWorkspaceOutput>;
147
149
  revokeExecutorToken(input: {
148
150
  workspace: string;
@@ -163,6 +165,7 @@ export declare class CloudBackend implements Backend {
163
165
  reason: string;
164
166
  success: false;
165
167
  }>;
168
+ unregisterExecutorBroker(input: executorBroker.UnregisterExecutorBrokerInput): Promise<executorBroker.UnregisterExecutorBrokerOutput>;
166
169
  unsetEnvVar(input: Parameters<typeof workspaceEnv.unsetEnvVar>[1]): Promise<import("../common.js").SetEnvVarOutput>;
167
170
  updateAppDeployment(input: appsDeploy.UpdateAppDeploymentInput): Promise<{
168
171
  reason: string;
@@ -1,5 +1,6 @@
1
1
  import * as appsDeploy from './cloud-backend/apps-deploy.js';
2
2
  import * as appsVersions from './cloud-backend/apps-versions.js';
3
+ import * as executorBroker from './cloud-backend/executor-broker.js';
3
4
  import * as executorJobs from './cloud-backend/executor-jobs.js';
4
5
  import * as misc from './cloud-backend/misc.js';
5
6
  import * as packagesPublish from './cloud-backend/packages-publish.js';
@@ -107,6 +108,9 @@ export class CloudBackend {
107
108
  publishPackage(input) {
108
109
  return packagesPublish.publishPackage(this.config, input);
109
110
  }
111
+ registerExecutorBroker(input) {
112
+ return executorBroker.registerExecutorBroker(this.config, input);
113
+ }
110
114
  removePackageFromWorkspace(input) {
111
115
  return packagesWorkspace.removePackageFromWorkspace(this.config, input);
112
116
  }
@@ -128,6 +132,9 @@ export class CloudBackend {
128
132
  submitExecutorJob(input) {
129
133
  return executorJobs.submitExecutorJob(this.config, input);
130
134
  }
135
+ unregisterExecutorBroker(input) {
136
+ return executorBroker.unregisterExecutorBroker(this.config, input);
137
+ }
131
138
  unsetEnvVar(input) {
132
139
  return workspaceEnv.unsetEnvVar(this.config, input);
133
140
  }
@@ -61,11 +61,11 @@ export declare const WorkspaceSchema: z.ZodObject<{
61
61
  name: string;
62
62
  id: string;
63
63
  env?: Record<string, string> | undefined;
64
+ profile?: string | undefined;
64
65
  packages?: Record<string, {
65
66
  version: string;
66
67
  parameters?: Record<string, any> | undefined;
67
68
  }> | undefined;
68
- profile?: string | undefined;
69
69
  hasExecutor?: boolean | undefined;
70
70
  isDeploy?: boolean | undefined;
71
71
  mirrorOf?: string | undefined;
@@ -77,11 +77,11 @@ export declare const WorkspaceSchema: z.ZodObject<{
77
77
  name: string;
78
78
  id: string;
79
79
  env?: Record<string, string> | undefined;
80
+ profile?: string | undefined;
80
81
  packages?: Record<string, {
81
82
  version: string;
82
83
  parameters?: Record<string, any> | undefined;
83
84
  }> | undefined;
84
- profile?: string | undefined;
85
85
  hasExecutor?: boolean | undefined;
86
86
  isDeploy?: boolean | undefined;
87
87
  mirrorOf?: string | undefined;
@@ -5,9 +5,12 @@ export default class ExecutorStart extends Command {
5
5
  static examples: string[];
6
6
  static flags: {
7
7
  concurrency: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
8
+ idleTimeout: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
9
  workspace: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
10
  };
10
11
  run(): Promise<void>;
11
12
  private executeAppJob;
12
13
  private executeInitJob;
14
+ private handleUnauthorizedPoll;
15
+ private runPollIteration;
13
16
  }
@@ -29,6 +29,11 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
29
29
  description: 'maximum number of parallel jobs',
30
30
  min: 1,
31
31
  }),
32
+ idleTimeout: Flags.integer({
33
+ char: 'i',
34
+ description: 'seconds idle before exit; omit to keep running indefinitely',
35
+ min: 1,
36
+ }),
32
37
  workspace: Flags.string({
33
38
  char: 'w',
34
39
  description: 'name of the workspace to poll jobs for',
@@ -37,7 +42,7 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
37
42
  };
38
43
  async run() {
39
44
  const { flags } = await this.parse(ExecutorStart);
40
- const { concurrency } = flags;
45
+ const { concurrency, idleTimeout } = flags;
41
46
  const executor = new LocalExecutor();
42
47
  const hereyaToken = process.env.HEREYA_TOKEN;
43
48
  const cloudUrl = process.env.HEREYA_CLOUD_URL || 'https://cloud.hereya.dev';
@@ -53,7 +58,8 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
53
58
  cloudBackend = backend;
54
59
  }
55
60
  const executorId = `${os.hostname()}-${randomUUID().slice(0, 8)}`;
56
- 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})`);
57
63
  this.log('Polling for jobs...');
58
64
  const state = { shuttingDown: false };
59
65
  const shutdown = () => {
@@ -64,42 +70,46 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
64
70
  process.on('SIGTERM', shutdown);
65
71
  const activeJobs = new Set();
66
72
  const hooks = { log: (m) => this.log(m), warn: (m) => this.warn(m) };
67
- while (!state.shuttingDown) {
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) {
68
84
  try {
69
- if (activeJobs.size >= concurrency) {
70
- // eslint-disable-next-line no-await-in-loop
71
- await Promise.race(activeJobs);
72
- continue;
73
- }
74
85
  // eslint-disable-next-line no-await-in-loop
75
- const pollResult = await cloudBackend.pollExecutorJobs({
86
+ const step = await this.runPollIteration({
87
+ activeJobs,
88
+ cloudBackend,
89
+ cloudUrl,
90
+ concurrency,
91
+ consecutive401s,
92
+ executor,
76
93
  executorId,
94
+ hereyaToken,
95
+ hooks,
96
+ idleTimeout,
97
+ lastJobAt,
98
+ lastSuccessfulPollAt,
99
+ maxConsecutive401s: MAX_CONSECUTIVE_401S,
77
100
  workspace: flags.workspace,
78
101
  });
79
- if (!pollResult.success) {
80
- if ('unauthorized' in pollResult && pollResult.unauthorized && hereyaToken) {
81
- this.log('Access token expired. Re-authenticating...');
82
- clearBackend();
83
- // eslint-disable-next-line no-await-in-loop
84
- cloudBackend = await createAuthenticatedBackend(hereyaToken, cloudUrl);
85
- continue;
86
- }
87
- this.warn(`Poll error: ${pollResult.reason}`);
88
- // eslint-disable-next-line no-await-in-loop
89
- await new Promise(resolve => {
90
- setTimeout(resolve, 5000);
91
- });
92
- 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;
93
110
  }
94
- if (!pollResult.job) {
95
- continue;
96
- }
97
- const { job } = pollResult;
98
- this.log(`Received job ${job.id} (${job.type}) [${activeJobs.size + 1}/${concurrency} slots used]`);
99
- const jobPromise = dispatchJob(job, executor, cloudBackend, hooks).finally(() => {
100
- activeJobs.delete(jobPromise);
101
- });
102
- activeJobs.add(jobPromise);
111
+ if (step.idleShutdown)
112
+ break;
103
113
  }
104
114
  catch (error) {
105
115
  this.warn(`Executor error: ${error.message}`);
@@ -114,6 +124,12 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
114
124
  await Promise.allSettled(activeJobs);
115
125
  }
116
126
  this.log('Executor stopped.');
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);
132
+ }
117
133
  }
118
134
  // Test-friendly delegators kept on the class for backwards compatibility with
119
135
  // tests that call `(cmd as any).executeInitJob(...)` / `executeAppJob(...)`.
@@ -123,4 +139,71 @@ Set the HEREYA_CLOUD_URL environment variable to target a specific hereya cloud
123
139
  executeInitJob(job, executor, logger) {
124
140
  return executeInitJob(job, executor, logger);
125
141
  }
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 };
147
+ }
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 };
153
+ }
154
+ this.warn(`Unauthorized poll (attempt ${consecutive401s}/${max}); no HEREYA_TOKEN available to re-authenticate.`);
155
+ return { exit: false };
156
+ }
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,
175
+ });
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);
184
+ });
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 };
199
+ }
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 };
206
+ }
207
+ return { consecutive401s, lastJobAt, lastSuccessfulPollAt };
208
+ }
126
209
  }
@@ -4,7 +4,10 @@ export default class WorkspaceExecutorInstall extends Command {
4
4
  static flags: {
5
5
  debug: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
6
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ mode: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ parameter: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
9
  workspace: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
10
  };
9
11
  run(): Promise<void>;
12
+ private parseParameterFlag;
10
13
  }
@@ -5,12 +5,27 @@ import { getBackend } from '../../../../backend/index.js';
5
5
  import { getExecutor } from '../../../../executor/index.js';
6
6
  import { getLogger, getLogPath, isDebug, setDebug } from '../../../../lib/log.js';
7
7
  import { delay } from '../../../../lib/shell.js';
8
- const DEFAULT_EXECUTOR_PACKAGE = 'hereya/remote-executor-aws';
8
+ const ALWAYS_ON_PACKAGE = 'hereya/remote-executor-aws';
9
+ const EPHEMERAL_PACKAGE = 'hereya/aws-executor-broker';
9
10
  export default class WorkspaceExecutorInstall extends Command {
10
- static description = 'Install a remote executor into a workspace';
11
+ static description = `Install a remote executor into a workspace.
12
+
13
+ Two modes are supported:
14
+ - ephemeral (default): provisions hereya/aws-executor-broker (Lambda + on-demand EC2).
15
+ - always-on: provisions hereya/remote-executor-aws (legacy always-on EC2).`;
11
16
  static flags = {
12
17
  debug: Flags.boolean({ default: false, description: 'enable debug mode' }),
13
18
  force: Flags.boolean({ char: 'f', default: false, description: 'force reinstall even if executor is already installed' }),
19
+ mode: Flags.string({
20
+ default: 'ephemeral',
21
+ description: 'executor mode: ephemeral (Lambda + on-demand EC2) or always-on (legacy)',
22
+ options: ['ephemeral', 'always-on'],
23
+ }),
24
+ parameter: Flags.string({
25
+ char: 'p',
26
+ description: "parameter for the package, in the form of 'key=value'. Can be specified multiple times.",
27
+ multiple: true,
28
+ }),
14
29
  workspace: Flags.string({
15
30
  char: 'w',
16
31
  description: 'name of the workspace',
@@ -21,84 +36,184 @@ export default class WorkspaceExecutorInstall extends Command {
21
36
  const { flags } = await this.parse(WorkspaceExecutorInstall);
22
37
  setDebug(flags.debug);
23
38
  const myLogger = new ListrLogger({ useIcons: false });
39
+ const isEphemeral = flags.mode === 'ephemeral';
40
+ const executorPackage = isEphemeral ? EPHEMERAL_PACKAGE : ALWAYS_ON_PACKAGE;
41
+ const userParameters = this.parseParameterFlag(flags.parameter);
42
+ const reservedKeys = ['workspaceId', 'workspaceName'];
43
+ for (const reserved of reservedKeys) {
44
+ if (Object.hasOwn(userParameters, reserved)) {
45
+ this.error(`--parameter ${reserved}=... is not allowed: this value is auto-inferred from the workspace and overriding it would corrupt the broker registration`);
46
+ }
47
+ }
48
+ const validateTask = {
49
+ async task(ctx) {
50
+ const backend = await getBackend();
51
+ if (!(backend instanceof CloudBackend)) {
52
+ throw new TypeError('Remote executor requires cloud backend. Run `hereya login` first.');
53
+ }
54
+ const workspace$ = await backend.getWorkspace(flags.workspace);
55
+ if (!workspace$.found) {
56
+ throw new Error(`Workspace ${flags.workspace} not found`);
57
+ }
58
+ if (workspace$.hasError) {
59
+ throw new Error(workspace$.error);
60
+ }
61
+ if (workspace$.workspace.hasExecutor && !flags.force) {
62
+ throw new Error(`Workspace ${flags.workspace} already has an executor installed. Use --force to reinstall.`);
63
+ }
64
+ ctx.workspaceId = workspace$.workspace.id;
65
+ await delay(500);
66
+ },
67
+ title: 'Validating workspace',
68
+ };
69
+ const provisionAlwaysOnTasks = [
70
+ {
71
+ async task(ctx) {
72
+ const backend = await getBackend();
73
+ const tokenResult = await backend.generateExecutorToken({
74
+ workspace: flags.workspace,
75
+ });
76
+ if (!tokenResult.success) {
77
+ throw new Error(`Failed to generate executor token: ${tokenResult.reason}`);
78
+ }
79
+ ctx.executorToken = tokenResult.token;
80
+ await delay(500);
81
+ },
82
+ title: 'Generating executor token',
83
+ },
84
+ {
85
+ rendererOptions: {
86
+ persistentOutput: isDebug(),
87
+ },
88
+ async task(ctx, task) {
89
+ const executor$ = getExecutor();
90
+ if (!executor$.success) {
91
+ throw new Error(executor$.reason);
92
+ }
93
+ const { executor } = executor$;
94
+ // Merge precedence (lowest -> highest):
95
+ // 1. --parameter key=value (catch-all for any remote-executor-aws inputs)
96
+ // 2. auto-inferred (EXECUTOR_TOKEN, WORKSPACE) — cloud-derived, must not be overridden
97
+ const parameters = {
98
+ ...userParameters,
99
+ EXECUTOR_TOKEN: ctx.executorToken,
100
+ WORKSPACE: flags.workspace,
101
+ };
102
+ const provisionOutput = await executor.provision({
103
+ logger: getLogger(task),
104
+ package: executorPackage,
105
+ parameters,
106
+ skipDeploy: true,
107
+ workspace: flags.workspace,
108
+ });
109
+ if (!provisionOutput.success) {
110
+ throw new Error(provisionOutput.reason);
111
+ }
112
+ },
113
+ title: 'Provisioning remote executor infrastructure',
114
+ },
115
+ {
116
+ async task() {
117
+ const backend = await getBackend();
118
+ await backend.updateWorkspace({
119
+ hasExecutor: true,
120
+ name: flags.workspace,
121
+ });
122
+ await delay(500);
123
+ },
124
+ title: 'Registering executor on workspace',
125
+ },
126
+ ];
127
+ const provisionEphemeralTasks = [
128
+ {
129
+ rendererOptions: {
130
+ persistentOutput: isDebug(),
131
+ },
132
+ async task(ctx, task) {
133
+ const executor$ = getExecutor();
134
+ if (!executor$.success) {
135
+ throw new Error(executor$.reason);
136
+ }
137
+ const { executor } = executor$;
138
+ const hereyaCloudUrl = process.env.HEREYA_CLOUD_URL || 'https://cloud.hereya.dev';
139
+ // Merge precedence (lowest -> highest):
140
+ // 1. auto-inferred (hereyaCloudUrl, workspaceId, workspaceName)
141
+ // 2. --parameter key=value (can override hereyaCloudUrl; workspaceId/workspaceName are
142
+ // rejected up-front, so we re-pin them here defensively)
143
+ const parameters = {
144
+ hereyaCloudUrl,
145
+ ...userParameters,
146
+ workspaceId: ctx.workspaceId,
147
+ workspaceName: flags.workspace,
148
+ };
149
+ const provisionOutput = await executor.provision({
150
+ logger: getLogger(task),
151
+ package: executorPackage,
152
+ parameters,
153
+ skipDeploy: true,
154
+ workspace: flags.workspace,
155
+ });
156
+ if (!provisionOutput.success) {
157
+ throw new Error(provisionOutput.reason);
158
+ }
159
+ ctx.brokerOutputs = provisionOutput.env;
160
+ },
161
+ title: 'Provisioning ephemeral executor broker infrastructure',
162
+ },
163
+ {
164
+ async task(ctx) {
165
+ const outputs = ctx.brokerOutputs ?? {};
166
+ const { brokerVersion, brokerWebhookUrl } = outputs;
167
+ if (!brokerWebhookUrl) {
168
+ throw new Error('Executor broker package did not return brokerWebhookUrl output');
169
+ }
170
+ if (!brokerVersion) {
171
+ throw new Error('Executor broker package did not return brokerVersion output');
172
+ }
173
+ const metadata = {
174
+ awsAccountId: outputs.awsAccountId,
175
+ brokerLambdaArn: outputs.brokerLambdaArn,
176
+ ec2InstanceId: outputs.ec2InstanceId,
177
+ ec2LaunchTemplateId: outputs.ec2LaunchTemplateId,
178
+ invokerRoleArn: outputs.invokerRoleArn,
179
+ region: outputs.region,
180
+ };
181
+ const backend = await getBackend();
182
+ const result = await backend.registerExecutorBroker({
183
+ brokerVersion,
184
+ brokerWebhookUrl,
185
+ metadata,
186
+ provider: 'aws',
187
+ workspace: flags.workspace,
188
+ });
189
+ if (!result.success) {
190
+ throw new Error(`Failed to register executor broker: ${result.reason}`);
191
+ }
192
+ await delay(500);
193
+ },
194
+ title: 'Registering executor broker with hereya cloud',
195
+ },
196
+ {
197
+ async task() {
198
+ const backend = await getBackend();
199
+ await backend.updateWorkspace({
200
+ hasExecutor: true,
201
+ name: flags.workspace,
202
+ });
203
+ await delay(500);
204
+ },
205
+ title: 'Marking workspace executor-enabled',
206
+ },
207
+ ];
208
+ const subTasks = isEphemeral
209
+ ? [validateTask, ...provisionEphemeralTasks]
210
+ : [validateTask, ...provisionAlwaysOnTasks];
24
211
  const task = new Listr([
25
212
  {
26
213
  async task(_ctx, task) {
27
- return task.newListr([
28
- {
29
- async task() {
30
- const backend = await getBackend();
31
- if (!(backend instanceof CloudBackend)) {
32
- throw new TypeError('Remote executor requires cloud backend. Run `hereya login` first.');
33
- }
34
- const workspace$ = await backend.getWorkspace(flags.workspace);
35
- if (!workspace$.found) {
36
- throw new Error(`Workspace ${flags.workspace} not found`);
37
- }
38
- if (workspace$.hasError) {
39
- throw new Error(workspace$.error);
40
- }
41
- if (workspace$.workspace.hasExecutor && !flags.force) {
42
- throw new Error(`Workspace ${flags.workspace} already has an executor installed. Use --force to reinstall.`);
43
- }
44
- await delay(500);
45
- },
46
- title: 'Validating workspace',
47
- },
48
- {
49
- async task(ctx) {
50
- const backend = await getBackend();
51
- const tokenResult = await backend.generateExecutorToken({
52
- workspace: flags.workspace,
53
- });
54
- if (!tokenResult.success) {
55
- throw new Error(`Failed to generate executor token: ${tokenResult.reason}`);
56
- }
57
- ctx.executorToken = tokenResult.token;
58
- await delay(500);
59
- },
60
- title: 'Generating executor token',
61
- },
62
- {
63
- rendererOptions: {
64
- persistentOutput: isDebug(),
65
- },
66
- async task(ctx, task) {
67
- const executor$ = getExecutor();
68
- if (!executor$.success) {
69
- throw new Error(executor$.reason);
70
- }
71
- const { executor } = executor$;
72
- const provisionOutput = await executor.provision({
73
- logger: getLogger(task),
74
- package: DEFAULT_EXECUTOR_PACKAGE,
75
- parameters: {
76
- EXECUTOR_TOKEN: ctx.executorToken,
77
- WORKSPACE: flags.workspace,
78
- },
79
- skipDeploy: true,
80
- workspace: flags.workspace,
81
- });
82
- if (!provisionOutput.success) {
83
- throw new Error(provisionOutput.reason);
84
- }
85
- },
86
- title: 'Provisioning remote executor infrastructure',
87
- },
88
- {
89
- async task() {
90
- const backend = await getBackend();
91
- await backend.updateWorkspace({
92
- hasExecutor: true,
93
- name: flags.workspace,
94
- });
95
- await delay(500);
96
- },
97
- title: 'Registering executor on workspace',
98
- },
99
- ], { concurrent: false, rendererOptions: { collapseSubtasks: !isDebug() } });
214
+ return task.newListr(subTasks, { concurrent: false, rendererOptions: { collapseSubtasks: !isDebug() } });
100
215
  },
101
- title: `Installing executor on workspace ${flags.workspace}`,
216
+ title: `Installing executor on workspace ${flags.workspace} (mode: ${flags.mode})`,
102
217
  },
103
218
  ], { concurrent: false });
104
219
  try {
@@ -109,4 +224,23 @@ export default class WorkspaceExecutorInstall extends Command {
109
224
  this.error(`${error.message}\n\nSee ${getLogPath()} for more details`);
110
225
  }
111
226
  }
227
+ parseParameterFlag(values) {
228
+ const result = {};
229
+ if (!values || values.length === 0) {
230
+ return result;
231
+ }
232
+ for (const raw of values) {
233
+ const eqIdx = raw.indexOf('=');
234
+ if (eqIdx === -1) {
235
+ this.error(`Invalid --parameter value '${raw}': expected format 'key=value'`);
236
+ }
237
+ const key = raw.slice(0, eqIdx);
238
+ const value = raw.slice(eqIdx + 1);
239
+ if (key.length === 0) {
240
+ this.error(`Invalid --parameter value '${raw}': key must not be empty`);
241
+ }
242
+ result[key] = value;
243
+ }
244
+ return result;
245
+ }
112
246
  }
@@ -3,6 +3,7 @@ export default class WorkspaceExecutorUninstall extends Command {
3
3
  static description: string;
4
4
  static flags: {
5
5
  debug: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
+ mode: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
6
7
  workspace: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
8
  };
8
9
  run(): Promise<void>;