@faable/faable 1.14.1 → 1.15.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.
@@ -79,11 +79,19 @@ class FaableApi {
79
79
  async createDeployment(params) {
80
80
  return data(this.client.post(`/deployment`, params));
81
81
  }
82
- // Phase transitions the CLI owns (e.g. BUILD_ERROR on a failed build).
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
+ }
87
95
  // Attach the captured build/deploy output to a deployment. The base client
88
96
  // timeout (10s) is too short for a multi-MB body on a slow uplink.
89
97
  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 { report_build_failure, upload_logs } from './upload_logs.js';
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
- // 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;
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
- 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
- });
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
- // 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
- }
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}`);
@@ -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
- // 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 }) => {
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(failed.id, { phase: "BUILD_ERROR" })
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, failed.id);
33
- log.error(`❌ Build failed — logs attached to deployment ${failed.id} · https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`);
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 { report_build_failure, upload_logs };
67
+ export { mark_build_failure, start_log_sync, upload_logs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",