@faable/faable 1.7.0 → 1.9.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/Buildpack.js +11 -0
- package/dist/commands/deploy/buildpacks/docker/index.js +42 -0
- package/dist/commands/deploy/{node-pipeline → buildpacks/node}/analyze_package.js +1 -1
- package/dist/commands/deploy/buildpacks/node/build_project.js +21 -0
- package/dist/commands/deploy/buildpacks/node/ensure_dependencies.js +76 -0
- package/dist/commands/deploy/{node-pipeline → buildpacks/node}/frameworks.js +4 -4
- package/dist/commands/deploy/buildpacks/node/index.js +65 -0
- package/dist/commands/deploy/{node-pipeline → buildpacks/node}/inject_serve.js +2 -2
- package/dist/commands/deploy/{runtime-detect/strategies/nodejs.js → buildpacks/node/node_version.js} +7 -15
- package/dist/commands/deploy/buildpacks/python/index.js +76 -0
- package/dist/commands/deploy/buildpacks/python/providers/pipfile.js +13 -0
- package/dist/commands/deploy/buildpacks/python/providers/pyproject.js +13 -0
- package/dist/commands/deploy/buildpacks/python/providers/requirements.js +14 -0
- package/dist/commands/deploy/{runtime-detect/strategies/python.js → buildpacks/python/python_version.js} +6 -14
- package/dist/commands/deploy/{python-pipeline/analyze_python.js → buildpacks/python/resolve_start.js} +28 -38
- package/dist/commands/deploy/buildpacks/registry.js +60 -0
- package/dist/commands/deploy/buildpacks/shared/docker_image.js +49 -0
- package/dist/commands/deploy/{python-pipeline → buildpacks/shared}/templates/Dockerfile +17 -3
- package/dist/commands/deploy/buildpacks/shared/templates/entrypoint.sh +4 -0
- package/dist/commands/deploy/index.js +22 -30
- package/dist/commands/link/workflow_template.js +0 -1
- package/dist/lib/Configuration.js +11 -0
- package/package.json +1 -1
- package/dist/commands/deploy/node-pipeline/build_docker.js +0 -52
- package/dist/commands/deploy/node-pipeline/build_project.js +0 -26
- package/dist/commands/deploy/node-pipeline/index.js +0 -40
- package/dist/commands/deploy/node-pipeline/templates/Dockerfile +0 -18
- package/dist/commands/deploy/node-pipeline/templates/entrypoint.sh +0 -8
- package/dist/commands/deploy/python-pipeline/build_docker.js +0 -42
- package/dist/commands/deploy/python-pipeline/index.js +0 -25
- package/dist/commands/deploy/python-pipeline/templates/entrypoint.sh +0 -7
- package/dist/commands/deploy/runtime-detect/runtime_detection.js +0 -25
- package/dist/commands/deploy/runtime-detect/strategies/docker.js +0 -9
- /package/dist/commands/deploy/{python-pipeline → buildpacks/python}/parse_procfile.js +0 -0
- /package/dist/commands/deploy/{runtime-detect/helpers → buildpacks/shared}/has_any_of_files.js +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
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 };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path__default from 'path';
|
|
3
|
+
import { cmd } from '../../../../lib/cmd.js';
|
|
4
|
+
import { log } from '../../../../log.js';
|
|
5
|
+
import { has_any_of_files } from '../shared/has_any_of_files.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 = fs.readJSONSync(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 };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { log } from '../../../../log.js';
|
|
2
|
+
import { cmd } from '../../../../lib/cmd.js';
|
|
3
|
+
|
|
4
|
+
const build_project = async (args) => {
|
|
5
|
+
const { command, cwd } = args;
|
|
6
|
+
if (command) {
|
|
7
|
+
log.info(`⚙️ Building project [${command}]...`);
|
|
8
|
+
const timeout = 1000 * 60 * 30; // 30 minute timeout
|
|
9
|
+
await cmd(command, {
|
|
10
|
+
timeout,
|
|
11
|
+
cwd,
|
|
12
|
+
enableOutput: true,
|
|
13
|
+
...(args?.env ? { env: args?.env } : {}),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
log.info(`⚡️ No build step`);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export { build_project };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { log } from '../../../../log.js';
|
|
4
|
+
import { cmd } from '../../../../lib/cmd.js';
|
|
5
|
+
|
|
6
|
+
// Installing can be slow on cold CI runners (no npm cache).
|
|
7
|
+
const INSTALL_TIMEOUT = 15 * 60 * 1000; // 15 minute timeout
|
|
8
|
+
const defaultRun = (command, cwd) => cmd(command, { cwd, timeout: INSTALL_TIMEOUT, enableOutput: true });
|
|
9
|
+
const defaultHasTool = async (name) => {
|
|
10
|
+
try {
|
|
11
|
+
await cmd(`command -v ${name}`);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Dependencies may live in the workdir or be hoisted to a parent directory
|
|
20
|
+
* (monorepos). Walk upwards checking each level, stopping at the repo root
|
|
21
|
+
* (first directory containing `.git`) or the filesystem root.
|
|
22
|
+
*/
|
|
23
|
+
const has_node_modules = (workdir, exists) => {
|
|
24
|
+
let dir = workdir;
|
|
25
|
+
while (true) {
|
|
26
|
+
if (exists(join(dir, "node_modules")))
|
|
27
|
+
return true;
|
|
28
|
+
if (exists(join(dir, ".git")))
|
|
29
|
+
return false;
|
|
30
|
+
const parent = dirname(dir);
|
|
31
|
+
if (parent === dir)
|
|
32
|
+
return false;
|
|
33
|
+
dir = parent;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Install dependencies when `node_modules` is missing, so `faable deploy`
|
|
38
|
+
* works without a prior `npm ci` step — the generated GitHub Actions workflow
|
|
39
|
+
* is language-agnostic and no longer installs anything itself. The build
|
|
40
|
+
* (`npm run build`) and the image (`COPY . .` ships node_modules) both need
|
|
41
|
+
* dependencies present on the host.
|
|
42
|
+
*
|
|
43
|
+
* The install command honors the project's lockfile; yarn/pnpm fall back to
|
|
44
|
+
* `npm install` when the tool is missing, which may resolve slightly
|
|
45
|
+
* different versions.
|
|
46
|
+
*/
|
|
47
|
+
const ensure_dependencies = async (workdir, deps = {}) => {
|
|
48
|
+
const { run = defaultRun, hasTool = defaultHasTool, exists = existsSync } = deps;
|
|
49
|
+
if (has_node_modules(workdir, exists))
|
|
50
|
+
return;
|
|
51
|
+
const has = (file) => exists(join(workdir, file));
|
|
52
|
+
let install = "npm install --no-audit --no-fund";
|
|
53
|
+
if (has("package-lock.json") || has("npm-shrinkwrap.json")) {
|
|
54
|
+
install = "npm ci";
|
|
55
|
+
}
|
|
56
|
+
else if (has("yarn.lock")) {
|
|
57
|
+
if (await hasTool("yarn")) {
|
|
58
|
+
install = "yarn install --frozen-lockfile";
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
log.warn("yarn.lock found but yarn is not installed — using npm install");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (has("pnpm-lock.yaml")) {
|
|
65
|
+
if (await hasTool("pnpm")) {
|
|
66
|
+
install = "pnpm install --frozen-lockfile";
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
log.warn("pnpm-lock.yaml found but pnpm is not installed — using npm install");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
log.info(`📦 node_modules missing — installing dependencies [${install}]`);
|
|
73
|
+
await run(install, workdir);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export { ensure_dependencies };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path__default from 'path';
|
|
3
3
|
import * as R from 'ramda';
|
|
4
|
-
import { log } from '
|
|
4
|
+
import { log } from '../../../../log.js';
|
|
5
5
|
|
|
6
6
|
const has_dep = (pkg, name) => Boolean(R.view(R.lensPath(["dependencies", name]), pkg) ||
|
|
7
7
|
R.view(R.lensPath(["devDependencies", name]), pkg));
|
|
@@ -39,13 +39,13 @@ const FRAMEWORKS = [
|
|
|
39
39
|
type: "astro",
|
|
40
40
|
deps: ["astro"],
|
|
41
41
|
outputDir: "dist",
|
|
42
|
-
serveCommand: (
|
|
42
|
+
serveCommand: (_dir) => `npx astro preview --host 0.0.0.0 --port $PORT`,
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
type: "gatsby",
|
|
46
46
|
deps: ["gatsby"],
|
|
47
47
|
outputDir: "public",
|
|
48
|
-
serveCommand: (
|
|
48
|
+
serveCommand: (_dir) => `npx gatsby serve --host 0.0.0.0 --port $PORT`,
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
type: "cra",
|
|
@@ -72,7 +72,7 @@ const FRAMEWORKS = [
|
|
|
72
72
|
type: "vite",
|
|
73
73
|
deps: ["vite"],
|
|
74
74
|
outputDir: "dist",
|
|
75
|
-
serveCommand: (
|
|
75
|
+
serveCommand: (_dir) => `npx vite preview --host 0.0.0.0 --port $PORT`,
|
|
76
76
|
},
|
|
77
77
|
];
|
|
78
78
|
/**
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as R from 'ramda';
|
|
2
|
+
import { log } from '../../../../log.js';
|
|
3
|
+
import { render_dockerfile, build_image } from '../shared/docker_image.js';
|
|
4
|
+
import { has_any_of_files } from '../shared/has_any_of_files.js';
|
|
5
|
+
import { analyze_package } from './analyze_package.js';
|
|
6
|
+
import { build_project } from './build_project.js';
|
|
7
|
+
import { ensure_dependencies } from './ensure_dependencies.js';
|
|
8
|
+
import { inject_serve } from './inject_serve.js';
|
|
9
|
+
import { resolve_node_version } from './node_version.js';
|
|
10
|
+
|
|
11
|
+
const BANNER = `NODE_VERSION=$(node --version)
|
|
12
|
+
NPM_VERSION=$(npm --version)
|
|
13
|
+
YARN_VERSION=$(yarn --version)
|
|
14
|
+
|
|
15
|
+
echo "Faable Cloud · [node $NODE_VERSION] [npm $NPM_VERSION] [yarn $YARN_VERSION]"`;
|
|
16
|
+
const node_buildpack = {
|
|
17
|
+
name: "node",
|
|
18
|
+
detect_files: ["package.json"],
|
|
19
|
+
async detect(ctx) {
|
|
20
|
+
if (!has_any_of_files(this.detect_files, ctx.workdir))
|
|
21
|
+
return null;
|
|
22
|
+
const version = await resolve_node_version(ctx.workdir);
|
|
23
|
+
const { build_script, type, start_command, inject_serve } = await analyze_package({ workdir: ctx.workdir });
|
|
24
|
+
// Precedence: explicit faable.json startCommand > framework-detected
|
|
25
|
+
// command (e.g. serving a static SPA) > default `npm run start`.
|
|
26
|
+
const start = ctx.config.startCommand ?? start_command ?? "npm run start";
|
|
27
|
+
return {
|
|
28
|
+
buildpack: "node",
|
|
29
|
+
runtime: { name: "node", version },
|
|
30
|
+
type,
|
|
31
|
+
start_command: start,
|
|
32
|
+
build_script,
|
|
33
|
+
inject_serve,
|
|
34
|
+
from: `node:${version}`,
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
async build(ctx, plan) {
|
|
38
|
+
const env = R.fromPairs(ctx.env_vars.map((e) => [e.name, e.value]));
|
|
39
|
+
log.info(`Building with env variables ${Object.keys(env).join(",")}`);
|
|
40
|
+
// The workflow no longer runs `npm ci` — install here when needed, before
|
|
41
|
+
// the build and before `COPY . .` packages node_modules into the image.
|
|
42
|
+
await ensure_dependencies(ctx.workdir);
|
|
43
|
+
// Host build step: npm script from the plan, else faable.json buildCommand.
|
|
44
|
+
const build_command = plan.build_script
|
|
45
|
+
? `npm run ${plan.build_script}`
|
|
46
|
+
: ctx.config.buildCommand;
|
|
47
|
+
await build_project({ command: build_command, env, cwd: ctx.workdir });
|
|
48
|
+
// Frameworks without a bundled static server (CRA/Vue/Angular) need
|
|
49
|
+
// `serve` installed into node_modules before packaging.
|
|
50
|
+
if (plan.inject_serve) {
|
|
51
|
+
await inject_serve(ctx.workdir);
|
|
52
|
+
}
|
|
53
|
+
log.info(`⚙️ Start command: ${plan.start_command}`);
|
|
54
|
+
log.info(`Using docker image ${plan.from}-slim`);
|
|
55
|
+
const dockerfile = render_dockerfile({
|
|
56
|
+
from: plan.from,
|
|
57
|
+
env: { NODE_ENV: "production" },
|
|
58
|
+
banner: BANNER,
|
|
59
|
+
start_command: plan.start_command,
|
|
60
|
+
});
|
|
61
|
+
await build_image({ app: ctx.app, workdir: ctx.workdir, dockerfile });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export { node_buildpack };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { log } from '
|
|
2
|
-
import { cmd } from '
|
|
1
|
+
import { log } from '../../../../log.js';
|
|
2
|
+
import { cmd } from '../../../../lib/cmd.js';
|
|
3
3
|
|
|
4
4
|
// Pinned for reproducible builds. `serve` is the standalone static server used
|
|
5
5
|
// for frameworks without a bundled preview tool (CRA, Vue, Angular).
|
package/dist/commands/deploy/{runtime-detect/strategies/nodejs.js → buildpacks/node/node_version.js}
RENAMED
|
@@ -9,19 +9,17 @@ const getCurrentNodeVersion = async () => {
|
|
|
9
9
|
return version;
|
|
10
10
|
};
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Resolve the Node version for the base image: `engines.node` from
|
|
13
|
+
* package.json (resolved to a concrete release via `npm view`), else the
|
|
14
|
+
* version running the deploy. Also validates the package has a `name`
|
|
15
|
+
* (required since the beginning; kept for compatibility).
|
|
16
16
|
*/
|
|
17
|
-
const
|
|
17
|
+
const resolve_node_version = async (workdir) => {
|
|
18
18
|
const packageJSONFile = path__default.join(workdir, "package.json");
|
|
19
|
-
// Check we have a valid name
|
|
20
19
|
const { name, engines } = fs.readJSONSync(packageJSONFile);
|
|
21
20
|
if (!name) {
|
|
22
21
|
throw new Error("Missing name in package.json");
|
|
23
22
|
}
|
|
24
|
-
// Use engines.node if found
|
|
25
23
|
let runtime_version = await getCurrentNodeVersion();
|
|
26
24
|
if (engines?.node) {
|
|
27
25
|
try {
|
|
@@ -37,13 +35,7 @@ const strategy_nodejs = async (workdir) => {
|
|
|
37
35
|
else {
|
|
38
36
|
log.info(`Node version ${runtime_version}`);
|
|
39
37
|
}
|
|
40
|
-
return
|
|
41
|
-
app_name: name,
|
|
42
|
-
runtime: {
|
|
43
|
-
name: "node",
|
|
44
|
-
version: runtime_version,
|
|
45
|
-
},
|
|
46
|
-
};
|
|
38
|
+
return runtime_version;
|
|
47
39
|
};
|
|
48
40
|
|
|
49
|
-
export {
|
|
41
|
+
export { resolve_node_version };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { log } from '../../../../log.js';
|
|
2
|
+
import { render_dockerfile, build_image } from '../shared/docker_image.js';
|
|
3
|
+
import { has_any_of_files } from '../shared/has_any_of_files.js';
|
|
4
|
+
import { pipfile_provider } from './providers/pipfile.js';
|
|
5
|
+
import { pyproject_provider } from './providers/pyproject.js';
|
|
6
|
+
import { requirements_provider } from './providers/requirements.js';
|
|
7
|
+
import { resolve_python_version } from './python_version.js';
|
|
8
|
+
import { read_dependencies_text, resolve_start, with_server_injection } from './resolve_start.js';
|
|
9
|
+
|
|
10
|
+
const BANNER = `PYTHON_VERSION=$(python --version 2>&1)
|
|
11
|
+
PIP_VERSION=$(pip --version 2>&1)
|
|
12
|
+
|
|
13
|
+
echo "Faable Cloud · [$PYTHON_VERSION] [$PIP_VERSION]"`;
|
|
14
|
+
// Evaluated in order: the first provider whose manifest exists supplies the
|
|
15
|
+
// install command. Classic manifests come before platform-specific ones.
|
|
16
|
+
const PROVIDERS = [
|
|
17
|
+
requirements_provider,
|
|
18
|
+
pyproject_provider,
|
|
19
|
+
pipfile_provider,
|
|
20
|
+
];
|
|
21
|
+
const detect_with_provider = (ctx, provider) => {
|
|
22
|
+
const resolved = provider.resolve(ctx.workdir);
|
|
23
|
+
// Framework detection blob: all classic manifests plus whatever the winning
|
|
24
|
+
// provider contributes (e.g. cerebrium pip package names).
|
|
25
|
+
const deps = [read_dependencies_text(ctx.workdir), resolved.deps_text ?? ""]
|
|
26
|
+
.join("\n")
|
|
27
|
+
.toLowerCase();
|
|
28
|
+
const { start_command, server } = resolve_start(ctx.workdir, deps, ctx.config, resolved.start_hint);
|
|
29
|
+
// faable.json buildCommand overrides the provider's install. The cacheable
|
|
30
|
+
// manifest layer only applies to the provider's own command — an arbitrary
|
|
31
|
+
// buildCommand may need the full source.
|
|
32
|
+
const base_install = ctx.config.buildCommand ?? resolved.install_command;
|
|
33
|
+
const install_command = with_server_injection(base_install, server, deps);
|
|
34
|
+
const install_files = ctx.config.buildCommand
|
|
35
|
+
? undefined
|
|
36
|
+
: resolved.install_files;
|
|
37
|
+
const version = resolve_python_version(ctx.workdir, resolved.python_version);
|
|
38
|
+
log.info(`Using python@${version}`);
|
|
39
|
+
log.info(`📦 Install command: ${install_command}`);
|
|
40
|
+
log.info(`⚙️ Start command: ${start_command}`);
|
|
41
|
+
return {
|
|
42
|
+
buildpack: "python",
|
|
43
|
+
runtime: { name: "python", version },
|
|
44
|
+
type: "python",
|
|
45
|
+
start_command,
|
|
46
|
+
install_command,
|
|
47
|
+
install_files,
|
|
48
|
+
from: `python:${version}`,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
const python_buildpack = {
|
|
52
|
+
name: "python",
|
|
53
|
+
detect_files: PROVIDERS.flatMap((p) => p.files),
|
|
54
|
+
async detect(ctx) {
|
|
55
|
+
const provider = PROVIDERS.find((p) => has_any_of_files(p.files, ctx.workdir));
|
|
56
|
+
if (!provider)
|
|
57
|
+
return null;
|
|
58
|
+
return detect_with_provider(ctx, provider);
|
|
59
|
+
},
|
|
60
|
+
async build(ctx, plan) {
|
|
61
|
+
// Dependencies install inside the image (unlike node); nothing runs on
|
|
62
|
+
// the host here.
|
|
63
|
+
log.info(`Using docker image ${plan.from}-slim`);
|
|
64
|
+
const dockerfile = render_dockerfile({
|
|
65
|
+
from: plan.from,
|
|
66
|
+
env: { PYTHONUNBUFFERED: "1" },
|
|
67
|
+
banner: BANNER,
|
|
68
|
+
start_command: plan.start_command,
|
|
69
|
+
install_command: plan.install_command,
|
|
70
|
+
install_files: plan.install_files,
|
|
71
|
+
});
|
|
72
|
+
await build_image({ app: ctx.app, workdir: ctx.workdir, dockerfile });
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export { python_buildpack };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const pipfile_provider = {
|
|
2
|
+
name: "pipfile",
|
|
3
|
+
files: ["Pipfile"],
|
|
4
|
+
resolve() {
|
|
5
|
+
return {
|
|
6
|
+
// `pipenv install --deploy` reads Pipfile.lock too — keep the install
|
|
7
|
+
// after `COPY . .` so it sees whatever the repo ships.
|
|
8
|
+
install_command: "pip install --no-cache-dir pipenv && pipenv install --system --deploy",
|
|
9
|
+
};
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export { pipfile_provider };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const pyproject_provider = {
|
|
2
|
+
name: "pyproject",
|
|
3
|
+
files: ["pyproject.toml"],
|
|
4
|
+
resolve() {
|
|
5
|
+
return {
|
|
6
|
+
// `pip install .` builds the package, so it needs the full source —
|
|
7
|
+
// no install_files: the install runs after `COPY . .`.
|
|
8
|
+
install_command: "pip install --no-cache-dir .",
|
|
9
|
+
};
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export { pyproject_provider };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const requirements_provider = {
|
|
2
|
+
name: "requirements",
|
|
3
|
+
files: ["requirements.txt"],
|
|
4
|
+
resolve() {
|
|
5
|
+
return {
|
|
6
|
+
install_command: "pip install --no-cache-dir -r requirements.txt",
|
|
7
|
+
// Copying only the manifest keeps the install layer cached until
|
|
8
|
+
// requirements.txt itself changes.
|
|
9
|
+
install_files: ["requirements.txt"],
|
|
10
|
+
};
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export { requirements_provider };
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path__default from 'path';
|
|
3
|
-
import { log } from '../../../../log.js';
|
|
4
3
|
|
|
5
4
|
const DEFAULT_VERSION = "3.11.3";
|
|
6
5
|
/**
|
|
7
6
|
* Resolve the Python version from (in order):
|
|
8
7
|
* 1. runtime.txt → `python-3.11.3`
|
|
9
8
|
* 2. .python-version → `3.11.3` (pyenv)
|
|
10
|
-
* 3.
|
|
9
|
+
* 3. provider hint → e.g. cerebrium.toml `python_version`
|
|
10
|
+
* 4. pyproject.toml → `requires-python = ">=3.11"` (first concrete X.Y[.Z])
|
|
11
11
|
* Falls back to DEFAULT_VERSION.
|
|
12
12
|
*/
|
|
13
|
-
const resolve_python_version = (workdir) => {
|
|
13
|
+
const resolve_python_version = (workdir, provider_hint) => {
|
|
14
14
|
const runtime_config = path__default.join(workdir, "runtime.txt");
|
|
15
15
|
if (fs.existsSync(runtime_config)) {
|
|
16
16
|
const runtime_data = fs.readFileSync(runtime_config).toString().trim();
|
|
@@ -25,6 +25,8 @@ const resolve_python_version = (workdir) => {
|
|
|
25
25
|
if (version)
|
|
26
26
|
return version;
|
|
27
27
|
}
|
|
28
|
+
if (provider_hint)
|
|
29
|
+
return provider_hint;
|
|
28
30
|
const pyproject = path__default.join(workdir, "pyproject.toml");
|
|
29
31
|
if (fs.existsSync(pyproject)) {
|
|
30
32
|
const toml = fs.readFileSync(pyproject).toString();
|
|
@@ -34,15 +36,5 @@ const resolve_python_version = (workdir) => {
|
|
|
34
36
|
}
|
|
35
37
|
return DEFAULT_VERSION;
|
|
36
38
|
};
|
|
37
|
-
const strategy_python = async (workdir) => {
|
|
38
|
-
const runtime_version = resolve_python_version(workdir);
|
|
39
|
-
log.info(`Using python@${runtime_version}`);
|
|
40
|
-
return {
|
|
41
|
-
runtime: {
|
|
42
|
-
name: "python",
|
|
43
|
-
version: runtime_version,
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
39
|
|
|
48
|
-
export {
|
|
40
|
+
export { resolve_python_version };
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path__default from 'path';
|
|
3
|
-
import { log } from '
|
|
4
|
-
import { Configuration } from '../../../lib/Configuration.js';
|
|
3
|
+
import { log } from '../../../../log.js';
|
|
5
4
|
import { parse_procfile } from './parse_procfile.js';
|
|
6
5
|
|
|
7
|
-
/** Combine all dependency manifests into one lowercased blob
|
|
6
|
+
/** Combine all classic dependency manifests into one lowercased blob. */
|
|
8
7
|
const read_dependencies_text = (workdir) => {
|
|
9
8
|
const files = ["requirements.txt", "pyproject.toml", "Pipfile"];
|
|
10
9
|
return files
|
|
@@ -65,13 +64,20 @@ const find_app_module = (workdir, pattern) => {
|
|
|
65
64
|
}
|
|
66
65
|
return first_existing;
|
|
67
66
|
};
|
|
68
|
-
/**
|
|
69
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the container start command and which server it needs installed.
|
|
69
|
+
* Precedence: faable.json startCommand → Procfile `web:` → provider start
|
|
70
|
+
* hint (e.g. cerebrium entrypoint) → framework detection (Django → FastAPI/
|
|
71
|
+
* ASGI → Flask).
|
|
72
|
+
*/
|
|
73
|
+
const resolve_start = (workdir, deps, config, start_hint) => {
|
|
70
74
|
// 1. Explicit override in faable.json
|
|
71
|
-
|
|
72
|
-
if (configured) {
|
|
75
|
+
if (config.startCommand) {
|
|
73
76
|
log.info(`Using start command from faable.json`);
|
|
74
|
-
return {
|
|
77
|
+
return {
|
|
78
|
+
start_command: config.startCommand,
|
|
79
|
+
server: server_from_command(config.startCommand),
|
|
80
|
+
};
|
|
75
81
|
}
|
|
76
82
|
// 2. Procfile `web:` line
|
|
77
83
|
const procfile = parse_procfile(workdir);
|
|
@@ -79,7 +85,12 @@ const resolve_start = (workdir, deps) => {
|
|
|
79
85
|
log.info(`Using start command from Procfile`);
|
|
80
86
|
return { start_command: procfile, server: server_from_command(procfile) };
|
|
81
87
|
}
|
|
82
|
-
// 3.
|
|
88
|
+
// 3. Provider hint (e.g. cerebrium.toml runtime entrypoint)
|
|
89
|
+
if (start_hint) {
|
|
90
|
+
log.info(`Using start command from the project manifest`);
|
|
91
|
+
return { start_command: start_hint, server: server_from_command(start_hint) };
|
|
92
|
+
}
|
|
93
|
+
// 4. Framework detection
|
|
83
94
|
// Django: manage.py + the package holding wsgi.py
|
|
84
95
|
if (fs.existsSync(path__default.join(workdir, "manage.py"))) {
|
|
85
96
|
const pkg = find_django_package(workdir);
|
|
@@ -115,23 +126,13 @@ const resolve_start = (workdir, deps) => {
|
|
|
115
126
|
}
|
|
116
127
|
throw new Error("Could not detect how to start this Python app. Set `startCommand` in faable.json or add a Procfile with a `web:` line.");
|
|
117
128
|
};
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
else if (fs.existsSync(path__default.join(workdir, "requirements.txt"))) {
|
|
126
|
-
steps.push("pip install --no-cache-dir -r requirements.txt");
|
|
127
|
-
}
|
|
128
|
-
else if (fs.existsSync(path__default.join(workdir, "pyproject.toml"))) {
|
|
129
|
-
steps.push("pip install --no-cache-dir .");
|
|
130
|
-
}
|
|
131
|
-
else if (fs.existsSync(path__default.join(workdir, "Pipfile"))) {
|
|
132
|
-
steps.push("pip install --no-cache-dir pipenv && pipenv install --system --deploy");
|
|
133
|
-
}
|
|
134
|
-
// Ensure the WSGI/ASGI server is available when not already declared.
|
|
129
|
+
/**
|
|
130
|
+
* Append the WSGI/ASGI server install when the start command needs one that
|
|
131
|
+
* isn't already declared in the dependencies. Returns "true" (no-op) when
|
|
132
|
+
* there is nothing to install at all.
|
|
133
|
+
*/
|
|
134
|
+
const with_server_injection = (base, server, deps) => {
|
|
135
|
+
const steps = base ? [base] : [];
|
|
135
136
|
if (server === "gunicorn" && !has_token(deps, "gunicorn")) {
|
|
136
137
|
steps.push("pip install --no-cache-dir gunicorn");
|
|
137
138
|
}
|
|
@@ -139,20 +140,9 @@ const build_install_command = (workdir, server, deps) => {
|
|
|
139
140
|
steps.push("pip install --no-cache-dir uvicorn[standard]");
|
|
140
141
|
}
|
|
141
142
|
if (steps.length === 0) {
|
|
142
|
-
// No manifest found — nothing to install, but warn the user.
|
|
143
|
-
log.warn("No requirements.txt/pyproject.toml/Pipfile found");
|
|
144
143
|
return "true";
|
|
145
144
|
}
|
|
146
145
|
return steps.join(" && ");
|
|
147
146
|
};
|
|
148
|
-
const analyze_python = async (params) => {
|
|
149
|
-
const { workdir } = params;
|
|
150
|
-
const deps = read_dependencies_text(workdir);
|
|
151
|
-
const { start_command, server } = resolve_start(workdir, deps);
|
|
152
|
-
const install_command = build_install_command(workdir, server, deps);
|
|
153
|
-
log.info(`📦 Install command: ${install_command}`);
|
|
154
|
-
log.info(`⚙️ Start command: ${start_command}`);
|
|
155
|
-
return { install_command, start_command };
|
|
156
|
-
};
|
|
157
147
|
|
|
158
|
-
export {
|
|
148
|
+
export { find_app_module, has_token, read_dependencies_text, resolve_start, with_server_injection };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { docker_buildpack } from './docker/index.js';
|
|
2
|
+
import { node_buildpack } from './node/index.js';
|
|
3
|
+
import { python_buildpack } from './python/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ordered registry, first claim wins. Order is load-bearing:
|
|
7
|
+
* 1. node before python — full-stack repos shipping both a package.json and
|
|
8
|
+
* Python manifests build as node (historical rule).
|
|
9
|
+
* 2. python (all its manifest providers) before docker — a dependency
|
|
10
|
+
* manifest beats a Dockerfile; the Dockerfile is the explicit escape
|
|
11
|
+
* hatch evaluated last among strong triggers.
|
|
12
|
+
* Weak signals (python's entrypoint fallback) run in a second pass so they
|
|
13
|
+
* lose against ANY strong trigger without buildpacks knowing about each other.
|
|
14
|
+
*/
|
|
15
|
+
const BUILDPACKS = [
|
|
16
|
+
node_buildpack,
|
|
17
|
+
python_buildpack,
|
|
18
|
+
docker_buildpack,
|
|
19
|
+
];
|
|
20
|
+
const buildpack_names = () => BUILDPACKS.map((b) => b.name);
|
|
21
|
+
const get_buildpack = (name) => BUILDPACKS.find((b) => b.name === name);
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the buildpack plan for a workdir. `override` (from --buildpack or
|
|
24
|
+
* faable.json) skips detection order and forces one buildpack; it still runs
|
|
25
|
+
* that buildpack's detect so the plan is computed from real project files.
|
|
26
|
+
*/
|
|
27
|
+
const detect_buildpack = async (ctx, override) => {
|
|
28
|
+
if (override) {
|
|
29
|
+
const buildpack = get_buildpack(override);
|
|
30
|
+
if (!buildpack) {
|
|
31
|
+
throw new Error(`Unknown buildpack "${override}". Valid buildpacks: ${buildpack_names().join(", ")}.`);
|
|
32
|
+
}
|
|
33
|
+
const plan = (await buildpack.detect(ctx)) ??
|
|
34
|
+
(await buildpack.detect_fallback?.(ctx)) ??
|
|
35
|
+
null;
|
|
36
|
+
if (!plan) {
|
|
37
|
+
throw new Error(`Buildpack "${override}" was forced but none of its trigger files ` +
|
|
38
|
+
`(${[...buildpack.detect_files, ...(buildpack.fallback_files ?? [])].join(", ")}) ` +
|
|
39
|
+
`exist in ${ctx.workdir}.`);
|
|
40
|
+
}
|
|
41
|
+
return plan;
|
|
42
|
+
}
|
|
43
|
+
// Strong pass: manifests and Dockerfiles.
|
|
44
|
+
for (const buildpack of BUILDPACKS) {
|
|
45
|
+
const plan = await buildpack.detect(ctx);
|
|
46
|
+
if (plan)
|
|
47
|
+
return plan;
|
|
48
|
+
}
|
|
49
|
+
// Fallback pass: weak signals, only when nothing claimed the project.
|
|
50
|
+
for (const buildpack of BUILDPACKS) {
|
|
51
|
+
if (!buildpack.detect_fallback)
|
|
52
|
+
continue;
|
|
53
|
+
const plan = await buildpack.detect_fallback(ctx);
|
|
54
|
+
if (plan)
|
|
55
|
+
return plan;
|
|
56
|
+
}
|
|
57
|
+
throw new Error("Cannot detect project type");
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export { BUILDPACKS, buildpack_names, detect_buildpack, get_buildpack };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import Handlebars from 'handlebars';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { cmd } from '../../../../lib/cmd.js';
|
|
6
|
+
import { log } from '../../../../log.js';
|
|
7
|
+
|
|
8
|
+
const __filename$1 = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname$1 = path.dirname(__filename$1);
|
|
10
|
+
const templates_dir = path.join(__dirname$1, "templates");
|
|
11
|
+
const dockerfile_source = fs
|
|
12
|
+
.readFileSync(path.join(templates_dir, "Dockerfile"))
|
|
13
|
+
.toString();
|
|
14
|
+
const entrypoint_source = fs
|
|
15
|
+
.readFileSync(path.join(templates_dir, "entrypoint.sh"))
|
|
16
|
+
.toString("utf-8");
|
|
17
|
+
// Backslash-escape content so it survives BOTH the unquoted bash heredoc the
|
|
18
|
+
// Dockerfile is piped through (docker build -f -<<EOF) and, for the entry
|
|
19
|
+
// script, the single-quoted `RUN echo '...'`.
|
|
20
|
+
Handlebars.registerHelper("escape", function (variable) {
|
|
21
|
+
const escaped_lines = variable
|
|
22
|
+
.replace(/(['`\\])/g, "\\$1")
|
|
23
|
+
.replace(/([$])/g, "\\$1");
|
|
24
|
+
return escaped_lines.split("\n").join("\\n");
|
|
25
|
+
});
|
|
26
|
+
const docker_template = Handlebars.compile(dockerfile_source);
|
|
27
|
+
const entrypoint_template = Handlebars.compile(entrypoint_source);
|
|
28
|
+
/** Pure render of the final Dockerfile contents — snapshot-tested. */
|
|
29
|
+
const render_dockerfile = (params) => {
|
|
30
|
+
// NOTE: use slim to build projects
|
|
31
|
+
const from = [params.from, "slim"].filter((e) => e).join("-");
|
|
32
|
+
const entry_script = entrypoint_template({ banner: params.banner });
|
|
33
|
+
return docker_template({
|
|
34
|
+
from,
|
|
35
|
+
env: params.env,
|
|
36
|
+
entry_script,
|
|
37
|
+
start_command: params.start_command,
|
|
38
|
+
install_command: params.install_command,
|
|
39
|
+
install_files: params.install_files,
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
const build_image = async (props) => {
|
|
43
|
+
const { app, workdir, dockerfile } = props;
|
|
44
|
+
log.info(`📦 Packaging inside a docker image`);
|
|
45
|
+
const timeout = 10 * 60 * 1000; // 10 minute timeout
|
|
46
|
+
await cmd(`docker build --platform linux/amd64 -t ${app.id} ${workdir} -f -<<EOF\n${dockerfile}\nEOF`, { timeout, enableOutput: true });
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export { build_image, render_dockerfile };
|
|
@@ -6,17 +6,31 @@ WORKDIR /faable/app
|
|
|
6
6
|
|
|
7
7
|
# Environment variables for runtime
|
|
8
8
|
ENV PORT=80
|
|
9
|
-
|
|
9
|
+
{{#each env}}
|
|
10
|
+
ENV {{@key}}={{this}}
|
|
11
|
+
{{/each}}
|
|
10
12
|
# `escape` keeps `$PORT` literal so the build heredoc (unquoted bash) doesn't
|
|
11
13
|
# eat it; Docker then expands $PORT (=80) when building the ENV value.
|
|
12
14
|
ENV START_COMMAND="{{{escape start_command}}}"
|
|
13
15
|
|
|
16
|
+
{{#if install_files}}
|
|
17
|
+
# Copy dependency manifests first so the install layer stays cached until
|
|
18
|
+
# they change (any source change no longer reinstalls dependencies).
|
|
19
|
+
{{#each install_files}}
|
|
20
|
+
COPY {{this}} ./
|
|
21
|
+
{{/each}}
|
|
22
|
+
RUN {{{escape install_command}}}
|
|
23
|
+
{{/if}}
|
|
24
|
+
|
|
14
25
|
# Copy Usercode
|
|
15
26
|
COPY . .
|
|
16
27
|
|
|
17
|
-
#
|
|
18
|
-
#
|
|
28
|
+
{{#unless install_files}}
|
|
29
|
+
{{#if install_command}}
|
|
30
|
+
# Install needs the full source (e.g. `pip install .`), so it runs after COPY.
|
|
19
31
|
RUN {{{escape install_command}}}
|
|
32
|
+
{{/if}}
|
|
33
|
+
{{/unless}}
|
|
20
34
|
|
|
21
35
|
# Entrypoint stript
|
|
22
36
|
RUN echo '{{{escape entry_script}}}' >> entrypoint.sh
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { requireApi } from '../../api/context.js';
|
|
2
|
-
import { cmd } from '../../lib/cmd.js';
|
|
3
2
|
import { Configuration } from '../../lib/Configuration.js';
|
|
4
3
|
import { log } from '../../log.js';
|
|
4
|
+
import { plan_summary } from './buildpacks/Buildpack.js';
|
|
5
|
+
import { detect_buildpack, get_buildpack, buildpack_names } from './buildpacks/registry.js';
|
|
5
6
|
import { check_environment } from './check_environment.js';
|
|
6
7
|
import { git_context } from './git_context.js';
|
|
7
|
-
import { build_node } from './node-pipeline/index.js';
|
|
8
|
-
import { build_python } from './python-pipeline/index.js';
|
|
9
|
-
import { runtime_detection } from './runtime-detect/runtime_detection.js';
|
|
10
8
|
import { upload_tag } from './upload_tag.js';
|
|
11
9
|
|
|
12
10
|
const deploy = {
|
|
@@ -22,6 +20,12 @@ const deploy = {
|
|
|
22
20
|
alias: 'w',
|
|
23
21
|
type: 'string',
|
|
24
22
|
description: 'Working directory'
|
|
23
|
+
})
|
|
24
|
+
.option('buildpack', {
|
|
25
|
+
alias: 'b',
|
|
26
|
+
type: 'string',
|
|
27
|
+
choices: buildpack_names(),
|
|
28
|
+
description: 'Force a specific buildpack (overrides auto-detection and faable.json)'
|
|
25
29
|
})
|
|
26
30
|
.showHelpOnFail(false);
|
|
27
31
|
},
|
|
@@ -29,8 +33,10 @@ const deploy = {
|
|
|
29
33
|
const workdir = args.workdir || process.cwd();
|
|
30
34
|
const ctx = await requireApi();
|
|
31
35
|
const { api } = ctx;
|
|
32
|
-
// Resolve
|
|
33
|
-
|
|
36
|
+
// Resolve the buildpack plan (detection or forced override). All the
|
|
37
|
+
// build thinking happens here; build() below just executes the plan.
|
|
38
|
+
const config = Configuration.instance().deployConfig();
|
|
39
|
+
const plan = await detect_buildpack({ workdir, config }, args.buildpack || config.buildpack);
|
|
34
40
|
// app_id resolution (the user never has to look one up):
|
|
35
41
|
// 1. explicit positional (monorepo escape hatch)
|
|
36
42
|
// 2. OIDC in CI — the backend resolves the app from the linked repository
|
|
@@ -42,33 +48,19 @@ const deploy = {
|
|
|
42
48
|
const app = await api.getApp(app_id);
|
|
43
49
|
// Check if we can build docker images
|
|
44
50
|
await check_environment();
|
|
45
|
-
|
|
51
|
+
const runtime_label = plan.runtime.version
|
|
52
|
+
? `${plan.runtime.name}-${plan.runtime.version}`
|
|
53
|
+
: plan.runtime.name;
|
|
54
|
+
log.info(`🚀 Deploying "${app.name}" (${app.id}) runtime=${runtime_label}`);
|
|
55
|
+
log.info(`🧩 Build plan ${plan_summary(plan)}`);
|
|
46
56
|
// get environment variables
|
|
47
57
|
const env_vars = await api.getAppSecrets(app.id);
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
workdir,
|
|
52
|
-
runtime,
|
|
53
|
-
env_vars
|
|
54
|
-
});
|
|
55
|
-
type = node_result.type;
|
|
56
|
-
}
|
|
57
|
-
else if (runtime.name == 'python') {
|
|
58
|
-
const python_result = await build_python(app, {
|
|
59
|
-
workdir,
|
|
60
|
-
runtime});
|
|
61
|
-
type = python_result.type;
|
|
62
|
-
}
|
|
63
|
-
else if (runtime.name == 'docker') {
|
|
64
|
-
type = 'node';
|
|
65
|
-
await cmd(`docker build -t ${app.id} .`, {
|
|
66
|
-
enableOutput: true
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
throw new Error(`No build pipeline for runtime=${runtime.name}`);
|
|
58
|
+
const buildpack = get_buildpack(plan.buildpack);
|
|
59
|
+
if (!buildpack) {
|
|
60
|
+
throw new Error(`No buildpack registered for plan=${plan.buildpack}`);
|
|
71
61
|
}
|
|
62
|
+
await buildpack.build({ workdir, config, app, env_vars }, plan);
|
|
63
|
+
const type = plan.type;
|
|
72
64
|
// Upload to Faable registry
|
|
73
65
|
const { upload_tagname } = await upload_tag({ app, api });
|
|
74
66
|
// Capture the commit/ref/actor so the deployment records which commit it
|
|
@@ -45,6 +45,17 @@ class Configuration {
|
|
|
45
45
|
get buildCommand() {
|
|
46
46
|
return this.config.buildCommand;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* faable.json subset consumed by the deploy buildpacks. Buildpacks receive
|
|
50
|
+
* this via their context and never touch the singleton directly.
|
|
51
|
+
*/
|
|
52
|
+
deployConfig() {
|
|
53
|
+
return {
|
|
54
|
+
startCommand: this.config.startCommand,
|
|
55
|
+
buildCommand: this.config.buildCommand,
|
|
56
|
+
buildpack: this.config.buildpack,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
48
59
|
get app_slug() {
|
|
49
60
|
return this.config.app_slug;
|
|
50
61
|
}
|
package/package.json
CHANGED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { log } from '../../../log.js';
|
|
2
|
-
import { cmd } from '../../../lib/cmd.js';
|
|
3
|
-
import fs from 'fs-extra';
|
|
4
|
-
import Handlebars from 'handlebars';
|
|
5
|
-
import * as path from 'path';
|
|
6
|
-
import { fileURLToPath } from 'url';
|
|
7
|
-
import { Configuration } from '../../../lib/Configuration.js';
|
|
8
|
-
|
|
9
|
-
const __filename$1 = fileURLToPath(import.meta.url);
|
|
10
|
-
const __dirname$1 = path.dirname(__filename$1);
|
|
11
|
-
const templates_dir = path.join(__dirname$1, "templates");
|
|
12
|
-
const dockerfile = fs.readFileSync(`${templates_dir}/Dockerfile`).toString();
|
|
13
|
-
const entrypoint = fs
|
|
14
|
-
.readFileSync(`${templates_dir}/entrypoint.sh`)
|
|
15
|
-
.toString("utf-8");
|
|
16
|
-
Handlebars.registerHelper("escape", function (variable) {
|
|
17
|
-
//const escaped_quotes = variable.replace(/(['"])/g, "\\$1");
|
|
18
|
-
const escaped_lines = variable
|
|
19
|
-
.replace(/(['`\\])/g, "\\$1")
|
|
20
|
-
.replace(/([$])/g, "\\$1");
|
|
21
|
-
return escaped_lines.split("\n").join("\\n");
|
|
22
|
-
//return escaped_lines.split("\n").join("\\n");
|
|
23
|
-
});
|
|
24
|
-
// Docker template file
|
|
25
|
-
const docker_template = Handlebars.compile(dockerfile);
|
|
26
|
-
const entrypoint_template = Handlebars.compile(entrypoint);
|
|
27
|
-
const build_docker = async (props) => {
|
|
28
|
-
const { app, workdir, template_context } = props;
|
|
29
|
-
const entrypoint_custom = entrypoint_template(template_context);
|
|
30
|
-
// Precedence: explicit faable.json startCommand > framework-detected command
|
|
31
|
-
// (e.g. serving a static SPA) > default `npm run start`.
|
|
32
|
-
const start_command = Configuration.instance().configuredStartCommand ??
|
|
33
|
-
props.start_command ??
|
|
34
|
-
"npm run start";
|
|
35
|
-
log.info(`⚙️ Start command: ${start_command}`);
|
|
36
|
-
// NOTE: use slim to build projects
|
|
37
|
-
const linux_distro = "slim";
|
|
38
|
-
const from = [template_context.from, linux_distro].filter((e) => e).join("-");
|
|
39
|
-
log.info(`Using docker image ${from}`);
|
|
40
|
-
// run template
|
|
41
|
-
const dockerfile = docker_template({
|
|
42
|
-
from,
|
|
43
|
-
entry_script: entrypoint_custom,
|
|
44
|
-
start_command,
|
|
45
|
-
});
|
|
46
|
-
log.info(`📦 Packaging inside a docker image`);
|
|
47
|
-
// Build options
|
|
48
|
-
const timeout = 10 * 60 * 1000; // 10 minute timeout
|
|
49
|
-
await cmd(`docker build --platform linux/amd64 -t ${app.id} ${workdir} -f -<<EOF\n${dockerfile}\nEOF`, { timeout, enableOutput: true });
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export { build_docker };
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { log } from '../../../log.js';
|
|
2
|
-
import { cmd } from '../../../lib/cmd.js';
|
|
3
|
-
import { Configuration } from '../../../lib/Configuration.js';
|
|
4
|
-
|
|
5
|
-
const build_project = async (args) => {
|
|
6
|
-
const build_script = args.build_script;
|
|
7
|
-
const build_command = build_script
|
|
8
|
-
? `npm run ${build_script}`
|
|
9
|
-
: Configuration.instance().buildCommand;
|
|
10
|
-
if (build_command) {
|
|
11
|
-
const cwd = args.cwd || process.cwd();
|
|
12
|
-
log.info(`⚙️ Building project [${build_command}]...`);
|
|
13
|
-
const timeout = 1000 * 60 * 30; // 30 minute timeout
|
|
14
|
-
await cmd(build_command, {
|
|
15
|
-
timeout,
|
|
16
|
-
cwd,
|
|
17
|
-
enableOutput: true,
|
|
18
|
-
...(args?.env ? { env: args?.env } : {}),
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
else {
|
|
22
|
-
log.info(`⚡️ No build step`);
|
|
23
|
-
}
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export { build_project };
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { build_docker } from './build_docker.js';
|
|
2
|
-
import { analyze_package } from './analyze_package.js';
|
|
3
|
-
import { build_project } from './build_project.js';
|
|
4
|
-
import { inject_serve } from './inject_serve.js';
|
|
5
|
-
import * as R from 'ramda';
|
|
6
|
-
import { log } from '../../../log.js';
|
|
7
|
-
|
|
8
|
-
const build_node = async (app, options) => {
|
|
9
|
-
// log.info(`🚀 Build Toolchain ${app.name} [${app.id}]`);
|
|
10
|
-
const { workdir, runtime, env_vars = [] } = options;
|
|
11
|
-
if (!runtime.version) {
|
|
12
|
-
throw new Error("Runtime version not specified for node");
|
|
13
|
-
}
|
|
14
|
-
// Analyze package.json to check if build is needed
|
|
15
|
-
const { build_script, type, start_command, inject_serve: needs_serve } = await analyze_package({
|
|
16
|
-
workdir,
|
|
17
|
-
});
|
|
18
|
-
// Environment variables
|
|
19
|
-
const env = R.fromPairs(env_vars.map((e) => [e.name, e.value]));
|
|
20
|
-
log.info(`Building with env variables ${Object.keys(env).join(",")}`);
|
|
21
|
-
// Do build
|
|
22
|
-
await build_project({ build_script, env });
|
|
23
|
-
// Frameworks without a bundled static server (CRA/Vue/Angular) need `serve`
|
|
24
|
-
// installed into node_modules before packaging, so it ships in the image.
|
|
25
|
-
if (needs_serve) {
|
|
26
|
-
await inject_serve(workdir);
|
|
27
|
-
}
|
|
28
|
-
// Bundle project inside a docker image
|
|
29
|
-
await build_docker({
|
|
30
|
-
app,
|
|
31
|
-
workdir,
|
|
32
|
-
start_command,
|
|
33
|
-
template_context: {
|
|
34
|
-
from: `node:${runtime.version}`,
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
return { type };
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export { build_node };
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
FROM {{from}}
|
|
2
|
-
LABEL com.faable.cloud="FaableCloud"
|
|
3
|
-
LABEL description="Faablecloud automatic deployment"
|
|
4
|
-
|
|
5
|
-
WORKDIR /faable/app
|
|
6
|
-
|
|
7
|
-
# Environment variables for runtime
|
|
8
|
-
ENV PORT=80
|
|
9
|
-
ENV NODE_ENV=production
|
|
10
|
-
ENV START_COMMAND="{{start_command}}"
|
|
11
|
-
|
|
12
|
-
# Copy Usercode
|
|
13
|
-
COPY . .
|
|
14
|
-
|
|
15
|
-
# Entrypoint stript
|
|
16
|
-
RUN echo '{{{escape entry_script}}}' >> entrypoint.sh
|
|
17
|
-
|
|
18
|
-
CMD ["/bin/sh", "./entrypoint.sh"]
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { log } from '../../../log.js';
|
|
2
|
-
import { cmd } from '../../../lib/cmd.js';
|
|
3
|
-
import fs from 'fs-extra';
|
|
4
|
-
import Handlebars from 'handlebars';
|
|
5
|
-
import * as path from 'path';
|
|
6
|
-
import { fileURLToPath } from 'url';
|
|
7
|
-
|
|
8
|
-
const __filename$1 = fileURLToPath(import.meta.url);
|
|
9
|
-
const __dirname$1 = path.dirname(__filename$1);
|
|
10
|
-
const templates_dir = path.join(__dirname$1, "templates");
|
|
11
|
-
const dockerfile = fs.readFileSync(`${templates_dir}/Dockerfile`).toString();
|
|
12
|
-
const entrypoint = fs
|
|
13
|
-
.readFileSync(`${templates_dir}/entrypoint.sh`)
|
|
14
|
-
.toString("utf-8");
|
|
15
|
-
Handlebars.registerHelper("escape", function (variable) {
|
|
16
|
-
const escaped_lines = variable
|
|
17
|
-
.replace(/(['`\\])/g, "\\$1")
|
|
18
|
-
.replace(/([$])/g, "\\$1");
|
|
19
|
-
return escaped_lines.split("\n").join("\\n");
|
|
20
|
-
});
|
|
21
|
-
const docker_template = Handlebars.compile(dockerfile);
|
|
22
|
-
const entrypoint_template = Handlebars.compile(entrypoint);
|
|
23
|
-
const build_docker = async (props) => {
|
|
24
|
-
const { app, workdir, install_command, start_command, template_context } = props;
|
|
25
|
-
const entrypoint_custom = entrypoint_template({});
|
|
26
|
-
log.info(`⚙️ Start command: ${start_command}`);
|
|
27
|
-
// NOTE: use slim to build projects
|
|
28
|
-
const linux_distro = "slim";
|
|
29
|
-
const from = [template_context.from, linux_distro].filter((e) => e).join("-");
|
|
30
|
-
log.info(`Using docker image ${from}`);
|
|
31
|
-
const dockerfile = docker_template({
|
|
32
|
-
from,
|
|
33
|
-
entry_script: entrypoint_custom,
|
|
34
|
-
install_command,
|
|
35
|
-
start_command,
|
|
36
|
-
});
|
|
37
|
-
log.info(`📦 Packaging inside a docker image`);
|
|
38
|
-
const timeout = 10 * 60 * 1000; // 10 minute timeout
|
|
39
|
-
await cmd(`docker build --platform linux/amd64 -t ${app.id} ${workdir} -f -<<EOF\n${dockerfile}\nEOF`, { timeout, enableOutput: true });
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export { build_docker };
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { analyze_python } from './analyze_python.js';
|
|
2
|
-
import { build_docker } from './build_docker.js';
|
|
3
|
-
|
|
4
|
-
const build_python = async (app, options) => {
|
|
5
|
-
const { workdir, runtime } = options;
|
|
6
|
-
if (!runtime.version) {
|
|
7
|
-
throw new Error("Runtime version not specified for python");
|
|
8
|
-
}
|
|
9
|
-
// Resolve how to install deps and start the app. Unlike node, there is no
|
|
10
|
-
// separate local build step: dependency install happens inside the Docker
|
|
11
|
-
// build (where network is available), so we go straight to packaging.
|
|
12
|
-
const { install_command, start_command } = await analyze_python({ workdir });
|
|
13
|
-
await build_docker({
|
|
14
|
-
app,
|
|
15
|
-
workdir,
|
|
16
|
-
install_command,
|
|
17
|
-
start_command,
|
|
18
|
-
template_context: {
|
|
19
|
-
from: `python:${runtime.version}`,
|
|
20
|
-
},
|
|
21
|
-
});
|
|
22
|
-
return { type: "python" };
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export { build_python };
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import * as R from 'ramda';
|
|
2
|
-
import { has_any_of_files } from './helpers/has_any_of_files.js';
|
|
3
|
-
import { strategy_docker } from './strategies/docker.js';
|
|
4
|
-
import { strategy_nodejs } from './strategies/nodejs.js';
|
|
5
|
-
import { strategy_python } from './strategies/python.js';
|
|
6
|
-
|
|
7
|
-
const runtime_detection = async (workdir) => {
|
|
8
|
-
const has = R.curry(has_any_of_files);
|
|
9
|
-
// Order matters: node wins for full-stack apps that ship both a package.json
|
|
10
|
-
// and Python deps; Dockerfile is the explicit escape hatch evaluated last.
|
|
11
|
-
const strategy = R.cond([
|
|
12
|
-
[has(['package.json']), R.always(strategy_nodejs)],
|
|
13
|
-
[
|
|
14
|
-
has(['requirements.txt', 'pyproject.toml', 'Pipfile']),
|
|
15
|
-
R.always(strategy_python)
|
|
16
|
-
],
|
|
17
|
-
[has(['Dockerfile']), R.always(strategy_docker)]
|
|
18
|
-
])(workdir);
|
|
19
|
-
if (!strategy) {
|
|
20
|
-
throw new Error('Cannot detect project type');
|
|
21
|
-
}
|
|
22
|
-
return strategy(workdir);
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export { runtime_detection };
|
|
File without changes
|
/package/dist/commands/deploy/{runtime-detect/helpers → buildpacks/shared}/has_any_of_files.js
RENAMED
|
File without changes
|