@faable/faable 1.14.1 → 1.15.1
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/dist/api/FaableApi.js
CHANGED
|
@@ -79,11 +79,25 @@ class FaableApi {
|
|
|
79
79
|
async createDeployment(params) {
|
|
80
80
|
return data(this.client.post(`/deployment`, params));
|
|
81
81
|
}
|
|
82
|
-
// Phase transitions the CLI owns (
|
|
83
|
-
// Runtime phases stay controller-territory.
|
|
82
|
+
// Phase transitions the CLI owns (BUILDING when the build starts,
|
|
83
|
+
// BUILD_ERROR on a failed build). Runtime phases stay controller-territory.
|
|
84
84
|
async updateDeploymentStatus(deployment_id, status) {
|
|
85
85
|
return data(this.client.post(`/status/${deployment_id}`, status));
|
|
86
86
|
}
|
|
87
|
+
// Complete a create-first deployment with the built image (write-once
|
|
88
|
+
// server-side). Setting the image is what makes the controller claim the
|
|
89
|
+
// deployment and materialize it.
|
|
90
|
+
async completeDeployment(deployment_id, image) {
|
|
91
|
+
return data(this.client.post(`/deployment/${deployment_id}`, {
|
|
92
|
+
image,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
// Fetch a deployment with its runtime status, so the deploy command can
|
|
96
|
+
// watch for a terminal failure (ERROR/BUILD_ERROR + reason) while polling
|
|
97
|
+
// for promotion — and fail the run fast instead of timing out green.
|
|
98
|
+
async getDeployment(deployment_id) {
|
|
99
|
+
return data(this.client.get(`/deployment/${deployment_id}`));
|
|
100
|
+
}
|
|
87
101
|
// Attach the captured build/deploy output to a deployment. The base client
|
|
88
102
|
// timeout (10s) is too short for a multi-MB body on a slow uplink.
|
|
89
103
|
async uploadDeploymentLogs(deployment_id, body) {
|
|
@@ -8,7 +8,7 @@ import { check_environment } from './check_environment.js';
|
|
|
8
8
|
import { git_context } from './git_context.js';
|
|
9
9
|
import { resolve_app_id } from './resolve_app_id.js';
|
|
10
10
|
import { secrets } from './secrets/index.js';
|
|
11
|
-
import {
|
|
11
|
+
import { start_log_sync, mark_build_failure, upload_logs } from './upload_logs.js';
|
|
12
12
|
import { upload_tag } from './upload_tag.js';
|
|
13
13
|
|
|
14
14
|
const deploy = {
|
|
@@ -47,9 +47,19 @@ const deploy = {
|
|
|
47
47
|
const plan = await detect_buildpack({ workdir, config }, args.buildpack || config.buildpack);
|
|
48
48
|
const app_id = await resolve_app_id(args.app_id, ctx.appId, api, workdir);
|
|
49
49
|
const app = await api.getApp(app_id);
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
50
|
+
// Capture the commit/ref/actor so the deployment records which commit
|
|
51
|
+
// it came from and who pushed it (env in CI, git fallback locally).
|
|
52
|
+
const git = await git_context({ workdir });
|
|
53
|
+
// Create-first: register the deployment BEFORE building. Gate rejections
|
|
54
|
+
// (free-plan quota 429, disabled app 409) surface here, at second 0 —
|
|
55
|
+
// before any build minutes are spent. The row is born QUEUED; the CLI
|
|
56
|
+
// owns it until the built image lands (or the build fails). `type` rides
|
|
57
|
+
// on the create as before — the buildpack plan is already resolved.
|
|
58
|
+
const deployment = await api.createDeployment({
|
|
59
|
+
app_id: app.id,
|
|
60
|
+
type: plan.type,
|
|
61
|
+
...git
|
|
62
|
+
});
|
|
53
63
|
try {
|
|
54
64
|
// Check if we can build docker images
|
|
55
65
|
await check_environment();
|
|
@@ -64,35 +74,33 @@ const deploy = {
|
|
|
64
74
|
if (!buildpack) {
|
|
65
75
|
throw new Error(`No buildpack registered for plan=${plan.buildpack}`);
|
|
66
76
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
image:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
77
|
+
// The build starts now: declare it (QUEUED → BUILDING) and stream the
|
|
78
|
+
// captured output to the deployment while it runs (best-effort).
|
|
79
|
+
await api
|
|
80
|
+
.updateDeploymentStatus(deployment.id, { phase: 'BUILDING' })
|
|
81
|
+
.catch((error) => log.warn(`Could not mark deployment BUILDING: ${error.message}`));
|
|
82
|
+
const stop_log_sync = start_log_sync(api, deployment.id);
|
|
83
|
+
try {
|
|
84
|
+
await buildpack.build({ workdir, config, app, env_vars }, plan);
|
|
85
|
+
// Upload to Faable registry
|
|
86
|
+
const { upload_tagname } = await upload_tag({ app, api });
|
|
87
|
+
// Complete the deployment with the built image — this is the handoff:
|
|
88
|
+
// the controller claims it and materializes the workload.
|
|
89
|
+
await api.completeDeployment(deployment.id, upload_tagname);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
stop_log_sync();
|
|
93
|
+
}
|
|
81
94
|
}
|
|
82
95
|
catch (error) {
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
error?.response?.status === 429 &&
|
|
89
|
-
error?.response?.data?.code === 'deployment_quota_exceeded';
|
|
90
|
-
if (!isQuotaRejection) {
|
|
91
|
-
await report_build_failure(api, { app, workdir });
|
|
92
|
-
}
|
|
96
|
+
// The deployment already exists (created pre-build), so a failed build
|
|
97
|
+
// marks THAT row BUILD_ERROR with the captured logs — no extra row, no
|
|
98
|
+
// second quota hit. Gate rejections can't reach here anymore: the
|
|
99
|
+
// create happens before any building.
|
|
100
|
+
await mark_build_failure(api, { deployment_id: deployment.id, app });
|
|
93
101
|
throw error;
|
|
94
102
|
}
|
|
95
|
-
// Attach the build output to the deployment (best-effort).
|
|
103
|
+
// Attach the final build output to the deployment (best-effort).
|
|
96
104
|
await upload_logs(api, deployment.id);
|
|
97
105
|
const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
|
|
98
106
|
log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
|
|
@@ -104,6 +112,7 @@ const deploy = {
|
|
|
104
112
|
const start = Date.now();
|
|
105
113
|
log.info(`⏳ Waiting for deployment to be promoted...`);
|
|
106
114
|
let promoted = false;
|
|
115
|
+
let failure = null;
|
|
107
116
|
while (Date.now() - start < timeoutMs) {
|
|
108
117
|
await wait(intervalMs);
|
|
109
118
|
try {
|
|
@@ -112,6 +121,16 @@ const deploy = {
|
|
|
112
121
|
promoted = true;
|
|
113
122
|
break;
|
|
114
123
|
}
|
|
124
|
+
// Watch for a terminal runtime failure so we fail fast (and red)
|
|
125
|
+
// instead of timing out green. The controller marks a crash-looping
|
|
126
|
+
// deployment ERROR — with a reason (e.g. a missing module) — within
|
|
127
|
+
// ~1min, long before this 5min promotion timeout.
|
|
128
|
+
const dep = await api.getDeployment(deployment.id);
|
|
129
|
+
const phase = dep.status?.phase;
|
|
130
|
+
if (phase === "ERROR" || phase === "BUILD_ERROR") {
|
|
131
|
+
failure = { phase, reason: dep.status?.reason };
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
115
134
|
}
|
|
116
135
|
catch (_error) {
|
|
117
136
|
// Ignore transient errors while polling and keep waiting
|
|
@@ -121,6 +140,15 @@ const deploy = {
|
|
|
121
140
|
if (promoted) {
|
|
122
141
|
log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
|
|
123
142
|
}
|
|
143
|
+
else if (failure) {
|
|
144
|
+
// Fail the command (non-zero exit → red GitHub Action) and surface the
|
|
145
|
+
// reason the controller captured so the user sees WHY without digging.
|
|
146
|
+
log.error(`❌ Deployment failed (${failure.phase}).`);
|
|
147
|
+
if (failure.reason)
|
|
148
|
+
log.error(failure.reason);
|
|
149
|
+
log.error(`Check the logs in the dashboard -> ${dashboard_url}`);
|
|
150
|
+
throw new Error(`Deployment ${deployment.id} failed with phase ${failure.phase}`);
|
|
151
|
+
}
|
|
124
152
|
else {
|
|
125
153
|
log.warn(`⌛ Timed out after 5min waiting for promotion. The deployment is still rolling out, check the dashboard -> ${dashboard_url}`);
|
|
126
154
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { buildLog } from '../../lib/log_buffer.js';
|
|
2
2
|
import { log } from '../../log.js';
|
|
3
|
-
import { git_context } from './git_context.js';
|
|
4
3
|
|
|
5
4
|
// Best-effort by design: attaching logs (or recording a failed build) must
|
|
6
5
|
// never break or fail a deploy — an older API without these endpoints just
|
|
@@ -17,24 +16,52 @@ const upload_logs = async (api, deployment_id) => {
|
|
|
17
16
|
log.warn(`Could not upload build logs (non-fatal): ${error.message}`);
|
|
18
17
|
}
|
|
19
18
|
};
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
19
|
+
// Live log sync during the build: re-upload the whole buffer every interval
|
|
20
|
+
// while it grows. The API endpoint is an idempotent replace (one log row per
|
|
21
|
+
// deployment), so partial uploads are simply superseded — the dashboard sees
|
|
22
|
+
// the build progressing instead of only the final dump. Rate-limit friendly:
|
|
23
|
+
// 10s interval = 6 req/min against the endpoint's 30/min cap.
|
|
24
|
+
const start_log_sync = (api, deployment_id, intervalMs = 10_000) => {
|
|
25
|
+
let lastSize = 0;
|
|
26
|
+
let inFlight = false;
|
|
27
|
+
const timer = setInterval(async () => {
|
|
28
|
+
if (inFlight)
|
|
29
|
+
return;
|
|
30
|
+
const { content, truncated, size } = buildLog.contents();
|
|
31
|
+
if (!content || size === lastSize)
|
|
32
|
+
return;
|
|
33
|
+
inFlight = true;
|
|
34
|
+
try {
|
|
35
|
+
await api.uploadDeploymentLogs(deployment_id, { content, truncated });
|
|
36
|
+
lastSize = size;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// Quiet: the final upload_logs still runs at the end of the deploy.
|
|
40
|
+
log.debug(`Log sync failed (non-fatal): ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
inFlight = false;
|
|
44
|
+
}
|
|
45
|
+
}, intervalMs);
|
|
46
|
+
// Never keep the process alive just for log syncing.
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
return () => clearInterval(timer);
|
|
49
|
+
};
|
|
50
|
+
// Mark the create-first deployment as a failed build (phase BUILD_ERROR) and
|
|
51
|
+
// attach the captured logs. The deployment row already exists — created
|
|
52
|
+
// before the build started — so unlike the old flow no extra row is created
|
|
53
|
+
// (and no quota is consumed) to record the failure.
|
|
54
|
+
const mark_build_failure = async (api, { deployment_id, app, }) => {
|
|
24
55
|
try {
|
|
25
|
-
const git = await git_context({ workdir }).catch(() => ({}));
|
|
26
|
-
// No `image` and no `type`: a failed build must not materialize anything
|
|
27
|
-
// nor rewrite the app's runtime_strategy.
|
|
28
|
-
const failed = await api.createDeployment({ app_id: app.id, ...git });
|
|
29
56
|
await api
|
|
30
|
-
.updateDeploymentStatus(
|
|
57
|
+
.updateDeploymentStatus(deployment_id, { phase: "BUILD_ERROR" })
|
|
31
58
|
.catch((error) => log.warn(`Could not mark deployment BUILD_ERROR: ${error.message}`));
|
|
32
|
-
await upload_logs(api,
|
|
33
|
-
log.error(`❌ Build failed — logs attached to deployment ${
|
|
59
|
+
await upload_logs(api, deployment_id);
|
|
60
|
+
log.error(`❌ Build failed — logs attached to deployment ${deployment_id} · https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`);
|
|
34
61
|
}
|
|
35
62
|
catch (error) {
|
|
36
63
|
log.warn(`Could not report the failed build (non-fatal): ${error.message}`);
|
|
37
64
|
}
|
|
38
65
|
};
|
|
39
66
|
|
|
40
|
-
export {
|
|
67
|
+
export { mark_build_failure, start_log_sync, upload_logs };
|