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.
- package/README.md +90 -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 +176 -76
- package/dist/commands/workspace/executor/uninstall/index.d.ts +1 -0
- package/dist/commands/workspace/executor/uninstall/index.js +29 -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 +102 -62
- 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,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
|
|
8
|
+
const EXECUTOR_PACKAGE = 'hereya/remote-executor-aws';
|
|
9
9
|
export default class WorkspaceExecutorInstall extends Command {
|
|
10
|
-
static description =
|
|
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>;
|