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.
- plinth/__init__.py +1 -0
- plinth/__main__.py +5 -0
- plinth/add.py +97 -0
- plinth/blueprints/ci/github/dot-github/workflows/ci.yaml.j2 +38 -0
- plinth/blueprints/ci/gitlab/dot-gitlab-ci.yml.j2 +36 -0
- plinth/blueprints/gitops/applicationset.yaml.j2 +46 -0
- plinth/blueprints/gitops/values.yaml.j2 +2 -0
- plinth/blueprints/repo/app/README.md.j2 +63 -0
- plinth/blueprints/repo/app/dagger.json.j2 +8 -0
- plinth/blueprints/repo/app/dot-dagger/dot-gitignore +3 -0
- plinth/blueprints/repo/app/dot-dagger/pyproject.toml +12 -0
- plinth/blueprints/repo/app/dot-dagger/src/ci/__init__.py +3 -0
- plinth/blueprints/repo/app/dot-dagger/src/ci/main.py.j2 +133 -0
- plinth/blueprints/repo/app/dot-gitignore +4 -0
- plinth/blueprints/repo/gitops/README.md.j2 +53 -0
- plinth/blueprints/repo/gitops/apps/project.yaml.j2 +15 -0
- plinth/blueprints/repo/gitops/bootstrap/root.yaml.j2 +20 -0
- plinth/blueprints/repo/gitops/charts/workload/Chart.yaml +5 -0
- plinth/blueprints/repo/gitops/charts/workload/templates/_helpers.tpl +13 -0
- plinth/blueprints/repo/gitops/charts/workload/templates/deployment.yaml +61 -0
- plinth/blueprints/repo/gitops/charts/workload/templates/service.yaml +20 -0
- plinth/blueprints/repo/gitops/charts/workload/values.yaml +9 -0
- plinth/blueprints/repo/gitops/scripts/bootstrap-local.sh.j2 +23 -0
- plinth/blueprints/workload/common/Dockerfile.j2 +14 -0
- plinth/blueprints/workload/common/app/__init__.py +0 -0
- plinth/blueprints/workload/common/dot-dockerignore +4 -0
- plinth/blueprints/workload/common/score.yaml.j2 +33 -0
- plinth/blueprints/workload/common/tests/__init__.py +0 -0
- plinth/blueprints/workload/modular-monolith/app/main.py.j2 +63 -0
- plinth/blueprints/workload/modular-monolith/app/modules/__init__.py +5 -0
- plinth/blueprints/workload/modular-monolith/app/modules/example.py +12 -0
- plinth/blueprints/workload/modular-monolith/tests/test_main.py.j2 +42 -0
- plinth/blueprints/workload/service/app/main.py.j2 +54 -0
- plinth/blueprints/workload/service/tests/test_main.py.j2 +41 -0
- plinth/blueprints/workload/worker/app/main.py.j2 +44 -0
- plinth/blueprints/workload/worker/tests/test_main.py.j2 +26 -0
- plinth/cli.py +185 -0
- plinth/config.py +51 -0
- plinth/loader.py +49 -0
- plinth/plan.py +70 -0
- plinth/render.py +119 -0
- plinth/schema.py +158 -0
- plinth/wizard.py +129 -0
- plinth/write.py +72 -0
- plinth_cli-0.2.0.dist-info/METADATA +174 -0
- plinth_cli-0.2.0.dist-info/RECORD +49 -0
- plinth_cli-0.2.0.dist-info/WHEEL +4 -0
- plinth_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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,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,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)
|