@faable/faable 1.15.2 → 1.15.4

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.
@@ -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 { start_log_sync, mark_build_failure, upload_logs } from './upload_logs.js';
11
+ import { mark_build_failure, start_log_sync, upload_logs } from './upload_logs.js';
12
12
  import { upload_tag } from './upload_tag.js';
13
13
 
14
14
  const deploy = {
@@ -41,20 +41,43 @@ const deploy = {
41
41
  const workdir = args.workdir || process.cwd();
42
42
  const ctx = await requireApi();
43
43
  const { api } = ctx;
44
- // Resolve the buildpack plan (detection or forced override). All the
45
- // build thinking happens here; build() below just executes the plan.
46
44
  const config = Configuration.instance().deployConfig();
47
- const plan = await detect_buildpack({ workdir, config }, args.buildpack || config.buildpack);
48
45
  const app_id = await resolve_app_id(args.app_id, ctx.appId, api, workdir);
49
46
  const app = await api.getApp(app_id);
50
47
  // Capture the commit/ref/actor so the deployment records which commit
51
48
  // it came from and who pushed it (env in CI, git fallback locally).
52
49
  const git = await git_context({ workdir });
50
+ // Resolve the buildpack plan (detection or forced override). All the build
51
+ // thinking happens here; build() below just executes the plan. Detection can
52
+ // fail (e.g. an app whose start command can't be inferred) — and it runs
53
+ // before the create-first deployment exists, so without this a detection
54
+ // failure would leave NO deployment row: no deploy-failed email, nothing in
55
+ // the dashboard, just a red X in the CI logs the user may never see. Record
56
+ // it as a failed deployment instead.
57
+ let plan;
58
+ try {
59
+ plan = await detect_buildpack({ workdir, config }, args.buildpack || config.buildpack);
60
+ }
61
+ catch (error) {
62
+ // Log the reason into the build buffer first (so it rides along in the
63
+ // attached logs), then record a typeless BUILD_ERROR row — typeless so it
64
+ // can't rewrite the app's runtime_strategy. Best-effort: a create that
65
+ // itself fails (e.g. the free-plan quota gate) must not mask the original
66
+ // detection error.
67
+ log.error(error.message);
68
+ const failed = await api
69
+ .createDeployment({ app_id: app.id, ...git })
70
+ .catch(() => null);
71
+ if (failed) {
72
+ await mark_build_failure(api, { deployment_id: failed.id, app });
73
+ }
74
+ throw error;
75
+ }
53
76
  // Create-first: register the deployment BEFORE building. Gate rejections
54
77
  // (free-plan quota 429, disabled app 409) surface here, at second 0 —
55
78
  // before any build minutes are spent. The row is born QUEUED; the CLI
56
79
  // 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.
80
+ // on the create — the buildpack plan is already resolved.
58
81
  const deployment = await api.createDeployment({
59
82
  app_id: app.id,
60
83
  type: plan.type,
@@ -82,11 +105,15 @@ const deploy = {
82
105
  const stop_log_sync = start_log_sync(api, deployment.id);
83
106
  try {
84
107
  await buildpack.build({ workdir, config, app, env_vars }, plan);
85
- // Upload to Faable registry
86
- const { upload_tagname } = await upload_tag({ app, api });
108
+ // Upload to Faable registry, tagged by version and pinned to digest
109
+ const { image_ref } = await upload_tag({
110
+ app,
111
+ api,
112
+ deployment_id: deployment.id
113
+ });
87
114
  // Complete the deployment with the built image — this is the handoff:
88
115
  // the controller claims it and materializes the workload.
89
- await api.completeDeployment(deployment.id, upload_tagname);
116
+ await api.completeDeployment(deployment.id, image_ref);
90
117
  }
91
118
  finally {
92
119
  stop_log_sync();
@@ -1,22 +1,73 @@
1
1
  import { log } from '../../log.js';
2
2
  import { cmd } from '../../lib/cmd.js';
3
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
+ };
4
35
  const upload_tag = async (args) => {
5
- const { api, app } = args;
36
+ const { api, app, deployment_id } = args;
6
37
  log.info(`🔁 Uploading...`);
7
38
  const registry = await api.getRegistry(app.id);
8
39
  // Registry login
9
40
  const { user, password, hostname, image } = registry;
10
41
  await cmd(`echo "${password}" | docker login --username "${user}" --password-stdin ${hostname}`);
11
- // Tag image for production
12
- const upload_tagname = `${hostname}/${image}`;
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);
13
44
  await cmd(`docker tag ${app.id} ${upload_tagname}`);
14
45
  // Upload the image to faable registry
15
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
+ }
16
65
  log.info(`✅ Upload completed.`);
17
66
  return {
18
67
  upload_tagname,
68
+ /** Immutable ref recorded on the deployment: `repo:tag@sha256:…` */
69
+ image_ref,
19
70
  };
20
71
  };
21
72
 
22
- export { upload_tag };
73
+ export { build_upload_tagname, pin_tag_to_digest, strip_image_tag, upload_tag };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.15.2",
3
+ "version": "1.15.4",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",