@faable/faable 3.0.0 → 3.1.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
CHANGED
|
@@ -28,7 +28,7 @@ npm i -g @faable/faable
|
|
|
28
28
|
| Group | What it does | Docs |
|
|
29
29
|
| --- | --- | --- |
|
|
30
30
|
| `faable login` / `whoami` / `logout` | Session management (device flow; CI uses OIDC, no login needed) | [Authentication](https://faable.com/docs/cli#authentication) |
|
|
31
|
-
| `faable deploy` |
|
|
31
|
+
| `faable deploy` | `launch` (the default: a bare `faable deploy` deploys the current directory), then `status`, `logs`, `deployments`, `inspect`, `trigger`, `redeploy`, `cancel`, `open`, `list` | [Deployment](https://faable.com/docs/cli#deployment) |
|
|
32
32
|
| `faable deploy secrets` | Environment variables: `list`, `set` (`KEY=VALUE` or `--env-file`), `rm` | [Secrets](https://faable.com/docs/cli#secrets) |
|
|
33
33
|
| `faable deploy domains` | Custom domains: `add` (prints the CNAME), `list`, `check`, `rm` | [Domains](https://faable.com/docs/cli#domains) |
|
|
34
34
|
| `faable deploy waf` | Edge rules: `block` (403), `sink` (404 without waking the app), `list`, `rm` | [Edge rules](https://faable.com/docs/cli#edge-rules-waf) |
|
|
@@ -45,7 +45,8 @@ to `jq`. The complete table is in the
|
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
faable login
|
|
48
|
-
faable deploy # deploy the current directory
|
|
48
|
+
faable deploy # deploy the current directory (= deploy launch)
|
|
49
|
+
faable deploy launch --app app_x # deploy another app, from anywhere
|
|
49
50
|
faable deploy status # phase, URL and detected stack
|
|
50
51
|
faable deploy logs --build # build output (--follow to tail)
|
|
51
52
|
faable deploy secrets set KEY=value
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { log } from '../../log.js';
|
|
2
|
+
|
|
3
|
+
// `faable deploy` with nothing to deploy has to show what `deploy` CAN do —
|
|
4
|
+
// the same way `faable auth` lists its subcommands. yargs only hands the
|
|
5
|
+
// parser to a builder, never to a handler, so the `deploy` builder parks its
|
|
6
|
+
// level here for `launch` to print.
|
|
7
|
+
let deploy_level = null;
|
|
8
|
+
const capture_deploy_help = (yargs) => {
|
|
9
|
+
deploy_level = yargs;
|
|
10
|
+
return yargs;
|
|
11
|
+
};
|
|
12
|
+
const show_deploy_help = () => {
|
|
13
|
+
if (deploy_level)
|
|
14
|
+
deploy_level.showHelp(help => log.info(help));
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export { capture_deploy_help, show_deploy_help };
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import prompts from 'prompts';
|
|
2
|
-
import { requireApi } from '../../api/context.js';
|
|
3
|
-
import { Configuration } from '../../lib/Configuration.js';
|
|
4
|
-
import { log } from '../../log.js';
|
|
5
1
|
import { link } from '../link/index.js';
|
|
6
2
|
import { domains } from './domains/index.js';
|
|
7
|
-
import {
|
|
3
|
+
import { capture_deploy_help } from './help.js';
|
|
8
4
|
import { cancel } from './inspect/cancel.js';
|
|
9
5
|
import { deployments } from './inspect/deployments.js';
|
|
10
6
|
import { inspect } from './inspect/inspect.js';
|
|
@@ -14,203 +10,46 @@ import { open_app } from './inspect/open.js';
|
|
|
14
10
|
import { redeploy } from './inspect/redeploy.js';
|
|
15
11
|
import { status } from './inspect/status.js';
|
|
16
12
|
import { trigger } from './inspect/trigger.js';
|
|
17
|
-
import {
|
|
18
|
-
import { deploy_remote } from './remote/index.js';
|
|
19
|
-
import { resolve_app_id } from './resolve_app_id.js';
|
|
13
|
+
import { launch } from './launch.js';
|
|
20
14
|
import { secrets } from './secrets/index.js';
|
|
21
|
-
import { is_superseded } from './superseded.js';
|
|
22
15
|
import { waf } from './waf/index.js';
|
|
23
16
|
|
|
17
|
+
// `faable deploy` — the deploy product. A command group and nothing else: it
|
|
18
|
+
// holds no handler of its own, so every verb is a subcommand and the
|
|
19
|
+
// hierarchy has no exception to remember. Deploying is `launch`, registered
|
|
20
|
+
// as the group's default ($0) so `faable deploy` on its own still deploys the
|
|
21
|
+
// current directory — the shortcut is an alias, not a second code path.
|
|
22
|
+
//
|
|
23
|
+
// The group deliberately has no positional. An app id typed where a
|
|
24
|
+
// subcommand belongs (`faable deploy app_xxx secrets list`) used to be
|
|
25
|
+
// swallowed by an `[app_id]` positional and silently DEPLOYED instead of
|
|
26
|
+
// running the subcommand; .strictCommands() in src/index.ts now rejects it as
|
|
27
|
+
// an unknown command. Another app is targeted with --app on `launch` and on
|
|
28
|
+
// every subcommand alike.
|
|
24
29
|
const deploy = {
|
|
25
|
-
// No positional: `deploy` takes ONLY subcommands. An app_id typed where a
|
|
26
|
-
// subcommand belongs (`faable deploy app_xxx secrets list`) used to be
|
|
27
|
-
// swallowed by an `[app_id]` positional and silently DEPLOYED instead of
|
|
28
|
-
// running the subcommand; with no positional, .strictCommands() in
|
|
29
|
-
// src/index.ts rejects it as an unknown command. Target another app with
|
|
30
|
-
// --app, exactly like every subcommand does.
|
|
31
30
|
command: 'deploy',
|
|
32
31
|
// Name the subcommand groups so `faable --help` makes them discoverable
|
|
33
32
|
// without digging into `faable deploy --help`.
|
|
34
33
|
describe: 'Deploy a faable app and manage it (secrets, domains, logs, deployments…)',
|
|
35
|
-
builder: yargs =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
description: 'App Identifier (defaults to the app linked to this repository)'
|
|
55
|
-
})
|
|
56
|
-
.option('workdir', {
|
|
57
|
-
alias: 'w',
|
|
58
|
-
type: 'string',
|
|
59
|
-
description: 'Working directory'
|
|
60
|
-
})
|
|
61
|
-
.option('release', {
|
|
62
|
-
type: 'string',
|
|
63
|
-
description: 'Release version to record on the deployment (injected as FAABLE_RELEASE). Defaults to FAABLE_RELEASE env or the latest git tag'
|
|
64
|
-
})
|
|
65
|
-
.option('yes', {
|
|
66
|
-
alias: 'y',
|
|
67
|
-
type: 'boolean',
|
|
68
|
-
default: false,
|
|
69
|
-
description: 'Skip the confirmation prompt (only asked in interactive terminals — CI is unaffected)'
|
|
70
|
-
})
|
|
71
|
-
.example('$0 deploy', 'Deploy the app linked to the current directory')
|
|
72
|
-
.example('$0 deploy --app app_a1b2c3', 'Deploy another app (monorepo: several apps, one repo)')
|
|
73
|
-
.showHelpOnFail(false);
|
|
74
|
-
},
|
|
75
|
-
handler: async (args) => {
|
|
76
|
-
const workdir = args.workdir || process.cwd();
|
|
77
|
-
// Pass the explicit app target to the OIDC exchange so a monorepo (several
|
|
78
|
-
// apps, one repo) can be disambiguated in CI.
|
|
79
|
-
const ctx = await requireApi(args.app);
|
|
80
|
-
const { api } = ctx;
|
|
81
|
-
const config = Configuration.instance().deployConfig();
|
|
82
|
-
const app_id = await resolve_app_id(args.app, ctx.appId, api, workdir);
|
|
83
|
-
const app = await api.getApp(app_id);
|
|
84
|
-
// Confirm which app/dir is about to go out. Only in a real terminal — CI
|
|
85
|
-
// (non-TTY) deploys unattended, so pipelines need no changes.
|
|
86
|
-
if (!args.yes && process.stdout.isTTY) {
|
|
87
|
-
const { confirm } = await prompts({
|
|
88
|
-
type: 'toggle',
|
|
89
|
-
name: 'confirm',
|
|
90
|
-
message: `Deploy "${app.name}" (${app.id}) from ${workdir}?`,
|
|
91
|
-
initial: false,
|
|
92
|
-
active: 'yes',
|
|
93
|
-
inactive: 'no'
|
|
94
|
-
});
|
|
95
|
-
if (!confirm) {
|
|
96
|
-
log.info('Cancelled.');
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
// Monorepo Root Directory — single precedence rule everywhere
|
|
101
|
-
// (arch/deploy/root-dir-faable-json.md): App.root_dir (platform
|
|
102
|
-
// override, exceptional) > repo faable.json rootDir (the supported
|
|
103
|
-
// user option) > repo root. Matches the remote builder.
|
|
104
|
-
if (app.root_dir) {
|
|
105
|
-
config.rootDir = app.root_dir;
|
|
106
|
-
log.info(`📁 Root directory: ${app.root_dir} (platform override)`);
|
|
107
|
-
}
|
|
108
|
-
else if (config.rootDir) {
|
|
109
|
-
log.info(`📁 Root directory: ${config.rootDir} (from faable.json)`);
|
|
110
|
-
}
|
|
111
|
-
// Capture the commit/ref/actor so the deployment records which commit
|
|
112
|
-
// it came from and who pushed it (env in CI, git fallback locally).
|
|
113
|
-
const git = await git_context({ workdir });
|
|
114
|
-
// Propose the release version (--release > FAABLE_RELEASE > git tag).
|
|
115
|
-
// Optional: when absent the platform injects no FAABLE_RELEASE and the
|
|
116
|
-
// app falls back to its own version source.
|
|
117
|
-
const proposed = await propose_release({ workdir, explicit: args.release });
|
|
118
|
-
if (proposed) {
|
|
119
|
-
log.info(`🏷️ Release: ${proposed.release} (from ${proposed.source})`);
|
|
120
|
-
}
|
|
121
|
-
// Remote build only (arch/deploy/remote-artifact-default-cutover.md): the
|
|
122
|
-
// CLI no longer builds — it uploads the source and the platform builds
|
|
123
|
-
// server-side (framework detection, buildpacks, artifact/image output all
|
|
124
|
-
// live in the builder). No Docker, no local fallback: a rejected admission
|
|
125
|
-
// (build_mode=local opt-out, or the global kill-switch off) or a build
|
|
126
|
-
// error throws and exits red.
|
|
127
|
-
log.info(`🚀 Deploying "${app.name}" (${app.id})`);
|
|
128
|
-
const deployment = await deploy_remote({
|
|
129
|
-
api,
|
|
130
|
-
app,
|
|
131
|
-
git,
|
|
132
|
-
release: proposed?.release,
|
|
133
|
-
workdir
|
|
134
|
-
});
|
|
135
|
-
const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
|
|
136
|
-
log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
|
|
137
|
-
log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
|
|
138
|
-
// Wait (up to 5 minutes) for the deployment to be promoted (live)
|
|
139
|
-
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
140
|
-
const timeoutMs = 5 * 60 * 1000;
|
|
141
|
-
const intervalMs = 5000;
|
|
142
|
-
const start = Date.now();
|
|
143
|
-
log.info(`⏳ Waiting for deployment to be promoted...`);
|
|
144
|
-
let promoted = false;
|
|
145
|
-
let failure = null;
|
|
146
|
-
let superseded = null;
|
|
147
|
-
// The pointer at wait-start is the PREVIOUS deployment (older than ours)
|
|
148
|
-
// — only a pointer CHANGE to something that isn't us needs a look.
|
|
149
|
-
const initialActiveId = app.status?.deployment;
|
|
150
|
-
while (Date.now() - start < timeoutMs) {
|
|
151
|
-
await wait(intervalMs);
|
|
152
|
-
try {
|
|
153
|
-
const current = await api.getApp(app.id);
|
|
154
|
-
if (current.status?.deployment === deployment.id) {
|
|
155
|
-
promoted = true;
|
|
156
|
-
break;
|
|
157
|
-
}
|
|
158
|
-
// Superseded: the pointer moved to a deployment at least as new as
|
|
159
|
-
// ours (twin workflows on the same push, or a rapid follow-up
|
|
160
|
-
// deploy). It only moves forward — waiting longer can never succeed.
|
|
161
|
-
const activeId = current.status?.deployment;
|
|
162
|
-
if (activeId && activeId !== initialActiveId) {
|
|
163
|
-
const active = await api.getDeployment(activeId).catch(() => null);
|
|
164
|
-
if (active &&
|
|
165
|
-
is_superseded(deployment, active)) {
|
|
166
|
-
superseded = activeId;
|
|
167
|
-
break;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
// Watch for a terminal runtime failure so we fail fast (and red)
|
|
171
|
-
// instead of timing out green. The controller marks a crash-looping
|
|
172
|
-
// deployment ERROR — with a reason (e.g. a missing module) — within
|
|
173
|
-
// ~1min, long before this 5min promotion timeout.
|
|
174
|
-
const dep = await api.getDeployment(deployment.id);
|
|
175
|
-
const phase = dep.status?.phase;
|
|
176
|
-
if (phase === 'ERROR' || phase === 'BUILD_ERROR') {
|
|
177
|
-
failure = { phase, reason: dep.status?.reason };
|
|
178
|
-
break;
|
|
179
|
-
}
|
|
180
|
-
// CANCELED: the platform superseded this build before it produced a
|
|
181
|
-
// runnable (a sibling deployment of the app won the promotion).
|
|
182
|
-
if (phase === 'CANCELED') {
|
|
183
|
-
superseded = dep.status?.reason ?? 'canceled';
|
|
184
|
-
break;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
catch (_error) {
|
|
188
|
-
// Ignore transient errors while polling and keep waiting
|
|
189
|
-
log.debug(`Polling app status failed, retrying...`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
if (promoted) {
|
|
193
|
-
log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
|
|
194
|
-
}
|
|
195
|
-
else if (superseded) {
|
|
196
|
-
// Not a failure: the app IS live, just on a sibling deployment (e.g.
|
|
197
|
-
// duplicated workflows firing on the same push). Green exit — there is
|
|
198
|
-
// nothing for the user to fix in THIS run.
|
|
199
|
-
log.warn(`⚠️ Deployment superseded — a newer deployment of this app was promoted (${superseded}). The app is live: https://${app.url}`);
|
|
200
|
-
}
|
|
201
|
-
else if (failure) {
|
|
202
|
-
// Fail the command (non-zero exit → red GitHub Action) and surface the
|
|
203
|
-
// reason the controller captured so the user sees WHY without digging.
|
|
204
|
-
log.error(`❌ Deployment failed (${failure.phase}).`);
|
|
205
|
-
if (failure.reason)
|
|
206
|
-
log.error(failure.reason);
|
|
207
|
-
log.error(`Check the logs in the dashboard -> ${dashboard_url}`);
|
|
208
|
-
throw new Error(`Deployment ${deployment.id} failed with phase ${failure.phase}`);
|
|
209
|
-
}
|
|
210
|
-
else {
|
|
211
|
-
log.warn(`⌛ Timed out after 5min waiting for promotion. The deployment is still rolling out, check the dashboard -> ${dashboard_url}`);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
34
|
+
builder: yargs => capture_deploy_help(yargs
|
|
35
|
+
.command(launch)
|
|
36
|
+
.command(secrets)
|
|
37
|
+
.command(domains)
|
|
38
|
+
.command(waf)
|
|
39
|
+
.command(logs)
|
|
40
|
+
.command(status)
|
|
41
|
+
.command(apps_list)
|
|
42
|
+
.command(deployments)
|
|
43
|
+
.command(inspect)
|
|
44
|
+
.command(open_app)
|
|
45
|
+
.command(trigger)
|
|
46
|
+
.command(redeploy)
|
|
47
|
+
.command(cancel)
|
|
48
|
+
.command(link)
|
|
49
|
+
.showHelpOnFail(false)),
|
|
50
|
+
// Unreachable: `launch` is the default command, so yargs routes a bare
|
|
51
|
+
// `faable deploy` there before it ever gets here.
|
|
52
|
+
handler: () => { }
|
|
214
53
|
};
|
|
215
54
|
|
|
216
55
|
export { deploy };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import { requireApi } from '../../api/context.js';
|
|
3
|
+
import { Configuration } from '../../lib/Configuration.js';
|
|
4
|
+
import { log } from '../../log.js';
|
|
5
|
+
import { git_context } from './git_context.js';
|
|
6
|
+
import { show_deploy_help } from './help.js';
|
|
7
|
+
import { propose_release } from './release_version.js';
|
|
8
|
+
import { deploy_remote } from './remote/index.js';
|
|
9
|
+
import { find_app_id } from './resolve_app_id.js';
|
|
10
|
+
import { is_superseded } from './superseded.js';
|
|
11
|
+
|
|
12
|
+
// `faable deploy launch` — the deploy itself, and the default command of the
|
|
13
|
+
// `deploy` group, so a bare `faable deploy` runs exactly this. Every verb
|
|
14
|
+
// under `deploy` is a subcommand now, this one included: the hierarchy holds
|
|
15
|
+
// with no special case, and nothing can fall through to a deploy by accident
|
|
16
|
+
// (that is how `faable deploy <app_id> secrets list` used to deploy).
|
|
17
|
+
const launch = {
|
|
18
|
+
command: ['launch', '$0'],
|
|
19
|
+
describe: 'Deploy the current directory (the default: `faable deploy`)',
|
|
20
|
+
builder: yargs => yargs
|
|
21
|
+
.option('app', {
|
|
22
|
+
alias: 'a',
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'App Identifier (defaults to the app linked to this repository)'
|
|
25
|
+
})
|
|
26
|
+
.option('workdir', {
|
|
27
|
+
alias: 'w',
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Working directory'
|
|
30
|
+
})
|
|
31
|
+
.option('release', {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'Release version to record on the deployment (injected as FAABLE_RELEASE). Defaults to FAABLE_RELEASE env or the latest git tag'
|
|
34
|
+
})
|
|
35
|
+
.option('yes', {
|
|
36
|
+
alias: 'y',
|
|
37
|
+
type: 'boolean',
|
|
38
|
+
default: false,
|
|
39
|
+
description: 'Skip the confirmation prompt (only asked in interactive terminals — CI is unaffected)'
|
|
40
|
+
})
|
|
41
|
+
.example('$0 deploy', 'Deploy the app linked to the current directory')
|
|
42
|
+
.example('$0 deploy --app app_a1b2c3', 'Deploy another app (monorepo: several apps, one repo)')
|
|
43
|
+
.showHelpOnFail(false),
|
|
44
|
+
handler: async (args) => {
|
|
45
|
+
const workdir = args.workdir || process.cwd();
|
|
46
|
+
// Pass the explicit app target to the OIDC exchange so a monorepo (several
|
|
47
|
+
// apps, one repo) can be disambiguated in CI.
|
|
48
|
+
const ctx = await requireApi(args.app);
|
|
49
|
+
const { api } = ctx;
|
|
50
|
+
const config = Configuration.instance().deployConfig();
|
|
51
|
+
const app_id = await find_app_id(args.app, ctx.appId, api, workdir);
|
|
52
|
+
// Nothing to deploy from here. `faable deploy` is the entry point to the
|
|
53
|
+
// whole product, so an unlinked directory is a perfectly normal way to
|
|
54
|
+
// arrive: list what deploy can do (like `faable auth` does) instead of
|
|
55
|
+
// answering a bare `faable deploy` with an error and nothing else.
|
|
56
|
+
if (!app_id) {
|
|
57
|
+
show_deploy_help();
|
|
58
|
+
log.error(`❌ No app linked to ${workdir}. Connect a repository from the dashboard (or run "faable deploy link"), or target one with --app <app_id>.`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const app = await api.getApp(app_id);
|
|
62
|
+
// Confirm which app/dir is about to go out. Only in a real terminal — CI
|
|
63
|
+
// (non-TTY) deploys unattended, so pipelines need no changes.
|
|
64
|
+
if (!args.yes && process.stdout.isTTY) {
|
|
65
|
+
const { confirm } = await prompts({
|
|
66
|
+
type: 'toggle',
|
|
67
|
+
name: 'confirm',
|
|
68
|
+
message: `Deploy "${app.name}" (${app.id}) from ${workdir}?`,
|
|
69
|
+
initial: false,
|
|
70
|
+
active: 'yes',
|
|
71
|
+
inactive: 'no'
|
|
72
|
+
});
|
|
73
|
+
if (!confirm) {
|
|
74
|
+
log.info('Cancelled.');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Monorepo Root Directory — single precedence rule everywhere
|
|
79
|
+
// (arch/deploy/root-dir-faable-json.md): App.root_dir (platform
|
|
80
|
+
// override, exceptional) > repo faable.json rootDir (the supported
|
|
81
|
+
// user option) > repo root. Matches the remote builder.
|
|
82
|
+
if (app.root_dir) {
|
|
83
|
+
config.rootDir = app.root_dir;
|
|
84
|
+
log.info(`📁 Root directory: ${app.root_dir} (platform override)`);
|
|
85
|
+
}
|
|
86
|
+
else if (config.rootDir) {
|
|
87
|
+
log.info(`📁 Root directory: ${config.rootDir} (from faable.json)`);
|
|
88
|
+
}
|
|
89
|
+
// Capture the commit/ref/actor so the deployment records which commit
|
|
90
|
+
// it came from and who pushed it (env in CI, git fallback locally).
|
|
91
|
+
const git = await git_context({ workdir });
|
|
92
|
+
// Propose the release version (--release > FAABLE_RELEASE > git tag).
|
|
93
|
+
// Optional: when absent the platform injects no FAABLE_RELEASE and the
|
|
94
|
+
// app falls back to its own version source.
|
|
95
|
+
const proposed = await propose_release({ workdir, explicit: args.release });
|
|
96
|
+
if (proposed) {
|
|
97
|
+
log.info(`🏷️ Release: ${proposed.release} (from ${proposed.source})`);
|
|
98
|
+
}
|
|
99
|
+
// Remote build only (arch/deploy/remote-artifact-default-cutover.md): the
|
|
100
|
+
// CLI no longer builds — it uploads the source and the platform builds
|
|
101
|
+
// server-side (framework detection, buildpacks, artifact/image output all
|
|
102
|
+
// live in the builder). No Docker, no local fallback: a rejected admission
|
|
103
|
+
// (build_mode=local opt-out, or the global kill-switch off) or a build
|
|
104
|
+
// error throws and exits red.
|
|
105
|
+
log.info(`🚀 Deploying "${app.name}" (${app.id})`);
|
|
106
|
+
const deployment = await deploy_remote({
|
|
107
|
+
api,
|
|
108
|
+
app,
|
|
109
|
+
git,
|
|
110
|
+
release: proposed?.release,
|
|
111
|
+
workdir
|
|
112
|
+
});
|
|
113
|
+
const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
|
|
114
|
+
log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
|
|
115
|
+
log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
|
|
116
|
+
// Wait (up to 5 minutes) for the deployment to be promoted (live)
|
|
117
|
+
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
118
|
+
const timeoutMs = 5 * 60 * 1000;
|
|
119
|
+
const intervalMs = 5000;
|
|
120
|
+
const start = Date.now();
|
|
121
|
+
log.info(`⏳ Waiting for deployment to be promoted...`);
|
|
122
|
+
let promoted = false;
|
|
123
|
+
let failure = null;
|
|
124
|
+
let superseded = null;
|
|
125
|
+
// The pointer at wait-start is the PREVIOUS deployment (older than ours)
|
|
126
|
+
// — only a pointer CHANGE to something that isn't us needs a look.
|
|
127
|
+
const initialActiveId = app.status?.deployment;
|
|
128
|
+
while (Date.now() - start < timeoutMs) {
|
|
129
|
+
await wait(intervalMs);
|
|
130
|
+
try {
|
|
131
|
+
const current = await api.getApp(app.id);
|
|
132
|
+
if (current.status?.deployment === deployment.id) {
|
|
133
|
+
promoted = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
// Superseded: the pointer moved to a deployment at least as new as
|
|
137
|
+
// ours (twin workflows on the same push, or a rapid follow-up
|
|
138
|
+
// deploy). It only moves forward — waiting longer can never succeed.
|
|
139
|
+
const activeId = current.status?.deployment;
|
|
140
|
+
if (activeId && activeId !== initialActiveId) {
|
|
141
|
+
const active = await api.getDeployment(activeId).catch(() => null);
|
|
142
|
+
if (active &&
|
|
143
|
+
is_superseded(deployment, active)) {
|
|
144
|
+
superseded = activeId;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Watch for a terminal runtime failure so we fail fast (and red)
|
|
149
|
+
// instead of timing out green. The controller marks a crash-looping
|
|
150
|
+
// deployment ERROR — with a reason (e.g. a missing module) — within
|
|
151
|
+
// ~1min, long before this 5min promotion timeout.
|
|
152
|
+
const dep = await api.getDeployment(deployment.id);
|
|
153
|
+
const phase = dep.status?.phase;
|
|
154
|
+
if (phase === 'ERROR' || phase === 'BUILD_ERROR') {
|
|
155
|
+
failure = { phase, reason: dep.status?.reason };
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
// CANCELED: the platform superseded this build before it produced a
|
|
159
|
+
// runnable (a sibling deployment of the app won the promotion).
|
|
160
|
+
if (phase === 'CANCELED') {
|
|
161
|
+
superseded = dep.status?.reason ?? 'canceled';
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (_error) {
|
|
166
|
+
// Ignore transient errors while polling and keep waiting
|
|
167
|
+
log.debug(`Polling app status failed, retrying...`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (promoted) {
|
|
171
|
+
log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
|
|
172
|
+
}
|
|
173
|
+
else if (superseded) {
|
|
174
|
+
// Not a failure: the app IS live, just on a sibling deployment (e.g.
|
|
175
|
+
// duplicated workflows firing on the same push). Green exit — there is
|
|
176
|
+
// nothing for the user to fix in THIS run.
|
|
177
|
+
log.warn(`⚠️ Deployment superseded — a newer deployment of this app was promoted (${superseded}). The app is live: https://${app.url}`);
|
|
178
|
+
}
|
|
179
|
+
else if (failure) {
|
|
180
|
+
// Fail the command (non-zero exit → red GitHub Action) and surface the
|
|
181
|
+
// reason the controller captured so the user sees WHY without digging.
|
|
182
|
+
log.error(`❌ Deployment failed (${failure.phase}).`);
|
|
183
|
+
if (failure.reason)
|
|
184
|
+
log.error(failure.reason);
|
|
185
|
+
log.error(`Check the logs in the dashboard -> ${dashboard_url}`);
|
|
186
|
+
throw new Error(`Deployment ${deployment.id} failed with phase ${failure.phase}`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
log.warn(`⌛ Timed out after 5min waiting for promotion. The deployment is still rolling out, check the dashboard -> ${dashboard_url}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export { launch };
|
|
@@ -10,7 +10,12 @@ import { log } from '../../log.js';
|
|
|
10
10
|
// 4. locally — the app whose linked repository matches the git origin remote
|
|
11
11
|
// of the working directory (repos are connected in the dashboard when the
|
|
12
12
|
// app is created, or via `faable deploy link`)
|
|
13
|
-
|
|
13
|
+
//
|
|
14
|
+
// "No app here" is an answer, not an error — a bare `faable deploy` in an
|
|
15
|
+
// unlinked directory lists the subcommands instead of failing, and only the
|
|
16
|
+
// caller knows which of the two it wants. An AMBIGUOUS repository does throw:
|
|
17
|
+
// several apps is a question only the user can answer.
|
|
18
|
+
const find_app_id = async (explicit, ctxAppId, api, workdir = process.cwd()) => {
|
|
14
19
|
const app_id = explicit || ctxAppId || Configuration.instance().app_id;
|
|
15
20
|
if (app_id)
|
|
16
21
|
return app_id;
|
|
@@ -28,7 +33,14 @@ const resolve_app_id = async (explicit, ctxAppId, api, workdir = process.cwd())
|
|
|
28
33
|
throw new Error(`Repository ${repository} is linked to several apps: ${ids}. Pass one with --app <app_id>.`);
|
|
29
34
|
}
|
|
30
35
|
}
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
// The same, for callers that have nothing to offer without an app.
|
|
39
|
+
const resolve_app_id = async (explicit, ctxAppId, api, workdir = process.cwd()) => {
|
|
40
|
+
const app_id = await find_app_id(explicit, ctxAppId, api, workdir);
|
|
41
|
+
if (app_id)
|
|
42
|
+
return app_id;
|
|
31
43
|
throw new Error('No app linked to this repository. Link it from the dashboard (or run "faable deploy link"), or pass one with --app <app_id>.');
|
|
32
44
|
};
|
|
33
45
|
|
|
34
|
-
export { resolve_app_id };
|
|
46
|
+
export { find_app_id, resolve_app_id };
|