@faable/faable 1.23.0 → 1.24.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.
@@ -1,5 +1,13 @@
1
1
  import { create_base_client } from './base_client.js';
2
2
 
3
+ // Socket-level failures where no response ever arrived: the connection died
4
+ // before the server produced anything, so a single retry is safe for any
5
+ // method (a reset mid-flight means the request was not processed).
6
+ const RESET_CODES = new Set(["ECONNRESET", "EPIPE"]);
7
+ const RETRY_DELAY_MS = 300;
8
+ const is_connection_reset = (e) => e.isAxiosError &&
9
+ !e.response &&
10
+ (RESET_CODES.has(e.code ?? "") || e.message.includes("socket hang up"));
3
11
  const firstPage = async (res) => {
4
12
  const items = (await res).results;
5
13
  return items;
@@ -27,6 +35,20 @@ class FaableApi {
27
35
  // Do something with request error
28
36
  return Promise.reject(error);
29
37
  });
38
+ // Registered before the error-wrapping interceptor so it sees the raw
39
+ // axios error. Retries once per request; the retried call re-enters the
40
+ // full chain (auth headers included).
41
+ const client = this.client;
42
+ this.client.interceptors.response.use(undefined, async (error) => {
43
+ const e = error;
44
+ const config = e.config;
45
+ if (config && !config._retried && is_connection_reset(e)) {
46
+ config._retried = true;
47
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
48
+ return client.request(config);
49
+ }
50
+ throw error;
51
+ });
30
52
  this.client.interceptors.response.use((response) => response, (error) => {
31
53
  const e = error;
32
54
  if (e.isAxiosError) {
@@ -22,14 +22,14 @@ const exchangeGithubOidcToken = async (gh_token, target_app_id) => {
22
22
  // actionable next step.
23
23
  if (status === 404) {
24
24
  throw new Error('No app linked to this repository. Run "faable link" locally to link it, ' +
25
- "or link it from the dashboard (https://dashboard.faable.com).");
25
+ "or link it from the dashboard (https://dashboard.faable.com).", { cause: err });
26
26
  }
27
27
  // Monorepo: several apps are linked to the same repository.
28
28
  if (status === 400) {
29
29
  throw new Error(serverMessage ||
30
- "This repository has multiple linked apps. Specify which one with `faable deploy <app_id>`.");
30
+ "This repository has multiple linked apps. Specify which one with `faable deploy <app_id>`.", { cause: err });
31
31
  }
32
- throw new Error(`Faable OIDC token exchange failed (${status ?? "network error"})${serverMessage ? `: ${serverMessage}` : ""}`);
32
+ throw new Error(`Faable OIDC token exchange failed (${status ?? "network error"})${serverMessage ? `: ${serverMessage}` : ""}`, { cause: err });
33
33
  }
34
34
  throw err;
35
35
  }
@@ -2,16 +2,11 @@ import { requireApi } from '../../api/context.js';
2
2
  import { Configuration } from '../../lib/Configuration.js';
3
3
  import { log } from '../../log.js';
4
4
  import { link } from '../link/index.js';
5
- import { configure_buildpacks, detect_buildpack, plan_summary, get_buildpack, buildpack_names } from '@faabletools/buildpacks';
6
- import { cmd } from '../../lib/cmd.js';
7
- import { check_environment } from './check_environment.js';
8
5
  import { git_context } from './git_context.js';
9
6
  import { deploy_remote } from './remote/index.js';
10
7
  import { resolve_app_id } from './resolve_app_id.js';
11
8
  import { secrets } from './secrets/index.js';
12
9
  import { is_superseded } from './superseded.js';
13
- import { mark_build_failure, start_log_sync, upload_logs } from './upload_logs.js';
14
- import { upload_tag } from './upload_tag.js';
15
10
 
16
11
  const deploy = {
17
12
  command: 'deploy [app_id]',
@@ -31,29 +26,10 @@ const deploy = {
31
26
  type: 'string',
32
27
  description: 'Working directory'
33
28
  })
34
- .option('buildpack', {
35
- alias: 'b',
36
- type: 'string',
37
- choices: buildpack_names(),
38
- description: 'Force a specific buildpack (overrides auto-detection and faable.json)'
39
- })
40
- .option('remote', {
41
- type: 'boolean',
42
- description: 'Build server-side (remote build), regardless of the app build_mode'
43
- })
44
- .option('local', {
45
- type: 'boolean',
46
- description: 'Build locally with Docker, even if the app is set to remote builds'
47
- })
48
- .conflicts('remote', 'local')
49
29
  .showHelpOnFail(false);
50
30
  },
51
31
  handler: async (args) => {
52
32
  const workdir = args.workdir || process.cwd();
53
- // Wire the shared buildpacks package to the CLI's sinks: pino (which tees
54
- // into the build-log buffer) and cmd() (which captures subprocess output
55
- // into the same buffer). Must happen before any detect/build call.
56
- configure_buildpacks({ log, exec: cmd });
57
33
  // Pass the explicit app target to the OIDC exchange so a monorepo (several
58
34
  // apps, one repo) can be disambiguated in CI.
59
35
  const ctx = await requireApi(args.app_id);
@@ -71,113 +47,14 @@ const deploy = {
71
47
  // Capture the commit/ref/actor so the deployment records which commit
72
48
  // it came from and who pushed it (env in CI, git fallback locally).
73
49
  const git = await git_context({ workdir });
74
- // Resolve the buildpack plan (detection or forced override). All the build
75
- // thinking happens here; build() below just executes the plan. Detection can
76
- // fail (e.g. an app whose start command can't be inferred) — and it runs
77
- // before the create-first deployment exists, so without this a detection
78
- // failure would leave NO deployment row: no deploy-failed email, nothing in
79
- // the dashboard, just a red X in the CI logs the user may never see. Record
80
- // it as a failed deployment instead.
81
- let plan;
82
- try {
83
- plan = await detect_buildpack({ workdir, config }, args.buildpack || config.buildpack);
84
- }
85
- catch (error) {
86
- // Log the reason into the build buffer first (so it rides along in the
87
- // attached logs), then record a typeless BUILD_ERROR row — typeless so it
88
- // can't rewrite the app's runtime_strategy. Best-effort: a create that
89
- // itself fails (e.g. the free-plan quota gate) must not mask the original
90
- // detection error.
91
- log.error(error.message);
92
- const failed = await api
93
- .createDeployment({ app_id: app.id, ...git })
94
- .catch(() => null);
95
- if (failed) {
96
- await mark_build_failure(api, { deployment_id: failed.id, app });
97
- }
98
- throw error;
99
- }
100
- // Remote build path (v2, arch/deploy/deploy-v2-remote-build.md): the
101
- // server decides via the app's build_mode; --remote/--local override for
102
- // testing. Pre-build failures with a server-decided mode fall back to the
103
- // local build below (deploy_remote returns null); with --remote they
104
- // fail hard.
105
- let deployment;
106
- const want_remote = !args.local && (Boolean(args.remote) || app.build_mode === 'remote');
107
- const remote_deployment = want_remote
108
- ? await deploy_remote({
109
- api,
110
- app,
111
- plan,
112
- git,
113
- workdir,
114
- explicit: Boolean(args.remote)
115
- })
116
- : null;
117
- if (remote_deployment) {
118
- deployment = remote_deployment;
119
- }
120
- else {
121
- if (want_remote) {
122
- log.warn('↩️ Falling back to a local build');
123
- }
124
- // Create-first: register the deployment BEFORE building. Gate rejections
125
- // (free-plan quota 429, disabled app 409) surface here, at second 0 —
126
- // before any build minutes are spent. The row is born QUEUED; the CLI
127
- // owns it until the built image lands (or the build fails). `type` rides
128
- // on the create — the buildpack plan is already resolved.
129
- deployment = await api.createDeployment({
130
- app_id: app.id,
131
- type: plan.type,
132
- ...git
133
- });
134
- try {
135
- // Check if we can build docker images
136
- await check_environment();
137
- const runtime_label = plan.runtime.version
138
- ? `${plan.runtime.name}-${plan.runtime.version}`
139
- : plan.runtime.name;
140
- log.info(`🚀 Deploying "${app.name}" (${app.id}) runtime=${runtime_label}`);
141
- log.info(`🧩 Build plan ${plan_summary(plan)}`);
142
- // get environment variables
143
- const env_vars = await api.getAppSecrets(app.id);
144
- const buildpack = get_buildpack(plan.buildpack);
145
- if (!buildpack) {
146
- throw new Error(`No buildpack registered for plan=${plan.buildpack}`);
147
- }
148
- // The build starts now: declare it (QUEUED → BUILDING) and stream the
149
- // captured output to the deployment while it runs (best-effort).
150
- await api
151
- .updateDeploymentStatus(deployment.id, { phase: 'BUILDING' })
152
- .catch((error) => log.warn(`Could not mark deployment BUILDING: ${error.message}`));
153
- const stop_log_sync = start_log_sync(api, deployment.id);
154
- try {
155
- await buildpack.build({ workdir, config, app, env_vars, deployment }, plan);
156
- // Upload to Faable registry, tagged by version and pinned to digest
157
- const { image_ref } = await upload_tag({
158
- app,
159
- api,
160
- deployment_id: deployment.id
161
- });
162
- // Complete the deployment with the built image — this is the handoff:
163
- // the controller claims it and materializes the workload.
164
- await api.completeDeployment(deployment.id, image_ref);
165
- }
166
- finally {
167
- stop_log_sync();
168
- }
169
- }
170
- catch (error) {
171
- // The deployment already exists (created pre-build), so a failed build
172
- // marks THAT row BUILD_ERROR with the captured logs — no extra row, no
173
- // second quota hit. Gate rejections can't reach here anymore: the
174
- // create happens before any building.
175
- await mark_build_failure(api, { deployment_id: deployment.id, app });
176
- throw error;
177
- }
178
- // Attach the final build output to the deployment (best-effort).
179
- await upload_logs(api, deployment.id);
180
- } // end local build path (remote builds upload their own logs server-side)
50
+ // Remote build only (arch/deploy/remote-artifact-default-cutover.md): the
51
+ // CLI no longer builds it uploads the source and the platform builds
52
+ // server-side (framework detection, buildpacks, artifact/image output all
53
+ // live in the builder). No Docker, no local fallback: a rejected admission
54
+ // (build_mode=local opt-out, or the global kill-switch off) or a build
55
+ // error throws and exits red.
56
+ log.info(`🚀 Deploying "${app.name}" (${app.id})`);
57
+ const deployment = await deploy_remote({ api, app, git, workdir });
181
58
  const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
182
59
  log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
183
60
  log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
@@ -4,42 +4,29 @@ import { collect_manifest } from './manifest.js';
4
4
  import { upload_missing_blobs } from './upload.js';
5
5
 
6
6
  /**
7
- * Remote build path (v2, arch/deploy/deploy-v2-remote-build.md): upload the
8
- * source delta to the CAS, create the deployment with `source` (manifest +
9
- * serialized BuildPlan) and follow the server-side build until the image
10
- * handoff. No Docker daemon needed.
7
+ * Remote build (arch/deploy/remote-artifact-default-cutover.md): upload the
8
+ * source delta to the CAS, create the deployment with `source` (manifest only —
9
+ * the builder re-detects the framework server-side) and follow the server-side
10
+ * build until the handoff. No Docker daemon, no local buildpacks.
11
11
  *
12
- * Fallback contract:
13
- * - PRE-build failures (collection, upload, create including the server
14
- * gate `remote_build_disabled`) return null when the mode came from the
15
- * server, so the caller falls back to a local build. With --remote they
16
- * fail hard (testing wants to see the errors).
17
- * - Failures AFTER the deployment exists never fall back: a BUILD_ERROR
18
- * would fail locally too (it's the user's build), and a timeout may still
19
- * be building — a local fallback would double-deploy.
12
+ * There is no local fallback anymore: the CLI no longer builds. Any failure —
13
+ * a rejected admission (`remote_build_disabled` when the app opted out with
14
+ * build_mode=local, or the global kill-switch is off), an upload error, or a
15
+ * BUILD_ERROR throws so the command exits red.
20
16
  */
21
17
  const deploy_remote = async (props) => {
22
- const { api, app, plan, git, workdir, explicit } = props;
23
- log.info(`☁️ Remote build (${explicit ? "--remote" : `app build_mode=remote`})`);
24
- let deployment;
25
- try {
26
- const manifest = await collect_manifest(workdir);
27
- log.info(`🗂️ ${manifest.length} files in the source manifest`);
28
- await upload_missing_blobs(api, app.id, workdir, manifest);
29
- deployment = await api.createDeployment({
30
- app_id: app.id,
31
- type: plan.type,
32
- source: { manifest, plan },
33
- ...git,
34
- });
35
- }
36
- catch (error) {
37
- if (explicit)
38
- throw error;
39
- const code = error?.response?.data?.code;
40
- log.warn(`☁️ Remote build unavailable${code ? ` (${code})` : `: ${error.message}`}`);
41
- return null;
42
- }
18
+ const { api, app, git, workdir } = props;
19
+ log.info(`☁️ Remote build`);
20
+ const manifest = await collect_manifest(workdir);
21
+ log.info(`🗂️ ${manifest.length} files in the source manifest`);
22
+ await upload_missing_blobs(api, app.id, workdir, manifest);
23
+ // No `plan`: the builder re-detects on the assembled tree and syncs the app's
24
+ // runtime_strategy on completion.
25
+ const deployment = await api.createDeployment({
26
+ app_id: app.id,
27
+ source: { manifest },
28
+ ...git,
29
+ });
43
30
  log.info(`Preparing to build in faable cloud · ${deployment.id}`);
44
31
  await follow_remote_build(api, deployment.id);
45
32
  return deployment;
@@ -15,15 +15,15 @@ const MAX_FILE_BYTES = 100 * 1024 * 1024;
15
15
  * them like a fresh clone would.
16
16
  *
17
17
  * Symlinks are rejected (no representation in the manifest by design — see
18
- * deploy-v2-remote-build.md) and a non-git directory is unsupported in the
19
- * MVP (deploy with --local instead).
18
+ * deploy-v2-remote-build.md) and a non-git directory is unsupported (the source
19
+ * tree is collected via git).
20
20
  */
21
21
  const collect_manifest = async (workdir) => {
22
22
  const inside = await cmd(`git rev-parse --is-inside-work-tree`, {
23
23
  cwd: workdir,
24
24
  }).catch(() => null);
25
25
  if (!inside || String(inside.stdout).trim() !== "true") {
26
- throw new Error("Remote builds need a git repository (the source tree is collected via git). Deploy with --local instead.");
26
+ throw new Error("Deploys need a git repository (the source tree is collected via git). Initialize one with `git init` and commit your files.");
27
27
  }
28
28
  // -c/-o: tracked + untracked; --exclude-standard: .gitignore/.git/info.
29
29
  // -z: NUL-separated (paths with spaces/UTF-8 survive).
@@ -57,7 +57,7 @@ const collect_manifest = async (workdir) => {
57
57
  if (stat.isDirectory())
58
58
  continue;
59
59
  if (stat.isSymbolicLink()) {
60
- throw new Error(`Symlinks are not supported in remote builds: ${rel}. Deploy with --local instead.`);
60
+ throw new Error(`Symlinks are not supported: ${rel}. Replace the symlink with the real file.`);
61
61
  }
62
62
  if (stat.size > MAX_FILE_BYTES) {
63
63
  throw new Error(`${rel} is too large for a remote build (${stat.size} bytes > ${MAX_FILE_BYTES}).`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.23.0",
3
+ "version": "1.24.1",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",
@@ -27,8 +27,7 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "@actions/core": "^3.0.0",
30
- "@faabletools/buildpacks": "^1.7.0",
31
- "axios": "^1.13.2",
30
+ "axios": "^1.18.1",
32
31
  "fs-extra": "^11.3.2",
33
32
  "handlebars": "^4.7.8",
34
33
  "open": "^11.0.0",
@@ -1,12 +0,0 @@
1
- import { cmd } from '../../lib/cmd.js';
2
-
3
- const check_environment = async () => {
4
- try {
5
- await cmd("docker ps");
6
- }
7
- catch (error) {
8
- throw new Error(`Docker is not running`, { cause: error });
9
- }
10
- };
11
-
12
- export { check_environment };
@@ -1,67 +0,0 @@
1
- import { buildLog } from '../../lib/log_buffer.js';
2
- import { log } from '../../log.js';
3
-
4
- // Best-effort by design: attaching logs (or recording a failed build) must
5
- // never break or fail a deploy — an older API without these endpoints just
6
- // produces a warn.
7
- const upload_logs = async (api, deployment_id) => {
8
- try {
9
- const { content, truncated } = buildLog.contents();
10
- if (!content)
11
- return;
12
- await api.uploadDeploymentLogs(deployment_id, { content, truncated });
13
- log.info(`📝 Build logs attached to deployment ${deployment_id}`);
14
- }
15
- catch (error) {
16
- log.warn(`Could not upload build logs (non-fatal): ${error.message}`);
17
- }
18
- };
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, }) => {
55
- try {
56
- await api
57
- .updateDeploymentStatus(deployment_id, { phase: "BUILD_ERROR" })
58
- .catch((error) => log.warn(`Could not mark deployment BUILD_ERROR: ${error.message}`));
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}`);
61
- }
62
- catch (error) {
63
- log.warn(`Could not report the failed build (non-fatal): ${error.message}`);
64
- }
65
- };
66
-
67
- export { mark_build_failure, start_log_sync, upload_logs };
@@ -1,73 +0,0 @@
1
- import { log } from '../../log.js';
2
- import { cmd } from '../../lib/cmd.js';
3
-
4
- /**
5
- * `<hostname>/<image>:<deployment_id>` — one tag per build, never
6
- * overwritten. Without a tag docker resolves `:latest`, which every deploy
7
- * rewrites: any rescheduled pod (node drain, eviction) would silently pull
8
- * whatever build was pushed last, breaking the deployment ↔ code identity.
9
- */
10
- const build_upload_tagname = (hostname, image, deployment_id) => `${hostname}/${image}:${deployment_id}`;
11
- /**
12
- * Strips the tag from an image ref, keeping registry ports intact
13
- * (`host:5000/img:tag` → `host:5000/img`). A colon only denotes a tag when
14
- * it appears after the last path segment separator.
15
- */
16
- const strip_image_tag = (ref) => {
17
- const last_slash = ref.lastIndexOf("/");
18
- const last_colon = ref.lastIndexOf(":");
19
- return last_colon > last_slash ? ref.slice(0, last_colon) : ref;
20
- };
21
- /**
22
- * Pins a pushed tag to its content digest: `repo:tag@sha256:…`. Kubernetes
23
- * pulls by digest (immutable even if the tag were re-pushed); the tag stays
24
- * as the human-readable label. `RepoDigests` entries come as `repo@sha256:…`
25
- * (no tag) and may reference other registries — only the entry for this
26
- * repo counts. Returns null when none matches (caller falls back to the tag).
27
- */
28
- const pin_tag_to_digest = (tagname, repo_digests) => {
29
- const repo = strip_image_tag(tagname);
30
- const match = repo_digests.find((d) => d.startsWith(`${repo}@sha256:`));
31
- if (!match)
32
- return null;
33
- return `${tagname}@${match.slice(repo.length + 1)}`;
34
- };
35
- const upload_tag = async (args) => {
36
- const { api, app, deployment_id } = args;
37
- log.info(`🔁 Uploading...`);
38
- const registry = await api.getRegistry(app.id);
39
- // Registry login
40
- const { user, password, hostname, image } = registry;
41
- await cmd(`echo "${password}" | docker login --username "${user}" --password-stdin ${hostname}`);
42
- // Tag the local build (tagged `app.id` by the buildpack) for this version
43
- const upload_tagname = build_upload_tagname(hostname, image, deployment_id);
44
- await cmd(`docker tag ${app.id} ${upload_tagname}`);
45
- // Upload the image to faable registry
46
- await cmd(`docker push ${upload_tagname}`);
47
- // Pin to the digest the registry just assigned. Best-effort: the tagged
48
- // ref alone is already unique per build, the digest just makes it
49
- // tamper-proof.
50
- let image_ref = upload_tagname;
51
- try {
52
- const inspect = await cmd(`docker inspect --format '{{json .RepoDigests}}' ${upload_tagname}`);
53
- const repo_digests = JSON.parse(String(inspect.stdout).trim());
54
- const pinned = pin_tag_to_digest(upload_tagname, repo_digests);
55
- if (pinned) {
56
- image_ref = pinned;
57
- }
58
- else {
59
- log.warn(`Could not resolve the pushed digest; deploying by tag only.`);
60
- }
61
- }
62
- catch (error) {
63
- log.warn(`Could not inspect the pushed image (${error.message}); deploying by tag only.`);
64
- }
65
- log.info(`✅ Upload completed.`);
66
- return {
67
- upload_tagname,
68
- /** Immutable ref recorded on the deployment: `repo:tag@sha256:…` */
69
- image_ref,
70
- };
71
- };
72
-
73
- export { build_upload_tagname, pin_tag_to_digest, strip_image_tag, upload_tag };