@faable/faable 1.13.2 โ†’ 1.14.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.
@@ -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,44 @@ 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
- // Check if we can build docker images
50
- await check_environment();
51
- const runtime_label = plan.runtime.version
52
- ? `${plan.runtime.name}-${plan.runtime.version}`
53
- : plan.runtime.name;
54
- log.info(`๐Ÿš€ Deploying "${app.name}" (${app.id}) runtime=${runtime_label}`);
55
- log.info(`๐Ÿงฉ Build plan ${plan_summary(plan)}`);
56
- // get environment variables
57
- const env_vars = await api.getAppSecrets(app.id);
58
- const buildpack = get_buildpack(plan.buildpack);
59
- if (!buildpack) {
60
- throw new Error(`No buildpack registered for plan=${plan.buildpack}`);
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
+ await report_build_failure(api, { app, workdir });
84
+ throw error;
61
85
  }
62
- await buildpack.build({ workdir, config, app, env_vars }, plan);
63
- const type = plan.type;
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
- });
86
+ // Attach the build output to the deployment (best-effort).
87
+ await upload_logs(api, deployment.id);
76
88
  const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
77
89
  log.info(`Preparing to deploy in faable cloud ยท ${deployment.id}`);
78
90
  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: enableOutput ? "inherit" : "pipe",
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 an in-process sync stream instead of a worker-thread
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
- const log = pino(pretty({
8
- colorize: true,
9
- sync: true,
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.13.2",
3
+ "version": "1.14.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",