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.
Files changed (68) hide show
  1. simplon/__init__.py +11 -0
  2. simplon/allure.py +100 -0
  3. simplon/awake.py +66 -0
  4. simplon/backend.py +59 -0
  5. simplon/bootstrap.py +461 -0
  6. simplon/catalogue.py +110 -0
  7. simplon/catalogue.yaml +154 -0
  8. simplon/clablifecycle.py +197 -0
  9. simplon/clabrender.py +260 -0
  10. simplon/cli.py +423 -0
  11. simplon/clitaxonomy.py +198 -0
  12. simplon/compose.py +181 -0
  13. simplon/context.py +118 -0
  14. simplon/credentials.py +154 -0
  15. simplon/degraded.py +58 -0
  16. simplon/disk.py +30 -0
  17. simplon/diskguard.py +40 -0
  18. simplon/docker.py +180 -0
  19. simplon/environments.py +149 -0
  20. simplon/githubpackages.py +144 -0
  21. simplon/healthgate.py +151 -0
  22. simplon/host.py +204 -0
  23. simplon/images.py +65 -0
  24. simplon/interact.py +63 -0
  25. simplon/labegress.py +321 -0
  26. simplon/labhost.py +433 -0
  27. simplon/labinstance.py +145 -0
  28. simplon/labnet.py +38 -0
  29. simplon/linux.py +66 -0
  30. simplon/log.py +87 -0
  31. simplon/nexus.py +1074 -0
  32. simplon/orchestrator/__init__.py +5 -0
  33. simplon/orchestrator/manifest.py +1016 -0
  34. simplon/orchestrator/model/__init__.py +1 -0
  35. simplon/orchestrator/model/treeform.py +322 -0
  36. simplon/orchestrator/product.py +126 -0
  37. simplon/orchestrator/steps.py +545 -0
  38. simplon/orchestrator/tui.py +255 -0
  39. simplon/portainer.py +165 -0
  40. simplon/ports.py +93 -0
  41. simplon/py.typed +0 -0
  42. simplon/pyvenv.py +53 -0
  43. simplon/run.py +97 -0
  44. simplon/signatures.py +230 -0
  45. simplon/taskgen.py +400 -0
  46. simplon/tasks/__init__.py +12 -0
  47. simplon/tasks/claudeplugins.py +253 -0
  48. simplon/tasks/docs.py +77 -0
  49. simplon/tasks/env.py +56 -0
  50. simplon/tasks/nexus.py +43 -0
  51. simplon/tasks/tasks.py +116 -0
  52. simplon/tasks/testrun.py +365 -0
  53. simplon/tasks/typecheck.py +123 -0
  54. simplon/tasks/vcs.py +56 -0
  55. simplon/templates/cli.py.j2 +83 -0
  56. simplon/templates/generic.clab.yml.j2 +61 -0
  57. simplon/templates/launch.cmd.j2 +29 -0
  58. simplon/templates/launch.sh.j2 +36 -0
  59. simplon/test_impls.py +56 -0
  60. simplon/topology.py +363 -0
  61. simplon/vcs.py +210 -0
  62. simplon/waits.py +123 -0
  63. simplon-0.1.0.dist-info/METADATA +76 -0
  64. simplon-0.1.0.dist-info/RECORD +68 -0
  65. simplon-0.1.0.dist-info/WHEEL +5 -0
  66. simplon-0.1.0.dist-info/entry_points.txt +2 -0
  67. simplon-0.1.0.dist-info/licenses/LICENSE +21 -0
  68. 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