@faable/faable 1.16.0 → 1.18.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.
- package/dist/api/FaableApi.js +15 -0
- package/dist/commands/deploy/index.js +93 -54
- package/dist/commands/deploy/remote/follow.js +61 -0
- package/dist/commands/deploy/remote/index.js +48 -0
- package/dist/commands/deploy/remote/manifest.js +77 -0
- package/dist/commands/deploy/remote/upload.js +61 -0
- package/package.json +2 -1
- package/dist/commands/deploy/buildpacks/Buildpack.js +0 -11
- package/dist/commands/deploy/buildpacks/DetectError.js +0 -78
- package/dist/commands/deploy/buildpacks/docker/index.js +0 -42
- package/dist/commands/deploy/buildpacks/foreign_platforms.js +0 -21
- package/dist/commands/deploy/buildpacks/node/analyze_package.js +0 -35
- package/dist/commands/deploy/buildpacks/node/build_project.js +0 -21
- package/dist/commands/deploy/buildpacks/node/ensure_dependencies.js +0 -78
- package/dist/commands/deploy/buildpacks/node/frameworks.js +0 -108
- package/dist/commands/deploy/buildpacks/node/index.js +0 -73
- package/dist/commands/deploy/buildpacks/node/inject_serve.js +0 -20
- package/dist/commands/deploy/buildpacks/node/node_version.js +0 -41
- package/dist/commands/deploy/buildpacks/python/index.js +0 -127
- package/dist/commands/deploy/buildpacks/python/parse_procfile.js +0 -23
- package/dist/commands/deploy/buildpacks/python/providers/cerebrium.js +0 -43
- package/dist/commands/deploy/buildpacks/python/providers/parse_cerebrium_toml.js +0 -56
- package/dist/commands/deploy/buildpacks/python/providers/pipfile.js +0 -13
- package/dist/commands/deploy/buildpacks/python/providers/pyproject.js +0 -13
- package/dist/commands/deploy/buildpacks/python/providers/requirements.js +0 -14
- package/dist/commands/deploy/buildpacks/python/python_version.js +0 -41
- package/dist/commands/deploy/buildpacks/python/resolve_start.js +0 -149
- package/dist/commands/deploy/buildpacks/registry.js +0 -61
- package/dist/commands/deploy/buildpacks/shared/docker_image.js +0 -49
- package/dist/commands/deploy/buildpacks/shared/has_any_of_files.js +0 -14
- package/dist/commands/deploy/buildpacks/shared/read_text_file.js +0 -31
- package/dist/commands/deploy/buildpacks/shared/templates/Dockerfile +0 -38
- package/dist/commands/deploy/buildpacks/shared/templates/entrypoint.sh +0 -4
package/dist/api/FaableApi.js
CHANGED
|
@@ -76,9 +76,24 @@ class FaableApi {
|
|
|
76
76
|
// `image`/`type` are optional to support the failure path: a failed build
|
|
77
77
|
// is recorded as a deployment without an image (and without `type`, which
|
|
78
78
|
// would otherwise rewrite the app's runtime_strategy server-side).
|
|
79
|
+
// `source` is the remote-build path (v2): content-addressed manifest +
|
|
80
|
+
// serialized BuildPlan; the platform builds and completes the image.
|
|
79
81
|
async createDeployment(params) {
|
|
80
82
|
return data(this.client.post(`/deployment`, params));
|
|
81
83
|
}
|
|
84
|
+
// Remote builds: diff the source manifest against the CAS. Returns
|
|
85
|
+
// presigned PUTs (sha-pinned by signature) for the missing blobs only.
|
|
86
|
+
async uploadMissing(app_id, files) {
|
|
87
|
+
return data(this.client.post(`/upload/missing`, {
|
|
88
|
+
app_id,
|
|
89
|
+
files: files.map(({ path, sha, size }) => ({ path, sha, size })),
|
|
90
|
+
}, { timeout: 60_000 }));
|
|
91
|
+
}
|
|
92
|
+
// Remote builds: read the build output the builder attaches to the
|
|
93
|
+
// deployment (same endpoint the CLI writes to in local builds).
|
|
94
|
+
async getDeploymentLogs(deployment_id) {
|
|
95
|
+
return data(this.client.get(`/deployment/${deployment_id}/logs`));
|
|
96
|
+
}
|
|
82
97
|
// Phase transitions the CLI owns (BUILDING when the build starts,
|
|
83
98
|
// BUILD_ERROR on a failed build). Runtime phases stay controller-territory.
|
|
84
99
|
async updateDeploymentStatus(deployment_id, status) {
|
|
@@ -2,10 +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 { plan_summary } from '
|
|
6
|
-
import {
|
|
5
|
+
import { configure_buildpacks, detect_buildpack, plan_summary, get_buildpack, buildpack_names } from '@faabletools/buildpacks';
|
|
6
|
+
import { cmd } from '../../lib/cmd.js';
|
|
7
7
|
import { check_environment } from './check_environment.js';
|
|
8
8
|
import { git_context } from './git_context.js';
|
|
9
|
+
import { deploy_remote } from './remote/index.js';
|
|
9
10
|
import { resolve_app_id } from './resolve_app_id.js';
|
|
10
11
|
import { secrets } from './secrets/index.js';
|
|
11
12
|
import { mark_build_failure, start_log_sync, upload_logs } from './upload_logs.js';
|
|
@@ -35,10 +36,23 @@ const deploy = {
|
|
|
35
36
|
choices: buildpack_names(),
|
|
36
37
|
description: 'Force a specific buildpack (overrides auto-detection and faable.json)'
|
|
37
38
|
})
|
|
39
|
+
.option('remote', {
|
|
40
|
+
type: 'boolean',
|
|
41
|
+
description: 'Build server-side (remote build), regardless of the app build_mode'
|
|
42
|
+
})
|
|
43
|
+
.option('local', {
|
|
44
|
+
type: 'boolean',
|
|
45
|
+
description: 'Build locally with Docker, even if the app is set to remote builds'
|
|
46
|
+
})
|
|
47
|
+
.conflicts('remote', 'local')
|
|
38
48
|
.showHelpOnFail(false);
|
|
39
49
|
},
|
|
40
50
|
handler: async (args) => {
|
|
41
51
|
const workdir = args.workdir || process.cwd();
|
|
52
|
+
// Wire the shared buildpacks package to the CLI's sinks: pino (which tees
|
|
53
|
+
// into the build-log buffer) and cmd() (which captures subprocess output
|
|
54
|
+
// into the same buffer). Must happen before any detect/build call.
|
|
55
|
+
configure_buildpacks({ log, exec: cmd });
|
|
42
56
|
const ctx = await requireApi();
|
|
43
57
|
const { api } = ctx;
|
|
44
58
|
const config = Configuration.instance().deployConfig();
|
|
@@ -73,62 +87,87 @@ const deploy = {
|
|
|
73
87
|
}
|
|
74
88
|
throw error;
|
|
75
89
|
}
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
90
|
+
// Remote build path (v2, arch/deploy/deploy-v2-remote-build.md): the
|
|
91
|
+
// server decides via the app's build_mode; --remote/--local override for
|
|
92
|
+
// testing. Pre-build failures with a server-decided mode fall back to the
|
|
93
|
+
// local build below (deploy_remote returns null); with --remote they
|
|
94
|
+
// fail hard.
|
|
95
|
+
let deployment;
|
|
96
|
+
const want_remote = !args.local && (Boolean(args.remote) || app.build_mode === 'remote');
|
|
97
|
+
const remote_deployment = want_remote
|
|
98
|
+
? await deploy_remote({
|
|
99
|
+
api,
|
|
100
|
+
app,
|
|
101
|
+
plan,
|
|
102
|
+
git,
|
|
103
|
+
workdir,
|
|
104
|
+
explicit: Boolean(args.remote)
|
|
105
|
+
})
|
|
106
|
+
: null;
|
|
107
|
+
if (remote_deployment) {
|
|
108
|
+
deployment = remote_deployment;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
if (want_remote) {
|
|
112
|
+
log.warn('↩️ Falling back to a local build');
|
|
99
113
|
}
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
// Create-first: register the deployment BEFORE building. Gate rejections
|
|
115
|
+
// (free-plan quota 429, disabled app 409) surface here, at second 0 —
|
|
116
|
+
// before any build minutes are spent. The row is born QUEUED; the CLI
|
|
117
|
+
// owns it until the built image lands (or the build fails). `type` rides
|
|
118
|
+
// on the create — the buildpack plan is already resolved.
|
|
119
|
+
deployment = await api.createDeployment({
|
|
120
|
+
app_id: app.id,
|
|
121
|
+
type: plan.type,
|
|
122
|
+
...git
|
|
123
|
+
});
|
|
106
124
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
});
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
125
|
+
// Check if we can build docker images
|
|
126
|
+
await check_environment();
|
|
127
|
+
const runtime_label = plan.runtime.version
|
|
128
|
+
? `${plan.runtime.name}-${plan.runtime.version}`
|
|
129
|
+
: plan.runtime.name;
|
|
130
|
+
log.info(`🚀 Deploying "${app.name}" (${app.id}) runtime=${runtime_label}`);
|
|
131
|
+
log.info(`🧩 Build plan ${plan_summary(plan)}`);
|
|
132
|
+
// get environment variables
|
|
133
|
+
const env_vars = await api.getAppSecrets(app.id);
|
|
134
|
+
const buildpack = get_buildpack(plan.buildpack);
|
|
135
|
+
if (!buildpack) {
|
|
136
|
+
throw new Error(`No buildpack registered for plan=${plan.buildpack}`);
|
|
137
|
+
}
|
|
138
|
+
// The build starts now: declare it (QUEUED → BUILDING) and stream the
|
|
139
|
+
// captured output to the deployment while it runs (best-effort).
|
|
140
|
+
await api
|
|
141
|
+
.updateDeploymentStatus(deployment.id, { phase: 'BUILDING' })
|
|
142
|
+
.catch((error) => log.warn(`Could not mark deployment BUILDING: ${error.message}`));
|
|
143
|
+
const stop_log_sync = start_log_sync(api, deployment.id);
|
|
144
|
+
try {
|
|
145
|
+
await buildpack.build({ workdir, config, app, env_vars, deployment }, plan);
|
|
146
|
+
// Upload to Faable registry, tagged by version and pinned to digest
|
|
147
|
+
const { image_ref } = await upload_tag({
|
|
148
|
+
app,
|
|
149
|
+
api,
|
|
150
|
+
deployment_id: deployment.id
|
|
151
|
+
});
|
|
152
|
+
// Complete the deployment with the built image — this is the handoff:
|
|
153
|
+
// the controller claims it and materializes the workload.
|
|
154
|
+
await api.completeDeployment(deployment.id, image_ref);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
stop_log_sync();
|
|
158
|
+
}
|
|
117
159
|
}
|
|
118
|
-
|
|
119
|
-
|
|
160
|
+
catch (error) {
|
|
161
|
+
// The deployment already exists (created pre-build), so a failed build
|
|
162
|
+
// marks THAT row BUILD_ERROR with the captured logs — no extra row, no
|
|
163
|
+
// second quota hit. Gate rejections can't reach here anymore: the
|
|
164
|
+
// create happens before any building.
|
|
165
|
+
await mark_build_failure(api, { deployment_id: deployment.id, app });
|
|
166
|
+
throw error;
|
|
120
167
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// marks THAT row BUILD_ERROR with the captured logs — no extra row, no
|
|
125
|
-
// second quota hit. Gate rejections can't reach here anymore: the
|
|
126
|
-
// create happens before any building.
|
|
127
|
-
await mark_build_failure(api, { deployment_id: deployment.id, app });
|
|
128
|
-
throw error;
|
|
129
|
-
}
|
|
130
|
-
// Attach the final build output to the deployment (best-effort).
|
|
131
|
-
await upload_logs(api, deployment.id);
|
|
168
|
+
// Attach the final build output to the deployment (best-effort).
|
|
169
|
+
await upload_logs(api, deployment.id);
|
|
170
|
+
} // end local build path (remote builds upload their own logs server-side)
|
|
132
171
|
const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
|
|
133
172
|
log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
|
|
134
173
|
log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { log } from '../../../log.js';
|
|
2
|
+
|
|
3
|
+
const POLL_INTERVAL_MS = 4000;
|
|
4
|
+
// Queue wait + build must fit here; the server's own stops (Job deadline
|
|
5
|
+
// 30min, abandoned janitor 60min) resolve the deployment first in practice.
|
|
6
|
+
const FOLLOW_TIMEOUT_MS = 45 * 60 * 1000;
|
|
7
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8
|
+
/**
|
|
9
|
+
* Follow a remote build until the image handoff: poll the phase and tail the
|
|
10
|
+
* build logs the builder uploads every ~10s (replace semantics → print the
|
|
11
|
+
* suffix beyond what we already showed). Returns on INITIALIZING/READY
|
|
12
|
+
* (image landed — the regular promotion poll takes over); throws on
|
|
13
|
+
* BUILD_ERROR/ERROR with the server-provided reason.
|
|
14
|
+
*/
|
|
15
|
+
const follow_remote_build = async (api, deployment_id) => {
|
|
16
|
+
let printed = 0;
|
|
17
|
+
let last_phase = "";
|
|
18
|
+
const started = Date.now();
|
|
19
|
+
const tail_logs = async () => {
|
|
20
|
+
try {
|
|
21
|
+
const logs = await api.getDeploymentLogs(deployment_id);
|
|
22
|
+
const content = logs?.content ?? "";
|
|
23
|
+
if (content.length > printed) {
|
|
24
|
+
process.stdout.write(content.slice(printed));
|
|
25
|
+
printed = content.length;
|
|
26
|
+
}
|
|
27
|
+
else if (content.length < printed) {
|
|
28
|
+
// Keep-tail truncation rotated the buffer — resync silently.
|
|
29
|
+
printed = content.length;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// No logs yet (404) or transient failure — next poll.
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
while (Date.now() - started < FOLLOW_TIMEOUT_MS) {
|
|
37
|
+
const deployment = await api
|
|
38
|
+
.getDeployment(deployment_id)
|
|
39
|
+
.catch(() => null);
|
|
40
|
+
const phase = deployment?.status?.phase ?? "";
|
|
41
|
+
if (phase !== last_phase && phase) {
|
|
42
|
+
log.info(`☁️ Remote build: ${phase}`);
|
|
43
|
+
last_phase = phase;
|
|
44
|
+
}
|
|
45
|
+
await tail_logs();
|
|
46
|
+
if (phase === "BUILD_ERROR" || phase === "ERROR") {
|
|
47
|
+
const reason = deployment?.status?.reason;
|
|
48
|
+
throw new Error(`Remote build failed${reason ? `: ${reason}` : ""}`.trim());
|
|
49
|
+
}
|
|
50
|
+
// Image handoff done: the controller claimed it (INITIALIZING) or it is
|
|
51
|
+
// already live (READY) — the promotion poll takes over from here.
|
|
52
|
+
if (phase === "INITIALIZING" || phase === "READY") {
|
|
53
|
+
await tail_logs();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await wait(POLL_INTERVAL_MS);
|
|
57
|
+
}
|
|
58
|
+
throw new Error("Timed out waiting for the remote build. Check the dashboard for its status.");
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { follow_remote_build };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { log } from '../../../log.js';
|
|
2
|
+
import { follow_remote_build } from './follow.js';
|
|
3
|
+
import { collect_manifest } from './manifest.js';
|
|
4
|
+
import { upload_missing_blobs } from './upload.js';
|
|
5
|
+
|
|
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.
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
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
|
+
}
|
|
43
|
+
log.info(`Preparing to build in faable cloud · ${deployment.id}`);
|
|
44
|
+
await follow_remote_build(api, deployment.id);
|
|
45
|
+
return deployment;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export { deploy_remote };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { lstatSync, readFileSync } from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { cmd } from '../../../lib/cmd.js';
|
|
5
|
+
|
|
6
|
+
// Server-side admission caps (mirror DeploymentSource on the API); checking
|
|
7
|
+
// here fails fast with a clearer message than a 400.
|
|
8
|
+
const MAX_FILES = 20_000;
|
|
9
|
+
const MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
10
|
+
/**
|
|
11
|
+
* Collect the source tree for a remote build: exactly what git tracks (plus
|
|
12
|
+
* untracked-but-not-ignored files), hashed for the CAS. Using git as the
|
|
13
|
+
* collector means the upload is "the source" — build artifacts, node_modules
|
|
14
|
+
* and local junk stay out via .gitignore, and the remote build re-derives
|
|
15
|
+
* them like a fresh clone would.
|
|
16
|
+
*
|
|
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).
|
|
20
|
+
*/
|
|
21
|
+
const collect_manifest = async (workdir) => {
|
|
22
|
+
const inside = await cmd(`git rev-parse --is-inside-work-tree`, {
|
|
23
|
+
cwd: workdir,
|
|
24
|
+
}).catch(() => null);
|
|
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.");
|
|
27
|
+
}
|
|
28
|
+
// -c/-o: tracked + untracked; --exclude-standard: .gitignore/.git/info.
|
|
29
|
+
// -z: NUL-separated (paths with spaces/UTF-8 survive).
|
|
30
|
+
const out = await cmd(`git ls-files -co --exclude-standard -z`, {
|
|
31
|
+
cwd: workdir,
|
|
32
|
+
});
|
|
33
|
+
const rel_paths = String(out.stdout)
|
|
34
|
+
.split("\0")
|
|
35
|
+
.filter((p) => p.length > 0);
|
|
36
|
+
if (rel_paths.length === 0) {
|
|
37
|
+
throw new Error("No files to upload (empty git tree?)");
|
|
38
|
+
}
|
|
39
|
+
if (rel_paths.length > MAX_FILES) {
|
|
40
|
+
throw new Error(`Too many files for a remote build (${rel_paths.length} > ${MAX_FILES}).`);
|
|
41
|
+
}
|
|
42
|
+
const manifest = [];
|
|
43
|
+
for (const rel of rel_paths) {
|
|
44
|
+
const abs = path.join(workdir, rel);
|
|
45
|
+
// lstat: a symlink must be detected as such, not followed.
|
|
46
|
+
const stat = (() => {
|
|
47
|
+
try {
|
|
48
|
+
return lstatSync(abs, { throwIfNoEntry: false });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
// Listed but unreadable/deleted (e.g. staged deletion) → skip.
|
|
55
|
+
if (!stat)
|
|
56
|
+
continue;
|
|
57
|
+
if (stat.isDirectory())
|
|
58
|
+
continue;
|
|
59
|
+
if (stat.isSymbolicLink()) {
|
|
60
|
+
throw new Error(`Symlinks are not supported in remote builds: ${rel}. Deploy with --local instead.`);
|
|
61
|
+
}
|
|
62
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
63
|
+
throw new Error(`${rel} is too large for a remote build (${stat.size} bytes > ${MAX_FILE_BYTES}).`);
|
|
64
|
+
}
|
|
65
|
+
const body = readFileSync(abs);
|
|
66
|
+
manifest.push({
|
|
67
|
+
// Manifest paths are forward-slash relative (API admission contract).
|
|
68
|
+
path: rel.split(path.sep).join("/"),
|
|
69
|
+
sha: createHash("sha256").update(body).digest("hex"),
|
|
70
|
+
size: stat.size,
|
|
71
|
+
mode: stat.mode & 0o777,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return manifest;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export { collect_manifest };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { log } from '../../../log.js';
|
|
5
|
+
|
|
6
|
+
const PUT_CONCURRENCY = 8;
|
|
7
|
+
const PUT_RETRIES = 2;
|
|
8
|
+
/**
|
|
9
|
+
* Delta upload to the CAS: ask the API which blobs are missing and PUT only
|
|
10
|
+
* those (presigned URLs, sha256-pinned by the signature — S3 rejects content
|
|
11
|
+
* that doesn't match). Redeploys upload just what changed.
|
|
12
|
+
*/
|
|
13
|
+
const upload_missing_blobs = async (api, app_id, workdir, manifest) => {
|
|
14
|
+
const { uploads } = await api.uploadMissing(app_id, manifest);
|
|
15
|
+
if (uploads.length === 0) {
|
|
16
|
+
log.info(`📦 Source already in the build store (0 files to upload)`);
|
|
17
|
+
return { uploaded: 0, bytes: 0 };
|
|
18
|
+
}
|
|
19
|
+
// First path wins per sha — content is identical by definition.
|
|
20
|
+
const by_sha = new Map();
|
|
21
|
+
for (const file of manifest) {
|
|
22
|
+
if (!by_sha.has(file.sha))
|
|
23
|
+
by_sha.set(file.sha, file);
|
|
24
|
+
}
|
|
25
|
+
let bytes = 0;
|
|
26
|
+
let queue = 0;
|
|
27
|
+
const worker = async () => {
|
|
28
|
+
while (queue < uploads.length) {
|
|
29
|
+
const upload = uploads[queue++];
|
|
30
|
+
const file = by_sha.get(upload.sha);
|
|
31
|
+
if (!file)
|
|
32
|
+
continue; // server echoed a sha we didn't send — ignore
|
|
33
|
+
const body = readFileSync(path.join(workdir, file.path));
|
|
34
|
+
let attempt = 0;
|
|
35
|
+
for (;;) {
|
|
36
|
+
try {
|
|
37
|
+
await axios.put(upload.url, body, {
|
|
38
|
+
headers: {
|
|
39
|
+
...upload.headers,
|
|
40
|
+
"content-type": "application/octet-stream",
|
|
41
|
+
},
|
|
42
|
+
maxBodyLength: Infinity,
|
|
43
|
+
timeout: 120_000,
|
|
44
|
+
});
|
|
45
|
+
bytes += body.length;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (attempt++ >= PUT_RETRIES) {
|
|
50
|
+
throw new Error(`Upload failed for ${file.path}: ${error?.message}`, { cause: error });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
await Promise.all(Array.from({ length: Math.min(PUT_CONCURRENCY, uploads.length) }, worker));
|
|
57
|
+
log.info(`📤 Uploaded ${uploads.length} changed files (${(bytes / 1024).toFixed(0)} KB) — delta only`);
|
|
58
|
+
return { uploaded: uploads.length, bytes };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { upload_missing_blobs };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faable/faable",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Pomar <marc@faable.com>",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@actions/core": "^3.0.0",
|
|
30
|
+
"@faabletools/buildpacks": "^1.0.0",
|
|
30
31
|
"axios": "^1.13.2",
|
|
31
32
|
"fs-extra": "^11.3.2",
|
|
32
33
|
"handlebars": "^4.7.8",
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/** One-line summary logged before build so the decision is reconstructible. */
|
|
2
|
-
const plan_summary = (plan) => JSON.stringify({
|
|
3
|
-
buildpack: plan.buildpack,
|
|
4
|
-
type: plan.type,
|
|
5
|
-
runtime: `${plan.runtime.name}${plan.runtime.version ? `-${plan.runtime.version}` : ""}`,
|
|
6
|
-
start: plan.start_command,
|
|
7
|
-
...(plan.install_command ? { install: plan.install_command } : {}),
|
|
8
|
-
...(plan.build_script ? { build_script: plan.build_script } : {}),
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
export { plan_summary };
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import fs from 'fs-extra';
|
|
2
|
-
import path__default from 'path';
|
|
3
|
-
import { FOREIGN_PLATFORMS } from './foreign_platforms.js';
|
|
4
|
-
|
|
5
|
-
const DOCS_URL = "https://faable.com/docs/deploy/build-requirements";
|
|
6
|
-
const MAX_LISTED_FILES = 20;
|
|
7
|
-
/** Workdir listing for diagnostics: dirs suffixed "/", noise skipped, capped. */
|
|
8
|
-
const list_found_files = (workdir) => {
|
|
9
|
-
try {
|
|
10
|
-
return fs
|
|
11
|
-
.readdirSync(workdir, { withFileTypes: true })
|
|
12
|
-
.filter((e) => ![".git", "node_modules"].includes(e.name))
|
|
13
|
-
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
14
|
-
.sort()
|
|
15
|
-
.slice(0, MAX_LISTED_FILES);
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
return [];
|
|
19
|
-
}
|
|
20
|
-
};
|
|
21
|
-
const render_message = (props) => {
|
|
22
|
-
const pad = Math.max(...props.diagnostics.map((d) => d.buildpack.length + 11));
|
|
23
|
-
const looked = props.diagnostics
|
|
24
|
-
.flatMap((d) => {
|
|
25
|
-
const lines = [
|
|
26
|
-
` ${d.buildpack.padEnd(pad)} → ${d.looked_for.join(", ")}`,
|
|
27
|
-
];
|
|
28
|
-
if (d.fallback?.length) {
|
|
29
|
-
lines.push(` ${`${d.buildpack} (fallback)`.padEnd(pad)} → ${d.fallback.join(", ")}`);
|
|
30
|
-
}
|
|
31
|
-
return lines;
|
|
32
|
-
})
|
|
33
|
-
.join("\n");
|
|
34
|
-
const found = props.found_files.length > 0
|
|
35
|
-
? `\n\nFiles found in ${props.workdir}:\n ${props.found_files.join(", ")}`
|
|
36
|
-
: `\n\nNo files found in ${props.workdir}.`;
|
|
37
|
-
const foreign = props.foreign.length > 0
|
|
38
|
-
? `\n\nFound config for another platform:\n${props.foreign
|
|
39
|
-
.map((f) => ` ${f.file} → ${f.platform} — Faable can't use it directly.`)
|
|
40
|
-
.join("\n")}`
|
|
41
|
-
: "";
|
|
42
|
-
return (`Cannot detect how to build this project.\n\n` +
|
|
43
|
-
`Faable looked for (in order):\n${looked}` +
|
|
44
|
-
found +
|
|
45
|
-
foreign +
|
|
46
|
-
`\n\nFix: add one of the files above, or force a buildpack with "buildpack" in faable.json or --buildpack.\n` +
|
|
47
|
-
`Docs: ${DOCS_URL}`);
|
|
48
|
-
};
|
|
49
|
-
/**
|
|
50
|
-
* Thrown by the registry when no buildpack claims the project. The full
|
|
51
|
-
* multi-line diagnostic lives in `message`, so the CLI's standard error path
|
|
52
|
-
* (yargs .fail → log.error → exit 1) prints it without special handling.
|
|
53
|
-
*/
|
|
54
|
-
class DetectError extends Error {
|
|
55
|
-
workdir;
|
|
56
|
-
diagnostics;
|
|
57
|
-
found_files;
|
|
58
|
-
foreign;
|
|
59
|
-
constructor(workdir, buildpacks) {
|
|
60
|
-
const diagnostics = buildpacks.map((b) => ({
|
|
61
|
-
buildpack: b.name,
|
|
62
|
-
looked_for: b.detect_files,
|
|
63
|
-
...(b.fallback_files ? { fallback: b.fallback_files } : {}),
|
|
64
|
-
}));
|
|
65
|
-
const found_files = list_found_files(workdir);
|
|
66
|
-
const foreign = Object.entries(FOREIGN_PLATFORMS)
|
|
67
|
-
.filter(([file]) => fs.existsSync(path__default.join(workdir, file)))
|
|
68
|
-
.map(([file, platform]) => ({ file, platform }));
|
|
69
|
-
super(render_message({ workdir, diagnostics, found_files, foreign }));
|
|
70
|
-
this.name = "DetectError";
|
|
71
|
-
this.workdir = workdir;
|
|
72
|
-
this.diagnostics = diagnostics;
|
|
73
|
-
this.found_files = found_files;
|
|
74
|
-
this.foreign = foreign;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export { DetectError };
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import path__default from 'path';
|
|
2
|
-
import { cmd } from '../../../../lib/cmd.js';
|
|
3
|
-
import { log } from '../../../../log.js';
|
|
4
|
-
import { has_any_of_files } from '../shared/has_any_of_files.js';
|
|
5
|
-
import { read_json_file } from '../shared/read_text_file.js';
|
|
6
|
-
|
|
7
|
-
// A package.json with a next dependency beside the Dockerfile means this is a
|
|
8
|
-
// Next.js app shipped with a custom image: emit type "next" so the backend
|
|
9
|
-
// provisions the build-cache PVC (runtime_strategy). Reachable via the
|
|
10
|
-
// --buildpack override, since the node buildpack claims package.json first.
|
|
11
|
-
const detect_next = (workdir) => {
|
|
12
|
-
try {
|
|
13
|
-
const pkg = read_json_file(path__default.join(workdir, "package.json"));
|
|
14
|
-
return Boolean(pkg?.dependencies?.next || pkg?.devDependencies?.next);
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
const docker_buildpack = {
|
|
21
|
-
name: "docker",
|
|
22
|
-
detect_files: ["Dockerfile"],
|
|
23
|
-
async detect(ctx) {
|
|
24
|
-
if (!has_any_of_files(this.detect_files, ctx.workdir))
|
|
25
|
-
return null;
|
|
26
|
-
const type = detect_next(ctx.workdir) ? "next" : "node";
|
|
27
|
-
return {
|
|
28
|
-
buildpack: "docker",
|
|
29
|
-
runtime: { name: "docker" },
|
|
30
|
-
type,
|
|
31
|
-
// The user's image defines its own CMD/ENTRYPOINT.
|
|
32
|
-
start_command: null,
|
|
33
|
-
};
|
|
34
|
-
},
|
|
35
|
-
async build(ctx) {
|
|
36
|
-
log.info(`📦 Building with the project's own Dockerfile`);
|
|
37
|
-
const timeout = 10 * 60 * 1000; // 10 minute timeout
|
|
38
|
-
await cmd(`docker build --platform linux/amd64 -t ${ctx.app.id} ${ctx.workdir} -f ${path__default.join(ctx.workdir, "Dockerfile")}`, { timeout, enableOutput: true });
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export { docker_buildpack };
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Config files from other deployment platforms. Recognizing them turns the
|
|
3
|
-
* detection-failure error from "nothing found" into "this repo is set up for
|
|
4
|
-
* X — Faable can't use that file directly". cerebrium.toml is deliberately
|
|
5
|
-
* absent (it has a real provider); Procfile too (it's a Faable input).
|
|
6
|
-
*/
|
|
7
|
-
const FOREIGN_PLATFORMS = {
|
|
8
|
-
"cog.yaml": "Replicate",
|
|
9
|
-
"fly.toml": "Fly.io",
|
|
10
|
-
"render.yaml": "Render",
|
|
11
|
-
"vercel.json": "Vercel",
|
|
12
|
-
"now.json": "Vercel",
|
|
13
|
-
"netlify.toml": "Netlify",
|
|
14
|
-
"app.yaml": "Google App Engine",
|
|
15
|
-
"railway.json": "Railway",
|
|
16
|
-
"railway.toml": "Railway",
|
|
17
|
-
"heroku.yml": "Heroku",
|
|
18
|
-
"captain-definition": "CapRover",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export { FOREIGN_PLATFORMS };
|