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.
- package/README.md +88 -75
- 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.d.ts +3 -0
- package/dist/backend/cloud/cloud-backend.js +7 -0
- package/dist/backend/common.d.ts +2 -2
- package/dist/commands/executor/start/index.d.ts +3 -0
- package/dist/commands/executor/start/index.js +115 -32
- 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/package/index.d.ts +22 -22
- package/oclif.manifest.json +94 -54
- package/package.json +1 -1
|
@@ -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
|
}
|
package/dist/backend/common.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 (
|
|
95
|
-
|
|
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
|
|
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 =
|
|
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>;
|