@faable/faable 1.13.2 โ 1.14.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 +13 -0
- package/dist/commands/deploy/buildpacks/node/index.js +1 -1
- package/dist/commands/deploy/index.js +47 -26
- package/dist/commands/deploy/upload_logs.js +40 -0
- package/dist/lib/cmd.js +16 -1
- package/dist/lib/log_buffer.js +50 -0
- package/dist/log.js +19 -6
- package/package.json +1 -1
package/dist/api/FaableApi.js
CHANGED
|
@@ -73,9 +73,22 @@ class FaableApi {
|
|
|
73
73
|
async getRegistry(app_id) {
|
|
74
74
|
return data(this.client.get(`/app/${app_id}/registry`));
|
|
75
75
|
}
|
|
76
|
+
// `image`/`type` are optional to support the failure path: a failed build
|
|
77
|
+
// is recorded as a deployment without an image (and without `type`, which
|
|
78
|
+
// would otherwise rewrite the app's runtime_strategy server-side).
|
|
76
79
|
async createDeployment(params) {
|
|
77
80
|
return data(this.client.post(`/deployment`, params));
|
|
78
81
|
}
|
|
82
|
+
// Phase transitions the CLI owns (e.g. BUILD_ERROR on a failed build).
|
|
83
|
+
// Runtime phases stay controller-territory.
|
|
84
|
+
async updateDeploymentStatus(deployment_id, status) {
|
|
85
|
+
return data(this.client.post(`/status/${deployment_id}`, status));
|
|
86
|
+
}
|
|
87
|
+
// Attach the captured build/deploy output to a deployment. The base client
|
|
88
|
+
// timeout (10s) is too short for a multi-MB body on a slow uplink.
|
|
89
|
+
async uploadDeploymentLogs(deployment_id, body) {
|
|
90
|
+
return data(this.client.post(`/deployment/${deployment_id}/logs`, body, { timeout: 60_000, maxBodyLength: Infinity, maxContentLength: Infinity }));
|
|
91
|
+
}
|
|
79
92
|
async getAppSecrets(app_id) {
|
|
80
93
|
return firstPage(data(this.client.get(`/secret/${app_id}`)));
|
|
81
94
|
}
|
|
@@ -10,7 +10,7 @@ import { resolve_node_version } from './node_version.js';
|
|
|
10
10
|
|
|
11
11
|
const BANNER = `NODE_VERSION=$(node --version)
|
|
12
12
|
NPM_VERSION=$(npm --version)
|
|
13
|
-
YARN_VERSION=$(yarn --version)
|
|
13
|
+
YARN_VERSION=$(yarn --version 2>/dev/null || echo "n/a")
|
|
14
14
|
|
|
15
15
|
echo "Faable Cloud ยท [node $NODE_VERSION] [npm $NPM_VERSION] [yarn $YARN_VERSION]"`;
|
|
16
16
|
const node_buildpack = {
|
|
@@ -8,6 +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 { report_build_failure, upload_logs } from './upload_logs.js';
|
|
11
12
|
import { upload_tag } from './upload_tag.js';
|
|
12
13
|
|
|
13
14
|
const deploy = {
|
|
@@ -46,33 +47,53 @@ const deploy = {
|
|
|
46
47
|
const plan = await detect_buildpack({ workdir, config }, args.buildpack || config.buildpack);
|
|
47
48
|
const app_id = await resolve_app_id(args.app_id, ctx.appId, api, workdir);
|
|
48
49
|
const app = await api.getApp(app_id);
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
50
|
+
// From here on there is an app to attach logs to: any build/push failure
|
|
51
|
+
// is recorded as a BUILD_ERROR deployment with the captured output.
|
|
52
|
+
let deployment;
|
|
53
|
+
try {
|
|
54
|
+
// Check if we can build docker images
|
|
55
|
+
await check_environment();
|
|
56
|
+
const runtime_label = plan.runtime.version
|
|
57
|
+
? `${plan.runtime.name}-${plan.runtime.version}`
|
|
58
|
+
: plan.runtime.name;
|
|
59
|
+
log.info(`๐ Deploying "${app.name}" (${app.id}) runtime=${runtime_label}`);
|
|
60
|
+
log.info(`๐งฉ Build plan ${plan_summary(plan)}`);
|
|
61
|
+
// get environment variables
|
|
62
|
+
const env_vars = await api.getAppSecrets(app.id);
|
|
63
|
+
const buildpack = get_buildpack(plan.buildpack);
|
|
64
|
+
if (!buildpack) {
|
|
65
|
+
throw new Error(`No buildpack registered for plan=${plan.buildpack}`);
|
|
66
|
+
}
|
|
67
|
+
await buildpack.build({ workdir, config, app, env_vars }, plan);
|
|
68
|
+
const type = plan.type;
|
|
69
|
+
// Upload to Faable registry
|
|
70
|
+
const { upload_tagname } = await upload_tag({ app, api });
|
|
71
|
+
// Capture the commit/ref/actor so the deployment records which commit
|
|
72
|
+
// it came from and who pushed it (env in CI, git fallback locally).
|
|
73
|
+
const git = await git_context({ workdir });
|
|
74
|
+
// Create a deployment for this image
|
|
75
|
+
deployment = await api.createDeployment({
|
|
76
|
+
app_id: app.id,
|
|
77
|
+
image: upload_tagname,
|
|
78
|
+
type,
|
|
79
|
+
...git
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// A free-plan quota rejection (429 deployment_quota_exceeded) is not a
|
|
84
|
+
// build failure โ the build itself succeeded. Skip the failure report
|
|
85
|
+
// so the app doesn't show a red build; the API's message (with the
|
|
86
|
+
// upgrade hint) still reaches the user via the error printer.
|
|
87
|
+
const isQuotaRejection = error?.isFaableApiError &&
|
|
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
|
+
}
|
|
93
|
+
throw error;
|
|
61
94
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// Upload to Faable registry
|
|
65
|
-
const { upload_tagname } = await upload_tag({ app, api });
|
|
66
|
-
// Capture the commit/ref/actor so the deployment records which commit it
|
|
67
|
-
// came from and who pushed it (env in CI, git fallback locally).
|
|
68
|
-
const git = await git_context({ workdir });
|
|
69
|
-
// Create a deployment for this image
|
|
70
|
-
const deployment = await api.createDeployment({
|
|
71
|
-
app_id: app.id,
|
|
72
|
-
image: upload_tagname,
|
|
73
|
-
type,
|
|
74
|
-
...git
|
|
75
|
-
});
|
|
95
|
+
// Attach the build output to the deployment (best-effort).
|
|
96
|
+
await upload_logs(api, deployment.id);
|
|
76
97
|
const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
|
|
77
98
|
log.info(`Preparing to deploy in faable cloud ยท ${deployment.id}`);
|
|
78
99
|
log.info(`๐ View it in the dashboard -> ${dashboard_url}`);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { buildLog } from '../../lib/log_buffer.js';
|
|
2
|
+
import { log } from '../../log.js';
|
|
3
|
+
import { git_context } from './git_context.js';
|
|
4
|
+
|
|
5
|
+
// Best-effort by design: attaching logs (or recording a failed build) must
|
|
6
|
+
// never break or fail a deploy โ an older API without these endpoints just
|
|
7
|
+
// produces a warn.
|
|
8
|
+
const upload_logs = async (api, deployment_id) => {
|
|
9
|
+
try {
|
|
10
|
+
const { content, truncated } = buildLog.contents();
|
|
11
|
+
if (!content)
|
|
12
|
+
return;
|
|
13
|
+
await api.uploadDeploymentLogs(deployment_id, { content, truncated });
|
|
14
|
+
log.info(`๐ Build logs attached to deployment ${deployment_id}`);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
log.warn(`Could not upload build logs (non-fatal): ${error.message}`);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
// Record a failed build as a BUILD_ERROR deployment (no image โ the
|
|
21
|
+
// controller skips materialization) with the captured logs attached, so
|
|
22
|
+
// private-repo CI failures are debuggable from the platform.
|
|
23
|
+
const report_build_failure = async (api, { app, workdir }) => {
|
|
24
|
+
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
|
+
await api
|
|
30
|
+
.updateDeploymentStatus(failed.id, { phase: "BUILD_ERROR" })
|
|
31
|
+
.catch((error) => log.warn(`Could not mark deployment BUILD_ERROR: ${error.message}`));
|
|
32
|
+
await upload_logs(api, failed.id);
|
|
33
|
+
log.error(`โ Build failed โ logs attached to deployment ${failed.id} ยท https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
log.warn(`Could not report the failed build (non-fatal): ${error.message}`);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export { report_build_failure, upload_logs };
|
package/dist/lib/cmd.js
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { spawn } from 'promisify-child-process';
|
|
2
2
|
import { log } from '../log.js';
|
|
3
|
+
import { buildLog } from './log_buffer.js';
|
|
3
4
|
|
|
4
5
|
const cmd = async (cmd, config) => {
|
|
5
6
|
// Defaults
|
|
6
7
|
const enableOutput = config?.enableOutput || false;
|
|
7
8
|
const timeout = config?.timeout;
|
|
8
9
|
const cwd = config?.cwd;
|
|
10
|
+
// Always pipe (never inherit) so the output can be captured into the
|
|
11
|
+
// build-log buffer; enableOutput now means "also mirror to the terminal".
|
|
12
|
+
// Subprocesses see a non-TTY and fall back to plain (uncolored) output โ
|
|
13
|
+
// the intended trade for being able to attach logs to the deployment.
|
|
9
14
|
const child = spawn("/bin/bash", ["-c", cmd], {
|
|
10
15
|
encoding: "utf8",
|
|
11
|
-
stdio:
|
|
16
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
17
|
+
// promisify-child-process kills the child with "maxBuffer size exceeded"
|
|
18
|
+
// at 200KB once stdio is piped + encoding set. A docker build easily
|
|
19
|
+
// exceeds that โ never kill on output volume (buildLog caps separately).
|
|
20
|
+
maxBuffer: Infinity,
|
|
12
21
|
timeout,
|
|
13
22
|
cwd,
|
|
14
23
|
env: {
|
|
@@ -19,9 +28,15 @@ const cmd = async (cmd, config) => {
|
|
|
19
28
|
const out_data = [];
|
|
20
29
|
child.stderr?.on("data", (data) => {
|
|
21
30
|
out_data.push(data);
|
|
31
|
+
buildLog.append(data);
|
|
32
|
+
if (enableOutput)
|
|
33
|
+
process.stderr.write(data);
|
|
22
34
|
});
|
|
23
35
|
child.stdout?.on("data", (data) => {
|
|
24
36
|
out_data.push(data);
|
|
37
|
+
buildLog.append(data);
|
|
38
|
+
if (enableOutput)
|
|
39
|
+
process.stdout.write(data);
|
|
25
40
|
});
|
|
26
41
|
try {
|
|
27
42
|
const result = await child;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// In-memory capture of everything the deploy prints (subprocess output via
|
|
2
|
+
// cmd(), CLI messages via the pino tee in log.ts) so it can be attached to
|
|
3
|
+
// the deployment at the end. Keep-tail: when the cap is hit, head chunks are
|
|
4
|
+
// dropped โ a failing build's useful signal is at the end of the output.
|
|
5
|
+
// Keep in lockstep with DEPLOYMENT_LOG_MAX_BYTES on the API side.
|
|
6
|
+
const LOG_CAP_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
// CSI (colors, cursor) + OSC (titles, hyperlinks) escape sequences.
|
|
8
|
+
const ANSI_RE =
|
|
9
|
+
// eslint-disable-next-line no-control-regex
|
|
10
|
+
/\x1B(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\))/g;
|
|
11
|
+
class LogBuffer {
|
|
12
|
+
cap;
|
|
13
|
+
chunks = [];
|
|
14
|
+
bytes = 0;
|
|
15
|
+
dropped = false;
|
|
16
|
+
constructor(cap = LOG_CAP_BYTES) {
|
|
17
|
+
this.cap = cap;
|
|
18
|
+
}
|
|
19
|
+
append(chunk) {
|
|
20
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
|
|
21
|
+
if (buf.length === 0)
|
|
22
|
+
return;
|
|
23
|
+
this.chunks.push(buf);
|
|
24
|
+
this.bytes += buf.length;
|
|
25
|
+
while (this.bytes > this.cap && this.chunks.length > 1) {
|
|
26
|
+
const head = this.chunks.shift();
|
|
27
|
+
this.bytes -= head.length;
|
|
28
|
+
this.dropped = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
contents() {
|
|
32
|
+
const content = Buffer.concat(this.chunks)
|
|
33
|
+
.toString("utf8")
|
|
34
|
+
.replace(ANSI_RE, "");
|
|
35
|
+
return {
|
|
36
|
+
content,
|
|
37
|
+
truncated: this.dropped,
|
|
38
|
+
size: Buffer.byteLength(content, "utf8"),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
reset() {
|
|
42
|
+
this.chunks = [];
|
|
43
|
+
this.bytes = 0;
|
|
44
|
+
this.dropped = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Process-wide buffer for the current deploy session.
|
|
48
|
+
const buildLog = new LogBuffer();
|
|
49
|
+
|
|
50
|
+
export { LOG_CAP_BYTES, LogBuffer, buildLog };
|
package/dist/log.js
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import pino from 'pino';
|
|
2
|
-
import pretty from 'pino-pretty';
|
|
2
|
+
import pretty, { prettyFactory } from 'pino-pretty';
|
|
3
|
+
import { buildLog } from './lib/log_buffer.js';
|
|
3
4
|
|
|
4
|
-
// Run pino-pretty as
|
|
5
|
+
// Run pino-pretty as in-process sync streams instead of a worker-thread
|
|
5
6
|
// transport: the worker's MessagePort can stay referenced after the last log
|
|
6
7
|
// (thread-stream race) and intermittently keep the CLI from exiting.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
})
|
|
8
|
+
//
|
|
9
|
+
// Tee: terminal stream (colorized) + a plain-text copy into the build-log
|
|
10
|
+
// buffer so CLI messages land in the logs attached to the deployment.
|
|
11
|
+
const toPlainText = prettyFactory({ colorize: false, sync: true });
|
|
12
|
+
// NB: the two-arg form matters โ pino(multistream) alone would treat the
|
|
13
|
+
// multistream object as the options bag and log raw JSON to stdout.
|
|
14
|
+
const log = pino({}, pino.multistream([
|
|
15
|
+
{ stream: pretty({ colorize: true, sync: true }) },
|
|
16
|
+
{
|
|
17
|
+
stream: {
|
|
18
|
+
write(line) {
|
|
19
|
+
buildLog.append(toPlainText(line));
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
]));
|
|
11
24
|
|
|
12
25
|
export { log };
|