plinth-cli 0.2.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 (49) hide show
  1. plinth/__init__.py +1 -0
  2. plinth/__main__.py +5 -0
  3. plinth/add.py +97 -0
  4. plinth/blueprints/ci/github/dot-github/workflows/ci.yaml.j2 +38 -0
  5. plinth/blueprints/ci/gitlab/dot-gitlab-ci.yml.j2 +36 -0
  6. plinth/blueprints/gitops/applicationset.yaml.j2 +46 -0
  7. plinth/blueprints/gitops/values.yaml.j2 +2 -0
  8. plinth/blueprints/repo/app/README.md.j2 +63 -0
  9. plinth/blueprints/repo/app/dagger.json.j2 +8 -0
  10. plinth/blueprints/repo/app/dot-dagger/dot-gitignore +3 -0
  11. plinth/blueprints/repo/app/dot-dagger/pyproject.toml +12 -0
  12. plinth/blueprints/repo/app/dot-dagger/src/ci/__init__.py +3 -0
  13. plinth/blueprints/repo/app/dot-dagger/src/ci/main.py.j2 +133 -0
  14. plinth/blueprints/repo/app/dot-gitignore +4 -0
  15. plinth/blueprints/repo/gitops/README.md.j2 +53 -0
  16. plinth/blueprints/repo/gitops/apps/project.yaml.j2 +15 -0
  17. plinth/blueprints/repo/gitops/bootstrap/root.yaml.j2 +20 -0
  18. plinth/blueprints/repo/gitops/charts/workload/Chart.yaml +5 -0
  19. plinth/blueprints/repo/gitops/charts/workload/templates/_helpers.tpl +13 -0
  20. plinth/blueprints/repo/gitops/charts/workload/templates/deployment.yaml +61 -0
  21. plinth/blueprints/repo/gitops/charts/workload/templates/service.yaml +20 -0
  22. plinth/blueprints/repo/gitops/charts/workload/values.yaml +9 -0
  23. plinth/blueprints/repo/gitops/scripts/bootstrap-local.sh.j2 +23 -0
  24. plinth/blueprints/workload/common/Dockerfile.j2 +14 -0
  25. plinth/blueprints/workload/common/app/__init__.py +0 -0
  26. plinth/blueprints/workload/common/dot-dockerignore +4 -0
  27. plinth/blueprints/workload/common/score.yaml.j2 +33 -0
  28. plinth/blueprints/workload/common/tests/__init__.py +0 -0
  29. plinth/blueprints/workload/modular-monolith/app/main.py.j2 +63 -0
  30. plinth/blueprints/workload/modular-monolith/app/modules/__init__.py +5 -0
  31. plinth/blueprints/workload/modular-monolith/app/modules/example.py +12 -0
  32. plinth/blueprints/workload/modular-monolith/tests/test_main.py.j2 +42 -0
  33. plinth/blueprints/workload/service/app/main.py.j2 +54 -0
  34. plinth/blueprints/workload/service/tests/test_main.py.j2 +41 -0
  35. plinth/blueprints/workload/worker/app/main.py.j2 +44 -0
  36. plinth/blueprints/workload/worker/tests/test_main.py.j2 +26 -0
  37. plinth/cli.py +185 -0
  38. plinth/config.py +51 -0
  39. plinth/loader.py +49 -0
  40. plinth/plan.py +70 -0
  41. plinth/render.py +119 -0
  42. plinth/schema.py +158 -0
  43. plinth/wizard.py +129 -0
  44. plinth/write.py +72 -0
  45. plinth_cli-0.2.0.dist-info/METADATA +174 -0
  46. plinth_cli-0.2.0.dist-info/RECORD +49 -0
  47. plinth_cli-0.2.0.dist-info/WHEEL +4 -0
  48. plinth_cli-0.2.0.dist-info/entry_points.txt +2 -0
  49. plinth_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ # Create a local k3d cluster, install Argo CD and point it at this repository.
3
+ # Needs: docker, k3d, kubectl, helm. Push this repo and the app repos first.
4
+ set -euo pipefail
5
+
6
+ CLUSTER="${CLUSTER:-{{ project.name }}}"
7
+ ARGO_CD_CHART_VERSION="{{ defaults.ARGO_CD_CHART_VERSION }}"
8
+ cd "$(dirname "$0")/.."
9
+
10
+ for tool in docker k3d kubectl helm; do
11
+ command -v "$tool" >/dev/null || { echo "missing required tool: $tool" >&2; exit 1; }
12
+ done
13
+
14
+ k3d cluster list "$CLUSTER" >/dev/null 2>&1 || k3d cluster create "$CLUSTER" --wait
15
+ helm repo add argo https://argoproj.github.io/argo-helm --force-update >/dev/null
16
+ helm upgrade --install argocd argo/argo-cd --version "$ARGO_CD_CHART_VERSION" \
17
+ --namespace argocd --create-namespace --wait
18
+ kubectl apply -n argocd -f bootstrap/root.yaml
19
+
20
+ echo "Argo CD is running. Initial admin password:"
21
+ kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d
22
+ echo
23
+ echo "Open the UI: kubectl -n argocd port-forward svc/argocd-server 8080:443, then https://localhost:8080"
@@ -0,0 +1,14 @@
1
+ FROM {{ defaults.PYTHON_IMAGE }}
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ APP_NAME={{ workload.name }}
6
+ WORKDIR /srv
7
+ RUN useradd --system --uid 10001 --no-create-home app
8
+ COPY app ./app
9
+ USER 10001
10
+ {% if workload.port %}
11
+ ENV PORT={{ workload.port }}
12
+ EXPOSE {{ workload.port }}
13
+ {% endif %}
14
+ CMD ["python", "-m", "app.main"]
File without changes
@@ -0,0 +1,4 @@
1
+ tests/
2
+ __pycache__/
3
+ *.pyc
4
+ score.yaml
@@ -0,0 +1,33 @@
1
+ apiVersion: score.dev/v1b1
2
+ metadata:
3
+ name: {{ workload.name }}
4
+ containers:
5
+ main:
6
+ image: .
7
+ variables:
8
+ APP_NAME: {{ workload.name }}
9
+ {% if workload.port %}
10
+ PORT: "{{ workload.port }}"
11
+ {% endif %}
12
+ resources:
13
+ requests:
14
+ cpu: "{{ defaults.RESOURCES.requests.cpu }}"
15
+ memory: "{{ defaults.RESOURCES.requests.memory }}"
16
+ limits:
17
+ cpu: "{{ defaults.RESOURCES.limits.cpu }}"
18
+ memory: "{{ defaults.RESOURCES.limits.memory }}"
19
+ {% if workload.port %}
20
+ livenessProbe:
21
+ httpGet:
22
+ path: {{ defaults.HEALTH_PATH }}
23
+ port: {{ workload.port }}
24
+ readinessProbe:
25
+ httpGet:
26
+ path: {{ defaults.HEALTH_PATH }}
27
+ port: {{ workload.port }}
28
+ service:
29
+ ports:
30
+ http:
31
+ port: {{ defaults.SERVICE_PORT }}
32
+ targetPort: {{ workload.port }}
33
+ {% endif %}
File without changes
@@ -0,0 +1,63 @@
1
+ """HTTP entry point for {{ workload.name }}, a modular monolith.
2
+
3
+ Feature code lives in app/modules/. Each module exposes ROUTES; this file only
4
+ wires them into one process. Standard library only; swap in a framework when you need one.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import signal
13
+ import threading
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+
16
+ from app.modules import ROUTES
17
+
18
+ APP_NAME = os.environ.get("APP_NAME", "{{ workload.name }}")
19
+ HEALTH_PATH = "{{ defaults.HEALTH_PATH }}"
20
+
21
+ log = logging.getLogger(APP_NAME)
22
+
23
+
24
+ def handle(path: str) -> tuple[int, dict]:
25
+ if path == HEALTH_PATH:
26
+ return 200, {"status": "ok"}
27
+ if path == "/":
28
+ return 200, {"service": APP_NAME, "modules": sorted({p.split("/")[1] for p in ROUTES})}
29
+ route = ROUTES.get(path)
30
+ if route is not None:
31
+ return route()
32
+ return 404, {"error": "not found"}
33
+
34
+
35
+ class Handler(BaseHTTPRequestHandler):
36
+ def do_GET(self) -> None:
37
+ status, body = handle(self.path.split("?", 1)[0])
38
+ payload = json.dumps(body).encode("utf-8")
39
+ self.send_response(status)
40
+ self.send_header("Content-Type", "application/json")
41
+ self.send_header("Content-Length", str(len(payload)))
42
+ self.end_headers()
43
+ self.wfile.write(payload)
44
+
45
+ def log_message(self, format: str, *args) -> None:
46
+ log.info("%s %s", self.address_string(), format % args)
47
+
48
+
49
+ def make_server(port: int) -> ThreadingHTTPServer:
50
+ return ThreadingHTTPServer(("0.0.0.0", port), Handler)
51
+
52
+
53
+ def main() -> None:
54
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
55
+ server = make_server(int(os.environ.get("PORT", "{{ workload.port }}")))
56
+ signal.signal(signal.SIGTERM, lambda *_: threading.Thread(target=server.shutdown).start())
57
+ log.info("listening on port %s", server.server_address[1])
58
+ server.serve_forever()
59
+ server.server_close()
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
@@ -0,0 +1,5 @@
1
+ """Internal modules. Import each module here and merge its ROUTES."""
2
+
3
+ from app.modules import example
4
+
5
+ ROUTES = {**example.ROUTES}
@@ -0,0 +1,12 @@
1
+ """An example module. Copy this file to start a new one, then register it in __init__.py.
2
+
3
+ Modules talk to each other only through functions they choose to expose, which keeps
4
+ the boundaries clean enough to split a module into its own service later.
5
+ """
6
+
7
+
8
+ def hello() -> tuple[int, dict]:
9
+ return 200, {"module": "example", "message": "hello"}
10
+
11
+
12
+ ROUTES = {"/example/hello": hello}
@@ -0,0 +1,42 @@
1
+ import json
2
+ import threading
3
+ import unittest
4
+ import urllib.request
5
+
6
+ from app.main import HEALTH_PATH, handle, make_server
7
+ from app.modules import ROUTES
8
+
9
+
10
+ class HandleTests(unittest.TestCase):
11
+ def test_health_is_ok(self):
12
+ self.assertEqual(handle(HEALTH_PATH), (200, {"status": "ok"}))
13
+
14
+ def test_every_module_route_answers(self):
15
+ for path in ROUTES:
16
+ with self.subTest(path=path):
17
+ status, _ = handle(path)
18
+ self.assertEqual(status, 200)
19
+
20
+ def test_unknown_path_is_not_found(self):
21
+ status, _ = handle("/no-such-path")
22
+ self.assertEqual(status, 404)
23
+
24
+
25
+ class ServerTests(unittest.TestCase):
26
+ def setUp(self):
27
+ self.server = make_server(0)
28
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
29
+ self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
30
+
31
+ def tearDown(self):
32
+ self.server.shutdown()
33
+ self.server.server_close()
34
+
35
+ def test_health_over_http(self):
36
+ with urllib.request.urlopen(self.base + HEALTH_PATH) as response:
37
+ self.assertEqual(response.status, 200)
38
+ self.assertEqual(json.load(response), {"status": "ok"})
39
+
40
+
41
+ if __name__ == "__main__":
42
+ unittest.main()
@@ -0,0 +1,54 @@
1
+ """HTTP entry point for {{ workload.name }}. Standard library only; swap in a framework when you need one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import signal
9
+ import threading
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+
12
+ APP_NAME = os.environ.get("APP_NAME", "{{ workload.name }}")
13
+ HEALTH_PATH = "{{ defaults.HEALTH_PATH }}"
14
+
15
+ log = logging.getLogger(APP_NAME)
16
+
17
+
18
+ def handle(path: str) -> tuple[int, dict]:
19
+ if path == HEALTH_PATH:
20
+ return 200, {"status": "ok"}
21
+ if path == "/":
22
+ return 200, {"service": APP_NAME}
23
+ return 404, {"error": "not found"}
24
+
25
+
26
+ class Handler(BaseHTTPRequestHandler):
27
+ def do_GET(self) -> None:
28
+ status, body = handle(self.path.split("?", 1)[0])
29
+ payload = json.dumps(body).encode("utf-8")
30
+ self.send_response(status)
31
+ self.send_header("Content-Type", "application/json")
32
+ self.send_header("Content-Length", str(len(payload)))
33
+ self.end_headers()
34
+ self.wfile.write(payload)
35
+
36
+ def log_message(self, format: str, *args) -> None:
37
+ log.info("%s %s", self.address_string(), format % args)
38
+
39
+
40
+ def make_server(port: int) -> ThreadingHTTPServer:
41
+ return ThreadingHTTPServer(("0.0.0.0", port), Handler)
42
+
43
+
44
+ def main() -> None:
45
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
46
+ server = make_server(int(os.environ.get("PORT", "{{ workload.port }}")))
47
+ signal.signal(signal.SIGTERM, lambda *_: threading.Thread(target=server.shutdown).start())
48
+ log.info("listening on port %s", server.server_address[1])
49
+ server.serve_forever()
50
+ server.server_close()
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
@@ -0,0 +1,41 @@
1
+ import json
2
+ import threading
3
+ import unittest
4
+ import urllib.error
5
+ import urllib.request
6
+
7
+ from app.main import HEALTH_PATH, handle, make_server
8
+
9
+
10
+ class HandleTests(unittest.TestCase):
11
+ def test_health_is_ok(self):
12
+ self.assertEqual(handle(HEALTH_PATH), (200, {"status": "ok"}))
13
+
14
+ def test_unknown_path_is_not_found(self):
15
+ status, _ = handle("/no-such-path")
16
+ self.assertEqual(status, 404)
17
+
18
+
19
+ class ServerTests(unittest.TestCase):
20
+ def setUp(self):
21
+ self.server = make_server(0)
22
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
23
+ self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
24
+
25
+ def tearDown(self):
26
+ self.server.shutdown()
27
+ self.server.server_close()
28
+
29
+ def test_health_over_http(self):
30
+ with urllib.request.urlopen(self.base + HEALTH_PATH) as response:
31
+ self.assertEqual(response.status, 200)
32
+ self.assertEqual(json.load(response), {"status": "ok"})
33
+
34
+ def test_not_found_over_http(self):
35
+ with self.assertRaises(urllib.error.HTTPError) as ctx:
36
+ urllib.request.urlopen(self.base + "/no-such-path")
37
+ self.assertEqual(ctx.exception.code, 404)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unittest.main()
@@ -0,0 +1,44 @@
1
+ """Entry point for {{ workload.name }}, a background worker. Replace run_once with the real job."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import signal
8
+ import threading
9
+
10
+ APP_NAME = os.environ.get("APP_NAME", "{{ workload.name }}")
11
+ INTERVAL_SECONDS = float(os.environ.get("INTERVAL_SECONDS", "{{ defaults.WORKER_INTERVAL_SECONDS }}"))
12
+
13
+ log = logging.getLogger(APP_NAME)
14
+
15
+
16
+ def run_once() -> str:
17
+ """Do one unit of work."""
18
+ message = f"{APP_NAME}: tick"
19
+ log.info(message)
20
+ return message
21
+
22
+
23
+ def run(stop: threading.Event, interval: float) -> int:
24
+ """Call run_once every `interval` seconds until `stop` is set. Returns the number of runs."""
25
+ runs = 0
26
+ while not stop.is_set():
27
+ run_once()
28
+ runs += 1
29
+ stop.wait(interval)
30
+ return runs
31
+
32
+
33
+ def main() -> None:
34
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
35
+ stop = threading.Event()
36
+ signal.signal(signal.SIGTERM, lambda *_: stop.set())
37
+ signal.signal(signal.SIGINT, lambda *_: stop.set())
38
+ log.info("started, interval %ss", INTERVAL_SECONDS)
39
+ run(stop, INTERVAL_SECONDS)
40
+ log.info("stopped")
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
@@ -0,0 +1,26 @@
1
+ import threading
2
+ import unittest
3
+
4
+ from app.main import run, run_once
5
+
6
+
7
+ class WorkerTests(unittest.TestCase):
8
+ def test_run_once_does_work(self):
9
+ self.assertTrue(run_once())
10
+
11
+ def test_run_stops_when_asked(self):
12
+ stop = threading.Event()
13
+ timer = threading.Timer(0.05, stop.set)
14
+ timer.start()
15
+ runs = run(stop, interval=0.01)
16
+ timer.join()
17
+ self.assertGreaterEqual(runs, 1)
18
+
19
+ def test_run_does_nothing_when_already_stopped(self):
20
+ stop = threading.Event()
21
+ stop.set()
22
+ self.assertEqual(run(stop, interval=0.01), 0)
23
+
24
+
25
+ if __name__ == "__main__":
26
+ unittest.main()
plinth/cli.py ADDED
@@ -0,0 +1,185 @@
1
+ """Command line entry point: `plinth ...` or `python -m plinth ...`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import questionary
10
+ import yaml
11
+
12
+ from plinth import config as defaults
13
+ from plinth.add import add_application
14
+ from plinth.loader import ConfigError, dump_config, load_config, parse_config
15
+ from plinth.plan import Plan, build_plan
16
+ from plinth.render import GENERATOR_VERSION, FileMap, render_plan
17
+ from plinth.schema import AppKind
18
+ from plinth.wizard import run_wizard
19
+ from plinth.write import OutputExistsError, write_project
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(prog="plinth")
24
+ parser.add_argument("--version", action="version", version=f"plinth {GENERATOR_VERSION}")
25
+ sub = parser.add_subparsers(dest="command", required=True)
26
+
27
+ project = sub.add_parser("project", help="interactive setup: answer questions, then generate")
28
+ project.add_argument(
29
+ "--output",
30
+ type=Path,
31
+ default=Path("."),
32
+ help="parent folder; <output>/<project>/ is created",
33
+ )
34
+ project.add_argument("--no-git", action="store_true", help="skip git init and first commit")
35
+
36
+ validate = sub.add_parser("validate", help="validate a config without writing files")
37
+ validate.add_argument("--config", required=True, type=Path)
38
+
39
+ generate = sub.add_parser("generate", help="generate local repository folders from a config")
40
+ generate.add_argument("--config", required=True, type=Path)
41
+ generate.add_argument("--output", required=True, type=Path)
42
+ generate.add_argument("--dry-run", action="store_true", help="list files, write nothing")
43
+ generate.add_argument("--no-git", action="store_true", help="skip git init and first commit")
44
+
45
+ add = sub.add_parser("add", help="add a service or worker to a generated project")
46
+ add.add_argument("kind", choices=[AppKind.SERVICE.value, AppKind.WORKER.value])
47
+ add.add_argument("name")
48
+ add.add_argument("--port", type=int, help="HTTP port (services only)")
49
+ add.add_argument(
50
+ "--project-dir",
51
+ type=Path,
52
+ default=Path("."),
53
+ help=f"folder containing {defaults.CONFIG_FILENAME} (default: current)",
54
+ )
55
+ add.add_argument("--no-git", action="store_true", help="skip git init for new repositories")
56
+ return parser
57
+
58
+
59
+ def _summary(plan: Plan, files: FileMap) -> list[str]:
60
+ lines = []
61
+ for repo in plan.repos:
62
+ count = sum(1 for k in files if k.startswith(repo.folder + "/"))
63
+ lines.append(f" {repo.folder}/ ({repo.role}, {count} files)")
64
+ return lines
65
+
66
+
67
+ def _generate(plan: Plan, target: Path, init_git: bool) -> int:
68
+ files = render_plan(plan)
69
+ files[defaults.CONFIG_FILENAME] = dump_config(plan.config).encode("utf-8")
70
+ try:
71
+ warnings = write_project(target, files, {r.folder: r.url for r in plan.repos}, init_git)
72
+ except OutputExistsError as exc:
73
+ print(f"error: {exc}", file=sys.stderr)
74
+ return 3
75
+ for warning in warnings:
76
+ print(f"warning: {warning}", file=sys.stderr)
77
+ print(f"generated {len(files)} files under {target}")
78
+ return 0
79
+
80
+
81
+ def save_answers(directory: Path, text: str) -> Path:
82
+ """Save wizard answers without overwriting anything already there."""
83
+ stem, suffix = Path(defaults.CONFIG_FILENAME).stem, Path(defaults.CONFIG_FILENAME).suffix
84
+ for n in range(1000):
85
+ path = directory / (f"{stem}{suffix}" if n == 0 else f"{stem}-{n}{suffix}")
86
+ try:
87
+ with path.open("x", encoding="utf-8") as fh:
88
+ fh.write(text)
89
+ return path
90
+ except FileExistsError:
91
+ continue
92
+ raise OSError(f"could not find a free file name for answers in {directory}")
93
+
94
+
95
+ def run_project(output: Path, init_git: bool) -> int:
96
+ try:
97
+ answers = run_wizard()
98
+ except KeyboardInterrupt:
99
+ print("\naborted, nothing written", file=sys.stderr)
100
+ return 130
101
+
102
+ try:
103
+ config = parse_config(answers, source="your answers")
104
+ except ConfigError as exc:
105
+ saved = save_answers(Path.cwd(), yaml.safe_dump(answers, sort_keys=False))
106
+ print(f"error: {exc}", file=sys.stderr)
107
+ print(
108
+ f"answers saved to {saved}; edit it and run: "
109
+ f"plinth generate --config {saved.name} --output {output}",
110
+ file=sys.stderr,
111
+ )
112
+ return 2
113
+
114
+ plan = build_plan(config)
115
+ target = output / config.project.name
116
+ print(f"\nWill create under {target.resolve()}:")
117
+ print("\n".join(_summary(plan, render_plan(plan))))
118
+ if not questionary.confirm("Generate now?", default=True).unsafe_ask():
119
+ print("nothing written")
120
+ return 0
121
+ return _generate(plan, target, init_git)
122
+
123
+
124
+ def run_add(args: argparse.Namespace) -> int:
125
+ try:
126
+ result = add_application(
127
+ args.project_dir, AppKind(args.kind), args.name, args.port, init_git=not args.no_git
128
+ )
129
+ except ConfigError as exc:
130
+ print(f"error: {exc}", file=sys.stderr)
131
+ return 2
132
+ for note in result.notes:
133
+ print(f"note: {note}")
134
+ for path in result.created:
135
+ print(f"created {path}")
136
+ for folder in result.new_repos:
137
+ print(f"new repository: {folder}/ (create it on your git host and push)")
138
+ touched = sorted({p.split("/", 1)[0] for p in result.created} - set(result.new_repos))
139
+ if touched:
140
+ print(f"commit the new files in: {', '.join(touched)}")
141
+ if result.kept:
142
+ print("\nkept as-is (plinth would now generate these differently; review by hand):")
143
+ for path in result.kept:
144
+ print(f" {path}")
145
+ for warning in result.warnings:
146
+ print(f"warning: {warning}", file=sys.stderr)
147
+ return 0
148
+
149
+
150
+ def main(argv: list[str] | None = None) -> int:
151
+ args = build_parser().parse_args(argv)
152
+
153
+ if args.command == "project":
154
+ return run_project(args.output, init_git=not args.no_git)
155
+ if args.command == "add":
156
+ return run_add(args)
157
+
158
+ try:
159
+ config = load_config(args.config)
160
+ except ConfigError as exc:
161
+ print(f"error: {exc}", file=sys.stderr)
162
+ return 2
163
+
164
+ plan = build_plan(config)
165
+ if args.command == "validate":
166
+ folders = ", ".join(r.folder for r in plan.repos)
167
+ print(
168
+ f"ok: {config.project.name} ({config.project.architecture}, "
169
+ f"{config.project.layout}) -> {folders}"
170
+ )
171
+ return 0
172
+
173
+ target = args.output / config.project.name
174
+ if args.dry_run:
175
+ files = render_plan(plan)
176
+ for rel in sorted(files):
177
+ print(target / rel)
178
+ print(f"dry run: {len(files)} files, nothing written", file=sys.stderr)
179
+ if target.exists():
180
+ print(
181
+ f"error: {target} already exists; generate would refuse to write", file=sys.stderr
182
+ )
183
+ return 3
184
+ return 0
185
+ return _generate(plan, target, init_git=not args.no_git)
plinth/config.py ADDED
@@ -0,0 +1,51 @@
1
+ """Defaults and pinned versions used across the schema, wizard and blueprints.
2
+
3
+ Change a value here and every generated file picks it up. Templates read this
4
+ module as `defaults`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ CONFIG_FILENAME = "plinth.yaml"
10
+ REPO_METADATA_FILENAME = ".plinth.yaml"
11
+
12
+ # naming
13
+ NAME_MIN_LEN = 2
14
+ NAME_MAX_LEN = 30
15
+ RESERVED_APP_NAMES = frozenset(("app", "gitops"))
16
+
17
+ # applications
18
+ DEFAULT_PORT = 8000
19
+ SERVICE_PORT = 80
20
+ HEALTH_PATH = "/health"
21
+ WORKER_INTERVAL_SECONDS = 30
22
+ RESOURCES = {
23
+ "requests": {"cpu": "250m", "memory": "256Mi"},
24
+ "limits": {"cpu": "1", "memory": "512Mi"},
25
+ }
26
+
27
+ # environments
28
+ DEFAULT_ENVIRONMENTS = ("dev", "production")
29
+ RELEASE_ENVIRONMENT = "dev" # CI releases here; other environments are promoted by hand
30
+ REPLICAS = {"dev": 1, "production": 2}
31
+ DEFAULT_REPLICAS = 1
32
+
33
+ # wizard defaults
34
+ DEFAULT_REGISTRY = "ghcr.io/YOUR_ACCOUNT"
35
+ DEFAULT_GIT_BASE_URL = {
36
+ "github": "https://github.com/YOUR_ACCOUNT",
37
+ "gitlab": "https://gitlab.com/YOUR_GROUP",
38
+ "generic": "https://git.example.com/YOUR_GROUP",
39
+ }
40
+
41
+ # user name paired with a token for HTTPS git pushes from CI
42
+ GIT_TOKEN_USER = {"github": "x-access-token", "gitlab": "oauth2", "generic": "git"}
43
+
44
+ # pinned tool versions
45
+ PYTHON_IMAGE = "python:3.12-slim"
46
+ GIT_IMAGE = "alpine:3.22"
47
+ DOCKER_IMAGE = "docker:28"
48
+ DAGGER_VERSION = "0.21.11"
49
+ DAGGER_GITHUB_ACTION = "dagger/dagger-for-github@v8"
50
+ CHECKOUT_ACTION = "actions/checkout@v5"
51
+ ARGO_CD_CHART_VERSION = "10.10.5" # ships Argo CD 3.5.6
plinth/loader.py ADDED
@@ -0,0 +1,49 @@
1
+ """Load, validate and serialise plinth.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+ from pydantic import ValidationError
10
+
11
+ from plinth.schema import ProjectConfig
12
+
13
+
14
+ class ConfigError(Exception):
15
+ """Raised for any user-facing configuration problem."""
16
+
17
+
18
+ def parse_config(data: Any, source: str = "<config>") -> ProjectConfig:
19
+ if not isinstance(data, dict):
20
+ raise ConfigError(f"{source}: top level must be a mapping")
21
+ try:
22
+ return ProjectConfig.model_validate(data)
23
+ except ValidationError as exc:
24
+ lines = []
25
+ for err in exc.errors():
26
+ loc = ".".join(str(part) for part in err["loc"]) or "<root>"
27
+ msg = err["msg"].removeprefix("Value error, ")
28
+ lines.append(f" {loc}: {msg}")
29
+ raise ConfigError(f"{source}: invalid configuration\n" + "\n".join(lines)) from None
30
+
31
+
32
+ def load_config(path: Path) -> ProjectConfig:
33
+ try:
34
+ text = path.read_text(encoding="utf-8")
35
+ except OSError as exc:
36
+ raise ConfigError(f"cannot read {path}: {exc.strerror}") from None
37
+ try:
38
+ data = yaml.safe_load(text)
39
+ except yaml.YAMLError as exc:
40
+ raise ConfigError(f"{path}: YAML parse error: {exc}") from None
41
+ return parse_config(data, source=str(path))
42
+
43
+
44
+ def config_to_dict(config: ProjectConfig) -> dict:
45
+ return config.model_dump(mode="json", by_alias=True, exclude_none=True)
46
+
47
+
48
+ def dump_config(config: ProjectConfig) -> str:
49
+ return yaml.safe_dump(config_to_dict(config), sort_keys=False)