@faable/faable 1.9.0 → 1.11.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/commands/deploy/buildpacks/DetectError.js +78 -0
- package/dist/commands/deploy/buildpacks/foreign_platforms.js +21 -0
- package/dist/commands/deploy/buildpacks/node/ensure_dependencies.js +3 -1
- package/dist/commands/deploy/buildpacks/python/index.js +53 -3
- package/dist/commands/deploy/buildpacks/python/providers/cerebrium.js +43 -0
- package/dist/commands/deploy/buildpacks/python/providers/parse_cerebrium_toml.js +56 -0
- package/dist/commands/deploy/buildpacks/registry.js +2 -1
- package/dist/commands/deploy/index.js +2 -2
- package/package.json +2 -1
|
@@ -0,0 +1,78 @@
|
|
|
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 };
|
|
@@ -0,0 +1,21 @@
|
|
|
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 };
|
|
@@ -46,8 +46,10 @@ const has_node_modules = (workdir, exists) => {
|
|
|
46
46
|
*/
|
|
47
47
|
const ensure_dependencies = async (workdir, deps = {}) => {
|
|
48
48
|
const { run = defaultRun, hasTool = defaultHasTool, exists = existsSync } = deps;
|
|
49
|
-
if (has_node_modules(workdir, exists))
|
|
49
|
+
if (has_node_modules(workdir, exists)) {
|
|
50
|
+
log.info(`📦 Dependencies already installed — skipping install (fresh dependencies)`);
|
|
50
51
|
return;
|
|
52
|
+
}
|
|
51
53
|
const has = (file) => exists(join(workdir, file));
|
|
52
54
|
let install = "npm install --no-audit --no-fund";
|
|
53
55
|
if (has("package-lock.json") || has("npm-shrinkwrap.json")) {
|
|
@@ -1,22 +1,29 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path__default from 'path';
|
|
1
3
|
import { log } from '../../../../log.js';
|
|
2
4
|
import { render_dockerfile, build_image } from '../shared/docker_image.js';
|
|
3
5
|
import { has_any_of_files } from '../shared/has_any_of_files.js';
|
|
6
|
+
import { cerebrium_provider } from './providers/cerebrium.js';
|
|
4
7
|
import { pipfile_provider } from './providers/pipfile.js';
|
|
5
8
|
import { pyproject_provider } from './providers/pyproject.js';
|
|
6
9
|
import { requirements_provider } from './providers/requirements.js';
|
|
7
10
|
import { resolve_python_version } from './python_version.js';
|
|
8
|
-
import {
|
|
11
|
+
import { resolve_start, with_server_injection, read_dependencies_text } from './resolve_start.js';
|
|
9
12
|
|
|
10
13
|
const BANNER = `PYTHON_VERSION=$(python --version 2>&1)
|
|
11
14
|
PIP_VERSION=$(pip --version 2>&1)
|
|
12
15
|
|
|
13
16
|
echo "Faable Cloud · [$PYTHON_VERSION] [$PIP_VERSION]"`;
|
|
17
|
+
const DOCS_URL = "https://faable.com/docs/deploy/build-requirements";
|
|
14
18
|
// Evaluated in order: the first provider whose manifest exists supplies the
|
|
15
|
-
// install command. Classic manifests come before platform-specific ones
|
|
19
|
+
// install command. Classic manifests come before platform-specific ones, so
|
|
20
|
+
// a repo shipping both requirements.txt and cerebrium.toml builds from the
|
|
21
|
+
// standard manifest.
|
|
16
22
|
const PROVIDERS = [
|
|
17
23
|
requirements_provider,
|
|
18
24
|
pyproject_provider,
|
|
19
25
|
pipfile_provider,
|
|
26
|
+
cerebrium_provider,
|
|
20
27
|
];
|
|
21
28
|
const detect_with_provider = (ctx, provider) => {
|
|
22
29
|
const resolved = provider.resolve(ctx.workdir);
|
|
@@ -25,7 +32,19 @@ const detect_with_provider = (ctx, provider) => {
|
|
|
25
32
|
const deps = [read_dependencies_text(ctx.workdir), resolved.deps_text ?? ""]
|
|
26
33
|
.join("\n")
|
|
27
34
|
.toLowerCase();
|
|
28
|
-
|
|
35
|
+
let start_command;
|
|
36
|
+
let server;
|
|
37
|
+
try {
|
|
38
|
+
({ start_command, server } = resolve_start(ctx.workdir, deps, ctx.config, resolved.start_hint));
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (provider.name === "cerebrium") {
|
|
42
|
+
throw new Error("Detected a Cerebrium project (cerebrium.toml) but couldn't find a web " +
|
|
43
|
+
"entrypoint. Faable runs web services — expose a FastAPI/Flask app or " +
|
|
44
|
+
`set \`startCommand\` in faable.json. Docs: ${DOCS_URL}`, { cause: error });
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
29
48
|
// faable.json buildCommand overrides the provider's install. The cacheable
|
|
30
49
|
// manifest layer only applies to the provider's own command — an arbitrary
|
|
31
50
|
// buildCommand may need the full source.
|
|
@@ -51,12 +70,43 @@ const detect_with_provider = (ctx, provider) => {
|
|
|
51
70
|
const python_buildpack = {
|
|
52
71
|
name: "python",
|
|
53
72
|
detect_files: PROVIDERS.flatMap((p) => p.files),
|
|
73
|
+
fallback_files: ["main.py", "app.py", "wsgi.py"],
|
|
54
74
|
async detect(ctx) {
|
|
55
75
|
const provider = PROVIDERS.find((p) => has_any_of_files(p.files, ctx.workdir));
|
|
56
76
|
if (!provider)
|
|
57
77
|
return null;
|
|
58
78
|
return detect_with_provider(ctx, provider);
|
|
59
79
|
},
|
|
80
|
+
// Weak-signal pass: a Python entrypoint with no dependency manifest at all.
|
|
81
|
+
// Registered as fallback so it loses against any strong trigger (Dockerfile).
|
|
82
|
+
async detect_fallback(ctx) {
|
|
83
|
+
const entries = this.fallback_files.filter((f) => fs.existsSync(path__default.join(ctx.workdir, f)));
|
|
84
|
+
if (entries.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
log.warn(`⚠️ No dependency manifest (requirements.txt/pyproject.toml/Pipfile) found — ` +
|
|
87
|
+
`building from ${entries.join(", ")} without installing dependencies. ` +
|
|
88
|
+
`Your app will likely need a requirements.txt; see ${DOCS_URL}`);
|
|
89
|
+
// Framework detection blob = the entry files themselves: an
|
|
90
|
+
// `from fastapi import FastAPI` line carries the token detection needs.
|
|
91
|
+
const deps = entries
|
|
92
|
+
.map((f) => fs.readFileSync(path__default.join(ctx.workdir, f)).toString())
|
|
93
|
+
.join("\n")
|
|
94
|
+
.toLowerCase();
|
|
95
|
+
const { start_command, server } = resolve_start(ctx.workdir, deps, ctx.config);
|
|
96
|
+
const install_command = with_server_injection(ctx.config.buildCommand, server, deps);
|
|
97
|
+
const version = resolve_python_version(ctx.workdir);
|
|
98
|
+
log.info(`Using python@${version}`);
|
|
99
|
+
log.info(`📦 Install command: ${install_command}`);
|
|
100
|
+
log.info(`⚙️ Start command: ${start_command}`);
|
|
101
|
+
return {
|
|
102
|
+
buildpack: "python",
|
|
103
|
+
runtime: { name: "python", version },
|
|
104
|
+
type: "python",
|
|
105
|
+
start_command,
|
|
106
|
+
install_command,
|
|
107
|
+
from: `python:${version}`,
|
|
108
|
+
};
|
|
109
|
+
},
|
|
60
110
|
async build(ctx, plan) {
|
|
61
111
|
// Dependencies install inside the image (unlike node); nothing runs on
|
|
62
112
|
// the host here.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path__default from 'path';
|
|
3
|
+
import { parse_cerebrium_toml } from './parse_cerebrium_toml.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cerebrium projects (cerebrium.toml + a Python entrypoint) declare their pip
|
|
7
|
+
* dependencies and python version in the toml instead of a requirements.txt.
|
|
8
|
+
* This provider translates that manifest so the standard python buildpack can
|
|
9
|
+
* build them — the masyosh/cere onboarding case.
|
|
10
|
+
*/
|
|
11
|
+
const cerebrium_provider = {
|
|
12
|
+
name: "cerebrium",
|
|
13
|
+
files: ["cerebrium.toml"],
|
|
14
|
+
resolve(workdir) {
|
|
15
|
+
const manifest = parse_cerebrium_toml(fs.readFileSync(path__default.join(workdir, "cerebrium.toml")).toString());
|
|
16
|
+
let install_command;
|
|
17
|
+
let install_files;
|
|
18
|
+
if (manifest.pip_requirements_file) {
|
|
19
|
+
install_command = `pip install --no-cache-dir -r ${manifest.pip_requirements_file}`;
|
|
20
|
+
// Copying the referenced file AND the toml ties the cached layer to both.
|
|
21
|
+
install_files = [manifest.pip_requirements_file, "cerebrium.toml"];
|
|
22
|
+
}
|
|
23
|
+
else if (manifest.pip_packages.length > 0) {
|
|
24
|
+
install_command = `pip install --no-cache-dir ${manifest.pip_packages
|
|
25
|
+
.map((spec) => `"${spec}"`)
|
|
26
|
+
.join(" ")}`;
|
|
27
|
+
// The command inlines the packages; copying the toml keeps the layer
|
|
28
|
+
// cache keyed to the manifest content.
|
|
29
|
+
install_files = ["cerebrium.toml"];
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
install_command,
|
|
33
|
+
install_files,
|
|
34
|
+
python_version: manifest.python_version,
|
|
35
|
+
// Feed the pip package names into the framework-detection blob so e.g.
|
|
36
|
+
// `fastapi` in the toml triggers the existing uvicorn start resolution.
|
|
37
|
+
deps_text: manifest.pip_names.join("\n"),
|
|
38
|
+
start_hint: manifest.entrypoint,
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export { cerebrium_provider };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { parse } from 'smol-toml';
|
|
2
|
+
import { log } from '../../../../../log.js';
|
|
3
|
+
|
|
4
|
+
const table = (value) => value && typeof value === "object" && !Array.isArray(value)
|
|
5
|
+
? value
|
|
6
|
+
: {};
|
|
7
|
+
/**
|
|
8
|
+
* Turn the pip table into installable requirement specs. Cerebrium uses
|
|
9
|
+
* `"latest"` / `""` for unpinned packages; anything else is a version spec —
|
|
10
|
+
* exact pins (`"2.0.0"`) become `==`, ranges (`">=2.0"`) pass through.
|
|
11
|
+
*/
|
|
12
|
+
const to_spec = (name, version) => {
|
|
13
|
+
const v = String(version ?? "").trim();
|
|
14
|
+
if (!v || v.toLowerCase() === "latest")
|
|
15
|
+
return name;
|
|
16
|
+
if (/^[0-9]/.test(v))
|
|
17
|
+
return `${name}==${v}`;
|
|
18
|
+
return `${name}${v}`;
|
|
19
|
+
};
|
|
20
|
+
const parse_cerebrium_toml = (content) => {
|
|
21
|
+
const root = table(table(parse(content)).cerebrium);
|
|
22
|
+
const deployment = table(root.deployment);
|
|
23
|
+
const dependencies = table(root.dependencies);
|
|
24
|
+
const pip = table(dependencies.pip);
|
|
25
|
+
const paths = table(dependencies.paths);
|
|
26
|
+
const custom = table(table(root.runtime).custom);
|
|
27
|
+
for (const skipped of ["apt", "conda"]) {
|
|
28
|
+
if (Object.keys(table(dependencies[skipped])).length > 0) {
|
|
29
|
+
log.warn(`cerebrium.toml declares ${skipped} dependencies — Faable only installs the pip table, ${skipped} packages are ignored`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const pip_names = Object.keys(pip);
|
|
33
|
+
const pip_packages = pip_names.map((name) => to_spec(name, pip[name]));
|
|
34
|
+
const entrypoint_raw = custom.entrypoint;
|
|
35
|
+
const entrypoint = Array.isArray(entrypoint_raw)
|
|
36
|
+
? entrypoint_raw.map(String).join(" ")
|
|
37
|
+
: typeof entrypoint_raw === "string" && entrypoint_raw.trim()
|
|
38
|
+
? entrypoint_raw.trim()
|
|
39
|
+
: undefined;
|
|
40
|
+
const python_version = typeof deployment.python_version === "string" &&
|
|
41
|
+
deployment.python_version.trim()
|
|
42
|
+
? deployment.python_version.trim()
|
|
43
|
+
: undefined;
|
|
44
|
+
const pip_requirements_file = typeof paths.pip === "string" && paths.pip.trim()
|
|
45
|
+
? paths.pip.trim()
|
|
46
|
+
: undefined;
|
|
47
|
+
return {
|
|
48
|
+
python_version,
|
|
49
|
+
pip_packages,
|
|
50
|
+
pip_names,
|
|
51
|
+
pip_requirements_file,
|
|
52
|
+
entrypoint,
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export { parse_cerebrium_toml };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DetectError } from './DetectError.js';
|
|
1
2
|
import { docker_buildpack } from './docker/index.js';
|
|
2
3
|
import { node_buildpack } from './node/index.js';
|
|
3
4
|
import { python_buildpack } from './python/index.js';
|
|
@@ -54,7 +55,7 @@ const detect_buildpack = async (ctx, override) => {
|
|
|
54
55
|
if (plan)
|
|
55
56
|
return plan;
|
|
56
57
|
}
|
|
57
|
-
throw new
|
|
58
|
+
throw new DetectError(ctx.workdir, BUILDPACKS);
|
|
58
59
|
};
|
|
59
60
|
|
|
60
61
|
export { BUILDPACKS, buildpack_names, detect_buildpack, get_buildpack };
|
|
@@ -74,7 +74,7 @@ const deploy = {
|
|
|
74
74
|
...git
|
|
75
75
|
});
|
|
76
76
|
const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
|
|
77
|
-
log.info(
|
|
77
|
+
log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
|
|
78
78
|
log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
|
|
79
79
|
// Wait (up to 5 minutes) for the deployment to be promoted (live)
|
|
80
80
|
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -98,7 +98,7 @@ const deploy = {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
if (promoted) {
|
|
101
|
-
log.info(
|
|
101
|
+
log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
|
|
102
102
|
}
|
|
103
103
|
else {
|
|
104
104
|
log.warn(`⌛ Timed out after 5min waiting for promotion. The deployment is still rolling out, check the dashboard -> ${dashboard_url}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faable/faable",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Pomar <marc@faable.com>",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"promisify-child-process": "^4.1.2",
|
|
38
38
|
"prompts": "^2.4.2",
|
|
39
39
|
"ramda": "^0.32.0",
|
|
40
|
+
"smol-toml": "^1.7.0",
|
|
40
41
|
"tslib": "^2.8.1",
|
|
41
42
|
"yaml": "^2.8.2",
|
|
42
43
|
"yargs": "^18.0.0"
|