simplon 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.
- simplon/__init__.py +11 -0
- simplon/allure.py +100 -0
- simplon/awake.py +66 -0
- simplon/backend.py +59 -0
- simplon/bootstrap.py +461 -0
- simplon/catalogue.py +110 -0
- simplon/catalogue.yaml +154 -0
- simplon/clablifecycle.py +197 -0
- simplon/clabrender.py +260 -0
- simplon/cli.py +423 -0
- simplon/clitaxonomy.py +198 -0
- simplon/compose.py +181 -0
- simplon/context.py +118 -0
- simplon/credentials.py +154 -0
- simplon/degraded.py +58 -0
- simplon/disk.py +30 -0
- simplon/diskguard.py +40 -0
- simplon/docker.py +180 -0
- simplon/environments.py +149 -0
- simplon/githubpackages.py +144 -0
- simplon/healthgate.py +151 -0
- simplon/host.py +204 -0
- simplon/images.py +65 -0
- simplon/interact.py +63 -0
- simplon/labegress.py +321 -0
- simplon/labhost.py +433 -0
- simplon/labinstance.py +145 -0
- simplon/labnet.py +38 -0
- simplon/linux.py +66 -0
- simplon/log.py +87 -0
- simplon/nexus.py +1074 -0
- simplon/orchestrator/__init__.py +5 -0
- simplon/orchestrator/manifest.py +1016 -0
- simplon/orchestrator/model/__init__.py +1 -0
- simplon/orchestrator/model/treeform.py +322 -0
- simplon/orchestrator/product.py +126 -0
- simplon/orchestrator/steps.py +545 -0
- simplon/orchestrator/tui.py +255 -0
- simplon/portainer.py +165 -0
- simplon/ports.py +93 -0
- simplon/py.typed +0 -0
- simplon/pyvenv.py +53 -0
- simplon/run.py +97 -0
- simplon/signatures.py +230 -0
- simplon/taskgen.py +400 -0
- simplon/tasks/__init__.py +12 -0
- simplon/tasks/claudeplugins.py +253 -0
- simplon/tasks/docs.py +77 -0
- simplon/tasks/env.py +56 -0
- simplon/tasks/nexus.py +43 -0
- simplon/tasks/tasks.py +116 -0
- simplon/tasks/testrun.py +365 -0
- simplon/tasks/typecheck.py +123 -0
- simplon/tasks/vcs.py +56 -0
- simplon/templates/cli.py.j2 +83 -0
- simplon/templates/generic.clab.yml.j2 +61 -0
- simplon/templates/launch.cmd.j2 +29 -0
- simplon/templates/launch.sh.j2 +36 -0
- simplon/test_impls.py +56 -0
- simplon/topology.py +363 -0
- simplon/vcs.py +210 -0
- simplon/waits.py +123 -0
- simplon-0.1.0.dist-info/METADATA +76 -0
- simplon-0.1.0.dist-info/RECORD +68 -0
- simplon-0.1.0.dist-info/WHEEL +5 -0
- simplon-0.1.0.dist-info/entry_points.txt +2 -0
- simplon-0.1.0.dist-info/licenses/LICENSE +21 -0
- simplon-0.1.0.dist-info/top_level.txt +1 -0
simplon/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""simplon - the delivery orchestrator kernel, the domain-agnostic shared core for the *ctl
|
|
2
|
+
product family (netctl, infractl).
|
|
3
|
+
|
|
4
|
+
A product installs `simplon` (PyPI) and pins it in its own requirements: the kernel arrives as an
|
|
5
|
+
ordinary dependency, nothing is vendored and no source path is prepended. NOTE: the import package is
|
|
6
|
+
deliberately `simplon`, not `platform` (a top-level `platform` package would shadow the Python stdlib
|
|
7
|
+
`platform` module) and not bare `orchestrator` (collides with product packages).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
simplon/allure.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Allure + pytest test-report primitives for the *ctl orchestrators (netctl#730, extracted from netctl's
|
|
2
|
+
orchestrator testrun).
|
|
3
|
+
|
|
4
|
+
The mechanics of the merged single-file Allure report - the timestamped archive name, the pytest argv
|
|
5
|
+
that emits raw allure results + a machine-readable junit.xml, the per-module result merge that tags a
|
|
6
|
+
parent suite, and the render of the merged single-file HTML - are product-agnostic. A product keeps its OWN
|
|
7
|
+
suite wiring (which modules, which gradle tasks, which lab waits) and calls these to produce/merge/render
|
|
8
|
+
the report. No product knowledge, so both netctl and infractl reuse them.
|
|
9
|
+
|
|
10
|
+
Everything here is pure + filesystem-only EXCEPT `render_report`, which shells out to the allure CLI (else
|
|
11
|
+
docker). It lives here rather than beside the caller because it is allure mechanism through and through and
|
|
12
|
+
because it composes `report_filename` (netctl#1406, moved out of netctl's orchestrator.testrun).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import glob
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
from simplon import log
|
|
23
|
+
from simplon.run import run
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def report_filename(now: datetime | None = None, *, prefix: str = "allure") -> str:
|
|
27
|
+
"""The archived single-file report name for a run: ``<prefix>-YYYYMMDD-HHMMSS.html`` (netctl#402). Pure
|
|
28
|
+
and now-injectable so the timestamped naming is unit-tested without invoking allure or the wall clock."""
|
|
29
|
+
return f"{prefix}-{(now or datetime.now()):%Y%m%d-%H%M%S}.html"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def integration_pytest_argv(py: str, results: str, junit: str, extra: list[str]) -> list[str]:
|
|
33
|
+
"""The integration pytest argv: allure raw results + a small machine-readable junit.xml, and NO
|
|
34
|
+
pytest-html (netctl#402 dropped --html/--self-contained-html; the single-file allure report supersedes
|
|
35
|
+
the per-suite report.html). Pure so the flag set is locked by a unit test; ``extra`` is appended
|
|
36
|
+
verbatim (e.g. a caller's -k filter)."""
|
|
37
|
+
return [py, "-m", "pytest", f"--alluredir={results}", f"--junit-xml={junit}", *extra]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def merge_results(dst: str, srcs: list[str], *, parent_suite: str = "Unit") -> None:
|
|
41
|
+
"""Copy each source dir's allure result files into ``dst``, tagging every ``*-result.json`` with
|
|
42
|
+
``parentSuite=<parent_suite>`` unless already labelled (so a merged report groups a module's results
|
|
43
|
+
under one suite). Non-result files are copied through unchanged. Ported from the inline python in
|
|
44
|
+
netctl's run_unit_tests."""
|
|
45
|
+
os.makedirs(dst, exist_ok=True)
|
|
46
|
+
for src in srcs:
|
|
47
|
+
if not os.path.isdir(src):
|
|
48
|
+
continue
|
|
49
|
+
for f in glob.glob(os.path.join(src, "*")):
|
|
50
|
+
base = os.path.basename(f)
|
|
51
|
+
if base.endswith("-result.json"):
|
|
52
|
+
with open(f, encoding="utf-8") as fh:
|
|
53
|
+
r = json.load(fh)
|
|
54
|
+
labels = r.setdefault("labels", [])
|
|
55
|
+
if not any(l.get("name") == "parentSuite" for l in labels):
|
|
56
|
+
labels.append({"name": "parentSuite", "value": parent_suite})
|
|
57
|
+
with open(os.path.join(dst, base), "w", encoding="utf-8") as fh:
|
|
58
|
+
json.dump(r, fh)
|
|
59
|
+
else:
|
|
60
|
+
shutil.copy(f, os.path.join(dst, base))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render_report(report_dir: str, results: str | None = None, *, prefix: str = "allure") -> None:
|
|
64
|
+
"""Render the merged single-file allure HTML to a timestamped ``report_dir/<prefix>-YYYYMMDD-HHMMSS.html``
|
|
65
|
+
(netctl#402) via the local allure CLI, else docker. ``results`` defaults to ``report_dir/allure-results``;
|
|
66
|
+
a caller running an EXPLORATORY (argument-filtered) suite passes its own quarantined results dir plus a
|
|
67
|
+
distinct prefix, so that run's archive can never be mistaken for the canonical one.
|
|
68
|
+
|
|
69
|
+
``allure --single-file`` emits index.html into an output DIR, so the render goes into a transient scratch
|
|
70
|
+
dir and that one self-contained file is moved out to the dated name; each run thus archives a portable,
|
|
71
|
+
diffable report (the regression baseline). Never raises: a missing render tool leaves the raw results in
|
|
72
|
+
place with a hint, because archiving must not itself be the reason a run is red.
|
|
73
|
+
"""
|
|
74
|
+
results = results or os.path.join(report_dir, "allure-results")
|
|
75
|
+
scratch = os.path.join(report_dir, "allure-report") # transient allure -o dir (holds the single index.html)
|
|
76
|
+
report = os.path.join(report_dir, report_filename(prefix=prefix))
|
|
77
|
+
if shutil.which("allure") is not None:
|
|
78
|
+
log.info("generating allure HTML report (local CLI, single-file)")
|
|
79
|
+
ok = run(["allure", "generate", "--single-file", "--clean", results, "-o", scratch]).ok
|
|
80
|
+
if not ok:
|
|
81
|
+
log.warn(f"allure generate failed; use: allure serve '{results}'")
|
|
82
|
+
return
|
|
83
|
+
elif shutil.which("docker") is not None:
|
|
84
|
+
log.info("no local allure CLI; rendering the allure HTML report via docker (single-file)")
|
|
85
|
+
ok = run(["docker", "run", "--rm", "-v", f"{report_dir}:/work", "-w", "/work", "--entrypoint", "allure",
|
|
86
|
+
"frankescobar/allure-docker-service", "generate", "--single-file", "--clean",
|
|
87
|
+
os.path.join("/work", os.path.relpath(results, report_dir)), "-o", "/work/allure-report"]).ok
|
|
88
|
+
if not ok:
|
|
89
|
+
log.warn(f"docker allure render failed; use: allure serve '{results}'")
|
|
90
|
+
return
|
|
91
|
+
else:
|
|
92
|
+
log.info("install allure (your product's `install` command, or 'brew install allure') for the report")
|
|
93
|
+
return
|
|
94
|
+
index = os.path.join(scratch, "index.html")
|
|
95
|
+
if os.path.isfile(index):
|
|
96
|
+
shutil.move(index, report)
|
|
97
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
98
|
+
log.ok(f"allure HTML report: {report}")
|
|
99
|
+
else:
|
|
100
|
+
log.warn(f"allure produced no {index}; raw results left at {results}")
|
simplon/awake.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Keep the host awake for the duration of a long lab/build run (netctl#546, extracted to the delivery
|
|
2
|
+
kernel in netctl#592 Train B).
|
|
3
|
+
|
|
4
|
+
A full `build`/`up`/`seed`/`monitor accept` cycle runs for tens of minutes with no keypress; on a laptop
|
|
5
|
+
the host then idle-sleeps mid-run, dropping the Colima VM / SSH channel and wedging the pipeline. This
|
|
6
|
+
wraps those long commands in a context manager that inhibits idle sleep while they run and lets the host
|
|
7
|
+
sleep normally again on exit. Product-agnostic: any *ctl orchestrator with multi-minute commands reuses it.
|
|
8
|
+
|
|
9
|
+
The OS decision is a PURE helper (`_keep_awake_argv`) so it is unit-tested without spawning anything: on
|
|
10
|
+
macOS it yields `caffeinate -dimsu` (prevent display/idle/system/disk sleep + keep-awake on AC and
|
|
11
|
+
battery), on Linux/other it yields a `systemd-inhibit --what=idle` wrapper around a blocking `sleep`, and
|
|
12
|
+
where neither applies it yields None (no-op). The context manager is STRICTLY best-effort: a missing or
|
|
13
|
+
failing inhibitor logs a WARNING and continues - keeping the host awake is a convenience, never a reason
|
|
14
|
+
to fail the actual run.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import platform
|
|
20
|
+
import subprocess
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
|
|
23
|
+
from simplon import log
|
|
24
|
+
|
|
25
|
+
__all__ = ["keep_awake"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _keep_awake_argv(os_name: str) -> list[str] | None:
|
|
29
|
+
"""PURE: the argv of a long-lived process that inhibits host idle-sleep on `os_name`, or None when
|
|
30
|
+
there is nothing sensible to spawn.
|
|
31
|
+
|
|
32
|
+
macOS (`Darwin`) -> ``caffeinate -dimsu``: -d display, -i idle system sleep, -m disk idle, -s system
|
|
33
|
+
sleep on AC, -u declare user active. It runs until killed, which is exactly the enter/exit lifetime.
|
|
34
|
+
Linux -> ``systemd-inhibit --what=idle sleep infinity``: holds an idle inhibitor lock for its own
|
|
35
|
+
lifetime; killed on exit. Anything else -> None (no-op)."""
|
|
36
|
+
if os_name == "Darwin":
|
|
37
|
+
return ["caffeinate", "-dimsu"]
|
|
38
|
+
if os_name == "Linux":
|
|
39
|
+
return ["systemd-inhibit", "--what=idle", "--why=netctl lab/build run", "sleep", "infinity"]
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@contextlib.contextmanager
|
|
44
|
+
def keep_awake() -> Iterator[None]:
|
|
45
|
+
"""Inhibit host idle-sleep for the duration of the `with` block; restore normal sleep on exit.
|
|
46
|
+
|
|
47
|
+
Best-effort by contract: if the platform has no inhibitor, or spawning it fails (binary missing,
|
|
48
|
+
permission), it logs a WARNING and yields anyway - a long run never fails because the host could not
|
|
49
|
+
be kept awake. The inhibitor process is always terminated in the `finally`, so sleep is re-enabled the
|
|
50
|
+
moment the wrapped command returns (or raises)."""
|
|
51
|
+
argv = _keep_awake_argv(platform.system())
|
|
52
|
+
proc: subprocess.Popen | None = None
|
|
53
|
+
if argv is not None:
|
|
54
|
+
try:
|
|
55
|
+
proc = subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
56
|
+
log.info(f"keeping the host awake for this run ({argv[0]})")
|
|
57
|
+
except (OSError, ValueError) as exc:
|
|
58
|
+
log.warn(f"could not keep the host awake ({argv[0]}: {exc}); the run may be interrupted by idle-sleep")
|
|
59
|
+
proc = None
|
|
60
|
+
try:
|
|
61
|
+
yield
|
|
62
|
+
finally:
|
|
63
|
+
if proc is not None:
|
|
64
|
+
proc.terminate()
|
|
65
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
66
|
+
proc.wait(timeout=5)
|
simplon/backend.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Backend - a deployment backend as a polymorphic object, not a dispatched string (netctl#735).
|
|
2
|
+
|
|
3
|
+
An environment names HOW it is realised through its `backend` tag (a local lab, a cloud provider, ...).
|
|
4
|
+
Everywhere else the delivery kernel is deliberately functional - stateless ops are functions - but the
|
|
5
|
+
backend is the ONE axis that is genuinely meant to be extended, and switching on the tag
|
|
6
|
+
(`if backend == "local": ... elif backend == "exoscale": ...`) across the deploy/destroy/status paths is
|
|
7
|
+
the classic non-polymorphic smell exactly there. So the kernel keeps backend SELECTION polymorphic: a
|
|
8
|
+
product registers one `Backend` implementation per backend name and `resolve` maps an `Environment` to that
|
|
9
|
+
INSTANCE. Adding or extending a backend is then a new class, never a new `if`.
|
|
10
|
+
|
|
11
|
+
This mirrors the kernel's other product seams (`ProductContext`, the `EnvironmentProvider` Protocol): the
|
|
12
|
+
Protocol lives here, the concrete implementations live in the PRODUCT (netctl's `LocalBackend` for
|
|
13
|
+
containerlab, its `ExoscaleBackend` skeleton, ...). The coupling flows product -> kernel, never the reverse:
|
|
14
|
+
this module names no backend and no product, so a second consumer (infractl) registers its own backends
|
|
15
|
+
against the same seam ("gleiche Maschine, anderer Katalog").
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Mapping, Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
from simplon.environments import Environment
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class Backend(Protocol):
|
|
26
|
+
"""One deployment backend: the object a product registers per `backend:` tag. Structural, so a
|
|
27
|
+
product's implementation need not import or subclass anything named here - any object exposing these
|
|
28
|
+
members satisfies it.
|
|
29
|
+
|
|
30
|
+
``name`` is the backend tag it answers to (the `backend:` value in the env matrix); it MUST equal the
|
|
31
|
+
key the product registers the instance under, so `resolve` and the product's env-gate can identify it.
|
|
32
|
+
``deploy``/``destroy``/``status`` are the environment lifecycle a CD command drives against ONE target
|
|
33
|
+
``Environment``: deploy/destroy return a process return code (for the caller's exit), status returns the
|
|
34
|
+
rendered status report. An unbuilt op raises ``NotImplementedError`` so a half-scaffolded backend fails
|
|
35
|
+
loud instead of silently mis-running another backend's path.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
|
|
40
|
+
def deploy(self, env: Environment) -> int: ...
|
|
41
|
+
def destroy(self, env: Environment) -> int: ...
|
|
42
|
+
def status(self, env: Environment) -> str: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve(env: Environment, backends: Mapping[str, Backend]) -> Backend:
|
|
46
|
+
"""Resolve an environment to its `Backend` INSTANCE by the env's backend tag - the polymorphic
|
|
47
|
+
replacement for `if env.backend == ...` dispatch. ``backends`` is the product-supplied registry (tag ->
|
|
48
|
+
instance); the kernel names no backend. Fails loud (a `ValueError` naming the tag and the known
|
|
49
|
+
backends) when a manifest env references a backend with no registered implementation, so the mistake
|
|
50
|
+
dies here at selection time, not deep inside a deployment - the same fail-loud discipline as
|
|
51
|
+
`environments.parse`.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
return backends[env.backend]
|
|
55
|
+
except KeyError:
|
|
56
|
+
known = ", ".join(sorted(backends)) or "(none registered)"
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"environment '{env.name}': no backend registered for '{env.backend}' (known: {known})"
|
|
59
|
+
) from None
|