@faable/faable 1.5.28 → 1.5.30
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/README.md +48 -0
- package/dist/api/context.js +2 -6
- package/dist/api/strategies/oidc.strategy.js +27 -5
- package/dist/commands/deploy/git_context.js +11 -1
- package/dist/commands/deploy/index.js +14 -2
- package/dist/commands/deploy/python-pipeline/analyze_python.js +158 -0
- package/dist/commands/deploy/python-pipeline/build_docker.js +42 -0
- package/dist/commands/deploy/python-pipeline/index.js +25 -0
- package/dist/commands/deploy/python-pipeline/parse_procfile.js +22 -0
- package/dist/commands/deploy/python-pipeline/templates/Dockerfile +24 -0
- package/dist/commands/deploy/python-pipeline/templates/entrypoint.sh +7 -0
- package/dist/commands/deploy/runtime-detect/runtime_detection.js +7 -1
- package/dist/commands/deploy/runtime-detect/strategies/python.js +48 -0
- package/dist/commands/link/index.js +22 -25
- package/dist/lib/Configuration.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,54 @@ To install the latest version of Faable CLI:
|
|
|
21
21
|
npm i -g @faable/faable
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
## Runtimes
|
|
25
|
+
|
|
26
|
+
`faable deploy` auto-detects the runtime from the files in your project (no
|
|
27
|
+
config needed in the common case):
|
|
28
|
+
|
|
29
|
+
| Detected by | Runtime |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `package.json` | Node.js (Next.js, Vite, Astro, Gatsby, CRA, Vue, Angular, …) |
|
|
32
|
+
| `requirements.txt` / `pyproject.toml` / `Pipfile` | Python |
|
|
33
|
+
| `Dockerfile` | Your own image (any language) |
|
|
34
|
+
|
|
35
|
+
> A project with a `package.json` is always treated as Node. To deploy a Python
|
|
36
|
+
> backend that also has a `package.json`, ship a `Dockerfile` instead.
|
|
37
|
+
|
|
38
|
+
### Node.js / static frameworks
|
|
39
|
+
|
|
40
|
+
SPA frameworks are built and served automatically. If the project has **no
|
|
41
|
+
`start` script**, the built output is served statically (e.g. Vite → `npx vite
|
|
42
|
+
preview`, CRA/Vue/Angular → `serve`). If it defines a `start` script (custom SSR,
|
|
43
|
+
Next.js, Nuxt, Remix, …), that command is used.
|
|
44
|
+
|
|
45
|
+
### Python
|
|
46
|
+
|
|
47
|
+
The start command is detected from your framework:
|
|
48
|
+
|
|
49
|
+
| Framework | Detected from | Start command |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| Django | `manage.py` + the package with `wsgi.py` | `gunicorn <pkg>.wsgi:application` |
|
|
52
|
+
| FastAPI / ASGI | `fastapi`/`uvicorn`/`starlette` dep | `uvicorn <module>:app` |
|
|
53
|
+
| Flask | `flask` dep | `gunicorn <module>:app` |
|
|
54
|
+
|
|
55
|
+
Dependencies are installed inside the image from `requirements.txt`,
|
|
56
|
+
`pyproject.toml` or `Pipfile`. `gunicorn`/`uvicorn` are installed automatically
|
|
57
|
+
if missing.
|
|
58
|
+
|
|
59
|
+
Pin the Python version with a `runtime.txt` (`python-3.11.3`), a
|
|
60
|
+
`.python-version`, or `requires-python` in `pyproject.toml`.
|
|
61
|
+
|
|
62
|
+
### Overriding detection
|
|
63
|
+
|
|
64
|
+
When auto-detection doesn't fit, set the commands explicitly:
|
|
65
|
+
|
|
66
|
+
- `faable.json` — `{ "buildCommand": "...", "startCommand": "..." }`
|
|
67
|
+
- `Procfile` — a `web:` line, e.g. `web: gunicorn app:app --bind 0.0.0.0:$PORT`
|
|
68
|
+
|
|
69
|
+
Precedence: `faable.json` → `Procfile` → auto-detection. The container listens on
|
|
70
|
+
`$PORT` (80).
|
|
71
|
+
|
|
24
72
|
## Documentation
|
|
25
73
|
|
|
26
74
|
For details on how to use Faable CLI, check out our
|
package/dist/api/context.js
CHANGED
|
@@ -7,12 +7,8 @@ import { CredentialsStore } from '../lib/CredentialsStore.js';
|
|
|
7
7
|
|
|
8
8
|
const context = async () => {
|
|
9
9
|
let api;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const apikey = process.env.FAABLE_APIKEY;
|
|
13
|
-
api = FaableApi.create({ authStrategy: apikey_strategy, auth: { apikey } });
|
|
14
|
-
}
|
|
15
|
-
else if (process.env.FAABLE_TOKEN) {
|
|
10
|
+
// Auth resolution: FAABLE_TOKEN → OIDC (CI) → local `faable login` credentials.
|
|
11
|
+
if (process.env.FAABLE_TOKEN) {
|
|
16
12
|
// Token in environment
|
|
17
13
|
const token = process.env.FAABLE_TOKEN;
|
|
18
14
|
api = FaableApi.create({ authStrategy: bearer_strategy, auth: { token } });
|
|
@@ -1,12 +1,34 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
1
2
|
import { create_base_client } from '../base_client.js';
|
|
2
3
|
|
|
3
4
|
const exchangeGithubOidcToken = async (gh_token) => {
|
|
4
5
|
const client = create_base_client();
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
try {
|
|
7
|
+
const res = await client.post("/auth/github-oidc", {
|
|
8
|
+
token: gh_token
|
|
9
|
+
});
|
|
10
|
+
const { access_token, app_id } = res.data;
|
|
11
|
+
return { access_token, app_id };
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
if (axios.isAxiosError(err)) {
|
|
15
|
+
const status = err.response?.status;
|
|
16
|
+
const serverMessage = err.response?.data?.message;
|
|
17
|
+
// No app is linked to this repository — turn the cryptic 404 into an
|
|
18
|
+
// actionable next step.
|
|
19
|
+
if (status === 404) {
|
|
20
|
+
throw new Error('No app linked to this repository. Run "faable link" locally to link it, ' +
|
|
21
|
+
"or link it from the dashboard (https://dashboard.faable.com).");
|
|
22
|
+
}
|
|
23
|
+
// Monorepo: several apps are linked to the same repository.
|
|
24
|
+
if (status === 400) {
|
|
25
|
+
throw new Error(serverMessage ||
|
|
26
|
+
"This repository has multiple linked apps. Specify which one with `faable deploy <app_id>`.");
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Faable OIDC token exchange failed (${status ?? "network error"})${serverMessage ? `: ${serverMessage}` : ""}`);
|
|
29
|
+
}
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
10
32
|
};
|
|
11
33
|
const oidc_strategy = (config) => {
|
|
12
34
|
const { idToken } = config;
|
|
@@ -14,7 +14,9 @@ const gitRunner = (workdir) => command => new Promise(resolve => {
|
|
|
14
14
|
// Resolve the commit / ref / actor for the current deploy. In GitHub Actions
|
|
15
15
|
// these come from the standard env vars; locally we fall back to git so manual
|
|
16
16
|
// deploys still record a commit. `github_actor` is CI-only (a GitHub login),
|
|
17
|
-
// left undefined locally where no reliable login is available.
|
|
17
|
+
// left undefined locally where no reliable login is available. The commit
|
|
18
|
+
// message has no standard CI env var, so it is always read from git (for the
|
|
19
|
+
// resolved commit, falling back to HEAD) — the dashboard shows its first line.
|
|
18
20
|
const git_context = async (opts) => {
|
|
19
21
|
const env = opts?.env ?? process.env;
|
|
20
22
|
const run = opts?.run ?? gitRunner(opts?.workdir);
|
|
@@ -26,6 +28,12 @@ const git_context = async (opts) => {
|
|
|
26
28
|
github_ref = `refs/heads/${branch}`;
|
|
27
29
|
}
|
|
28
30
|
const github_actor = env.GITHUB_ACTOR || undefined;
|
|
31
|
+
// Full commit message (subject + body) for the resolved commit; the dashboard
|
|
32
|
+
// renders only the subject line. Fall back to HEAD when the SHA isn't
|
|
33
|
+
// resolvable in the local checkout (e.g. shallow clones).
|
|
34
|
+
const github_commit_message = (github_commit &&
|
|
35
|
+
(await run(`git show -s --format=%B ${github_commit}`))) ||
|
|
36
|
+
(await run("git log -1 --pretty=%B"));
|
|
29
37
|
const ctx = {};
|
|
30
38
|
if (github_commit)
|
|
31
39
|
ctx.github_commit = github_commit;
|
|
@@ -33,6 +41,8 @@ const git_context = async (opts) => {
|
|
|
33
41
|
ctx.github_ref = github_ref;
|
|
34
42
|
if (github_actor)
|
|
35
43
|
ctx.github_actor = github_actor;
|
|
44
|
+
if (github_commit_message)
|
|
45
|
+
ctx.github_commit_message = github_commit_message;
|
|
36
46
|
return ctx;
|
|
37
47
|
};
|
|
38
48
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { context } from '../../api/context.js';
|
|
2
2
|
import { cmd } from '../../lib/cmd.js';
|
|
3
|
+
import { Configuration } from '../../lib/Configuration.js';
|
|
3
4
|
import { log } from '../../log.js';
|
|
4
5
|
import { check_environment } from './check_environment.js';
|
|
5
6
|
import { git_context } from './git_context.js';
|
|
6
7
|
import { build_node } from './node-pipeline/index.js';
|
|
8
|
+
import { build_python } from './python-pipeline/index.js';
|
|
7
9
|
import { runtime_detection } from './runtime-detect/runtime_detection.js';
|
|
8
10
|
import { upload_tag } from './upload_tag.js';
|
|
9
11
|
|
|
@@ -29,9 +31,13 @@ const deploy = {
|
|
|
29
31
|
const { api } = ctx;
|
|
30
32
|
// Resolve runtime
|
|
31
33
|
const { runtime } = await runtime_detection(workdir);
|
|
32
|
-
|
|
34
|
+
// app_id resolution (the user never has to look one up):
|
|
35
|
+
// 1. explicit positional (monorepo escape hatch)
|
|
36
|
+
// 2. OIDC in CI — the backend resolves the app from the linked repository
|
|
37
|
+
// 3. locally — the app saved by `faable link` in faable.json
|
|
38
|
+
const app_id = args.app_id || ctx.appId || Configuration.instance().app_id;
|
|
33
39
|
if (!app_id) {
|
|
34
|
-
throw new Error('
|
|
40
|
+
throw new Error('No app linked to this repository. Run "faable link" to link it (or link it from the dashboard).');
|
|
35
41
|
}
|
|
36
42
|
const app = await api.getApp(app_id);
|
|
37
43
|
// Check if we can build docker images
|
|
@@ -48,6 +54,12 @@ const deploy = {
|
|
|
48
54
|
});
|
|
49
55
|
type = node_result.type;
|
|
50
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
|
+
}
|
|
51
63
|
else if (runtime.name == 'docker') {
|
|
52
64
|
type = 'node';
|
|
53
65
|
await cmd(`docker build -t ${app.id} .`, {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path__default from 'path';
|
|
3
|
+
import { log } from '../../../log.js';
|
|
4
|
+
import { Configuration } from '../../../lib/Configuration.js';
|
|
5
|
+
import { parse_procfile } from './parse_procfile.js';
|
|
6
|
+
|
|
7
|
+
/** Combine all dependency manifests into one lowercased blob for cheap lookups. */
|
|
8
|
+
const read_dependencies_text = (workdir) => {
|
|
9
|
+
const files = ["requirements.txt", "pyproject.toml", "Pipfile"];
|
|
10
|
+
return files
|
|
11
|
+
.map((f) => path__default.join(workdir, f))
|
|
12
|
+
.filter((p) => fs.existsSync(p))
|
|
13
|
+
.map((p) => fs.readFileSync(p).toString())
|
|
14
|
+
.join("\n")
|
|
15
|
+
.toLowerCase();
|
|
16
|
+
};
|
|
17
|
+
const has_token = (deps, token) => new RegExp(`\\b${token}\\b`).test(deps);
|
|
18
|
+
/** Identify which server binary a start command relies on, to ensure it's installed. */
|
|
19
|
+
const server_from_command = (command) => {
|
|
20
|
+
const bin = command.trim().split(/\s+/)[0];
|
|
21
|
+
if (bin === "gunicorn")
|
|
22
|
+
return "gunicorn";
|
|
23
|
+
if (bin === "uvicorn")
|
|
24
|
+
return "uvicorn";
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
/** Django project package = the directory that contains wsgi.py. */
|
|
28
|
+
const find_django_package = (workdir) => {
|
|
29
|
+
const entries = fs.readdirSync(workdir, { withFileTypes: true });
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (entry.isDirectory() &&
|
|
32
|
+
fs.existsSync(path__default.join(workdir, entry.name, "wsgi.py"))) {
|
|
33
|
+
return entry.name;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Find the module that defines the app object, returning its dotted module path
|
|
40
|
+
* (e.g. `main`, `app.main`). Prefers a file matching `pattern`, else the first
|
|
41
|
+
* existing candidate.
|
|
42
|
+
*/
|
|
43
|
+
const find_app_module = (workdir, pattern) => {
|
|
44
|
+
const candidates = [
|
|
45
|
+
"main.py",
|
|
46
|
+
"app.py",
|
|
47
|
+
"asgi.py",
|
|
48
|
+
"wsgi.py",
|
|
49
|
+
"application.py",
|
|
50
|
+
"server.py",
|
|
51
|
+
path__default.join("app", "main.py"),
|
|
52
|
+
path__default.join("app", "app.py"),
|
|
53
|
+
path__default.join("src", "main.py"),
|
|
54
|
+
];
|
|
55
|
+
let first_existing = null;
|
|
56
|
+
for (const rel of candidates) {
|
|
57
|
+
const abs = path__default.join(workdir, rel);
|
|
58
|
+
if (!fs.existsSync(abs))
|
|
59
|
+
continue;
|
|
60
|
+
const module = rel.replace(/\.py$/, "").split(path__default.sep).join(".");
|
|
61
|
+
if (!first_existing)
|
|
62
|
+
first_existing = module;
|
|
63
|
+
if (pattern.test(fs.readFileSync(abs).toString()))
|
|
64
|
+
return module;
|
|
65
|
+
}
|
|
66
|
+
return first_existing;
|
|
67
|
+
};
|
|
68
|
+
/** Resolve the container start command and which server it needs installed. */
|
|
69
|
+
const resolve_start = (workdir, deps) => {
|
|
70
|
+
// 1. Explicit override in faable.json
|
|
71
|
+
const configured = Configuration.instance().configuredStartCommand;
|
|
72
|
+
if (configured) {
|
|
73
|
+
log.info(`Using start command from faable.json`);
|
|
74
|
+
return { start_command: configured, server: server_from_command(configured) };
|
|
75
|
+
}
|
|
76
|
+
// 2. Procfile `web:` line
|
|
77
|
+
const procfile = parse_procfile(workdir);
|
|
78
|
+
if (procfile) {
|
|
79
|
+
log.info(`Using start command from Procfile`);
|
|
80
|
+
return { start_command: procfile, server: server_from_command(procfile) };
|
|
81
|
+
}
|
|
82
|
+
// 3. Framework detection
|
|
83
|
+
// Django: manage.py + the package holding wsgi.py
|
|
84
|
+
if (fs.existsSync(path__default.join(workdir, "manage.py"))) {
|
|
85
|
+
const pkg = find_django_package(workdir);
|
|
86
|
+
if (!pkg) {
|
|
87
|
+
throw new Error("Detected Django (manage.py) but couldn't find the wsgi.py package. Set `startCommand` in faable.json or add a Procfile.");
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
start_command: `gunicorn ${pkg}.wsgi:application --bind 0.0.0.0:$PORT`,
|
|
91
|
+
server: "gunicorn",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// FastAPI / ASGI
|
|
95
|
+
if (has_token(deps, "fastapi") ||
|
|
96
|
+
has_token(deps, "starlette") ||
|
|
97
|
+
has_token(deps, "uvicorn")) {
|
|
98
|
+
const module = find_app_module(workdir, /app\s*=\s*(FastAPI|Starlette)\(/i);
|
|
99
|
+
if (module) {
|
|
100
|
+
return {
|
|
101
|
+
start_command: `uvicorn ${module}:app --host 0.0.0.0 --port $PORT`,
|
|
102
|
+
server: "uvicorn",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Flask
|
|
107
|
+
if (has_token(deps, "flask")) {
|
|
108
|
+
const module = find_app_module(workdir, /app\s*=\s*Flask\(/i);
|
|
109
|
+
if (module) {
|
|
110
|
+
return {
|
|
111
|
+
start_command: `gunicorn ${module}:app --bind 0.0.0.0:$PORT`,
|
|
112
|
+
server: "gunicorn",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
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
|
+
};
|
|
118
|
+
/** Build the dependency-install command run during the Docker build. */
|
|
119
|
+
const build_install_command = (workdir, server, deps) => {
|
|
120
|
+
const steps = [];
|
|
121
|
+
const configured_build = Configuration.instance().buildCommand;
|
|
122
|
+
if (configured_build) {
|
|
123
|
+
steps.push(configured_build);
|
|
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.
|
|
135
|
+
if (server === "gunicorn" && !has_token(deps, "gunicorn")) {
|
|
136
|
+
steps.push("pip install --no-cache-dir gunicorn");
|
|
137
|
+
}
|
|
138
|
+
if (server === "uvicorn" && !has_token(deps, "uvicorn")) {
|
|
139
|
+
steps.push("pip install --no-cache-dir uvicorn[standard]");
|
|
140
|
+
}
|
|
141
|
+
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
|
+
return "true";
|
|
145
|
+
}
|
|
146
|
+
return steps.join(" && ");
|
|
147
|
+
};
|
|
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
|
+
|
|
158
|
+
export { analyze_python };
|
|
@@ -0,0 +1,42 @@
|
|
|
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 };
|
|
@@ -0,0 +1,25 @@
|
|
|
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 };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path__default from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read the `web:` process command from a Procfile, if present. Returns null when
|
|
6
|
+
* there's no Procfile or no `web` entry. Used as a manual start-command override
|
|
7
|
+
* (Heroku-style) before falling back to framework detection.
|
|
8
|
+
*/
|
|
9
|
+
const parse_procfile = (workdir) => {
|
|
10
|
+
const procfile = path__default.join(workdir, "Procfile");
|
|
11
|
+
if (!fs.existsSync(procfile))
|
|
12
|
+
return null;
|
|
13
|
+
const content = fs.readFileSync(procfile).toString();
|
|
14
|
+
for (const line of content.split("\n")) {
|
|
15
|
+
const match = line.match(/^\s*web\s*:\s*(.+?)\s*$/);
|
|
16
|
+
if (match?.[1])
|
|
17
|
+
return match[1];
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export { parse_procfile };
|
|
@@ -0,0 +1,24 @@
|
|
|
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 PYTHONUNBUFFERED=1
|
|
10
|
+
# `escape` keeps `$PORT` literal so the build heredoc (unquoted bash) doesn't
|
|
11
|
+
# eat it; Docker then expands $PORT (=80) when building the ENV value.
|
|
12
|
+
ENV START_COMMAND="{{{escape start_command}}}"
|
|
13
|
+
|
|
14
|
+
# Copy Usercode
|
|
15
|
+
COPY . .
|
|
16
|
+
|
|
17
|
+
# Install dependencies (Python needs them built into the image, unlike node_modules).
|
|
18
|
+
# Triple-stache + escape: keep `&&` intact and any `$` heredoc-safe.
|
|
19
|
+
RUN {{{escape install_command}}}
|
|
20
|
+
|
|
21
|
+
# Entrypoint stript
|
|
22
|
+
RUN echo '{{{escape entry_script}}}' >> entrypoint.sh
|
|
23
|
+
|
|
24
|
+
CMD ["/bin/sh", "./entrypoint.sh"]
|
|
@@ -2,12 +2,18 @@ import * as R from 'ramda';
|
|
|
2
2
|
import { has_any_of_files } from './helpers/has_any_of_files.js';
|
|
3
3
|
import { strategy_docker } from './strategies/docker.js';
|
|
4
4
|
import { strategy_nodejs } from './strategies/nodejs.js';
|
|
5
|
+
import { strategy_python } from './strategies/python.js';
|
|
5
6
|
|
|
6
7
|
const runtime_detection = async (workdir) => {
|
|
7
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.
|
|
8
11
|
const strategy = R.cond([
|
|
9
12
|
[has(['package.json']), R.always(strategy_nodejs)],
|
|
10
|
-
|
|
13
|
+
[
|
|
14
|
+
has(['requirements.txt', 'pyproject.toml', 'Pipfile']),
|
|
15
|
+
R.always(strategy_python)
|
|
16
|
+
],
|
|
11
17
|
[has(['Dockerfile']), R.always(strategy_docker)]
|
|
12
18
|
])(workdir);
|
|
13
19
|
if (!strategy) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path__default from 'path';
|
|
3
|
+
import { log } from '../../../../log.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_VERSION = "3.11.3";
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the Python version from (in order):
|
|
8
|
+
* 1. runtime.txt → `python-3.11.3`
|
|
9
|
+
* 2. .python-version → `3.11.3` (pyenv)
|
|
10
|
+
* 3. pyproject.toml → `requires-python = ">=3.11"` (first concrete X.Y[.Z])
|
|
11
|
+
* Falls back to DEFAULT_VERSION.
|
|
12
|
+
*/
|
|
13
|
+
const resolve_python_version = (workdir) => {
|
|
14
|
+
const runtime_config = path__default.join(workdir, "runtime.txt");
|
|
15
|
+
if (fs.existsSync(runtime_config)) {
|
|
16
|
+
const runtime_data = fs.readFileSync(runtime_config).toString().trim();
|
|
17
|
+
if (!runtime_data.startsWith("python-")) {
|
|
18
|
+
throw new Error("runtime.txt must have runtime format with python-<version>");
|
|
19
|
+
}
|
|
20
|
+
return runtime_data.split("-")[1];
|
|
21
|
+
}
|
|
22
|
+
const python_version_file = path__default.join(workdir, ".python-version");
|
|
23
|
+
if (fs.existsSync(python_version_file)) {
|
|
24
|
+
const version = fs.readFileSync(python_version_file).toString().trim();
|
|
25
|
+
if (version)
|
|
26
|
+
return version;
|
|
27
|
+
}
|
|
28
|
+
const pyproject = path__default.join(workdir, "pyproject.toml");
|
|
29
|
+
if (fs.existsSync(pyproject)) {
|
|
30
|
+
const toml = fs.readFileSync(pyproject).toString();
|
|
31
|
+
const match = toml.match(/requires-python\s*=\s*["'][^0-9]*([0-9]+\.[0-9]+(?:\.[0-9]+)?)/);
|
|
32
|
+
if (match?.[1])
|
|
33
|
+
return match[1];
|
|
34
|
+
}
|
|
35
|
+
return DEFAULT_VERSION;
|
|
36
|
+
};
|
|
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
|
+
|
|
48
|
+
export { strategy_python };
|
|
@@ -24,14 +24,10 @@ const getGitRemoteUrl = async (workdir) => {
|
|
|
24
24
|
}
|
|
25
25
|
};
|
|
26
26
|
const link = {
|
|
27
|
-
command: "link
|
|
27
|
+
command: "link",
|
|
28
28
|
describe: "Link the local repository with a Faable app",
|
|
29
29
|
builder: (yargs) => {
|
|
30
30
|
return yargs
|
|
31
|
-
.positional("app_id", {
|
|
32
|
-
type: "string",
|
|
33
|
-
description: "app_id to link this repository",
|
|
34
|
-
})
|
|
35
31
|
.option("workdir", {
|
|
36
32
|
alias: "w",
|
|
37
33
|
type: "string",
|
|
@@ -41,7 +37,14 @@ const link = {
|
|
|
41
37
|
},
|
|
42
38
|
handler: async (args) => {
|
|
43
39
|
const workdir = args.workdir || process.cwd();
|
|
44
|
-
|
|
40
|
+
// `faable link <app_id>` is deprecated. Linking is now fully interactive and
|
|
41
|
+
// the repository is auto-detected from the current folder — users never need
|
|
42
|
+
// to look up an app_id. Warn if an extra positional was passed and ignore it.
|
|
43
|
+
const stray = (args._ ?? []).slice(1);
|
|
44
|
+
if (stray.length > 0) {
|
|
45
|
+
log.warn('Passing an app_id to "faable link" is deprecated and ignored. ' +
|
|
46
|
+
'Just run "faable link" and select the app from the list.');
|
|
47
|
+
}
|
|
45
48
|
const config = Configuration.instance();
|
|
46
49
|
if (config.app_id) {
|
|
47
50
|
log.info(`This repository is already linked to app: "${config.app_slug}" (${config.app_id})`);
|
|
@@ -60,26 +63,20 @@ const link = {
|
|
|
60
63
|
const { api } = await context();
|
|
61
64
|
log.info("Checking local git repository...");
|
|
62
65
|
const gitUrl = await getGitRemoteUrl(workdir);
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
log.error("No apps found in your account. Create one first at https://faable.com");
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
selectedApp = await prompts({
|
|
71
|
-
type: "select",
|
|
72
|
-
name: "selectedApp",
|
|
73
|
-
message: "Select the Faable app to link with this repository:",
|
|
74
|
-
choices: apps.map((app) => ({
|
|
75
|
-
title: `${app.name} (${app.url})`,
|
|
76
|
-
value: app,
|
|
77
|
-
})),
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
selectedApp = await api.getApp(app_id);
|
|
66
|
+
const apps = await api.list();
|
|
67
|
+
if (apps.length === 0) {
|
|
68
|
+
log.error("No apps found in your account. Create one first at https://faable.com");
|
|
69
|
+
return;
|
|
82
70
|
}
|
|
71
|
+
const { selectedApp } = await prompts({
|
|
72
|
+
type: "select",
|
|
73
|
+
name: "selectedApp",
|
|
74
|
+
message: "Select the Faable app to link with this repository:",
|
|
75
|
+
choices: apps.map((app) => ({
|
|
76
|
+
title: `${app.name} (${app.url})`,
|
|
77
|
+
value: app,
|
|
78
|
+
})),
|
|
79
|
+
});
|
|
83
80
|
if (!selectedApp) {
|
|
84
81
|
log.info("Link cancelled.");
|
|
85
82
|
return;
|