hereya-cli 0.90.1 → 0.92.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,26 @@ 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 EXECUTOR_PACKAGE = 'hereya/remote-executor-aws';
9
9
  export default class WorkspaceExecutorInstall extends Command {
10
- static description = 'Install a remote executor into a workspace';
10
+ static description = `Install a remote executor into a workspace.
11
+
12
+ Provisions hereya/remote-executor-aws. Two modes are supported:
13
+ - always-on (default): a long-lived EC2 polls hereya-cloud 24/7.
14
+ - ephemeral: ASG scaled to 0 with a broker Lambda + OIDC for on-demand wake.`;
11
15
  static flags = {
12
16
  debug: Flags.boolean({ default: false, description: 'enable debug mode' }),
13
17
  force: Flags.boolean({ char: 'f', default: false, description: 'force reinstall even if executor is already installed' }),
18
+ mode: Flags.string({
19
+ default: 'always-on',
20
+ description: 'executor mode: always-on (legacy long-lived EC2) or ephemeral (ASG min=0/max=1 with broker Lambda)',
21
+ options: ['ephemeral', 'always-on'],
22
+ }),
23
+ parameter: Flags.string({
24
+ char: 'p',
25
+ description: "parameter for the package, in the form of 'key=value'. Can be specified multiple times.",
26
+ multiple: true,
27
+ }),
14
28
  workspace: Flags.string({
15
29
  char: 'w',
16
30
  description: 'name of the workspace',
@@ -21,84 +35,151 @@ export default class WorkspaceExecutorInstall extends Command {
21
35
  const { flags } = await this.parse(WorkspaceExecutorInstall);
22
36
  setDebug(flags.debug);
23
37
  const myLogger = new ListrLogger({ useIcons: false });
38
+ const isEphemeral = flags.mode === 'ephemeral';
39
+ const userParameters = this.parseParameterFlag(flags.parameter);
40
+ // workspaceId/workspace are auto-inferred from the cloud workspace and must
41
+ // not be overridden by --parameter — overriding would corrupt the broker
42
+ // registration / IAM trust policy on the customer side.
43
+ const reservedKeys = ['workspaceId', 'workspace'];
44
+ for (const reserved of reservedKeys) {
45
+ if (Object.hasOwn(userParameters, reserved)) {
46
+ this.error(`--parameter ${reserved}=... is not allowed: this value is auto-inferred from the workspace and overriding it would corrupt the broker registration`);
47
+ }
48
+ }
49
+ const validateTask = {
50
+ async task(ctx) {
51
+ const backend = await getBackend();
52
+ if (!(backend instanceof CloudBackend)) {
53
+ throw new TypeError('Remote executor requires cloud backend. Run `hereya login` first.');
54
+ }
55
+ const workspace$ = await backend.getWorkspace(flags.workspace);
56
+ if (!workspace$.found) {
57
+ throw new Error(`Workspace ${flags.workspace} not found`);
58
+ }
59
+ if (workspace$.hasError) {
60
+ throw new Error(workspace$.error);
61
+ }
62
+ if (workspace$.workspace.hasExecutor && !flags.force) {
63
+ throw new Error(`Workspace ${flags.workspace} already has an executor installed. Use --force to reinstall.`);
64
+ }
65
+ ctx.workspaceId = workspace$.workspace.id;
66
+ await delay(500);
67
+ },
68
+ title: 'Validating workspace',
69
+ };
70
+ const generateTokenTask = {
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
+ const provisionTask = {
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
+ const hereyaCloudUrl = process.env.HEREYA_CLOUD_URL || 'https://cloud.hereya.dev';
95
+ // Merge precedence (lowest -> highest):
96
+ // 1. auto-inferred defaults (hereyaCloudUrl) — may be overridden by --parameter
97
+ // so callers can point a test stack at a non-default hereya-cloud
98
+ // 2. --parameter key=value (catch-all for any remote-executor-aws inputs)
99
+ // 3. mandatory auto-inferred (mode, EXECUTOR_TOKEN, WORKSPACE) — must not be
100
+ // overridable; mode is the user's --mode choice and the others are cloud-derived
101
+ // 4. ephemeral-only auto-inferred (workspaceId) — re-pinned defensively;
102
+ // reserved-key check above already rejects --parameter workspaceId/workspace
103
+ const parameters = {
104
+ hereyaCloudUrl,
105
+ ...userParameters,
106
+ EXECUTOR_TOKEN: ctx.executorToken,
107
+ mode: flags.mode,
108
+ WORKSPACE: flags.workspace,
109
+ };
110
+ if (isEphemeral) {
111
+ parameters.workspaceId = ctx.workspaceId;
112
+ }
113
+ const provisionOutput = await executor.provision({
114
+ logger: getLogger(task),
115
+ package: EXECUTOR_PACKAGE,
116
+ parameters,
117
+ skipDeploy: true,
118
+ workspace: flags.workspace,
119
+ });
120
+ if (!provisionOutput.success) {
121
+ throw new Error(provisionOutput.reason);
122
+ }
123
+ ctx.brokerOutputs = provisionOutput.env;
124
+ },
125
+ title: 'Provisioning remote executor infrastructure',
126
+ };
127
+ const registerBrokerTask = {
128
+ async task(ctx) {
129
+ const outputs = ctx.brokerOutputs ?? {};
130
+ const { brokerVersion, brokerWebhookUrl } = outputs;
131
+ if (!brokerWebhookUrl) {
132
+ throw new Error('Executor package did not return brokerWebhookUrl output (required for mode=ephemeral)');
133
+ }
134
+ if (!brokerVersion) {
135
+ throw new Error('Executor package did not return brokerVersion output (required for mode=ephemeral)');
136
+ }
137
+ const metadata = {
138
+ asgName: outputs.executorAsgName ?? outputs.asgName,
139
+ awsAccountId: outputs.awsAccountId,
140
+ brokerLambdaArn: outputs.brokerLambdaArn,
141
+ invokerRoleArn: outputs.invokerRoleArn,
142
+ region: outputs.region,
143
+ };
144
+ const backend = await getBackend();
145
+ const result = await backend.registerExecutorBroker({
146
+ brokerVersion,
147
+ brokerWebhookUrl,
148
+ metadata,
149
+ provider: 'aws',
150
+ workspace: flags.workspace,
151
+ });
152
+ if (!result.success) {
153
+ throw new Error(`Failed to register executor broker: ${result.reason}`);
154
+ }
155
+ await delay(500);
156
+ },
157
+ title: 'Registering executor broker with hereya cloud',
158
+ };
159
+ const markExecutorEnabledTask = {
160
+ async task() {
161
+ const backend = await getBackend();
162
+ await backend.updateWorkspace({
163
+ hasExecutor: true,
164
+ name: flags.workspace,
165
+ });
166
+ await delay(500);
167
+ },
168
+ title: 'Registering executor on workspace',
169
+ };
170
+ const subTasks = [
171
+ validateTask,
172
+ generateTokenTask,
173
+ provisionTask,
174
+ ...(isEphemeral ? [registerBrokerTask] : []),
175
+ markExecutorEnabledTask,
176
+ ];
24
177
  const task = new Listr([
25
178
  {
26
179
  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() } });
180
+ return task.newListr(subTasks, { concurrent: false, rendererOptions: { collapseSubtasks: !isDebug() } });
100
181
  },
101
- title: `Installing executor on workspace ${flags.workspace}`,
182
+ title: `Installing executor on workspace ${flags.workspace} (mode: ${flags.mode})`,
102
183
  },
103
184
  ], { concurrent: false });
104
185
  try {
@@ -109,4 +190,23 @@ export default class WorkspaceExecutorInstall extends Command {
109
190
  this.error(`${error.message}\n\nSee ${getLogPath()} for more details`);
110
191
  }
111
192
  }
193
+ parseParameterFlag(values) {
194
+ const result = {};
195
+ if (!values || values.length === 0) {
196
+ return result;
197
+ }
198
+ for (const raw of values) {
199
+ const eqIdx = raw.indexOf('=');
200
+ if (eqIdx === -1) {
201
+ this.error(`Invalid --parameter value '${raw}': expected format 'key=value'`);
202
+ }
203
+ const key = raw.slice(0, eqIdx);
204
+ const value = raw.slice(eqIdx + 1);
205
+ if (key.length === 0) {
206
+ this.error(`Invalid --parameter value '${raw}': key must not be empty`);
207
+ }
208
+ result[key] = value;
209
+ }
210
+ return result;
211
+ }
112
212
  }
@@ -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>;