hox-cli 0.1.0__py3-none-any.whl
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.
- hox_cli/__init__.py +0 -0
- hox_cli/cli.py +791 -0
- hox_cli/config.py +205 -0
- hox_cli/deploy.py +95 -0
- hox_cli/dev.py +162 -0
- hox_cli/lock.py +198 -0
- hox_cli/master.py +371 -0
- hox_cli/node.py +302 -0
- hox_cli/panel.py +275 -0
- hox_cli/port.py +705 -0
- hox_cli/report.py +208 -0
- hox_cli/status.py +361 -0
- hox_cli/try_start.py +327 -0
- hox_cli-0.1.0.dist-info/METADATA +8 -0
- hox_cli-0.1.0.dist-info/RECORD +17 -0
- hox_cli-0.1.0.dist-info/WHEEL +4 -0
- hox_cli-0.1.0.dist-info/entry_points.txt +3 -0
hox_cli/port.py
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"""From a Hop3 recipe (a catalog app's hop3.toml and its files) to a SlapOS
|
|
2
|
+
software release on the hop3 stack of slapos_abilian_edge (stack/hop3/).
|
|
3
|
+
|
|
4
|
+
port() maps the recipe to a Release, a section of hop3.toml at a time;
|
|
5
|
+
software_cfg(), versions_cfg() and write() turn the Release into files."""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import tomllib
|
|
13
|
+
from collections.abc import Callable, Collection
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from packaging.requirements import Requirement
|
|
20
|
+
from packaging.utils import canonicalize_name
|
|
21
|
+
|
|
22
|
+
from .lock import (
|
|
23
|
+
BUILDOUT_TOOLS,
|
|
24
|
+
Pin,
|
|
25
|
+
environment,
|
|
26
|
+
node_tags,
|
|
27
|
+
render_versions,
|
|
28
|
+
wheel_kind,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PortError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# The catalog's presentation layer and Hop3's own checks: not part of the recipe.
|
|
37
|
+
OVERLAY = re.compile(
|
|
38
|
+
r"catalog\.toml|readme(-\w+)?\.md|icon\.\w+|check\.py|screenshots/.*"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# What the hop3 stack does without a word from the recipe, or what doesn't apply
|
|
42
|
+
# on SlapOS; any other section without a mapper (SECTIONS) is reported.
|
|
43
|
+
IGNORED = {
|
|
44
|
+
"metadata",
|
|
45
|
+
"test",
|
|
46
|
+
"port",
|
|
47
|
+
"domains",
|
|
48
|
+
"deploy",
|
|
49
|
+
"backup",
|
|
50
|
+
"limits",
|
|
51
|
+
"contexts",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
PIP_INSTALL = "pip install -r requirements.txt"
|
|
55
|
+
|
|
56
|
+
# The build commands that need Node (SBE's version, already in the node's shared parts).
|
|
57
|
+
NODE_COMMAND = re.compile(r"(\w+=\S*\s+)*(npm|npx|node)\b")
|
|
58
|
+
COMPOSER_COMMAND = re.compile(r"(\w+=\S*\s+)*composer\b")
|
|
59
|
+
NODE_VERSION = "24.11.0"
|
|
60
|
+
# SlapOS's Go components, oldest first: an app gets the first that its go.mod allows.
|
|
61
|
+
GO_VERSIONS = ("1.23", "1.24", "1.26")
|
|
62
|
+
GO_MOD_VERSION = re.compile(r"^go\s+(\d+\.\d+)", re.MULTILINE)
|
|
63
|
+
# Packages that buildout can't build by name alone, and the SlapOS component that
|
|
64
|
+
# has what their build needs.
|
|
65
|
+
COMPONENTS = {"misaka": "python-misaka"}
|
|
66
|
+
# In a venv, pip builds them by name: the SlapOS component with the libraries an sdist needs.
|
|
67
|
+
BUILD_LIBRARIES = {"cffi": "libffi"}
|
|
68
|
+
# The kinds of [env] secret that the stack encodes from 32 random bytes, as Hop3 does
|
|
69
|
+
# by default (`secrets` in the stack: NAME:kind). A password is the stack's own kind.
|
|
70
|
+
ENCODED_SECRETS = {"hex", "base64", "urlsafe"}
|
|
71
|
+
ADMIN_SCRIPT = "hop3-admin-create.sh"
|
|
72
|
+
|
|
73
|
+
# Fetches a package's release files from PyPI: name, version -> file names.
|
|
74
|
+
PyPI = Callable[[str, str], list[str]]
|
|
75
|
+
|
|
76
|
+
# Pins a project's dependencies for the node: its pyproject.toml, Python -> requirements.
|
|
77
|
+
Resolve = Callable[[Path, str], str]
|
|
78
|
+
|
|
79
|
+
# The pins as a requirements file with their hashes: pins, Python -> its text.
|
|
80
|
+
Hashes = Callable[[list[Pin], str], str]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Release:
|
|
85
|
+
"""What the release's software.cfg and versions.cfg say, before they are written."""
|
|
86
|
+
|
|
87
|
+
name: str
|
|
88
|
+
title: str
|
|
89
|
+
version: str
|
|
90
|
+
python: str
|
|
91
|
+
recipe: Path # the recipe's directory
|
|
92
|
+
files: dict[str, str] # the recipe's files, relative to it: their md5
|
|
93
|
+
generated: dict[str, str] = field(
|
|
94
|
+
default_factory=dict
|
|
95
|
+
) # what the port adds to them: their text
|
|
96
|
+
commands: list[str] = field(default_factory=list) # run in the build
|
|
97
|
+
toolchain: str = ""
|
|
98
|
+
node: bool = False # the commands need Node
|
|
99
|
+
go: str = "" # the app is built with this Go component
|
|
100
|
+
prebuilt: bool = (
|
|
101
|
+
False # the recipe fetches a binary: provisional, SlapOS builds from source
|
|
102
|
+
)
|
|
103
|
+
egg: str | None = None # the app's distribution, when installed from its source
|
|
104
|
+
pins: list[Pin] = field(default_factory=list)
|
|
105
|
+
eggs: list[str] = field(default_factory=list)
|
|
106
|
+
venv: bool = False # installed by pip in a venv of its own, not as eggs by buildout
|
|
107
|
+
requirements: str = "" # for the venv: the pins with their hashes
|
|
108
|
+
uploaded_prior_to: str = "" # for the venv: what its packages' builds may fetch
|
|
109
|
+
# What Hop3 gives every app, the address the node's health promise uses
|
|
110
|
+
# (an app that checks the Host header has to trust it), then what the
|
|
111
|
+
# recipe asks for. $${...}: substituted in the instance, not at build time.
|
|
112
|
+
environment: list[str] = field(
|
|
113
|
+
default_factory=lambda: [
|
|
114
|
+
"HOST_NAME=$DOMAIN",
|
|
115
|
+
"HOP3_PUBLIC_URL=$URL",
|
|
116
|
+
"HOP3_BACKEND_URL=$${app-parameters:url}",
|
|
117
|
+
]
|
|
118
|
+
)
|
|
119
|
+
secrets: list[str] = field(default_factory=list)
|
|
120
|
+
volumes: list[str] = field(default_factory=list) # "name target"
|
|
121
|
+
processes: dict[str, list[str]] = field(default_factory=dict) # name: its commands
|
|
122
|
+
health: str | None = None
|
|
123
|
+
database: bool = False
|
|
124
|
+
mariadb: bool = False
|
|
125
|
+
composer: bool = False # the build runs composer: stack/hop3/composer.cfg
|
|
126
|
+
notes: list[str] = field(
|
|
127
|
+
default_factory=list
|
|
128
|
+
) # what wasn't mapped, or needs a look
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def pypi_files(name: str, version: str) -> list[str]:
|
|
132
|
+
import httpx2
|
|
133
|
+
|
|
134
|
+
response = httpx2.get(f"https://pypi.org/pypi/{name}/{version}/json", timeout=30)
|
|
135
|
+
if response.status_code == 404:
|
|
136
|
+
raise PortError(f"{name} {version} is not on PyPI")
|
|
137
|
+
response.raise_for_status()
|
|
138
|
+
return [f["filename"] for f in response.json()["urls"]]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def uv_compile(pyproject: Path, python: str) -> str:
|
|
142
|
+
done = subprocess.run(
|
|
143
|
+
[
|
|
144
|
+
"uv",
|
|
145
|
+
"pip",
|
|
146
|
+
"compile",
|
|
147
|
+
str(pyproject),
|
|
148
|
+
"--python-version",
|
|
149
|
+
python,
|
|
150
|
+
"--python-platform",
|
|
151
|
+
"x86_64-unknown-linux-gnu",
|
|
152
|
+
"--no-header",
|
|
153
|
+
"--no-annotate",
|
|
154
|
+
"-q",
|
|
155
|
+
],
|
|
156
|
+
capture_output=True,
|
|
157
|
+
text=True,
|
|
158
|
+
check=False,
|
|
159
|
+
)
|
|
160
|
+
if done.returncode:
|
|
161
|
+
raise PortError(f"uv pip compile {pyproject}: {done.stderr.strip()}")
|
|
162
|
+
return done.stdout
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def uv_hashes(pins: list[Pin], python: str) -> str:
|
|
166
|
+
done = subprocess.run(
|
|
167
|
+
[
|
|
168
|
+
"uv",
|
|
169
|
+
"pip",
|
|
170
|
+
"compile",
|
|
171
|
+
"-",
|
|
172
|
+
"--no-deps",
|
|
173
|
+
"--generate-hashes",
|
|
174
|
+
"--python-version",
|
|
175
|
+
python,
|
|
176
|
+
"--python-platform",
|
|
177
|
+
"x86_64-unknown-linux-gnu",
|
|
178
|
+
"--no-header",
|
|
179
|
+
"--no-annotate",
|
|
180
|
+
"-q",
|
|
181
|
+
],
|
|
182
|
+
input="".join(f"{p.name}=={p.version}\n" for p in pins),
|
|
183
|
+
capture_output=True,
|
|
184
|
+
text=True,
|
|
185
|
+
check=False,
|
|
186
|
+
)
|
|
187
|
+
if done.returncode:
|
|
188
|
+
raise PortError(f"uv pip compile --generate-hashes: {done.stderr.strip()}")
|
|
189
|
+
return done.stdout
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def port(
|
|
193
|
+
recipe: Path,
|
|
194
|
+
python: str,
|
|
195
|
+
pypi: PyPI = pypi_files,
|
|
196
|
+
source: Path | None = None,
|
|
197
|
+
resolve: Resolve = uv_compile,
|
|
198
|
+
venv: bool = False,
|
|
199
|
+
hashes: Hashes = uv_hashes,
|
|
200
|
+
) -> Release:
|
|
201
|
+
"""The release for the recipe in `recipe` (the directory of its hop3.toml).
|
|
202
|
+
|
|
203
|
+
An app without a requirements.txt is installed from its source, which the recipe
|
|
204
|
+
fetches at build time: `source` is an unpacked copy, for its name and dependencies,
|
|
205
|
+
or to tell a Go app (Hop3 finds its go.mod, the recipe doesn't name the toolchain)."""
|
|
206
|
+
spec = tomllib.loads((recipe / "hop3.toml").read_text())
|
|
207
|
+
build = spec.setdefault("build", {}) # its mapper refuses what it can't build
|
|
208
|
+
if "toolchain" not in build and source is not None and (source / "go.mod").exists():
|
|
209
|
+
build["toolchain"] = "go"
|
|
210
|
+
meta = spec["metadata"]
|
|
211
|
+
files = sorted(
|
|
212
|
+
f
|
|
213
|
+
for f in (str(p.relative_to(recipe)) for p in recipe.rglob("*") if p.is_file())
|
|
214
|
+
if not OVERLAY.fullmatch(f)
|
|
215
|
+
)
|
|
216
|
+
release = Release(
|
|
217
|
+
meta["id"],
|
|
218
|
+
meta.get("title", meta["id"]),
|
|
219
|
+
meta.get("version", ""),
|
|
220
|
+
python,
|
|
221
|
+
recipe,
|
|
222
|
+
{f: hashlib.md5((recipe / f).read_bytes()).hexdigest() for f in files},
|
|
223
|
+
)
|
|
224
|
+
for name, mapper in SECTIONS.items():
|
|
225
|
+
if name in spec:
|
|
226
|
+
mapper(release, spec[name])
|
|
227
|
+
release.notes += [
|
|
228
|
+
f"[{s}]: not mapped" for s in spec if s not in SECTIONS and s not in IGNORED
|
|
229
|
+
]
|
|
230
|
+
if release.go and source is not None:
|
|
231
|
+
release.go = _go_component((source / "go.mod").read_text())
|
|
232
|
+
if release.toolchain != "python":
|
|
233
|
+
if venv:
|
|
234
|
+
raise PortError(f"{release.name}: --venv is for a Python app")
|
|
235
|
+
return release
|
|
236
|
+
if (recipe / "requirements.txt").exists():
|
|
237
|
+
requirements = (recipe / "requirements.txt").read_text()
|
|
238
|
+
else:
|
|
239
|
+
requirements = _from_source(release, source, resolve)
|
|
240
|
+
release.pins, release.eggs = _requirements(
|
|
241
|
+
requirements, python, pypi, frozenset() if venv else BUILDOUT_TOOLS
|
|
242
|
+
)
|
|
243
|
+
if venv:
|
|
244
|
+
release.venv = True
|
|
245
|
+
release.requirements = hashes(release.pins, python)
|
|
246
|
+
release.uploaded_prior_to = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
247
|
+
return release
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _from_source(release: Release, source: Path | None, resolve: Resolve) -> str:
|
|
251
|
+
if source is None:
|
|
252
|
+
raise PortError(
|
|
253
|
+
"no requirements.txt: for an app installed from its source, "
|
|
254
|
+
"give its unpacked release with --source"
|
|
255
|
+
)
|
|
256
|
+
pyproject = source / "pyproject.toml"
|
|
257
|
+
if not pyproject.exists():
|
|
258
|
+
raise PortError(f"{source}: no pyproject.toml")
|
|
259
|
+
release.egg = tomllib.loads(pyproject.read_text())["project"]["name"]
|
|
260
|
+
release.notes.append(
|
|
261
|
+
f"{release.egg} from its source: its dependencies are pinned as resolved today"
|
|
262
|
+
)
|
|
263
|
+
return resolve(pyproject, release.python)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _build(release: Release, build: dict) -> None:
|
|
267
|
+
if build.get("builder", "local") != "local":
|
|
268
|
+
raise PortError(
|
|
269
|
+
f"builder {build['builder']!r}: only the local builder is supported"
|
|
270
|
+
)
|
|
271
|
+
toolchain = build.get("toolchain")
|
|
272
|
+
if toolchain not in ("python", "go", "generic", "node", "php"):
|
|
273
|
+
raise PortError(
|
|
274
|
+
f"toolchain {toolchain!r}: only python, go, generic, node and php are supported so far "
|
|
275
|
+
"(a Go recipe that doesn't name it: give its source with --source)"
|
|
276
|
+
)
|
|
277
|
+
release.toolchain = toolchain
|
|
278
|
+
install = _commands(build.get("build"))
|
|
279
|
+
release.go = GO_VERSIONS[0] if toolchain == "go" else ""
|
|
280
|
+
release.prebuilt = toolchain == "generic"
|
|
281
|
+
if toolchain == "python" and install not in ([], [PIP_INSTALL]):
|
|
282
|
+
raise PortError(
|
|
283
|
+
f"[build] build = {install}: only {PIP_INSTALL!r} is supported so far"
|
|
284
|
+
)
|
|
285
|
+
if release.prebuilt:
|
|
286
|
+
release.notes.append(
|
|
287
|
+
"toolchain 'generic': provisional, runs the binary the recipe fetches, "
|
|
288
|
+
"until a variant of the recipe builds it from source"
|
|
289
|
+
)
|
|
290
|
+
release.commands = (
|
|
291
|
+
_commands(build.get("before-build"))
|
|
292
|
+
+ ([] if toolchain == "python" else install)
|
|
293
|
+
+ _commands(build.get("after-build"))
|
|
294
|
+
)
|
|
295
|
+
release.node = toolchain == "node" or any(
|
|
296
|
+
NODE_COMMAND.match(c) for c in release.commands
|
|
297
|
+
)
|
|
298
|
+
release.composer = toolchain == "php" and any(
|
|
299
|
+
COMPOSER_COMMAND.match(c) for c in release.commands
|
|
300
|
+
)
|
|
301
|
+
if toolchain == "php":
|
|
302
|
+
release.processes |= {"fpm": ["hop3-php-fpm"], "web": ["hop3-php-web"]}
|
|
303
|
+
release.environment.append("PHPRC=${hop3-php-ini:output}")
|
|
304
|
+
_packages(release, "build", build)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _env(release: Release, env: dict) -> None:
|
|
308
|
+
for key, value in env.items():
|
|
309
|
+
if key == "computed":
|
|
310
|
+
release.environment += [
|
|
311
|
+
f"{k}={_shell(_variables(v))}" for k, v in value.items()
|
|
312
|
+
]
|
|
313
|
+
elif key.startswith("_"):
|
|
314
|
+
continue
|
|
315
|
+
elif isinstance(value, str):
|
|
316
|
+
release.environment.append(f"{key}={_shell(value)}")
|
|
317
|
+
elif "generate" in value:
|
|
318
|
+
_secret(release, key, value)
|
|
319
|
+
elif value.get("key") in ("domain", "hostname") and "from" not in value:
|
|
320
|
+
release.environment.append(f"{key}=$DOMAIN")
|
|
321
|
+
elif value.get("key") == "name" and "from" not in value:
|
|
322
|
+
release.environment.append(f"{key}={release.name}")
|
|
323
|
+
else:
|
|
324
|
+
release.notes.append(f"[env] {key} = {value}: not mapped")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _secret(release: Release, key: str, spec: dict) -> None:
|
|
328
|
+
kind = spec["generate"]
|
|
329
|
+
if (
|
|
330
|
+
"prefix" in spec
|
|
331
|
+
): # the stack's secret under its own name, the app's variable built from it
|
|
332
|
+
secret = f"HOP3_SECRET_{key}"
|
|
333
|
+
release.environment.append(f"{key}={_shell(spec['prefix'] + '$' + secret)}")
|
|
334
|
+
key, spec = secret, {k: v for k, v in spec.items() if k != "prefix"}
|
|
335
|
+
release.secrets.append(f"{key}:{kind}" if kind in ENCODED_SECRETS else key)
|
|
336
|
+
if kind not in ENCODED_SECRETS | {"password"}:
|
|
337
|
+
release.notes.append(
|
|
338
|
+
f"[env] {key}: generated as a 32-character password, not {kind!r}"
|
|
339
|
+
)
|
|
340
|
+
elif kind != "password" and spec.get("length", 32) != 32:
|
|
341
|
+
release.notes.append(
|
|
342
|
+
f"[env] {key}: generated from 32 bytes, not {spec['length']}"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _run(release: Release, run: dict) -> None:
|
|
347
|
+
release.processes["release"] = _commands(run.get("before-run"))
|
|
348
|
+
if "start" in run:
|
|
349
|
+
release.processes["web"] = _commands(run["start"])
|
|
350
|
+
release.processes |= {k: _commands(v) for k, v in run.get("workers", {}).items()}
|
|
351
|
+
_packages(release, "run", run)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _admin(release: Release, admin: dict) -> None:
|
|
355
|
+
release.secrets.append("HOP3_ADMIN_PASSWORD")
|
|
356
|
+
if "username" in admin:
|
|
357
|
+
release.environment.append(f"HOP3_ADMIN_USER={_shell(admin['username'])}")
|
|
358
|
+
if (
|
|
359
|
+
admin.get("email") == "operator"
|
|
360
|
+
): # Hop3's operator: the create scripts require an address
|
|
361
|
+
release.environment.append(f"HOP3_ADMIN_EMAIL=admin@{release.name}.invalid")
|
|
362
|
+
release.notes.append(
|
|
363
|
+
f"[admin] email: admin@{release.name}.invalid, unless HOP3_ADMIN_EMAIL is an instance parameter"
|
|
364
|
+
)
|
|
365
|
+
elif "email" in admin:
|
|
366
|
+
release.environment.append(f"HOP3_ADMIN_EMAIL={_shell(admin['email'])}")
|
|
367
|
+
create = admin.get("create")
|
|
368
|
+
if not create: # the app makes its admin from the HOP3_ADMIN_* variables
|
|
369
|
+
return
|
|
370
|
+
if (
|
|
371
|
+
"\n" in create.strip() or "${" in create
|
|
372
|
+
): # in a script: buildout would split or substitute it
|
|
373
|
+
release.generated[ADMIN_SCRIPT] = create
|
|
374
|
+
release.files[ADMIN_SCRIPT] = hashlib.md5(create.encode()).hexdigest()
|
|
375
|
+
create = f"bash {ADMIN_SCRIPT}"
|
|
376
|
+
release.processes["after-start"] = _commands(create)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _probe(release: Release, probe: dict) -> None:
|
|
380
|
+
release.notes.append("[probe]: not created, nothing on SlapOS signs in with it")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _volumes(release: Release, volumes: list[dict]) -> None:
|
|
384
|
+
# A scratch volume is reset by every deploy, as the copy of the build is.
|
|
385
|
+
release.volumes = [
|
|
386
|
+
f"{v['name']} {v['target']}"
|
|
387
|
+
for v in volumes
|
|
388
|
+
if v.get("type", "persist") == "persist"
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _addons(release: Release, addons: list[dict]) -> None:
|
|
393
|
+
for addon in addons:
|
|
394
|
+
if addon["type"] == "postgres":
|
|
395
|
+
release.database = True
|
|
396
|
+
elif addon["type"] == "mysql":
|
|
397
|
+
release.mariadb = True
|
|
398
|
+
else:
|
|
399
|
+
release.notes.append(f"[[addons]] {addon['type']}: not supported")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _proxy(release: Release, proxy: dict) -> None:
|
|
403
|
+
if release.toolchain == "php" and proxy.keys() == {
|
|
404
|
+
"deny"
|
|
405
|
+
}: # for nginx in stack/hop3/php.cfg
|
|
406
|
+
release.environment.append(f"HOP3_PROXY_DENY={_shell(' '.join(proxy['deny']))}")
|
|
407
|
+
else:
|
|
408
|
+
release.notes.append(f"[proxy] {proxy}: not mapped")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _healthcheck(release: Release, healthcheck: dict) -> None:
|
|
412
|
+
release.health = healthcheck.get("path")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# The sections mapped, in this order: [env.computed] can read what [admin] sets.
|
|
416
|
+
SECTIONS: dict[str, Callable[[Release, Any], None]] = {
|
|
417
|
+
"build": _build,
|
|
418
|
+
"run": _run,
|
|
419
|
+
"admin": _admin,
|
|
420
|
+
"env": _env,
|
|
421
|
+
"probe": _probe,
|
|
422
|
+
"volumes": _volumes,
|
|
423
|
+
"addons": _addons,
|
|
424
|
+
"proxy": _proxy,
|
|
425
|
+
"healthcheck": _healthcheck,
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def software_cfg(release: Release) -> str:
|
|
430
|
+
processes = {k: " && ".join(v) for k, v in release.processes.items() if v}
|
|
431
|
+
lines = [
|
|
432
|
+
f"# {release.title} {release.version}, from its Hop3 recipe in hop3/,",
|
|
433
|
+
"# by `hox port`: change the recipe and port it again rather than edit this file.",
|
|
434
|
+
*(
|
|
435
|
+
[
|
|
436
|
+
"# Provisional: runs the binary the recipe fetches, where a SlapOS release builds",
|
|
437
|
+
"# from source. To replace with a port of a variant of the recipe that does.",
|
|
438
|
+
]
|
|
439
|
+
if release.prebuilt
|
|
440
|
+
else []
|
|
441
|
+
),
|
|
442
|
+
"[buildout]",
|
|
443
|
+
"extends =",
|
|
444
|
+
*([" ../../component/golang/buildout.cfg"] if release.go else []),
|
|
445
|
+
*([" ../../component/nodejs/buildout.cfg"] if release.node else []),
|
|
446
|
+
*(
|
|
447
|
+
[
|
|
448
|
+
" ../../component/apache-php/buildout.cfg",
|
|
449
|
+
" ../../component/nginx/buildout.cfg",
|
|
450
|
+
]
|
|
451
|
+
if release.toolchain == "php"
|
|
452
|
+
else []
|
|
453
|
+
),
|
|
454
|
+
*(
|
|
455
|
+
[" ../../component/postgresql/buildout.cfg"]
|
|
456
|
+
if release.database or release.toolchain == "php"
|
|
457
|
+
else []
|
|
458
|
+
),
|
|
459
|
+
*([" ../../component/mariadb/buildout.cfg"] if release.mariadb else []),
|
|
460
|
+
*(f" ../../component/{c}/buildout.cfg" for c in _components(release)),
|
|
461
|
+
" ../../stack/hop3/buildout.cfg",
|
|
462
|
+
*([" ../../stack/hop3/php.cfg"] if release.toolchain == "php" else []),
|
|
463
|
+
*([" ../../stack/hop3/composer.cfg"] if release.composer else []),
|
|
464
|
+
*(
|
|
465
|
+
[" ../../stack/python-app/venv.cfg", " ../../stack/hop3/venv.cfg"]
|
|
466
|
+
if release.venv
|
|
467
|
+
else [" ./versions.cfg"]
|
|
468
|
+
if release.pins
|
|
469
|
+
else []
|
|
470
|
+
),
|
|
471
|
+
"",
|
|
472
|
+
"[python3]",
|
|
473
|
+
f"<= python{release.python}",
|
|
474
|
+
"",
|
|
475
|
+
*(["[nodejs]", f"<= nodejs-{NODE_VERSION}", ""] if release.node else []),
|
|
476
|
+
*(
|
|
477
|
+
[
|
|
478
|
+
"[nodejs-headers]",
|
|
479
|
+
f"<= nodejs-headers-{NODE_VERSION}",
|
|
480
|
+
"",
|
|
481
|
+
"# Native modules from their source, where npm would fetch a prebuilt binary.",
|
|
482
|
+
"[python-app-git]",
|
|
483
|
+
"environment +=",
|
|
484
|
+
" npm_config_build_from_source=true",
|
|
485
|
+
" npm_config_tarball=${nodejs-headers:target}",
|
|
486
|
+
" NODE_GYP_FORCE_PYTHON=${python3:location}/bin/python3",
|
|
487
|
+
"",
|
|
488
|
+
]
|
|
489
|
+
if release.toolchain == "node"
|
|
490
|
+
else []
|
|
491
|
+
),
|
|
492
|
+
"[hop3]",
|
|
493
|
+
"base = ${:_profile_base_location_}/hop3",
|
|
494
|
+
"files =",
|
|
495
|
+
*(f" {f} {md5}" for f, md5 in release.files.items()),
|
|
496
|
+
"commands =",
|
|
497
|
+
*(f" {c}" for c in release.commands),
|
|
498
|
+
*(
|
|
499
|
+
[f"tools = {_tools(release)}"]
|
|
500
|
+
if release.go or release.node or release.toolchain == "php"
|
|
501
|
+
else []
|
|
502
|
+
),
|
|
503
|
+
"",
|
|
504
|
+
*(
|
|
505
|
+
[
|
|
506
|
+
"[python-app-requirements]",
|
|
507
|
+
"url = ${:_profile_base_location_}/requirements.txt",
|
|
508
|
+
f"md5sum = {hashlib.md5(release.requirements.encode()).hexdigest()}",
|
|
509
|
+
"",
|
|
510
|
+
]
|
|
511
|
+
if release.venv
|
|
512
|
+
else []
|
|
513
|
+
),
|
|
514
|
+
"[python-app]",
|
|
515
|
+
f"name = {release.name}",
|
|
516
|
+
*([f"egg = {release.egg}"] if release.egg else []),
|
|
517
|
+
*(_venv_options(release) if release.venv else _eggs(release)),
|
|
518
|
+
*([f"health = {release.health}"] if release.health else []),
|
|
519
|
+
*(["postgresql-bin = ${postgresql:location}/bin"] if release.database else []),
|
|
520
|
+
*(["mariadb-bin = ${mariadb:location}/bin"] if release.mariadb else []),
|
|
521
|
+
*([f"secrets = {' '.join(release.secrets)}"] if release.secrets else []),
|
|
522
|
+
"environment =",
|
|
523
|
+
*(f" {e}" for e in release.environment),
|
|
524
|
+
*(
|
|
525
|
+
["volumes =", *(f" {v}" for v in release.volumes)]
|
|
526
|
+
if release.volumes
|
|
527
|
+
else []
|
|
528
|
+
),
|
|
529
|
+
"",
|
|
530
|
+
"[python-app-processes]",
|
|
531
|
+
*(f"{k} = {v}" for k, v in processes.items()),
|
|
532
|
+
"",
|
|
533
|
+
]
|
|
534
|
+
return "\n".join(lines)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def versions_cfg(release: Release) -> str:
|
|
538
|
+
source = "the app's pyproject.toml" if release.egg else "hop3/requirements.txt"
|
|
539
|
+
return render_versions(release.pins, f"{source} by `hox port`", release.python)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def write(release: Release, out: Path) -> None:
|
|
543
|
+
"""Write the release in `out` (software/<name>/), the recipe's files in out/hop3/."""
|
|
544
|
+
cfg = out / "software.cfg"
|
|
545
|
+
if cfg.exists() and "by `hox port`" not in cfg.read_text():
|
|
546
|
+
raise PortError(f"{cfg} doesn't come from hox port: give the recipe another id")
|
|
547
|
+
if (out / "hop3").exists():
|
|
548
|
+
shutil.rmtree(out / "hop3")
|
|
549
|
+
for f in release.files:
|
|
550
|
+
(out / "hop3" / f).parent.mkdir(parents=True, exist_ok=True)
|
|
551
|
+
if f in release.generated:
|
|
552
|
+
(out / "hop3" / f).write_text(release.generated[f])
|
|
553
|
+
else:
|
|
554
|
+
shutil.copyfile(release.recipe / f, out / "hop3" / f)
|
|
555
|
+
cfg.write_text(software_cfg(release))
|
|
556
|
+
if release.venv:
|
|
557
|
+
(out / "requirements.txt").write_text(release.requirements)
|
|
558
|
+
elif release.pins:
|
|
559
|
+
(out / "versions.cfg").write_text(versions_cfg(release))
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _compiled(release: Release) -> list[str]:
|
|
563
|
+
"""What a venv builds from its sdist, where a wheel would bring compiled code."""
|
|
564
|
+
return [canonicalize_name(p.name) for p in release.pins if p.wheel == "binary"]
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _components(release: Release) -> list[str]:
|
|
568
|
+
if release.venv:
|
|
569
|
+
return [BUILD_LIBRARIES[n] for n in _compiled(release) if n in BUILD_LIBRARIES]
|
|
570
|
+
return [COMPONENTS[e] for e in release.eggs if e in COMPONENTS]
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _eggs(release: Release) -> list[str]:
|
|
574
|
+
return [
|
|
575
|
+
"eggs =",
|
|
576
|
+
*([" ${python-app-develop:egg}"] if release.egg else []),
|
|
577
|
+
*(
|
|
578
|
+
f" ${{{COMPONENTS[e]}:egg}}" if e in COMPONENTS else f" {e}"
|
|
579
|
+
for e in release.eggs
|
|
580
|
+
),
|
|
581
|
+
]
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _venv_options(release: Release) -> list[str]:
|
|
585
|
+
compiled = _compiled(release)
|
|
586
|
+
return [
|
|
587
|
+
*([f"no-binary = {' '.join(compiled)}"] if compiled else []),
|
|
588
|
+
*(
|
|
589
|
+
[
|
|
590
|
+
f"build-libraries = {' '.join(f'${{{c}:location}}' for c in _components(release))}"
|
|
591
|
+
]
|
|
592
|
+
if _components(release)
|
|
593
|
+
else []
|
|
594
|
+
),
|
|
595
|
+
f"uploaded-prior-to = {release.uploaded_prior_to}",
|
|
596
|
+
]
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _tools(release: Release) -> str:
|
|
600
|
+
"""What the build finds on PATH, before what the stack gives every release."""
|
|
601
|
+
tools = [f"${{golang{release.go}:location}}/bin"] if release.go else []
|
|
602
|
+
tools += ["${nodejs:location}/bin"] if release.node else []
|
|
603
|
+
tools += (
|
|
604
|
+
["${gcc:prefix}/bin", "${binutils:location}/bin"]
|
|
605
|
+
if release.toolchain == "node"
|
|
606
|
+
else []
|
|
607
|
+
)
|
|
608
|
+
tools += ["${hop3-composer:location}"] if release.composer else []
|
|
609
|
+
tools += ["${hop3-php:tools}"] if release.toolchain == "php" else []
|
|
610
|
+
return ":".join([*tools, "${:base-tools}"])
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _go_component(go_mod: str) -> str:
|
|
614
|
+
"""The first Go component at least as new as what a go.mod asks for."""
|
|
615
|
+
asked = GO_MOD_VERSION.search(go_mod)
|
|
616
|
+
if asked is None:
|
|
617
|
+
return GO_VERSIONS[0]
|
|
618
|
+
for version in GO_VERSIONS:
|
|
619
|
+
if _minor(version) >= _minor(asked[1]):
|
|
620
|
+
return version
|
|
621
|
+
raise PortError(
|
|
622
|
+
f"go.mod asks for Go {asked[1]}: SlapOS's newest component here is {GO_VERSIONS[-1]}"
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _minor(version: str) -> int:
|
|
627
|
+
return int(version.split(".")[1])
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _commands(value: str | list[str] | None) -> list[str]:
|
|
631
|
+
"""A command or a list of them. Each one line, without ${…}: buildout would
|
|
632
|
+
split the one and substitute the other (write $NAME)."""
|
|
633
|
+
commands = [
|
|
634
|
+
c.strip()
|
|
635
|
+
for c in ([] if value is None else [value] if isinstance(value, str) else value)
|
|
636
|
+
]
|
|
637
|
+
for command in commands:
|
|
638
|
+
if "\n" in command or "${" in command:
|
|
639
|
+
raise PortError(f"{command!r}: commands must be one line, without ${{…}}")
|
|
640
|
+
return [c for c in commands if c]
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _packages(release: Release, section: str, value: dict) -> None:
|
|
644
|
+
if value.get("packages"):
|
|
645
|
+
release.notes.append(
|
|
646
|
+
f"[{section}] packages = {value['packages']}: needs SlapOS components"
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _requirements(
|
|
651
|
+
requirements: str, python: str, pypi: PyPI, skip: Collection[str]
|
|
652
|
+
) -> tuple[list[Pin], list[str]]:
|
|
653
|
+
"""The pins of compiled requirements, and the eggs to install (all of them, as
|
|
654
|
+
`pip install -r` does, but those in `skip`: buildout's own, whose versions are SlapOS's)."""
|
|
655
|
+
env = environment(python)
|
|
656
|
+
supported = node_tags(python)
|
|
657
|
+
pins, eggs = [], []
|
|
658
|
+
for line in requirements.splitlines():
|
|
659
|
+
line = line.split("#", 1)[0].strip()
|
|
660
|
+
if not line:
|
|
661
|
+
continue
|
|
662
|
+
if line.startswith("-"):
|
|
663
|
+
raise PortError(
|
|
664
|
+
f"requirements: {line!r}: only pinned requirements are supported"
|
|
665
|
+
)
|
|
666
|
+
req = Requirement(line)
|
|
667
|
+
if (req.marker and not req.marker.evaluate(env)) or canonicalize_name(
|
|
668
|
+
req.name
|
|
669
|
+
) in skip:
|
|
670
|
+
continue
|
|
671
|
+
version = next((s.version for s in req.specifier if s.operator == "=="), None)
|
|
672
|
+
if version is None:
|
|
673
|
+
raise PortError(f"requirements: {line!r} isn't pinned")
|
|
674
|
+
files = pypi(req.name, version)
|
|
675
|
+
pins.append(
|
|
676
|
+
Pin(
|
|
677
|
+
req.name,
|
|
678
|
+
version,
|
|
679
|
+
any(f.endswith((".tar.gz", ".zip")) for f in files),
|
|
680
|
+
wheel_kind([f for f in files if f.endswith(".whl")], supported),
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
eggs.append(
|
|
684
|
+
f"{req.name}[{','.join(sorted(req.extras))}]" if req.extras else req.name
|
|
685
|
+
)
|
|
686
|
+
return sorted(pins, key=lambda p: p.name.lower()), eggs
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _variables(value: str) -> str:
|
|
690
|
+
"""${NAME} as $NAME, which buildout leaves alone, where the shell reads the same."""
|
|
691
|
+
converted = re.sub(r"\$\{(\w+)\}(?!\w)", r"$\1", value)
|
|
692
|
+
if "${" in converted:
|
|
693
|
+
raise PortError(
|
|
694
|
+
f"{value!r}: a ${{…}} followed by a name character can't be written for buildout"
|
|
695
|
+
)
|
|
696
|
+
return converted
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _shell(value: str) -> str:
|
|
700
|
+
"""A value for a shell assignment, $NAME expanded."""
|
|
701
|
+
if "$" not in value:
|
|
702
|
+
return shlex.quote(value)
|
|
703
|
+
return (
|
|
704
|
+
'"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`") + '"'
|
|
705
|
+
)
|